/ concept-collection / libflame2wasm
Sign in
concept-collection / libflame2wasm
libflame2wasm
Go to fileHistoryFork
benchlibflame + BLIS compiled to WebAssembly
demolibflame + BLIS compiled to WebAssembly
weblibflame + BLIS compiled to WebAssembly
.gitignorelibflame + BLIS compiled to WebAssembly
build-all-wasm.shlibflame + BLIS compiled to WebAssembly
build-blis-wasm.shlibflame + BLIS compiled to WebAssembly
build-wasm.shlibflame + BLIS compiled to WebAssembly
README.mdlibflame + BLIS compiled to WebAssembly

libflame → WebAssembly#

Compiling 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, which clones this repo in CI and runs it — use:

./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:

./build-wasm.sh

Demo#

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 mismatchwasm-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):

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:

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:

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 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.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).
moveopenescclose