/ concept-collection / libflame2wasm
Sign in
concept-collection / libflame2wasm
libflame2wasm / README.md
10.7 KBPreviewCodeBlameHistoryRaw

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#

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

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:

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

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:

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#

moveopenescclose