go-libwebp

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

main.go (11767B)


      1 // Command bench compares libwebp backends — native shared object (SIMD and
      2 // scalar), WebAssembly under wazero, and the ccgo transpilation — across a
      3 // corpus of synthetic images chosen to exercise different codec paths.
      4 package main
      5 
      6 import (
      7 	"bytes"
      8 	"context"
      9 	"crypto/sha256"
     10 	"encoding/hex"
     11 	"flag"
     12 	"fmt"
     13 	"image"
     14 	"image/png"
     15 	"math"
     16 	"os"
     17 	"path/filepath"
     18 	"sort"
     19 	"strings"
     20 	"time"
     21 
     22 	"git.sr.ht/~jackmordaunt/go-libwebp/v2/lib/common"
     23 	transpiled "git.sr.ht/~jackmordaunt/go-libwebp/v2/lib/transpiled/webp"
     24 	wasmwebp "git.sr.ht/~jackmordaunt/go-libwebp/v2/lib/wasm/webp"
     25 	"github.com/ebitengine/purego"
     26 )
     27 
     28 // Backend is one way of reaching libwebp.
     29 type Backend struct {
     30 	Name   string
     31 	Encode func(m *image.NRGBA, q float32) ([]byte, error)
     32 	Decode func(data []byte) (image.Image, error)
     33 }
     34 
     35 var (
     36 	budget   = flag.Duration("budget", 750*time.Millisecond, "time budget per measurement")
     37 	minIter  = flag.Int("min-iter", 3, "minimum iterations per measurement")
     38 	outDir   = flag.String("out", "bench/testdata", "where to write corpus PNGs")
     39 	soDir    = flag.String("so", "", "directory holding libwebp_simd.so and libwebp_scalar.so")
     40 	wasmMods = flag.String("wasm", "", "comma-separated name=path.wasm modules to compare")
     41 	only     = flag.String("only", "", "comma-separated corpus entry names to restrict the run to")
     42 	noPNG    = flag.Bool("no-png", false, "skip writing corpus PNGs")
     43 )
     44 
     45 func main() {
     46 	flag.Parse()
     47 	if *soDir == "" {
     48 		fmt.Fprintln(os.Stderr, "-so is required")
     49 		os.Exit(1)
     50 	}
     51 
     52 	corpus := BuildCorpus()
     53 	if *only != "" {
     54 		want := map[string]bool{}
     55 		for _, n := range strings.Split(*only, ",") {
     56 			want[n] = true
     57 		}
     58 		var filtered []Entry
     59 		for _, e := range corpus {
     60 			if want[e.Name] {
     61 				filtered = append(filtered, e)
     62 			}
     63 		}
     64 		if len(filtered) == 0 {
     65 			fmt.Fprintf(os.Stderr, "-only matched no corpus entries\n")
     66 			os.Exit(1)
     67 		}
     68 		corpus = filtered
     69 	}
     70 	if err := dumpPNGs(corpus, *outDir); err != nil {
     71 		fmt.Fprintf(os.Stderr, "writing corpus: %v\n", err)
     72 		os.Exit(1)
     73 	}
     74 
     75 	backends, cleanup, err := setupBackends(*soDir)
     76 	if err != nil {
     77 		fmt.Fprintf(os.Stderr, "setup: %v\n", err)
     78 		os.Exit(1)
     79 	}
     80 	defer cleanup()
     81 
     82 	verify(backends, corpus)
     83 	run(backends, corpus)
     84 	reportInstantiation(corpus)
     85 }
     86 
     87 func setupBackends(soDir string) ([]Backend, func(), error) {
     88 	var backends []Backend
     89 
     90 	for _, cfg := range []struct{ name, file string }{
     91 		{"native-simd", "libwebp_simd.so"},
     92 		{"native-scalar", "libwebp_scalar.so"},
     93 	} {
     94 		b, err := loadNative(cfg.name, filepath.Join(soDir, cfg.file))
     95 		if err != nil {
     96 			return nil, nil, err
     97 		}
     98 		backends = append(backends, b)
     99 	}
    100 
    101 	ctx := context.Background()
    102 	var closers []func()
    103 
    104 	// Each -wasm entry is a separately built module; comparing them isolates
    105 	// toolchain and SIMD effects from the cost of the sandbox itself.
    106 	for _, spec := range strings.Split(*wasmMods, ",") {
    107 		if spec == "" {
    108 			continue
    109 		}
    110 		name, path, ok := strings.Cut(spec, "=")
    111 		if !ok {
    112 			return nil, nil, fmt.Errorf("-wasm entry %q is not name=path", spec)
    113 		}
    114 		raw, err := os.ReadFile(path)
    115 		if err != nil {
    116 			return nil, nil, err
    117 		}
    118 		compiled, err := wasmwebp.CompileBytes(ctx, raw)
    119 		if err != nil {
    120 			return nil, nil, fmt.Errorf("%s: %w", name, err)
    121 		}
    122 		inst, err := compiled.NewInstance(ctx)
    123 		if err != nil {
    124 			return nil, nil, fmt.Errorf("%s: %w", name, err)
    125 		}
    126 		closers = append(closers, func() { inst.Close(ctx) })
    127 		backends = append(backends, Backend{
    128 			Name:   name,
    129 			Encode: func(m *image.NRGBA, q float32) ([]byte, error) { return inst.Encode(ctx, m, q) },
    130 			Decode: func(d []byte) (image.Image, error) { return inst.Decode(ctx, d) },
    131 		})
    132 	}
    133 
    134 	backends = append(backends,
    135 		Backend{
    136 			Name: "transpiled",
    137 			Encode: func(m *image.NRGBA, q float32) ([]byte, error) {
    138 				var buf bytes.Buffer
    139 				err := transpiled.EncodeImpl(&buf, m, q)
    140 				return buf.Bytes(), err
    141 			},
    142 			Decode: transpiled.DecodeImpl,
    143 		},
    144 	)
    145 
    146 	return backends, func() {
    147 		for _, c := range closers {
    148 			c()
    149 		}
    150 	}, nil
    151 }
    152 
    153 // loadNative binds the four entry points out of a shared object with purego,
    154 // the same mechanism lib/dynamic uses, but against an explicit path so the
    155 // SIMD and scalar builds can be compared side by side.
    156 func loadNative(name, path string) (Backend, error) {
    157 	h, err := purego.Dlopen(path, purego.RTLD_NOW|purego.RTLD_LOCAL)
    158 	if err != nil {
    159 		return Backend{}, fmt.Errorf("dlopen %s: %w", path, err)
    160 	}
    161 	var (
    162 		encodeRGBA     func(in uintptr, w, h, bps int32, q float32, out uintptr) uint64
    163 		encodeLossless func(in uintptr, w, h, bps int32, out uintptr) uint64
    164 		decodeRGBA     func(data uintptr, size uint64, w, h uintptr) uintptr
    165 		webpFree       func(uintptr)
    166 	)
    167 	purego.RegisterLibFunc(&encodeRGBA, h, "WebPEncodeRGBA")
    168 	purego.RegisterLibFunc(&encodeLossless, h, "WebPEncodeLosslessRGBA")
    169 	purego.RegisterLibFunc(&decodeRGBA, h, "WebPDecodeRGBA")
    170 	purego.RegisterLibFunc(&webpFree, h, "WebPFree")
    171 
    172 	enc := func(in uintptr, w, h, bps int32, q float32, out uintptr) uint64 {
    173 		if q >= 100 {
    174 			return encodeLossless(in, w, h, bps, out)
    175 		}
    176 		return encodeRGBA(in, w, h, bps, q, out)
    177 	}
    178 	return Backend{
    179 		Name: name,
    180 		Encode: func(m *image.NRGBA, q float32) ([]byte, error) {
    181 			var buf bytes.Buffer
    182 			err := common.Encode(&buf, m, q*100, enc, webpFree)
    183 			return buf.Bytes(), err
    184 		},
    185 		Decode: func(d []byte) (image.Image, error) {
    186 			return common.Decode(d, decodeRGBA, webpFree)
    187 		},
    188 	}, nil
    189 }
    190 
    191 // verify checks that every backend produces byte-identical output for the
    192 // same input, which is the property the whole per-target build story exists
    193 // to protect.
    194 func verify(backends []Backend, corpus []Entry) {
    195 	fmt.Println("== output equivalence (sha256 of encoded bytes) ==")
    196 	fmt.Printf("%-18s %-10s %s\n", "image", "mode", "agreement")
    197 	mismatch := 0
    198 	for _, e := range corpus {
    199 		for _, mode := range []struct {
    200 			name string
    201 			q    float32
    202 		}{{"lossless", 1.0}, {"lossy-75", 0.75}} {
    203 			sums := map[string][]string{}
    204 			for _, b := range backends {
    205 				out, err := b.Encode(e.Img, mode.q)
    206 				if err != nil {
    207 					fmt.Printf("%-18s %-10s ERROR %s: %v\n", e.Name, mode.name, b.Name, err)
    208 					mismatch++
    209 					continue
    210 				}
    211 				sum := sha256.Sum256(out)
    212 				s := hex.EncodeToString(sum[:])
    213 				sums[s] = append(sums[s], b.Name)
    214 			}
    215 			switch len(sums) {
    216 			case 1:
    217 				fmt.Printf("%-18s %-10s all identical\n", e.Name, mode.name)
    218 			default:
    219 				mismatch++
    220 				fmt.Printf("%-18s %-10s DIVERGENT:\n", e.Name, mode.name)
    221 				for s, names := range sums {
    222 					fmt.Printf("%-18s %-10s   %s  %v\n", "", "", s[:12], names)
    223 				}
    224 			}
    225 		}
    226 	}
    227 	if mismatch == 0 {
    228 		fmt.Println("all backends agree byte-for-byte on every corpus entry")
    229 	}
    230 	fmt.Println()
    231 }
    232 
    233 type result struct {
    234 	image, mode, backend string
    235 	pixels               int
    236 	perOp                time.Duration
    237 	bytes                int
    238 }
    239 
    240 func run(backends []Backend, corpus []Entry) {
    241 	var results []result
    242 
    243 	for _, e := range corpus {
    244 		px := e.Img.Bounds().Dx() * e.Img.Bounds().Dy()
    245 
    246 		for _, mode := range []struct {
    247 			name string
    248 			q    float32
    249 		}{{"encode-lossless", 1.0}, {"encode-lossy75", 0.75}} {
    250 			for _, b := range backends {
    251 				var size int
    252 				d := measure(func() {
    253 					out, err := b.Encode(e.Img, mode.q)
    254 					if err != nil {
    255 						panic(fmt.Sprintf("%s %s %s: %v", e.Name, mode.name, b.Name, err))
    256 					}
    257 					size = len(out)
    258 				})
    259 				results = append(results, result{e.Name, mode.name, b.Name, px, d, size})
    260 			}
    261 		}
    262 
    263 		// Decode the lossy encoding; it is the representative case and every
    264 		// backend produces the same bytes.
    265 		encoded, err := backends[0].Encode(e.Img, 0.75)
    266 		if err != nil {
    267 			panic(err)
    268 		}
    269 		for _, b := range backends {
    270 			d := measure(func() {
    271 				if _, err := b.Decode(encoded); err != nil {
    272 					panic(fmt.Sprintf("%s decode %s: %v", e.Name, b.Name, err))
    273 				}
    274 			})
    275 			results = append(results, result{e.Name, "decode", b.Name, px, d, len(encoded)})
    276 		}
    277 	}
    278 
    279 	report(backends, corpus, results)
    280 }
    281 
    282 func measure(fn func()) time.Duration {
    283 	fn() // warm up: first call pays lazy dsp-table init
    284 	start := time.Now()
    285 	n := 0
    286 	for (n < *minIter && time.Since(start) < 4*(*budget)) || time.Since(start) < *budget {
    287 		fn()
    288 		n++
    289 		if n >= 5000 {
    290 			break
    291 		}
    292 	}
    293 	return time.Since(start) / time.Duration(n)
    294 }
    295 
    296 func report(backends []Backend, corpus []Entry, results []result) {
    297 	index := map[string]result{}
    298 	for _, r := range results {
    299 		index[r.image+"|"+r.mode+"|"+r.backend] = r
    300 	}
    301 
    302 	for _, mode := range []string{"encode-lossless", "encode-lossy75", "decode"} {
    303 		fmt.Printf("== %s ==\n", mode)
    304 		fmt.Printf("%-18s %10s", "image", "out KiB")
    305 		for _, b := range backends {
    306 			fmt.Printf(" %14s", b.Name)
    307 		}
    308 		fmt.Printf("   (MP/s; x = vs native-simd)\n")
    309 
    310 		for _, e := range corpus {
    311 			base := index[e.Name+"|"+mode+"|native-simd"]
    312 			fmt.Printf("%-18s %10.1f", e.Name, float64(base.bytes)/1024)
    313 			for _, b := range backends {
    314 				r := index[e.Name+"|"+mode+"|"+b.Name]
    315 				mps := float64(r.pixels) / 1e6 / r.perOp.Seconds()
    316 				fmt.Printf(" %8.2f/%4.1fx", mps, float64(r.perOp)/float64(base.perOp))
    317 			}
    318 			fmt.Println()
    319 		}
    320 
    321 		// Geometric-mean slowdown against native-simd, so one large image
    322 		// does not dominate the headline number.
    323 		fmt.Printf("%-18s %10s", "GEOMEAN", "")
    324 		for _, b := range backends {
    325 			prod := 1.0
    326 			for _, e := range corpus {
    327 				base := index[e.Name+"|"+mode+"|native-simd"]
    328 				r := index[e.Name+"|"+mode+"|"+b.Name]
    329 				prod *= float64(r.perOp) / float64(base.perOp)
    330 			}
    331 			fmt.Printf(" %8s/%4.1fx", "", math.Pow(prod, 1/float64(len(corpus))))
    332 		}
    333 		fmt.Printf("\n\n")
    334 	}
    335 }
    336 
    337 func reportInstantiation(corpus []Entry) {
    338 	ctx := context.Background()
    339 	const n = 200
    340 	start := time.Now()
    341 	for i := 0; i < n; i++ {
    342 		inst, err := wasmwebp.NewInstance(ctx)
    343 		if err != nil {
    344 			panic(err)
    345 		}
    346 		inst.Close(ctx)
    347 	}
    348 	fmt.Printf("== wasm instantiation ==\nfresh instance: %v per instantiate+close\n",
    349 		time.Since(start)/n)
    350 
    351 	// Show how far linear memory grows and stays grown.
    352 	inst, err := wasmwebp.NewInstance(ctx)
    353 	if err != nil {
    354 		panic(err)
    355 	}
    356 	defer inst.Close(ctx)
    357 	fmt.Printf("linear memory at start:       %6.1f MiB\n", mib(inst.MemorySize()))
    358 
    359 	// Worst case first: the largest entry sets the high-water mark that a
    360 	// pooled instance would then hold onto forever.
    361 	largest := corpus[0]
    362 	for _, e := range corpus {
    363 		if area(e.Img) > area(largest.Img) {
    364 			largest = e
    365 		}
    366 	}
    367 	if _, err := inst.Encode(ctx, largest.Img, 1.0); err != nil {
    368 		panic(err)
    369 	}
    370 	b := largest.Img.Bounds()
    371 	fmt.Printf("after lossless %dx%-4d:    %6.1f MiB\n", b.Dx(), b.Dy(), mib(inst.MemorySize()))
    372 
    373 	for _, e := range corpus {
    374 		if e.Name == "flat-128" {
    375 			if _, err := inst.Encode(ctx, e.Img, 1.0); err != nil {
    376 				panic(err)
    377 			}
    378 			fmt.Printf("after a subsequent 128x128:   %6.1f MiB (never shrinks)\n", mib(inst.MemorySize()))
    379 		}
    380 	}
    381 }
    382 
    383 func area(m *image.NRGBA) int { return m.Bounds().Dx() * m.Bounds().Dy() }
    384 
    385 func mib(b uint32) float64 { return float64(b) / (1 << 20) }
    386 
    387 func dumpPNGs(corpus []Entry, dir string) error {
    388 	fmt.Println("== corpus ==")
    389 	if *noPNG {
    390 		for _, e := range corpus {
    391 			b := e.Img.Bounds()
    392 			fmt.Printf("%-18s %5dx%-5d %s\n", e.Name, b.Dx(), b.Dy(), e.Desc)
    393 		}
    394 		fmt.Println()
    395 		return nil
    396 	}
    397 	if err := os.MkdirAll(dir, 0o755); err != nil {
    398 		return err
    399 	}
    400 	names := make([]string, 0, len(corpus))
    401 	for _, e := range corpus {
    402 		p := filepath.Join(dir, e.Name+".png")
    403 		f, err := os.Create(p)
    404 		if err != nil {
    405 			return err
    406 		}
    407 		if err := png.Encode(f, e.Img); err != nil {
    408 			f.Close()
    409 			return err
    410 		}
    411 		st, _ := f.Stat()
    412 		f.Close()
    413 		b := e.Img.Bounds()
    414 		fmt.Printf("%-18s %5dx%-5d %7.1f KiB png  %s\n",
    415 			e.Name, b.Dx(), b.Dy(), float64(st.Size())/1024, e.Desc)
    416 		names = append(names, e.Name)
    417 	}
    418 	sort.Strings(names)
    419 	fmt.Printf("written to %s/\n\n", dir)
    420 	return nil
    421 }