# libflame → WebAssembly Compiling [libflame](https://github.com/flame/libflame) (dense linear algebra / LAPACK) to WASM with Emscripten. **Status: working.** The static library builds, links, and passes numerical tests under Node. ## Layout - `build-all-wasm.sh` — builds every artifact downstream projects need (see below) - `build-wasm.sh` — end-to-end reproducible libflame build (clone → patch → configure → make → install) - `build-blis-wasm.sh` — builds BLIS (generic config) to WASM as a fast BLAS backend - `libflame/` — upstream clone, re-cloned + patched by `build-wasm.sh` (gitignored) - `blis/` — upstream BLIS clone, re-cloned + patched by `build-blis-wasm.sh` (gitignored) - `install/` — built artifacts: `lib/libflame.a` (~15 MB, 4812 objects) and a single flattened `include/FLAME.h` (gitignored) - `demo/` — test program exercising the native FLAME/C API (`FLA_Chol`) and the LAPACK compatibility layer (`dgesv_`) - `bench/` — benchmark comparing the WASM build against native OpenBLAS (same source compiled both ways) - `web/` — browser benchmark page (see "Running in the browser") Only source (the build scripts, `*.c`, `web/*`, `demo/*.c`, this README) is committed; the upstream clones and all build outputs are gitignored and regenerated by the scripts. ## Build `build-wasm.sh` alone builds single-threaded libflame. To build **every** WASM static library downstream projects link against — used e.g. by [concept-collection/matmul-bench](https://github.com/concept-collection/matmul-bench), which clones this repo in CI and runs it — use: ```sh ./build-all-wasm.sh ``` which produces (building libflame and BLIS both single- and multi-threaded): - `install/lib/libflame.a` — single-threaded libflame - `install/lib/libflame-mt.a` — libflame compiled with `-pthread` - `install/lib/libblis-st.a` — single-threaded BLIS - `blis/lib/generic/libblis.a` — BLIS compiled with pthreads This is slow (libflame is compiled twice); downstream CI should cache the outputs keyed on this repo's commit SHA. For just the single-threaded libflame: ```sh ./build-wasm.sh ``` ## Demo ```sh cd demo emcc demo.c -I ../install/include ../install/lib/libflame.a -O2 -o demo.js node demo.js ``` Output: ``` libflame WASM demo FLA_Chol: L(0,0) = 2.039608 (expected 2.039608) OK dgesv_: info = 0, x = [0.800000, 1.400000] (expected [0.8, 1.4]) OK ALL TESTS PASSED ``` The linked demo (`FLA_Init` + Cholesky + `dgesv_` and their transitive dependencies) comes out to ~1 MB of wasm — dead code elimination keeps only what you call. ## What was needed to make it work 1. **No Fortran.** Emscripten has no Fortran compiler, so configure runs with `--disable-autodetect-f77-*`. The build stays all-C: `--enable-builtin-blas` uses libflame's f2c-translated reference BLAS, and `--enable-lapack2flame --enable-legacy-lapack` adds a complete LAPACK API from f2c'd C sources. 2. **No `--host` triple.** The bundled `config.sub` predates wasm targets and rejects `wasm32-unknown-emscripten`. Setting `CC=emcc` (via `emconfigure`) is sufficient; configure even recognizes `emcc` as a compiler vendor — though it doesn't know its optimization flags, so `-O2` is injected into `config.mk` after configure. 3. **`void` vs `int` prototype mismatch (the real WASM blocker).** libflame's internal headers declare Fortran BLAS routines as returning `void`, while the f2c'd built-in BLAS defines them returning `int`. On native targets this ABI mismatch is silently harmless; on WebAssembly call sites and definitions must agree exactly, so the module **fails wasm validation at link time** (`wasm-ld: function signature mismatch` → `wasm-validator error`). Fix: a one-line sed changing the 78 `void F77_*` prototypes to `int` in `src/base/flamec/blis/include/blis_prototypes_blas.h`. 4. **Stale-archive quirk.** The Makefile's `--enable-max-arg-list-hack` archiving appends object paths to `ar_obj_list` per compile, so `make` after an incremental rebuild can produce a stale or even empty `libflame.a`. The build script re-archives from the full `obj/` tree with `emar crs`. ## Performance vs native OpenBLAS `bench/bench.c` runs `dgemm`, `dpotrf`, and `dgetrf` through the same Fortran-style interface in all builds (12-core machine, OpenBLAS 0.3.29, emcc 5.0.4 under Node 24; results numerically identical across builds). GFLOP/s: | routine | n | WASM f2c BLAS | WASM BLIS | OpenBLAS 1 thread | OpenBLAS 12 threads | |---------|------|---------------|-----------|-------------------|---------------------| | dgemm | 2000 | 3.0 | 11.7 | 46.8 | 90.1 | | dgemm | 4000 | 1.8 | 12.1 | 53.5 | 183.0 | | dpotrf | 2000 | 3.9 | 10.9 | 44.3 | 8.2* | | dpotrf | 4000 | 4.2 | 11.2 | 51.3 | 142.5 | | dgetrf | 2000 | 3.9 | 10.1 | 40.8 | 58.0 | | dgetrf | 4000 | 4.2 | 10.0 | 48.3 | 71.0 | \* multithreaded numbers at smaller sizes are noisy (thread-pool warmup). Takeaways: - With the built-in f2c BLAS, the WASM build runs at ~1.8–4.2 GFLOP/s — the f2c reference BLAS is scalar C with no SIMD or cache blocking, and reference `dgemm` collapses at n=4000 when the working set falls out of cache (libflame's blocked factorizations hold ~4.2 even then). - Swapping in **WASM-built BLIS** (generic C kernels + `-msimd128`) lifts everything to **~10–12 GFLOP/s** — a 2.5–7× improvement, now only **4–5× slower than single-threaded** native OpenBLAS (and ~7–17× slower than all 12 cores, which WASM can't use single-threaded). - Concretely, at n=4000: LU factorization takes 4.3 s (was 10.2 s with f2c), and dgemm takes 10.6 s (was 73 s), vs 0.9 s / 2.4 s native single-threaded. ## Using BLIS as the BLAS backend `./build-blis-wasm.sh` builds `blis/lib/generic/libblis.a`. Then link it **before** `libflame.a` — the linker resolves every BLAS symbol from BLIS and libflame's f2c BLAS members are never pulled in (they remain as fallback for the banded/packed level-2 routines BLIS doesn't provide): ```sh emcc app.c -I install/include blis/lib/generic/libblis.a install/lib/libflame.a \ -sALLOW_MEMORY_GROWTH -o app.js ``` (`-sALLOW_MEMORY_GROWTH` is required: BLIS allocates memory pools beyond the default 16 MB heap.) ### Threading (wasm pthreads) Building both libraries with `-pthread` and BLIS with `--enable-threading=pthreads` enables real multithreading via Web Workers + SharedArrayBuffer: ```sh PTHREAD=1 ./build-wasm.sh # → install/lib/libflame-mt.a THREADING=pthreads ./build-blis-wasm.sh # → blis/lib/generic/libblis.a (mt) emcc -O2 -pthread app.c blis/lib/generic/libblis.a install/lib/libflame-mt.a \ -sPTHREAD_POOL_SIZE=14 -sINITIAL_MEMORY=1024MB -o app.js ``` Every object linked into a shared-memory wasm module must be compiled with `-pthread` (atomics + bulk-memory), hence the libflame rebuild. Set the thread count at runtime with `bli_thread_set_num_threads(n)` (or `BLIS_NUM_THREADS`). GFLOP/s at n=4000 under Node (12-core machine): | routine | 1t | 4t | 8t | 12t | |---------|------|------|------|------| | dgemm | 12.1 | 31.0 | 46.1 | 33.2 | | dpotrf | 11.2 | 23.3 | 29.3 | 5.9 | | dgetrf | 10.0 | 22.7 | 29.1 | 3.4 | - **8 threads is the sweet spot**: threaded WASM `dgemm` (46 GFLOP/s) matches *single-threaded native OpenBLAS* (53.5), and the factorizations land within ~1.7× of it — dgetrf is just 2.4× off native OpenBLAS using all 12 cores. - Full thread counts (12) collapse, badly for the factorizations: BLIS spawns/joins threads per BLAS call, wasm worker scheduling is expensive, and the main thread + 12 workers oversubscribe 12 cores. Leave headroom. - Browser deployment requires cross-origin isolation (COOP/COEP headers) for SharedArrayBuffer; Node needs nothing special. Making BLIS coexist with libflame under WASM's exact-signature rules required patches (all scripted in `build-blis-wasm.sh`): 1. BLAS interface functions flipped from `void` to `int` returns (f2c convention, matching every declaration in libflame's f2c code). Bare `return;` → `return 0;` (a hard error in C23). 2. BLIS's f2c-derived compat sources (banded/packed level-2, `lsame_`, `xerbla_`) deleted — libflame already provides them, and BLIS's versions use 4-arg `lsame_` / 3-arg `xerbla_` (hidden Fortran string lengths) while libflame's 1,000+ call sites use the 2-arg form. BLIS's internal calls had the `(ftnlen)` args stripped to match. 3. `defined(EMSCRIPTEN)` → `defined(__EMSCRIPTEN__)` in `bli_system.h` (BLIS has an Emscripten branch, but tests the obsolete macro name). 4. Built with `CC_VENDOR=clang` (the generic config rejects the "emcc" vendor string; emcc is clang underneath). ## Running in the browser `web/` contains an interactive benchmark page: ```sh cd web ./build-web.sh # builds dist/bench_st.{js,wasm} and dist/bench_mt.{js,wasm} python3 serve.py # serves on http://localhost:8123 with COOP/COEP headers ``` Then open . Pick build (single-threaded / pthreads), routine, matrix size, and thread count; results accumulate in a table. Notes: - The threaded build needs cross-origin isolation (SharedArrayBuffer), which is why `serve.py` sets `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp`. The page shows a `crossOriginIsolated` badge; without isolation it falls back to the single-threaded build. - The benchmark runs in a Web Worker, so the UI stays responsive; the threaded build spawns its pthread workers from that worker (nested workers — fine in Chrome/Firefox, may fail in older Safari). - The threaded module reserves 1 GB of shared memory up front; the first threaded run includes worker-pool startup cost, so run twice for steady-state numbers. Measured in-browser results (Chrome-family, same 12-core machine, n=2000, GFLOP/s) — essentially identical to Node, and threaded dgemm **beats single-threaded native OpenBLAS** (46.8): | routine | browser 1t | browser 8t | native OpenBLAS 1T | |---------|------------|------------|--------------------| | dgemm | 12.7 | 50.3 | 46.8 | | dpotrf | 10.9 | 21.9 | 44.3 | | dgetrf | 10.2 | 19.4 | 40.8 | ## Caveats - Single-threaded (no `--enable-multithreading`); SuperMatrix and SSE intrinsics disabled. BLIS built with `--disable-threading`. - wasm32: 32-bit `int`/pointers, 4 GB memory ceiling. LAPACK integer arguments are C `int` (LP32-compatible). - Two archive members define `lsame_` upstream; the manual re-archive keeps one (they're the same trivial routine).