Skip to content

Latest commit

 

History

13 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

JS and wasm BLAKE3 versus WebCrypto sha256

Optimisations

src/blake3-fast.js is generated by tools/gen-fast.py, applying the techniques from https://parsa.wtf/blake3/ that need code generation:

  • all 7 rounds emitted straight-line, with the message permutation resolved at generation time, so the running code never permutes anything
  • message words read through a Uint32Array view, one load per word, instead of four byte loads and three shifts
  • the whole 16 block loop of a chunk inside one function, so nothing is passed and the chaining value stays in locals from a chunk's first block to its last
  • no allocation per chunk: one reused scratch buffer for 4096 chunk CVs

Generated offline and committed, not new Function() at load time: runtime code generation is exactly what would force unsafe-eval into the CSP, and staying out of the CSP is one of pure JS's two advantages.

Firefox runs this code about 3x slower than Chromium. Eight shapes of the same algorithm were generated and measured to find out why, and the JIT was profiled. None closed the gap. The generator still emits them all, so the comparison can be re-run:

shape what changes Firefox Chromium
chunkCVs (used) 7 rounds unrolled, message words in locals 249 MiB/s 789 MiB/s
chunkCVsB2 + the 16 block loop unrolled by 2 249 798
chunkCVsB4 + unrolled by 4 250 648
chunkCVsMem message words read from the array at each use 249 763
chunkCVsX2 two chunks interleaved, to break the dependency chain 229 790
chunkCVsLoop one round in a loop of 7, permutation by variable swaps 198 298
chunkCVsSmall the rounds behind a per-block function call 193 170
chunkCVsBlocks16 the whole chunk, 16 blocks x 7 rounds, straight-line 4 4

tools/profile.py runs the Gecko profiler over a long single-threaded run and reports the JIT tier of every frame. What it shows:

  • The code is fully Ion compiled: 99.7% of samples in the working shapes are Ion frames, 0.3% baseline. So the gap is not a tiering, bailout or deoptimisation problem - Firefox is running optimised machine code and that code is simply ~3x slower.
  • chunkCVsBlocks16 falls off a cliff into baseline: 99.1% of its samples are baseline frames, because the function is too large for the optimising JIT to take. Both engines do this, at 60x the cost. Any generated code has a size limit worth knowing about, and the shapes above sit under it.
  • No single operation explains it. bench/micro.html times the integer operations in isolation: Firefox is ~2x slower on dependent add and xor chains, ~1.4x on the rotate idiom - and faster than Chromium on a g() mix (654 vs 473 Mops/s). The 3x only appears in the full function.

Switching the typed arrays from Uint32Array to Int32Array, so no loaded word is ever above 2^31 and has to become a double, was within noise. So the remaining difference is in how Ion and TurboFan compile this particular shape of integer code at scale, and no rearrangement of the JavaScript found a way round it.

Measured

Every row hashes 4 MiB, so latencies compare directly as well as rates. Run to run these vary by about 10%.

1 worker, 4 MiB Firefox 155 Chromium Android WebView 151
WebCrypto SHA-256 (the bar) 5.1 ms - 781 MiB/s 4.4 ms - 901 MiB/s 11.7 ms - 427 MiB/s
WebCrypto SHA-512 11.2 ms - 357 MiB/s 8.3 ms - 483 MiB/s 9.8 ms - 510 MiB/s
wasm, wasm32_simd 2.1 ms - 1905 MiB/s 2.2 ms - 1798 MiB/s 2.8 ms - 1429 MiB/s
wasm, portable scalar 7.0 ms - 573 MiB/s 5.7 ms - 703 MiB/s 5.7 ms - 702 MiB/s
wasm, +simd128 flag only 6.8 ms - 587 MiB/s 5.9 ms - 677 MiB/s 5.6 ms - 714 MiB/s
wasm kernel only, no copy 6.9 ms - 580 MiB/s 5.8 ms - 690 MiB/s 5.5 ms - 727 MiB/s
wasm copy into linear memory alone 0.1 ms 0.1 ms 0.1 ms
optimised JS 16.0 ms - 249 MiB/s 5.0 ms - 798 MiB/s 6.2 ms - 645 MiB/s
readable JS 30.1 ms - 133 MiB/s 33.8 ms - 118 MiB/s 43.7 ms - 92 MiB/s

The Android numbers are an x86_64 emulator on this desktop, not a phone: an emulated Android runs the host's cores, so treat it as "the WebView engine on this hardware", which is what isolates the engine from the phone. A real device will be slower in proportion to its cpu, and the ratios are what carry over.

Two things carry over from it regardless. WebView is the one engine where the optimised JS beats the sha256 it would replace (645 vs 427 MiB/s, 1.5x), because Chromium's WebCrypto sha256 on this platform is half the speed of the desktop one while V8's JIT is not. And the wasm build is 3.3x sha256 there, the widest margin of the three.

At 5 MiB sha256 measures 814 MiB/s (Firefox) and 1033 (Chromium), so the rate hardly moves with input size.

aggregate across workers, 4 MiB each Firefox 155 Chromium
wasm simd x2 / x4 / x16 2186 / 4051 / 8193 MiB/s 3256 / 5626 / 9739 MiB/s
optimised JS x2 / x4 / x16 386 / 730 / 1182 MiB/s 679 / 1281 / 2154 MiB/s
sha256 x2 / x4 / x16 1405 / 2602 / 3459 MiB/s 1891 / 3061 / 5804 MiB/s

Artefact sizes, which land in the initial page load: wasm 12 KB scalar, 14 KB with +simd128, 29 KB with wasm32_simd; the generated JS is 1566 lines, ~60 KB unminified.

What is here

  • wasm/ — a small Rust crate wrapping the reference blake3 crate, built for wasm32-unknown-unknown, exposing the subtree entry points: subtree_cv (which is set_input_offset + update + finalize_non_root), merge_non_root, merge_root, plus hash_all for the vectors. Deliberately not wasm-bindgen: one exported memory and a preallocated input buffer, so the artefact is small, there is no generated JS glue, and the copy into linear memory stays visible to the benchmark.
  • src/blake3-fast.jsgenerated by tools/gen-fast.py: the optimised pure JS candidate, fast only for the shape that matters here: whole, power-of-two, aligned runs of chunks. Do not edit it; edit the generator.
  • src/blake3.js — the readable reference: hash (one-shot, what b3sum prints) plus the same subtree API — subtreeCV, mergeNonRoot, mergeRoot. Written with the V8 techniques from https://parsa.wtf/blake3/ that cost nothing in readability: state in locals rather than arrays, straight-line rounds, permutation by renaming, no allocation per block. Not the generated-code or SIMD versions from that write-up.
  • test/tests.mjs — the 35 official BLAKE3 vectors, the subtree-rebuild property at several sizes including the 4 MiB chunk shape, and rejection of misaligned subtrees.
  • test/wasm.html — the same vectors through each wasm build, and every wasm subtree call and merge checked against the JS implementation.
  • test/b3sum.mjs — the claim checked against the reference binary.
  • bench/ — the benchmark page, worker, wasm loader, and the built .wasm files.
  • tools/browser.py — runs a page headless in Firefox (Marionette, no driver needed) or Chromium (chromedriver) and prints what it produced.
  • tools/crosscheck.py — the b3sum comparison, driven through a browser.
  • tools/fuzz.py — random data at random lengths through every implementation, with b3sum as the oracle for whole-input hashes and the readable reference as the oracle for subtree chaining values. Lengths are weighted towards block and chunk boundaries.
  • tools/profile.py — runs the Gecko profiler over a long single-threaded run and reports the JIT tier of every frame.
  • tools/android.py — runs a page in the real Android WebView. There is no marionette or chromedriver for a phone's WebView, so the page posts its results back to the server instead, reached over adb reverse. --build builds and installs the tiny host app in tools/wvbench straight from the SDK build tools: no gradle, no network.

Running it

python3 tools/browser.py firefox test        # js tests, in a browser
python3 tools/browser.py firefox wasm        # wasm vs vectors and vs the js impl
python3 tools/browser.py chromium bench      # the numbers above
python3 tools/crosscheck.py firefox          # vs the b3sum binary
python3 tools/fuzz.py 150 firefox            # fuzz everything, random seed
python3 tools/fuzz.py 80 20260918 chromium   # ... or a fixed one, as CI does
python3 tools/profile.py fast 8              # what the firefox jit did with it

# android webview, against a booted emulator or a usb device
$ANDROID_HOME/emulator/emulator -avd <avd> -no-window -gpu swiftshader_indirect &
python3 tools/android.py wasm --build        # correctness, building the host app first
python3 tools/android.py bench               # the numbers
node test/node.mjs                           # js tests, no browser
node test/b3sum.mjs                          # vs b3sum, no browser

# rebuilding the wasm (needs: rustup target add wasm32-unknown-unknown)
cd wasm && RUSTFLAGS="-C target-feature=+simd128" \
  cargo build --release --target wasm32-unknown-unknown
cp target/wasm32-unknown-unknown/release/blake3_wasm.wasm ../bench/wasm/blake3-simdfeature.wasm

Verified

  • All 35 official BLAKE3 test vectors pass, in Firefox and Chromium, for the JS implementations and for all three wasm builds (153 assertions in total).
  • Every wasm subtree CV and merge agrees with the JS implementation, at several chunk indices including a 4 MiB subtree at offset 4 MiB.
  • b3sum agreement on random files at 0, 1, 1023, 1024, 1025, 4 MiB−1, 4 MiB, 4 MiB+1, 8 MiB and 10 MiB bytes.
  • All 207 cross-implementation assertions pass in the Android WebView too.
  • Fuzzing: 5677 checks over 168 MiB of random data at random lengths, in Firefox and Chromium, with zero failures - every shape, every wasm build, whole-input hashes against b3sum and subtree chaining values against the reference. The fuzzer was itself checked by injecting a single wrong xor into one shape, which it caught in 21 of 305 checks while reporting nothing against the others.
  • The property the whole approach rests on: an 8 MiB file hashed as two 4 MiB subtree chaining values and merged gives the same hash b3sum prints. That is what makes the stored root hash the file's real BLAKE3 hash rather than a tree of one's own.

About

A fast pure JS blake3 impl

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages