1# libflame → WebAssembly
3Compiling [libflame](https://github.com/flame/libflame) (dense linear algebra /
4LAPACK) to WASM with Emscripten. **Status: working.** The static library builds,
5links, and passes numerical tests under Node.
7## Layout
9- `build-all-wasm.sh` — builds every artifact downstream projects need (see below)
10- `build-wasm.sh` — end-to-end reproducible libflame build (clone → patch → configure → make → install)
11- `build-blis-wasm.sh` — builds BLIS (generic config) to WASM as a fast BLAS backend
12- `libflame/` — upstream clone, re-cloned + patched by `build-wasm.sh` (gitignored)
13- `blis/` — upstream BLIS clone, re-cloned + patched by `build-blis-wasm.sh` (gitignored)
14- `install/` — built artifacts: `lib/libflame.a` (~15 MB, 4812 objects) and a
15 single flattened `include/FLAME.h` (gitignored)
16- `demo/` — test program exercising the native FLAME/C API (`FLA_Chol`) and the
17 LAPACK compatibility layer (`dgesv_`)
18- `bench/` — benchmark comparing the WASM build against native OpenBLAS
19 (same source compiled both ways)
20- `web/` — browser benchmark page (see "Running in the browser")
22Only source (the build scripts, `*.c`, `web/*`, `demo/*.c`, this README) is
23committed; the upstream clones and all build outputs are gitignored and
24regenerated by the scripts.
26## Build
28`build-wasm.sh` alone builds single-threaded libflame. To build **every** WASM
29static library downstream projects link against — used e.g. by
30[concept-collection/matmul-bench](https://github.com/concept-collection/matmul-bench),
31which clones this repo in CI and runs it — use:
33```sh
34./build-all-wasm.sh
35```
37which produces (building libflame and BLIS both single- and multi-threaded):
39- `install/lib/libflame.a` — single-threaded libflame
40- `install/lib/libflame-mt.a` — libflame compiled with `-pthread`
41- `install/lib/libblis-st.a` — single-threaded BLIS
42- `blis/lib/generic/libblis.a` — BLIS compiled with pthreads
44This is slow (libflame is compiled twice); downstream CI should cache the outputs
45keyed on this repo's commit SHA. For just the single-threaded libflame:
47```sh
48./build-wasm.sh
49```
51## Demo
53```sh
54cd demo
55emcc demo.c -I ../install/include ../install/lib/libflame.a -O2 -o demo.js
56node demo.js
57```
59Output:
61```
62libflame WASM demo
63FLA_Chol: L(0,0) = 2.039608 (expected 2.039608) OK
64dgesv_: info = 0, x = [0.800000, 1.400000] (expected [0.8, 1.4]) OK
65ALL TESTS PASSED
66```
68The linked demo (`FLA_Init` + Cholesky + `dgesv_` and their transitive
69dependencies) comes out to ~1 MB of wasm — dead code elimination keeps only
70what you call.
72## What was needed to make it work
741. **No Fortran.** Emscripten has no Fortran compiler, so configure runs with
75 `--disable-autodetect-f77-*`. The build stays all-C:
76 `--enable-builtin-blas` uses libflame's f2c-translated reference BLAS, and
77 `--enable-lapack2flame --enable-legacy-lapack` adds a complete LAPACK API
78 from f2c'd C sources.
802. **No `--host` triple.** The bundled `config.sub` predates wasm targets and
81 rejects `wasm32-unknown-emscripten`. Setting `CC=emcc` (via `emconfigure`)
82 is sufficient; configure even recognizes `emcc` as a compiler vendor —
83 though it doesn't know its optimization flags, so `-O2` is injected into
84 `config.mk` after configure.
863. **`void` vs `int` prototype mismatch (the real WASM blocker).** libflame's
87 internal headers declare Fortran BLAS routines as returning `void`, while
88 the f2c'd built-in BLAS defines them returning `int`. On native targets this
89 ABI mismatch is silently harmless; on WebAssembly call sites and definitions
90 must agree exactly, so the module **fails wasm validation at link time**
91 (`wasm-ld: function signature mismatch` → `wasm-validator error`). Fix: a
92 one-line sed changing the 78 `void F77_*` prototypes to `int` in
93 `src/base/flamec/blis/include/blis_prototypes_blas.h`.
954. **Stale-archive quirk.** The Makefile's `--enable-max-arg-list-hack`
96 archiving appends object paths to `ar_obj_list` per compile, so `make` after
97 an incremental rebuild can produce a stale or even empty `libflame.a`. The
98 build script re-archives from the full `obj/` tree with `emar crs`.
100## Performance vs native OpenBLAS
102`bench/bench.c` runs `dgemm`, `dpotrf`, and `dgetrf` through the same
103Fortran-style interface in all builds (12-core machine, OpenBLAS 0.3.29,
104emcc 5.0.4 under Node 24; results numerically identical across builds).
105GFLOP/s:
107| routine | n | WASM f2c BLAS | WASM BLIS | OpenBLAS 1 thread | OpenBLAS 12 threads |
108|---------|------|---------------|-----------|-------------------|---------------------|
109| dgemm | 2000 | 3.0 | 11.7 | 46.8 | 90.1 |
110| dgemm | 4000 | 1.8 | 12.1 | 53.5 | 183.0 |
111| dpotrf | 2000 | 3.9 | 10.9 | 44.3 | 8.2* |
112| dpotrf | 4000 | 4.2 | 11.2 | 51.3 | 142.5 |
113| dgetrf | 2000 | 3.9 | 10.1 | 40.8 | 58.0 |
114| dgetrf | 4000 | 4.2 | 10.0 | 48.3 | 71.0 |
116\* multithreaded numbers at smaller sizes are noisy (thread-pool warmup).
118Takeaways:
120- With the built-in f2c BLAS, the WASM build runs at ~1.8–4.2 GFLOP/s — the
121 f2c reference BLAS is scalar C with no SIMD or cache blocking, and reference
122 `dgemm` collapses at n=4000 when the working set falls out of cache
123 (libflame's blocked factorizations hold ~4.2 even then).
124- Swapping in **WASM-built BLIS** (generic C kernels + `-msimd128`) lifts
125 everything to **~10–12 GFLOP/s** — a 2.5–7× improvement, now only **4–5×
126 slower than single-threaded** native OpenBLAS (and ~7–17× slower than all
127 12 cores, which WASM can't use single-threaded).
128- Concretely, at n=4000: LU factorization takes 4.3 s (was 10.2 s with f2c),
129 and dgemm takes 10.6 s (was 73 s), vs 0.9 s / 2.4 s native single-threaded.
131## Using BLIS as the BLAS backend
133`./build-blis-wasm.sh` builds `blis/lib/generic/libblis.a`. Then link it
134**before** `libflame.a` — the linker resolves every BLAS symbol from BLIS and
135libflame's f2c BLAS members are never pulled in (they remain as fallback for
136the banded/packed level-2 routines BLIS doesn't provide):
138```sh
139emcc app.c -I install/include blis/lib/generic/libblis.a install/lib/libflame.a \
140 -sALLOW_MEMORY_GROWTH -o app.js
141```
143(`-sALLOW_MEMORY_GROWTH` is required: BLIS allocates memory pools beyond the
144default 16 MB heap.)
146### Threading (wasm pthreads)
148Building both libraries with `-pthread` and BLIS with
149`--enable-threading=pthreads` enables real multithreading via Web Workers +
150SharedArrayBuffer:
152```sh
153PTHREAD=1 ./build-wasm.sh # → install/lib/libflame-mt.a
154THREADING=pthreads ./build-blis-wasm.sh # → blis/lib/generic/libblis.a (mt)
156emcc -O2 -pthread app.c blis/lib/generic/libblis.a install/lib/libflame-mt.a \
157 -sPTHREAD_POOL_SIZE=14 -sINITIAL_MEMORY=1024MB -o app.js
158```
160Every object linked into a shared-memory wasm module must be compiled with
161`-pthread` (atomics + bulk-memory), hence the libflame rebuild. Set the thread
162count at runtime with `bli_thread_set_num_threads(n)` (or `BLIS_NUM_THREADS`).
163GFLOP/s at n=4000 under Node (12-core machine):
165| routine | 1t | 4t | 8t | 12t |
166|---------|------|------|------|------|
167| dgemm | 12.1 | 31.0 | 46.1 | 33.2 |
168| dpotrf | 11.2 | 23.3 | 29.3 | 5.9 |
169| dgetrf | 10.0 | 22.7 | 29.1 | 3.4 |
171- **8 threads is the sweet spot**: threaded WASM `dgemm` (46 GFLOP/s) matches
172 *single-threaded native OpenBLAS* (53.5), and the factorizations land within
173 ~1.7× of it — dgetrf is just 2.4× off native OpenBLAS using all 12 cores.
174- Full thread counts (12) collapse, badly for the factorizations: BLIS
175 spawns/joins threads per BLAS call, wasm worker scheduling is expensive, and
176 the main thread + 12 workers oversubscribe 12 cores. Leave headroom.
177- Browser deployment requires cross-origin isolation (COOP/COEP headers) for
178 SharedArrayBuffer; Node needs nothing special.
180Making BLIS coexist with libflame under WASM's exact-signature rules required
181patches (all scripted in `build-blis-wasm.sh`):
1831. BLAS interface functions flipped from `void` to `int` returns (f2c
184 convention, matching every declaration in libflame's f2c code). Bare
185 `return;` → `return 0;` (a hard error in C23).
1862. BLIS's f2c-derived compat sources (banded/packed level-2, `lsame_`,
187 `xerbla_`) deleted — libflame already provides them, and BLIS's versions
188 use 4-arg `lsame_` / 3-arg `xerbla_` (hidden Fortran string lengths) while
189 libflame's 1,000+ call sites use the 2-arg form. BLIS's internal calls had
190 the `(ftnlen)` args stripped to match.
1913. `defined(EMSCRIPTEN)` → `defined(__EMSCRIPTEN__)` in `bli_system.h`
192 (BLIS has an Emscripten branch, but tests the obsolete macro name).
1934. Built with `CC_VENDOR=clang` (the generic config rejects the "emcc" vendor
194 string; emcc is clang underneath).
196## Running in the browser
198`web/` contains an interactive benchmark page:
200```sh
201cd web
202./build-web.sh # builds dist/bench_st.{js,wasm} and dist/bench_mt.{js,wasm}
203python3 serve.py # serves on http://localhost:8123 with COOP/COEP headers
204```
206Then open <http://localhost:8123>. Pick build (single-threaded / pthreads),
207routine, matrix size, and thread count; results accumulate in a table.
209Notes:
210- The threaded build needs cross-origin isolation (SharedArrayBuffer), which
211 is why `serve.py` sets `Cross-Origin-Opener-Policy: same-origin` and
212 `Cross-Origin-Embedder-Policy: require-corp`. The page shows a
213 `crossOriginIsolated` badge; without isolation it falls back to the
214 single-threaded build.
215- The benchmark runs in a Web Worker, so the UI stays responsive; the threaded
216 build spawns its pthread workers from that worker (nested workers — fine in
217 Chrome/Firefox, may fail in older Safari).
218- The threaded module reserves 1 GB of shared memory up front; the first
219 threaded run includes worker-pool startup cost, so run twice for steady-state
220 numbers.
222Measured in-browser results (Chrome-family, same 12-core machine, n=2000,
223GFLOP/s) — essentially identical to Node, and threaded dgemm **beats
224single-threaded native OpenBLAS** (46.8):
226| routine | browser 1t | browser 8t | native OpenBLAS 1T |
227|---------|------------|------------|--------------------|
228| dgemm | 12.7 | 50.3 | 46.8 |
229| dpotrf | 10.9 | 21.9 | 44.3 |
230| dgetrf | 10.2 | 19.4 | 40.8 |
232## Caveats
234- Single-threaded (no `--enable-multithreading`); SuperMatrix and SSE
235 intrinsics disabled. BLIS built with `--disable-threading`.
236- wasm32: 32-bit `int`/pointers, 4 GB memory ceiling. LAPACK integer arguments
237 are C `int` (LP32-compatible).
238- Two archive members define `lsame_` upstream; the manual re-archive keeps one
239 (they're the same trivial routine).