Building C libraries as QIP components

QIP C components can use Emscripten's Clang and system libraries without using Emscripten's browser runtime. Inspect the final .wasm imports, exports, memory declaration, and instructions. The compiler name does not establish the module's runtime requirements.

The libwebp encoders and the OpenJPEG decoder use this approach. They statically link selected upstream C sources with Clang and wasm-ld, then run Binaryen directly. They have no imports, export only their QIP interfaces and diagnostics, and do 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 supply the SIMD compatibility flags, sysroot libraries, linker limits, exports, and Binaryen features itself

The direct Clang build was selected for bmp-bgra32-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 libwebp build depended on four driver 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.

Treat the upstream build as configuration evidence #

Many C libraries do not ship the configuration headers included by their .c files. CMake or Autoconf generates them after probing the target. A self-contained QIP build can check in those generated headers, but the values must describe wasm32 rather than the developer's machine.

OpenJPEG is a compact example. The vendored opj_config.h records the upstream version. opj_config_private.h deliberately leaves optional platform features undefined for a little-endian, single-threaded wasm32 build. The Makefile also defines MUTEX_stub, matching the fallback selected by OpenJPEG's own CMake rules. This avoids accidentally compiling pthread or Win32 paths merely because the host that prepared the source supports them.

Keep a short record beside the vendored code with:

See third_party/openjpeg-2.5.4/QIP-VENDOR.md for the repository example. Do not copy a native build directory wholesale. Its feature probes, absolute paths, and cached compiler results are inputs from a different target.

Vendor a coherent tree, compile a narrow source set #

Vendoring and linking have different boundaries. Keep enough of the upstream source tree to preserve license context, internal headers, and a reviewable upgrade path. Compile only the closure needed by the component contract.

The JP2 component vendors OpenJPEG's complete src/lib/openjp2 directory, but the Makefile names only its decoder library sources. It does not build the command-line applications or their optional PNG, TIFF, zlib, and LCMS dependencies. This keeps the repository self-contained without turning unused tools and formats into Wasm code or build prerequisites.

Start the source list from the public operation the adapter calls, resolve its library-local symbols, and compare the selected files with the upstream library target so core decoder sources are not missed. Do not remove a source merely because one fixture did not reach it: format features often select code late at runtime. Test a matrix of supported features before treating the set as complete.

Run LTO after source selection to remove unreachable functions inside the selected translation units. Do not compile every optional frontend and dependency and expect LTO to repair the source boundary.

Prefer memory callbacks to a virtual filesystem #

An upstream library may expose both filename helpers and a stream API. Use the stream API for a QIP Content component. The JP2 adapter implements OpenJPEG's read, skip, and seek callbacks over the QIP input buffer, so the decoder never needs FILE, WASI, or Emscripten's virtual filesystem.

Memory callbacks need the same review as a parser:

Do not stub file functions until the link succeeds and assume the path is dead. Use wasm-objdump to verify zero imports, then exercise malformed and truncated inputs so error paths cannot discover a missing host service.

Replace allocation as a complete C contract #

A monotonic bump allocator is attractive in a fixed-memory module, but it is often a poor fit for a decoder. OpenJPEG allocates and frees many temporary buffers and uses realloc; never reclaiming those buffers would size memory for the sum of the whole decode rather than its peak live working set.

The JP2 adapter provides a fixed, reclaiming arena with C-compatible malloc, calloc, realloc, and free. The build disables all four compiler builtins:

-fno-builtin-malloc
-fno-builtin-calloc
-fno-builtin-realloc
-fno-builtin-free

Disabling only malloc and free is incomplete. Without all four flags, the optimizer can rewrite a call or apply assumptions that bypass the adapter's bookkeeping.

A replacement allocator must preserve the behaviors its library relies on: alignment, overflow checks, zero-filled calloc, data preservation across realloc, free(NULL), and a null return on out-of-memory. Test failure paths as well as successful decoding.

Exporting integer telemetry made the fixed budget reviewable without adding host imports. The OpenJPEG component reports peak live bytes, current live bytes, allocation count, largest and failed allocation, frees, and unmatched frees. Its 25 MP fixture used about 303 MiB of the 384 MiB arena and returned to zero live bytes after cleanup.

This is separate from linear-memory reporting. The module declares 640 MiB of fixed Wasm memory because its input, output, arena, stack, and static data must all fit. qip bench reports that host-visible allocation; allocator telemetry reports the smaller amount of arena storage actually live during the decode. Record both when memory is a design constraint. See Benchmarking Components for the measurement workflow.

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-bgra32-to-webp-lossy.wasm
make -j components/image/bmp/bmp-bgra32-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.

Inspect the artifact and exercise its runtime policy:

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

For the OpenJPEG decoder:

make -j components/image/jp2/jp2-to-bmp-bgra32.wasm
./qip comply components/image/jp2/jp2-to-bmp-bgra32.wasm
wasm-objdump -x components/image/jp2/jp2-to-bmp-bgra32.wasm
node --test test/jp2-bmp.mjs

For the MozJPEG encoder, CMake generates wasm32 configuration headers and a static jpeg-static library in the same temporary Emscripten cache. The QIP wrapper replaces allocation, destination I/O, environment lookup, and error reporting; its output is a fixed memory slice rather than a FILE or an Emscripten filesystem stream.

make -j components/image/bmp/bmp-bgra32-to-jpeg-lossy.wasm
./qip comply components/image/bmp/bmp-bgra32-to-jpeg-lossy.wasm
node --test test/bmp-bgra32-jpeg-lossy.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 those facilities produce a measured speed or size improvement. Re-test that improvement after upgrading the SDK or upstream library because the recipe reproduces driver details that can change between releases.