commit 4c5b328b64936ef33eab2764e83994974b100691
parent d163123589e9e2ed6dde377b66d7668b80616ac5
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Sun, 20 Sep 2026 09:08:29 -0300
bench: add a backend benchmark harness
Compares the backends across a 14-image synthetic corpus chosen to exercise
what the codec actually branches on: predictor effectiveness, palette and
colour-cache eligibility, the separate alpha plane, and the transform's
response to smooth versus high-frequency content. Sizes run from 128x128 to
3840x2160 so scaling behaviour is visible.
It also verifies that every backend encodes byte-identically, which is the
property the whole per-target build story exists to protect.
build-native-bench-libs.sh supplies the two native shared objects it compares
against. The scalar one exists to separate the cost of a sandbox or a
transpiler from the cost of simply not having SIMD.
Diffstat:
4 files changed, 789 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -1,3 +1,5 @@
cdb.json
.DS_Store
golden-got.webp
+bench/lib
+bench/testdata
diff --git a/bench/corpus.go b/bench/corpus.go
@@ -0,0 +1,319 @@
+package main
+
+import (
+ "image"
+ "image/color"
+ "math"
+ "math/rand"
+ "runtime"
+ "sync"
+)
+
+// Corpus entries are chosen to exercise the code paths a WebP encoder
+// actually branches on: predictor effectiveness, palette/colour-cache
+// eligibility, the separate alpha plane, and the transform's response to
+// smooth versus high-frequency content.
+type Entry struct {
+ Name string
+ Desc string
+ Img *image.NRGBA
+}
+
+func BuildCorpus() []Entry {
+ return []Entry{
+ {"flat-128", "solid colour, 128x128 — best case for every path",
+ flat(128, 128)},
+ {"gradient-512", "smooth 2D gradient, 512x384 — spatial predictors win",
+ gradient(512, 384)},
+ {"photo-512", "synthetic photographic fBm, 512x384 — realistic lossy load",
+ photo(512, 384, 1)},
+ {"photo-1280", "synthetic photographic fBm, 1280x720 — scaling check",
+ photo(1280, 720, 2)},
+ {"noise-512", "uniform random RGB, 512x384 — incompressible worst case",
+ noise(512, 384, 3)},
+ {"noise-128", "uniform random RGB, 128x128 — worst case, small",
+ noise(128, 128, 4)},
+ {"text-512", "sharp black-on-white line art, 512x384 — hard for lossy",
+ text(512, 384)},
+ {"palette-512", "16 distinct colours in flat regions, 512x384 — VP8L palette",
+ palette(512, 384, 5)},
+ {"alpha-512", "photographic RGB under a radial alpha ramp, 512x384",
+ alpha(512, 384, 6)},
+ {"stripes-512", "1px high-frequency periodic pattern, 512x384",
+ stripes(512, 384)},
+ {"mandelbrot-512", "escape-time fractal, 512x384 — flat interior, smooth bands, fractal edge",
+ mandelbrot(512, 384)},
+ {"photo-alpha-1280", "fBm with binary alpha cutout, 1280x720",
+ photoAlpha(1280, 720, 7)},
+ {"mandelbrot-3840", "escape-time fractal, 3840x2160 — very large, 8.3 MP",
+ mandelbrot(3840, 2160)},
+ {"photo-3840", "synthetic photographic fBm, 3840x2160 — very large, 8.3 MP",
+ photo(3840, 2160, 8)},
+ }
+}
+
+func newImg(w, h int) *image.NRGBA { return image.NewNRGBA(image.Rect(0, 0, w, h)) }
+
+// eachRow runs fn over every row, in parallel. Generators are pure functions
+// of (x, y) so the result does not depend on scheduling.
+func eachRow(h int, fn func(y int)) {
+ workers := runtime.NumCPU()
+ if workers > h {
+ workers = h
+ }
+ var wg sync.WaitGroup
+ for w := 0; w < workers; w++ {
+ wg.Add(1)
+ go func(start int) {
+ defer wg.Done()
+ for y := start; y < h; y += workers {
+ fn(y)
+ }
+ }(w)
+ }
+ wg.Wait()
+}
+
+// mandelbrot renders the classic view of the set with smooth (continuous)
+// escape-time colouring. It is a useful corpus entry because one image holds
+// three regimes at once: a large flat interior, wide smooth exterior bands
+// that spatial predictors handle well, and a boundary with detail at every
+// scale that they cannot.
+func mandelbrot(w, h int) *image.NRGBA {
+ const maxIter = 512
+ m := newImg(w, h)
+ // Full set, with the aspect ratio driven by the output size.
+ const cx, halfW = -0.7, 1.5
+ halfH := halfW * float64(h) / float64(w)
+
+ eachRow(h, func(py int) {
+ ci := (float64(py)/float64(h)*2 - 1) * halfH
+ for px := 0; px < w; px++ {
+ cr := cx + (float64(px)/float64(w)*2-1)*halfW
+ var zr, zi float64
+ n := 0
+ for ; n < maxIter; n++ {
+ zr2, zi2 := zr*zr, zi*zi
+ if zr2+zi2 > 4 {
+ break
+ }
+ zr, zi = zr2-zi2+cr, 2*zr*zi+ci
+ }
+ if n == maxIter {
+ m.SetNRGBA(px, py, color.NRGBA{A: 255}) // interior: flat black
+ continue
+ }
+ // Continuous escape time, so the bands are smooth ramps rather
+ // than posterised steps.
+ mag := math.Sqrt(zr*zr + zi*zi)
+ smooth := float64(n) + 1 - math.Log(math.Log(mag))/math.Ln2
+ t := smooth / maxIter
+ m.SetNRGBA(px, py, color.NRGBA{
+ R: uint8(clamp(255 * (0.5 + 0.5*math.Cos(3+t*20)))),
+ G: uint8(clamp(255 * (0.5 + 0.5*math.Cos(3+t*20+2.1)))),
+ B: uint8(clamp(255 * (0.5 + 0.5*math.Cos(3+t*20+4.2)))),
+ A: 255,
+ })
+ }
+ })
+ return m
+}
+
+func flat(w, h int) *image.NRGBA {
+ m := newImg(w, h)
+ for i := 0; i < len(m.Pix); i += 4 {
+ m.Pix[i], m.Pix[i+1], m.Pix[i+2], m.Pix[i+3] = 0x3a, 0x7b, 0xd5, 0xff
+ }
+ return m
+}
+
+func gradient(w, h int) *image.NRGBA {
+ m := newImg(w, h)
+ for y := 0; y < h; y++ {
+ for x := 0; x < w; x++ {
+ m.SetNRGBA(x, y, color.NRGBA{
+ R: uint8(255 * x / w),
+ G: uint8(255 * y / h),
+ B: uint8(255 * (x + y) / (w + h)),
+ A: 255,
+ })
+ }
+ }
+ return m
+}
+
+// fbm is value noise summed over octaves: smooth at low frequency with detail
+// at high frequency, which is what makes real photographs compress the way
+// they do.
+func fbm(x, y float64, seed int64) float64 {
+ var sum, amp, norm float64 = 0, 1, 0
+ freq := 1.0 / 64.0
+ for o := 0; o < 5; o++ {
+ sum += amp * valueNoise(x*freq, y*freq, seed+int64(o))
+ norm += amp
+ amp *= 0.5
+ freq *= 2
+ }
+ return sum / norm
+}
+
+func valueNoise(x, y float64, seed int64) float64 {
+ xi, yi := math.Floor(x), math.Floor(y)
+ xf, yf := x-xi, y-yi
+ sx := xf * xf * (3 - 2*xf) // smoothstep
+ sy := yf * yf * (3 - 2*yf)
+ n00 := hash2(int64(xi), int64(yi), seed)
+ n10 := hash2(int64(xi)+1, int64(yi), seed)
+ n01 := hash2(int64(xi), int64(yi)+1, seed)
+ n11 := hash2(int64(xi)+1, int64(yi)+1, seed)
+ return (n00*(1-sx)+n10*sx)*(1-sy) + (n01*(1-sx)+n11*sx)*sy
+}
+
+func hash2(x, y, seed int64) float64 {
+ n := x*374761393 + y*668265263 + seed*1442695040888963407
+ n = (n ^ (n >> 13)) * 1274126177
+ return float64(uint32(n^(n>>16))) / float64(math.MaxUint32)
+}
+
+func photo(w, h int, seed int64) *image.NRGBA {
+ m := newImg(w, h)
+ cx, cy := float64(w)/2, float64(h)/2
+ maxr := math.Hypot(cx, cy)
+ for y := 0; y < h; y++ {
+ for x := 0; x < w; x++ {
+ fx, fy := float64(x), float64(y)
+ // Vignette gives a large-scale luminance ramp on top of the
+ // detail, as in a real photo.
+ vig := 1 - 0.35*math.Hypot(fx-cx, fy-cy)/maxr
+ r := fbm(fx, fy, seed) * vig
+ g := fbm(fx+512, fy+512, seed) * vig
+ b := fbm(fx+1024, fy+1024, seed) * vig
+ m.SetNRGBA(x, y, color.NRGBA{
+ R: uint8(clamp(r * 255)), G: uint8(clamp(g * 255)),
+ B: uint8(clamp(b * 255)), A: 255,
+ })
+ }
+ }
+ return m
+}
+
+func clamp(v float64) float64 { return math.Max(0, math.Min(255, v)) }
+
+func noise(w, h int, seed int64) *image.NRGBA {
+ m := newImg(w, h)
+ rng := rand.New(rand.NewSource(seed))
+ for i := 0; i < len(m.Pix); i += 4 {
+ v := rng.Uint32()
+ m.Pix[i], m.Pix[i+1], m.Pix[i+2] = uint8(v), uint8(v>>8), uint8(v>>16)
+ m.Pix[i+3] = 0xff
+ }
+ return m
+}
+
+// text draws axis-aligned bars and diagonals: pure two-tone, hard edges. This
+// is the content class lossy WebP handles worst and lossless handles best.
+func text(w, h int) *image.NRGBA {
+ m := newImg(w, h)
+ for i := 0; i < len(m.Pix); i += 4 {
+ m.Pix[i], m.Pix[i+1], m.Pix[i+2], m.Pix[i+3] = 0xff, 0xff, 0xff, 0xff
+ }
+ ink := color.NRGBA{A: 255}
+ for row := 0; row < h/24; row++ {
+ y0 := row*24 + 6
+ for gl := 0; gl < w/16; gl++ {
+ x0 := gl * 16
+ // Vary the shape per cell so it is not one repeated motif.
+ switch (row*7 + gl*3) % 4 {
+ case 0:
+ fillRect(m, x0+2, y0, 8, 12, ink)
+ case 1:
+ fillRect(m, x0+2, y0, 2, 12, ink)
+ fillRect(m, x0+2, y0+5, 8, 2, ink)
+ case 2:
+ for d := 0; d < 12; d++ {
+ fillRect(m, x0+2+d/2, y0+d, 2, 1, ink)
+ }
+ case 3:
+ fillRect(m, x0+2, y0, 8, 2, ink)
+ fillRect(m, x0+6, y0, 2, 12, ink)
+ }
+ }
+ }
+ return m
+}
+
+func fillRect(m *image.NRGBA, x, y, w, h int, c color.NRGBA) {
+ b := m.Bounds()
+ for yy := y; yy < y+h && yy < b.Max.Y; yy++ {
+ for xx := x; xx < x+w && xx < b.Max.X; xx++ {
+ m.SetNRGBA(xx, yy, c)
+ }
+ }
+}
+
+// palette builds large flat regions drawn from a 16-entry colour set, which
+// is what makes VP8L take its palette / colour-cache path.
+func palette(w, h int, seed int64) *image.NRGBA {
+ rng := rand.New(rand.NewSource(seed))
+ var pal [16]color.NRGBA
+ for i := range pal {
+ pal[i] = color.NRGBA{
+ R: uint8(rng.Intn(256)), G: uint8(rng.Intn(256)),
+ B: uint8(rng.Intn(256)), A: 255,
+ }
+ }
+ m := newImg(w, h)
+ const cell = 17 // deliberately not a macroblock multiple
+ for y := 0; y < h; y++ {
+ for x := 0; x < w; x++ {
+ m.SetNRGBA(x, y, pal[((x/cell)*5+(y/cell)*3)%16])
+ }
+ }
+ return m
+}
+
+// alpha exercises the separate alpha-plane encoder with a smooth ramp.
+func alpha(w, h int, seed int64) *image.NRGBA {
+ m := photo(w, h, seed)
+ cx, cy := float64(w)/2, float64(h)/2
+ maxr := math.Hypot(cx, cy)
+ for y := 0; y < h; y++ {
+ for x := 0; x < w; x++ {
+ d := math.Hypot(float64(x)-cx, float64(y)-cy) / maxr
+ m.Pix[m.PixOffset(x, y)+3] = uint8(clamp(255 * (1 - d)))
+ }
+ }
+ return m
+}
+
+// photoAlpha uses a hard-edged alpha cutout rather than a ramp: the alpha
+// plane is then highly compressible while the colour plane is not.
+func photoAlpha(w, h int, seed int64) *image.NRGBA {
+ m := photo(w, h, seed)
+ cx, cy := float64(w)/2, float64(h)/2
+ r := math.Min(cx, cy) * 0.8
+ for y := 0; y < h; y++ {
+ for x := 0; x < w; x++ {
+ var a uint8
+ if math.Hypot(float64(x)-cx, float64(y)-cy) < r {
+ a = 255
+ }
+ m.Pix[m.PixOffset(x, y)+3] = a
+ }
+ }
+ return m
+}
+
+func stripes(w, h int) *image.NRGBA {
+ m := newImg(w, h)
+ for y := 0; y < h; y++ {
+ for x := 0; x < w; x++ {
+ v := uint8(0)
+ if (x+y)%2 == 0 {
+ v = 255
+ }
+ m.SetNRGBA(x, y, color.NRGBA{R: v, G: uint8(x % 256), B: 255 - v, A: 255})
+ }
+ }
+ return m
+}
diff --git a/bench/main.go b/bench/main.go
@@ -0,0 +1,421 @@
+// Command bench compares libwebp backends — native shared object (SIMD and
+// scalar), WebAssembly under wazero, and the ccgo transpilation — across a
+// corpus of synthetic images chosen to exercise different codec paths.
+package main
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "flag"
+ "fmt"
+ "image"
+ "image/png"
+ "math"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "git.sr.ht/~jackmordaunt/go-libwebp/v2/lib/common"
+ transpiled "git.sr.ht/~jackmordaunt/go-libwebp/v2/lib/transpiled/webp"
+ wasmwebp "git.sr.ht/~jackmordaunt/go-libwebp/v2/lib/wasm/webp"
+ "github.com/ebitengine/purego"
+)
+
+// Backend is one way of reaching libwebp.
+type Backend struct {
+ Name string
+ Encode func(m *image.NRGBA, q float32) ([]byte, error)
+ Decode func(data []byte) (image.Image, error)
+}
+
+var (
+ budget = flag.Duration("budget", 750*time.Millisecond, "time budget per measurement")
+ minIter = flag.Int("min-iter", 3, "minimum iterations per measurement")
+ outDir = flag.String("out", "bench/testdata", "where to write corpus PNGs")
+ soDir = flag.String("so", "", "directory holding libwebp_simd.so and libwebp_scalar.so")
+ wasmMods = flag.String("wasm", "", "comma-separated name=path.wasm modules to compare")
+ only = flag.String("only", "", "comma-separated corpus entry names to restrict the run to")
+ noPNG = flag.Bool("no-png", false, "skip writing corpus PNGs")
+)
+
+func main() {
+ flag.Parse()
+ if *soDir == "" {
+ fmt.Fprintln(os.Stderr, "-so is required")
+ os.Exit(1)
+ }
+
+ corpus := BuildCorpus()
+ if *only != "" {
+ want := map[string]bool{}
+ for _, n := range strings.Split(*only, ",") {
+ want[n] = true
+ }
+ var filtered []Entry
+ for _, e := range corpus {
+ if want[e.Name] {
+ filtered = append(filtered, e)
+ }
+ }
+ if len(filtered) == 0 {
+ fmt.Fprintf(os.Stderr, "-only matched no corpus entries\n")
+ os.Exit(1)
+ }
+ corpus = filtered
+ }
+ if err := dumpPNGs(corpus, *outDir); err != nil {
+ fmt.Fprintf(os.Stderr, "writing corpus: %v\n", err)
+ os.Exit(1)
+ }
+
+ backends, cleanup, err := setupBackends(*soDir)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "setup: %v\n", err)
+ os.Exit(1)
+ }
+ defer cleanup()
+
+ verify(backends, corpus)
+ run(backends, corpus)
+ reportInstantiation(corpus)
+}
+
+func setupBackends(soDir string) ([]Backend, func(), error) {
+ var backends []Backend
+
+ for _, cfg := range []struct{ name, file string }{
+ {"native-simd", "libwebp_simd.so"},
+ {"native-scalar", "libwebp_scalar.so"},
+ } {
+ b, err := loadNative(cfg.name, filepath.Join(soDir, cfg.file))
+ if err != nil {
+ return nil, nil, err
+ }
+ backends = append(backends, b)
+ }
+
+ ctx := context.Background()
+ var closers []func()
+
+ // Each -wasm entry is a separately built module; comparing them isolates
+ // toolchain and SIMD effects from the cost of the sandbox itself.
+ for _, spec := range strings.Split(*wasmMods, ",") {
+ if spec == "" {
+ continue
+ }
+ name, path, ok := strings.Cut(spec, "=")
+ if !ok {
+ return nil, nil, fmt.Errorf("-wasm entry %q is not name=path", spec)
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ return nil, nil, err
+ }
+ compiled, err := wasmwebp.CompileBytes(ctx, raw)
+ if err != nil {
+ return nil, nil, fmt.Errorf("%s: %w", name, err)
+ }
+ inst, err := compiled.NewInstance(ctx)
+ if err != nil {
+ return nil, nil, fmt.Errorf("%s: %w", name, err)
+ }
+ closers = append(closers, func() { inst.Close(ctx) })
+ backends = append(backends, Backend{
+ Name: name,
+ Encode: func(m *image.NRGBA, q float32) ([]byte, error) { return inst.Encode(ctx, m, q) },
+ Decode: func(d []byte) (image.Image, error) { return inst.Decode(ctx, d) },
+ })
+ }
+
+ backends = append(backends,
+ Backend{
+ Name: "transpiled",
+ Encode: func(m *image.NRGBA, q float32) ([]byte, error) {
+ var buf bytes.Buffer
+ err := transpiled.EncodeImpl(&buf, m, q)
+ return buf.Bytes(), err
+ },
+ Decode: transpiled.DecodeImpl,
+ },
+ )
+
+ return backends, func() {
+ for _, c := range closers {
+ c()
+ }
+ }, nil
+}
+
+// loadNative binds the four entry points out of a shared object with purego,
+// the same mechanism lib/dynamic uses, but against an explicit path so the
+// SIMD and scalar builds can be compared side by side.
+func loadNative(name, path string) (Backend, error) {
+ h, err := purego.Dlopen(path, purego.RTLD_NOW|purego.RTLD_LOCAL)
+ if err != nil {
+ return Backend{}, fmt.Errorf("dlopen %s: %w", path, err)
+ }
+ var (
+ encodeRGBA func(in uintptr, w, h, bps int32, q float32, out uintptr) uint64
+ encodeLossless func(in uintptr, w, h, bps int32, out uintptr) uint64
+ decodeRGBA func(data uintptr, size uint64, w, h uintptr) uintptr
+ webpFree func(uintptr)
+ )
+ purego.RegisterLibFunc(&encodeRGBA, h, "WebPEncodeRGBA")
+ purego.RegisterLibFunc(&encodeLossless, h, "WebPEncodeLosslessRGBA")
+ purego.RegisterLibFunc(&decodeRGBA, h, "WebPDecodeRGBA")
+ purego.RegisterLibFunc(&webpFree, h, "WebPFree")
+
+ enc := func(in uintptr, w, h, bps int32, q float32, out uintptr) uint64 {
+ if q >= 100 {
+ return encodeLossless(in, w, h, bps, out)
+ }
+ return encodeRGBA(in, w, h, bps, q, out)
+ }
+ return Backend{
+ Name: name,
+ Encode: func(m *image.NRGBA, q float32) ([]byte, error) {
+ var buf bytes.Buffer
+ err := common.Encode(&buf, m, q*100, enc, webpFree)
+ return buf.Bytes(), err
+ },
+ Decode: func(d []byte) (image.Image, error) {
+ return common.Decode(d, decodeRGBA, webpFree)
+ },
+ }, nil
+}
+
+// verify checks that every backend produces byte-identical output for the
+// same input, which is the property the whole per-target build story exists
+// to protect.
+func verify(backends []Backend, corpus []Entry) {
+ fmt.Println("== output equivalence (sha256 of encoded bytes) ==")
+ fmt.Printf("%-18s %-10s %s\n", "image", "mode", "agreement")
+ mismatch := 0
+ for _, e := range corpus {
+ for _, mode := range []struct {
+ name string
+ q float32
+ }{{"lossless", 1.0}, {"lossy-75", 0.75}} {
+ sums := map[string][]string{}
+ for _, b := range backends {
+ out, err := b.Encode(e.Img, mode.q)
+ if err != nil {
+ fmt.Printf("%-18s %-10s ERROR %s: %v\n", e.Name, mode.name, b.Name, err)
+ mismatch++
+ continue
+ }
+ sum := sha256.Sum256(out)
+ s := hex.EncodeToString(sum[:])
+ sums[s] = append(sums[s], b.Name)
+ }
+ switch len(sums) {
+ case 1:
+ fmt.Printf("%-18s %-10s all identical\n", e.Name, mode.name)
+ default:
+ mismatch++
+ fmt.Printf("%-18s %-10s DIVERGENT:\n", e.Name, mode.name)
+ for s, names := range sums {
+ fmt.Printf("%-18s %-10s %s %v\n", "", "", s[:12], names)
+ }
+ }
+ }
+ }
+ if mismatch == 0 {
+ fmt.Println("all backends agree byte-for-byte on every corpus entry")
+ }
+ fmt.Println()
+}
+
+type result struct {
+ image, mode, backend string
+ pixels int
+ perOp time.Duration
+ bytes int
+}
+
+func run(backends []Backend, corpus []Entry) {
+ var results []result
+
+ for _, e := range corpus {
+ px := e.Img.Bounds().Dx() * e.Img.Bounds().Dy()
+
+ for _, mode := range []struct {
+ name string
+ q float32
+ }{{"encode-lossless", 1.0}, {"encode-lossy75", 0.75}} {
+ for _, b := range backends {
+ var size int
+ d := measure(func() {
+ out, err := b.Encode(e.Img, mode.q)
+ if err != nil {
+ panic(fmt.Sprintf("%s %s %s: %v", e.Name, mode.name, b.Name, err))
+ }
+ size = len(out)
+ })
+ results = append(results, result{e.Name, mode.name, b.Name, px, d, size})
+ }
+ }
+
+ // Decode the lossy encoding; it is the representative case and every
+ // backend produces the same bytes.
+ encoded, err := backends[0].Encode(e.Img, 0.75)
+ if err != nil {
+ panic(err)
+ }
+ for _, b := range backends {
+ d := measure(func() {
+ if _, err := b.Decode(encoded); err != nil {
+ panic(fmt.Sprintf("%s decode %s: %v", e.Name, b.Name, err))
+ }
+ })
+ results = append(results, result{e.Name, "decode", b.Name, px, d, len(encoded)})
+ }
+ }
+
+ report(backends, corpus, results)
+}
+
+func measure(fn func()) time.Duration {
+ fn() // warm up: first call pays lazy dsp-table init
+ start := time.Now()
+ n := 0
+ for (n < *minIter && time.Since(start) < 4*(*budget)) || time.Since(start) < *budget {
+ fn()
+ n++
+ if n >= 5000 {
+ break
+ }
+ }
+ return time.Since(start) / time.Duration(n)
+}
+
+func report(backends []Backend, corpus []Entry, results []result) {
+ index := map[string]result{}
+ for _, r := range results {
+ index[r.image+"|"+r.mode+"|"+r.backend] = r
+ }
+
+ for _, mode := range []string{"encode-lossless", "encode-lossy75", "decode"} {
+ fmt.Printf("== %s ==\n", mode)
+ fmt.Printf("%-18s %10s", "image", "out KiB")
+ for _, b := range backends {
+ fmt.Printf(" %14s", b.Name)
+ }
+ fmt.Printf(" (MP/s; x = vs native-simd)\n")
+
+ for _, e := range corpus {
+ base := index[e.Name+"|"+mode+"|native-simd"]
+ fmt.Printf("%-18s %10.1f", e.Name, float64(base.bytes)/1024)
+ for _, b := range backends {
+ r := index[e.Name+"|"+mode+"|"+b.Name]
+ mps := float64(r.pixels) / 1e6 / r.perOp.Seconds()
+ fmt.Printf(" %8.2f/%4.1fx", mps, float64(r.perOp)/float64(base.perOp))
+ }
+ fmt.Println()
+ }
+
+ // Geometric-mean slowdown against native-simd, so one large image
+ // does not dominate the headline number.
+ fmt.Printf("%-18s %10s", "GEOMEAN", "")
+ for _, b := range backends {
+ prod := 1.0
+ for _, e := range corpus {
+ base := index[e.Name+"|"+mode+"|native-simd"]
+ r := index[e.Name+"|"+mode+"|"+b.Name]
+ prod *= float64(r.perOp) / float64(base.perOp)
+ }
+ fmt.Printf(" %8s/%4.1fx", "", math.Pow(prod, 1/float64(len(corpus))))
+ }
+ fmt.Printf("\n\n")
+ }
+}
+
+func reportInstantiation(corpus []Entry) {
+ ctx := context.Background()
+ const n = 200
+ start := time.Now()
+ for i := 0; i < n; i++ {
+ inst, err := wasmwebp.NewInstance(ctx)
+ if err != nil {
+ panic(err)
+ }
+ inst.Close(ctx)
+ }
+ fmt.Printf("== wasm instantiation ==\nfresh instance: %v per instantiate+close\n",
+ time.Since(start)/n)
+
+ // Show how far linear memory grows and stays grown.
+ inst, err := wasmwebp.NewInstance(ctx)
+ if err != nil {
+ panic(err)
+ }
+ defer inst.Close(ctx)
+ fmt.Printf("linear memory at start: %6.1f MiB\n", mib(inst.MemorySize()))
+
+ // Worst case first: the largest entry sets the high-water mark that a
+ // pooled instance would then hold onto forever.
+ largest := corpus[0]
+ for _, e := range corpus {
+ if area(e.Img) > area(largest.Img) {
+ largest = e
+ }
+ }
+ if _, err := inst.Encode(ctx, largest.Img, 1.0); err != nil {
+ panic(err)
+ }
+ b := largest.Img.Bounds()
+ fmt.Printf("after lossless %dx%-4d: %6.1f MiB\n", b.Dx(), b.Dy(), mib(inst.MemorySize()))
+
+ for _, e := range corpus {
+ if e.Name == "flat-128" {
+ if _, err := inst.Encode(ctx, e.Img, 1.0); err != nil {
+ panic(err)
+ }
+ fmt.Printf("after a subsequent 128x128: %6.1f MiB (never shrinks)\n", mib(inst.MemorySize()))
+ }
+ }
+}
+
+func area(m *image.NRGBA) int { return m.Bounds().Dx() * m.Bounds().Dy() }
+
+func mib(b uint32) float64 { return float64(b) / (1 << 20) }
+
+func dumpPNGs(corpus []Entry, dir string) error {
+ fmt.Println("== corpus ==")
+ if *noPNG {
+ for _, e := range corpus {
+ b := e.Img.Bounds()
+ fmt.Printf("%-18s %5dx%-5d %s\n", e.Name, b.Dx(), b.Dy(), e.Desc)
+ }
+ fmt.Println()
+ return nil
+ }
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+ names := make([]string, 0, len(corpus))
+ for _, e := range corpus {
+ p := filepath.Join(dir, e.Name+".png")
+ f, err := os.Create(p)
+ if err != nil {
+ return err
+ }
+ if err := png.Encode(f, e.Img); err != nil {
+ f.Close()
+ return err
+ }
+ st, _ := f.Stat()
+ f.Close()
+ b := e.Img.Bounds()
+ fmt.Printf("%-18s %5dx%-5d %7.1f KiB png %s\n",
+ e.Name, b.Dx(), b.Dy(), float64(st.Size())/1024, e.Desc)
+ names = append(names, e.Name)
+ }
+ sort.Strings(names)
+ fmt.Printf("written to %s/\n\n", dir)
+ return nil
+}
diff --git a/tools/build-native-bench-libs.sh b/tools/build-native-bench-libs.sh
@@ -0,0 +1,47 @@
+#!/bin/sh
+#
+# Build two native shared objects from the vendored c-lib source, for the
+# benchmark harness in ./bench:
+#
+# libwebp_simd.so normal build — SSE2/SSE4.1 kernels, runtime dispatch
+# libwebp_scalar.so SIMD disabled, matching how tools/transpile.sh and the
+# wasm builds are configured
+#
+# The scalar one exists so the benchmark can separate the cost of a sandbox or
+# a transpiler from the cost of simply not having SIMD.
+
+set -eux
+
+HERE=$(cd "$(dirname "$0")" && pwd)
+SRC=$HERE/../c-lib
+OUT=${OUT:-$HERE/../bench/lib}
+mkdir -p "$OUT"
+
+# dsp.h only honours WEBP_HAVE_SSE2 when HAVE_CONFIG_H is set, so an empty
+# config.h is how the scalar build turns SIMD off. On x86-64 __SSE2__ is
+# always defined and -mno-sse2 is not a legal option, so this is the only way.
+INC=$(mktemp -d)
+mkdir -p "$INC/src/webp"
+: > "$INC/src/webp/config.h"
+trap 'rm -rf "$INC" "$OBJ"' EXIT
+
+build() {
+ name=$1
+ shift
+ OBJ=$(mktemp -d)
+ cd "$SRC"
+ for f in src/dec/*.c src/enc/*.c src/utils/*.c src/dsp/*.c; do
+ extra=""
+ # The SSE4.1 kernels need the instruction set enabled on their own TU.
+ case "$f:$name" in *_sse41.c:simd) extra="-msse4.1" ;; esac
+ gcc -O3 -fPIC -DNDEBUG -I"$SRC" "$@" $extra -c "$f" \
+ -o "$OBJ/$(echo "$f" | tr / _).o"
+ done
+ gcc -shared -o "$OUT/libwebp_$name.so" "$OBJ"/*.o -lm
+ rm -rf "$OBJ"
+}
+
+build simd
+build scalar -DHAVE_CONFIG_H -I"$INC"
+
+ls -la "$OUT"