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:
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")