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 }