go-libwebp

Experimental translation from libwebp to Go source.
Log | Files | Refs | README | LICENSE

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:
M.gitignore | 2++
Abench/corpus.go | 319+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abench/main.go | 421+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/build-native-bench-libs.sh | 47+++++++++++++++++++++++++++++++++++++++++++++++
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"