Compiling libflame (dense linear algebra / LAPACK) to WASM with Emscripten. Status: working. The static library builds, links, and passes numerical tests under Node.
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 backendlibflame/— upstream clone, re-cloned + patched bybuild-wasm.sh(gitignored)blis/— upstream BLIS clone, re-cloned + patched bybuild-blis-wasm.sh(gitignored)install/— built artifacts:lib/libflame.a(~15 MB, 4812 objects) and a single flattenedinclude/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-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,
which clones this repo in CI and runs it — use:
./build-all-wasm.shwhich produces (building libflame and BLIS both single- and multi-threaded):
install/lib/libflame.a— single-threaded libflameinstall/lib/libflame-mt.a— libflame compiled with-pthreadinstall/lib/libblis-st.a— single-threaded BLISblis/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:
./build-wasm.shcd demo
emcc demo.c -I ../install/include ../install/lib/libflame.a -O2 -o demo.js
node demo.jsOutput:
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.
-
No Fortran. Emscripten has no Fortran compiler, so configure runs with
--disable-autodetect-f77-*. The build stays all-C:--enable-builtin-blasuses libflame's f2c-translated reference BLAS, and--enable-lapack2flame --enable-legacy-lapackadds a complete LAPACK API from f2c'd C sources. -
No
--hosttriple. The bundledconfig.subpredates wasm targets and rejectswasm32-unknown-emscripten. SettingCC=emcc(viaemconfigure) is sufficient; configure even recognizesemccas a compiler vendor — though it doesn't know its optimization flags, so-O2is injected intoconfig.mkafter configure. -
voidvsintprototype mismatch (the real WASM blocker). libflame's internal headers declare Fortran BLAS routines as returningvoid, while the f2c'd built-in BLAS defines them returningint. 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 78void F77_*prototypes tointinsrc/base/flamec/blis/include/blis_prototypes_blas.h. -
Stale-archive quirk. The Makefile's
--enable-max-arg-list-hackarchiving appends object paths toar_obj_listper compile, somakeafter an incremental rebuild can produce a stale or even emptylibflame.a. The build script re-archives from the fullobj/tree withemar crs.
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
dgemmcollapses 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.
./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):
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.)
Building both libraries with -pthread and BLIS with
--enable-threading=pthreads enables real multithreading via Web Workers +
SharedArrayBuffer:
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.jsEvery 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):
- BLAS interface functions flipped from
voidtointreturns (f2c convention, matching every declaration in libflame's f2c code). Barereturn;→return 0;(a hard error in C23). - BLIS's f2c-derived compat sources (banded/packed level-2,
lsame_,xerbla_) deleted — libflame already provides them, and BLIS's versions use 4-arglsame_/ 3-argxerbla_(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. defined(EMSCRIPTEN)→defined(__EMSCRIPTEN__)inbli_system.h(BLIS has an Emscripten branch, but tests the obsolete macro name).- Built with
CC_VENDOR=clang(the generic config rejects the "emcc" vendor string; emcc is clang underneath).
web/ contains an interactive benchmark page:
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 headersThen open http://localhost:8123. 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.pysetsCross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corp. The page shows acrossOriginIsolatedbadge; 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 |
- 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 Cint(LP32-compatible). - Two archive members define
lsame_upstream; the manual re-archive keeps one (they're the same trivial routine).