go-libwebp

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

commit 4dd33a3a91ea730d6e6721b58508be1d7a6991df
parent d95f8e51faa5bfc46e88f564ae4cf0966f9c4f17
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Sun, 20 Sep 2026 09:07:56 -0300

tools: add wasm build scripts for zig and emscripten

Both produce a wasm32 reactor module that wazero can run, from the vendored
c-lib source, as a single artifact for every GOOS/GOARCH.

build-wasm-emscripten.sh builds the shipped module. Emscripten is required
for SIMD specifically: libwebp has no simd128 DSP backend, so its SSE2 and
SSE4.1 kernels are only reachable through Emscripten's SSE-to-simd128 compat
headers, which wasi-sdk and zig do not ship and clang's own x86 headers
refuse to provide off-x86. It also needs -DEMSCRIPTEN, because libwebp gates
its compile-time CPU feature reporting on that bare macro rather than on
__EMSCRIPTEN__; without it the kernels are compiled but never dispatched.

build-wasm.sh is a zig-only scalar build, kept as the lighter-weight
toolchain and as a control when comparing. Both write comparison artifacts
into bench/lib; installing the shipped module is explicit via INSTALL=1.

Diffstat:
Atools/build-wasm-emscripten.sh | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/build-wasm.sh | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atools/strip-wasm-debug.py | 40++++++++++++++++++++++++++++++++++++++++
3 files changed, 159 insertions(+), 0 deletions(-)

diff --git a/tools/build-wasm-emscripten.sh b/tools/build-wasm-emscripten.sh @@ -0,0 +1,63 @@ +#!/bin/sh +# +# Build libwebp to standalone wasm with Emscripten, in two configurations: +# +# libwebp-em-scalar.wasm no SIMD — a control against the zig/wasi build, +# isolating toolchain codegen from SIMD. +# libwebp-em-simd.wasm libwebp's SSE2/SSE4.1 kernels lowered to simd128. +# +# Emscripten is required for the SIMD build specifically: it ships SSE-to- +# simd128 compat headers in its sysroot. wasi-sdk and zig do not, and clang's +# own emmintrin.h rejects non-x86 targets. +# +# Requires an activated emsdk (source emsdk_env.sh). + +set -eux + +SRC=$(cd "$(dirname "$0")/../c-lib" && pwd) +OUT=${OUT:-$(cd "$(dirname "$0")/.." && pwd)/bench/lib} +mkdir -p "$OUT" + +# INSTALL=1 replaces the checked-in module with the SIMD build. +INSTALLED=$(cd "$(dirname "$0")/.." && pwd)/lib/wasm/webp/libwebp.wasm + +EXPORTS='_WebPEncodeRGBA,_WebPEncodeLosslessRGBA,_WebPDecodeRGBA,_WebPFree,_malloc,_free' + +build() { + name=$1 + shift + obj=$(mktemp -d) + + cd "$SRC" + # The object name has to be derived per item, so the compile runs inside + # sh -c rather than being expanded once by the parent shell. + OBJ=$obj SRC=$SRC EXTRA="$*" \ + ls src/dec/*.c src/enc/*.c src/utils/*.c src/dsp/*.c \ + | OBJ=$obj SRC=$SRC EXTRA="$*" xargs -P "$(nproc)" -I{} \ + sh -c 'emcc -O3 -DNDEBUG -I"$SRC" $EXTRA -c "$1" -o "$OBJ/$(echo "$1" | tr / _).o"' _ {} + + # STANDALONE_WASM drops the JS glue and leaves a module whose only + # imports are wasi_snapshot_preview1, which wazero provides natively. + emcc -O3 -sSTANDALONE_WASM=1 --no-entry \ + -sEXPORTED_FUNCTIONS="$EXPORTS" \ + -sALLOW_MEMORY_GROWTH=1 \ + -sINITIAL_MEMORY=2MB \ + -sSTACK_SIZE=1MB \ + -o "$OUT/$name" "$obj"/*.o + + rm -rf "$obj" +} + +build libwebp-em-scalar.wasm + +# -DEMSCRIPTEN matters: libwebp gates its compile-time CPU feature reporting +# (src/dsp/cpu.c) on that bare macro, not on __EMSCRIPTEN__. Without it +# VP8GetCPUInfo stays NULL and the SIMD kernels never dispatch. +build libwebp-em-simd.wasm -msimd128 -msse2 -msse4.1 -DEMSCRIPTEN + +if [ "${INSTALL:-0}" = "1" ]; then + cp "$OUT/libwebp-em-simd.wasm" "$INSTALLED" + echo "installed $INSTALLED" +fi + +ls -la "$OUT"/*.wasm diff --git a/tools/build-wasm.sh b/tools/build-wasm.sh @@ -0,0 +1,56 @@ +#!/bin/sh +# +# Build libwebp to a single wasm32-wasi reactor module: scalar only, because +# reaching libwebp's SSE kernels on wasm needs Emscripten's compat headers +# (see tools/build-wasm-emscripten.sh, which builds the shipped module). +# +# This is the lighter-weight toolchain and the control for comparing against +# the Emscripten output. It writes a comparison artifact into bench/lib and +# never touches the checked-in lib/wasm/webp/libwebp.wasm. +# +# Requires only `zig` (tested with 0.15.2) — no mingw, no osxcross, no ccgo. + +set -eux + +ZIG=${ZIG:-zig} +HERE=$(cd "$(dirname "$0")" && pwd) +SRC=$HERE/../c-lib +OUT=${OUT:-$HERE/../bench/lib}/libwebp-zig-scalar.wasm +mkdir -p "$(dirname "$OUT")" +OBJ=$(mktemp -d) +trap 'rm -rf "$OBJ"' EXIT + +# The arch-specific dsp translation units are internally guarded by +# WEBP_USE_{SSE2,NEON,MSA,...} and reduce to stubs on wasm, so the whole +# directory can be compiled unconditionally. +# +# Note: no SIMD. Enabling libwebp's SSE2 kernels on wasm needs Emscripten's +# SSE-to-simd128 compat headers, which wasi-sdk and zig do not ship; clang's +# own emmintrin.h rejects non-x86 targets and zig rejects -msse2 outright. +cd "$SRC" +for f in src/dec/*.c src/enc/*.c src/utils/*.c src/dsp/*.c; do + $ZIG cc -target wasm32-wasi -O3 -DNDEBUG -I"$SRC" \ + -c "$f" -o "$OBJ/$(echo "$f" | tr / _).o" +done + +# A reactor exports _initialize instead of _start and stays resident. +# The default 16 MiB wasm stack sets the floor on every instance's linear +# memory; 1 MiB is ample for libwebp and makes instances ~6x cheaper to +# create and far cheaper to pool. +$ZIG cc -target wasm32-wasi -O3 -mexec-model=reactor \ + -Wl,--export=WebPEncodeRGBA \ + -Wl,--export=WebPEncodeLosslessRGBA \ + -Wl,--export=WebPDecodeRGBA \ + -Wl,--export=WebPFree \ + -Wl,--export=malloc \ + -Wl,--export=free \ + -Wl,--gc-sections \ + -Wl,-z,stack-size=1048576 \ + -Wl,--initial-memory=2097152 \ + -o "$OUT.dbg" "$OBJ"/*.o + +# DWARF is ~76% of the module and wazero does not use it. Keep the `name` +# section: it is what turns a trap into a readable frame. +python3 "$HERE/strip-wasm-debug.py" "$OUT.dbg" "$OUT" +rm -f "$OUT.dbg" +ls -la "$OUT" diff --git a/tools/strip-wasm-debug.py b/tools/strip-wasm-debug.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Drop .debug_* custom sections from a wasm module, keeping `name`.""" +import sys + + +def uleb(buf, i): + result = shift = 0 + while True: + byte = buf[i] + i += 1 + result |= (byte & 0x7F) << shift + shift += 7 + if not byte & 0x80: + return result, i + + +def strip(data): + assert data[:4] == b"\0asm", "not a wasm module" + out, i = bytearray(data[:8]), 8 + while i < len(data): + start = i + section_id = data[i] + i += 1 + size, i = uleb(data, i) + keep = True + if section_id == 0: # custom + n, j = uleb(data, i) + keep = not data[j:j + n].decode("utf8", "replace").startswith(".debug") + if keep: + out += data[start:i + size] + i += size + return bytes(out) + + +if __name__ == "__main__": + src, dst = sys.argv[1], sys.argv[2] + data = open(src, "rb").read() + out = strip(data) + open(dst, "wb").write(out) + print(f"{len(data):,} -> {len(out):,} bytes")