Building C libraries as QIP components

QIP C components can use Emscripten's Clang and system libraries without using Emscripten's browser runtime. The final .wasm is the boundary that matters: inspect its imports, exports, memory declaration, and instructions rather than inferring its runtime requirements from the compiler name.

bmp-to-webp-lossy.wasm uses this approach. It statically links libwebp and SharpYUV with Clang and wasm-ld, then runs Binaryen directly. It has no imports, exports only its QIP interface and diagnostics, and does not require JavaScript, WASI, a filesystem, or a dynamically loaded library.

The available build paths #

PathWhat it buysWhat it costs
ZigA compact freestanding toolchain and straightforward QIP ABI controllibwebp's upstream Wasm SIMD route uses Emscripten's SSE compatibility headers, so Zig's scalar build was substantially slower
emcc standaloneThe simplest way to activate libwebp's translated SSE SIMD and select compatible system librariesEmscripten 2.0.34 also exported its function table, errno accessor, and three stack helpers
Clang + wasm-ldExplicit imports, exports, memory, libraries, and post-link passesThe recipe must reproduce the relevant parts of the Emscripten driver correctly

The direct Clang build was selected for bmp-to-webp-lossy.wasm. On the 12 MP photograph used during development it had the same encoding speed and produced byte-identical WebP output as the standalone emcc build. It was about 362 KB instead of 353 KB, but reduced the export surface from 31 entries to the 26 entries requested by the component.

The five removed exports were not browser dependencies:

An export makes an internal value callable by the host. An import is a runtime dependency that the host must provide. The earlier module had no imports and was already a valid QIP component; direct linking gives it a narrower public surface.

What emcc was doing #

Invoking Clang from the Emscripten SDK is not equivalent to replacing emcc with clang in the command line. The driver supplied four relevant behaviors.

First, -msse4.1 is a driver-level compatibility feature for Wasm. Direct Clang 14 ignores that option for a wasm32 target. The QIP recipe explicitly adds Emscripten's include/compat directory and defines the SSE feature macros that select libwebp's SSE2 and SSE4.1 sources. Those headers lower the intrinsics to WebAssembly SIMD.

Second, libwebp's CPU dispatcher checks EMSCRIPTEN, not only __EMSCRIPTEN__, to treat compile-time SIMD support as available at runtime. Omitting that definition produces a valid but effectively scalar module. In the development benchmark it took about 3.1 seconds instead of about 1.8 seconds for 12 MP.

Third, Emscripten's normal optimized memcpy delegates copies of 512 bytes or more to a JavaScript helper. Its standalone fallback deliberately traps if that path is reached. Compiling and linking with -mbulk-memory lets LLVM lower memcpy and memset operations to WebAssembly memory.copy and memory.fill instead. This removes the JavaScript path without maintaining a replacement C copy loop. The final libwebp module currently contains 149 memory.copy and 95 memory.fill instructions; QIP's strict profile accepts both bounded-memory operations.

Finally, emcc chose and prepared its musl-derived C library, compiler runtime, and standalone support library, then ran Binaryen. The Makefile now makes those steps explicit:

There is no direct-Clang equivalent of -sFILESYSTEM=0: that option controls Emscripten's generated runtime. The direct build never links that runtime. A zero-entry import section is the stronger artifact-level check.

What LTO can remove #

The lossy component fixes config.lossless to zero, but libwebp's public WebPEncode dispatcher receives a pointer and is large enough that ordinary LTO does not specialize the call. Against the current build, replacing that branch with a compile-time lossy path reduced the optimized artifact from 324,820 to 316,614 bytes (2.5%). Opaque and transparent outputs remained byte-identical, with no consistent speed difference. An earlier experiment that raised LLVM's inline threshold to encourage constant propagation made its build about 32% larger instead.

Most VP8L code in the alpha-preserving lossy module is not dead. Lossy WebP stores transparency in a separately lossless-compressed alpha plane. That component keeps the public dispatcher because carrying a specialization for a 9 KB saving would add maintenance without changing its contract.

The separate opaque component makes a different tradeoff. It composites declared BMP alpha before encoding, defines WEBP_OPAQUE_ONLY around the lossless dispatcher branch, and replaces alpha_enc.c with four fixed opaque entry points. LTO can then remove VP8L and its lossless DSP, producing a 179,625-byte module instead of the current 324,920 bytes. The guarded libwebp source change does not affect ordinary builds; its larger size and memory saving justify the small vendored patch for this deliberately narrower contract.

Reproducing the build #

The compiler is pinned because Emscripten 2.0.34/Clang 14 generated materially faster libwebp Wasm SIMD than the newer Emscripten version tested during this work.

mise install emsdk@2.0.34
make -j components/image/bmp/bmp-to-webp-lossy.wasm
make -j components/image/bmp/bmp-to-webp-lossy-opaque.wasm

The Makefile locates the SDK through mise, prepares the LTO sysroot libraries, and keeps intermediate objects in /private/tmp on macOS or /tmp elsewhere. Preparing a fresh cache took about one minute on the development machine; subsequent builds reuse it.

Check the result rather than relying on build flags alone:

./qip comply components/image/bmp/bmp-to-webp-lossy.wasm
wasm-objdump -x components/image/bmp/bmp-to-webp-lossy.wasm
node --test test/bmp-webp.mjs test/qip-wasm-policy.mjs

For a freestanding algorithm that does not need Emscripten's translated SIMD headers or system libraries, Zig or a normal freestanding Clang build is less fragile. Use the explicit Emscripten-Clang path when an upstream C library's performance depends on those facilities and the narrower final Wasm surface is worth maintaining the additional linker detail.