<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>Frank DENIS random thoughts.</title>
  <link href="https://00f.net/atom.xml" rel="self"/>
  <link href="https://00f.net/"/>
  <updated>2026-08-19T18:06:02+02:00</updated>
  <id>https://00f.net</id>
  
  <author>
    <name>Frank Denis (Jedi/Sector One)</name>
  </author>
  
  
  <entry>
    <title>Why compiling Rust to WebAssembly is slow</title>
    <link href="https://00f.net/2026/08/19/why-compiling-rust-to-webassembly-is-slow/"/>
   <updated>2026-08-19T00:00:00+02:00</updated>
   <id>https://00f.net/2026/08/19/why-compiling-rust-to-webassembly-is-slow</id>
   <content type="html">&lt;p&gt;Compiling Rust to WebAssembly with debug info is slower than it should be. Sometimes unbearably slower.&lt;/p&gt;

&lt;p&gt;For example, here’s a &lt;a href=&quot;https://github.com/dip-proto/rust-wasm-debug-superslow-compile-time&quot;&gt;40-line Rust reproducer&lt;/a&gt; that takes 50 seconds to build with debug info and 1.5 seconds without it.&lt;/p&gt;

&lt;p&gt;This reproducer was reduced from a crate (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ed25519-compact&lt;/code&gt;) where enabling debug info made compilation ~40x slower, but the bug itself is broader and affects all Rust code compiled to WebAssembly to varying degrees.&lt;/p&gt;

&lt;p&gt;This is actually a known LLVM bug that had already been reported and fixed for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;clang&lt;/code&gt;. But the fix is incomplete.&lt;/p&gt;

&lt;h2 id=&quot;debug-info-becomes-records-in-the-instruction-list&quot;&gt;Debug info becomes records in the instruction list&lt;/h2&gt;

&lt;p&gt;Cargo has a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;debug&lt;/code&gt; setting to control debug info. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;debug = 2&lt;/code&gt; asks LLVM for full DWARF information, which very few people use in practice with WebAssembly, but which people like to enable anyway (if only because &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;debug = true&lt;/code&gt; is an alias for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;debug = 2&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;This debugging data is designed for profiling a wasm binary and getting real symbol names in stack traces. It’s also the default for Rust’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dev&lt;/code&gt; profile.&lt;/p&gt;

&lt;p&gt;Something important to understand first: LLVM represents a source variable’s location with a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DBG_VALUE&lt;/code&gt; record.&lt;/p&gt;

&lt;p&gt;The record says that, at this point in the generated code, a variable lives in a register, a stack slot, or a constant. It sits in LLVM’s machine-level intermediate representation, or MIR, and produces no code by itself.&lt;/p&gt;

&lt;p&gt;But it’s relevant when code is moved. A debugger must see the right value, so every pass that moves an instruction has to move or update its &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DBG_VALUE&lt;/code&gt; records too.&lt;/p&gt;

&lt;p&gt;With &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;debug = 2&lt;/code&gt;, heavy inlining can produce hundreds of thousands of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DBG_VALUE&lt;/code&gt; records in one function.&lt;/p&gt;

&lt;p&gt;And when targeting WebAssembly, a lot of code has to be moved.&lt;/p&gt;

&lt;h2 id=&quot;webassembly-has-to-move-values-onto-the-stack&quot;&gt;WebAssembly has to move values onto the stack&lt;/h2&gt;

&lt;p&gt;Unlike native targets, WebAssembly is a stack machine.&lt;/p&gt;

&lt;p&gt;LLVM first generates instructions using named temporary registers, then a backend pass called “Register Stackify” moves values it can use onto the stack near the end of code generation.&lt;/p&gt;

&lt;p&gt;A definition computes a value, while a use consumes it.&lt;/p&gt;

&lt;p&gt;And when a definition has one use, Register Stackify can move that definition immediately before the use.&lt;/p&gt;

&lt;p&gt;The value then stays on the wasm operand stack instead of passing through a local, which saves wasm code and runtime work.&lt;/p&gt;

&lt;p&gt;This happens inside a basic block, a straight sequence of instructions with no branches into or out of its middle.&lt;/p&gt;

&lt;p&gt;But as we saw before, moving a definition also means moving its debug records. This is where things start to suck.&lt;/p&gt;

&lt;h2 id=&quot;the-pass-keeps-rescanning-the-list-it-grows&quot;&gt;The pass keeps rescanning the list it grows&lt;/h2&gt;

&lt;p&gt;Before it can move a definition, the pass scans from that definition to the end of its basic block for its debug records, stopping if the register is defined again.&lt;/p&gt;

&lt;p&gt;It also scans from the definition to the place where the instruction will be inserted, collecting records for the variables it tracks.&lt;/p&gt;

&lt;p&gt;Those are linear scans.&lt;/p&gt;

&lt;p&gt;But repeating them for many definitions turns them into quadratic work: twice as many records can mean four times as much scanning. Yikes.&lt;/p&gt;

&lt;p&gt;The pass also makes its own input larger as it runs.&lt;/p&gt;

&lt;p&gt;When it sinks a definition, it leaves the old debug records in the instruction list with their locations blanked out instead of deleting them.&lt;/p&gt;

&lt;p&gt;When a value is cheap to compute, such as a constant, it computes that value again at every use instead of carrying it around. Each copy gets fresh &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DBG_VALUE&lt;/code&gt; records. Re-yikes.&lt;/p&gt;

&lt;p&gt;So the pass keeps adding records to the same list it keeps rescanning. It’s very inefficient and awful for large functions.&lt;/p&gt;

&lt;h2 id=&quot;a-small-reproducer&quot;&gt;A small reproducer&lt;/h2&gt;

&lt;p&gt;That &lt;a href=&quot;https://github.com/dip-proto/rust-wasm-debug-superslow-compile-time&quot;&gt;reproducer&lt;/a&gt; repeatedly squares a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[u64; 5]&lt;/code&gt; through a chain of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;#[inline(always)]&lt;/code&gt; functions.&lt;/p&gt;

&lt;p&gt;On my machine, this command:&lt;/p&gt;

&lt;div class=&quot;language-sh highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;cargo build &lt;span class=&quot;nt&quot;&gt;--release&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--target&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;wasm32-unknown-unknown
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;produced:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Configuration&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Build time&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;debug = 2&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;50.56s&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;debug = 0&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;1.55s&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;The 1.55 seconds is the whole &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cargo build&lt;/code&gt; time with debug info off.
Adding full debug info turns the same build into a 50-second wait. Ouch!&lt;/p&gt;

&lt;p&gt;And &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rustc -Z time-llvm-passes&lt;/code&gt; shows where it goes.&lt;/p&gt;

&lt;p&gt;With &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;debug = 2&lt;/code&gt;, LLVM pass time was 50.85s: WebAssembly Register Stackify took 43.51s, or 85.6%, and Explicit Locals took 6.31s, or 12.4%. Everything else is negligible.&lt;/p&gt;

&lt;p&gt;With &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;debug = 0&lt;/code&gt;, total pass time was 1.42s. Register Stackify took 0.96s and Explicit Locals took 0.003s, making them roughly 45x and 2000x slower with debug info.&lt;/p&gt;

&lt;p&gt;This is all due to the inefficient handling of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DBG_VALUE&lt;/code&gt; records.&lt;/p&gt;

&lt;p&gt;The crate looks small and innocent: it just produces one function with one basic block.&lt;/p&gt;

&lt;p&gt;But by the time Register Stackify is done, it has about 90k real instructions, 267k &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DBG_VALUE&lt;/code&gt; records, and 355k lines of MIR. Explicit Locals isn’t broken. It’s a linear pass that receives 350k instructions instead of 90k. Pretty bad.&lt;/p&gt;

&lt;h2 id=&quot;llvms-fix-is-incomplete&quot;&gt;LLVM’s fix is incomplete&lt;/h2&gt;

&lt;p&gt;There’s already &lt;a href=&quot;https://github.com/llvm/llvm-project/issues/168326&quot;&gt;llvm/llvm-project issue #168326&lt;/a&gt;, which was reported against &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;clang&lt;/code&gt; and describes the same problem.&lt;/p&gt;

&lt;p&gt;It was closed on 2026-03-27 by commit &lt;a href=&quot;https://github.com/llvm/llvm-project/commit/fe990b9005260bcf4a5630b577483e954c6bb60e&quot;&gt;fe990b9005260bcf4a5630b577483e954c6bb60e&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The way it works is that it counts a register’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DBG_VALUE&lt;/code&gt; uses, then stops the forward scan after it has found them all. The counter is provided by a use list, the compiler’s unordered list of every place a value is used.&lt;/p&gt;

&lt;p&gt;But that doesn’t help Rust (TBH it does, but very little).&lt;/p&gt;

&lt;p&gt;The catch is that, while moving values, Register Stackify can point an existing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DBG_VALUE&lt;/code&gt; at a different register without moving the record itself.&lt;/p&gt;

&lt;p&gt;And after enough copies, a record near the top of the block can refer to a register defined near the bottom.&lt;/p&gt;

&lt;p&gt;It appears in that register’s use list, yet a forward scan from the definition can never reach it. The counter includes the earlier record, so it never reaches zero.&lt;/p&gt;

&lt;h2 id=&quot;a-better-fix&quot;&gt;A better fix&lt;/h2&gt;

&lt;p&gt;Here’s &lt;a href=&quot;https://github.com/dip-proto/rust-wasm-debug-superslow-compile-time/blob/master/llvm-wasm-debug-fix.patch&quot;&gt;a better fix as a single patch&lt;/a&gt; that can be applied to the LLVM code that currently ships with Rust.&lt;/p&gt;

&lt;p&gt;It contains three independent changes.&lt;/p&gt;

&lt;p&gt;The first change is in the WebAssembly debug-record helper.&lt;/p&gt;

&lt;p&gt;It stops Register Stackify from reading to the end of a block when it doesn’t need to.&lt;/p&gt;

&lt;p&gt;The compiler already keeps a list of every place a value is used, including the debug records that mention it, so it knows how many records it needs to find. The old code still read forward from the definition until it reached the end of the block. After copies, some records can sit above the definition.&lt;/p&gt;

&lt;p&gt;A forward scan can never reach them, so its count never reaches zero and it always reads to the end. That’s why LLVM’s existing change doesn’t help this Rust case.&lt;/p&gt;

&lt;p&gt;The patch makes the thing walk upward as well as forward. The upward walk counts off records above the definition, while the forward walk keeps the same order and still stops at another definition.&lt;/p&gt;

&lt;p&gt;Once both walks have accounted for every record, they stop. The compiler finds the same records in the same order without reading the rest of the block.&lt;/p&gt;

&lt;p&gt;That same first change also avoids building expensive lookup keys for records it will discard. While collecting records between two points, the old code built and hashed a full identifier for every record it passed, then usually threw it away because it described a variable it didn’t track.&lt;/p&gt;

&lt;p&gt;Now it first asks whether it cares about that variable with one cheap comparison. That reduced hash-table lookups from 27.4M to 3.0M. Pretty significant.&lt;/p&gt;

&lt;p&gt;The second change is a non-WebAssembly-specific change.&lt;/p&gt;

&lt;p&gt;LLVM gives real instructions position numbers so it can compare their order without walking the instruction list. But debug records never get a position number, yet the old code looked each one up before learning that it wasn’t there. The patch just skips those useless lookups. And every target benefits from it, not just WebAssembly.&lt;/p&gt;

&lt;p&gt;The third change is back in the WebAssembly pass.&lt;/p&gt;

&lt;p&gt;It asks a cheaper question about whether one instruction always runs before another. The general-purpose helper answered by walking the block from the beginning every time. The instructions already have position numbers, so comparing two numbers gives the same answer.&lt;/p&gt;

&lt;p&gt;These changes don’t affect the compiled code itself at all, so there are no runtime performance regressions or behavior changes.&lt;/p&gt;

&lt;h2 id=&quot;lets-benchmark-the-reproducer-again&quot;&gt;Let’s benchmark the reproducer again&lt;/h2&gt;

&lt;p&gt;Here are &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;llc -O3 -time-passes&lt;/code&gt; measurements on the reproducer’s bitcode, the compiled form of the program that LLVM reads:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Pass&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Before&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;After&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;WebAssembly Register Stackify&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;45.9s&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;1.19s&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;WebAssembly Explicit Locals&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;6.5s&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0.013s&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Total codegen pass time&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;53.3s&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;2.17s&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;With debug info disabled, Register Stackify takes 1.14s on this input. The remaining debug-info overhead in that pass is about 0.05 seconds.&lt;/p&gt;

&lt;p&gt;End to end, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;debug = 2&lt;/code&gt; fell from 50.56s to 2.72s.&lt;/p&gt;

&lt;p&gt;And &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;debug = 0&lt;/code&gt; fell from 1.55s to 0.89s. Pretty cool.&lt;/p&gt;

&lt;p&gt;Processing and emitting 267k debug records still costs time, but the quadratic blowup is gone.&lt;/p&gt;

&lt;h2 id=&quot;testing-on-real-world-code&quot;&gt;Testing on real-world code&lt;/h2&gt;

&lt;p&gt;Does this affect code people actually compile? Yes.&lt;/p&gt;

&lt;p&gt;I repeated the crate benchmark with common crates.&lt;/p&gt;

&lt;p&gt;Every crate used the release profile with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;debug = 2&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;codegen-units = 1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I emitted LLVM bitcode for every crate with:&lt;/p&gt;

&lt;div class=&quot;language-sh highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nv&quot;&gt;RUSTFLAGS&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;--emit=llvm-bc&quot;&lt;/span&gt; cargo build &lt;span class=&quot;nt&quot;&gt;--release&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--target&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;wasm32-unknown-unknown
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Then I ran each module through both compilers:&lt;/p&gt;

&lt;div class=&quot;language-sh highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;llc &lt;span class=&quot;nt&quot;&gt;-O3&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-time-passes&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-filetype&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;obj &lt;span class=&quot;nt&quot;&gt;-o&lt;/span&gt; /dev/null &amp;lt;crate&amp;gt;.bc
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Both &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;llc&lt;/code&gt; binaries came from the same source tree with the same build configuration. The patch was the only difference.&lt;/p&gt;

&lt;h3 id=&quot;cryptography&quot;&gt;Cryptography&lt;/h3&gt;

&lt;p&gt;I benchmarked an app with a bunch of crypto crates: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;aegis&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sha2&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sha3&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;blake2&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;blake3&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;md-5&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ripemd&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;chacha20poly1305&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;aes-gcm&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;argon2&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curve25519-dalek&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ed25519-compact&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;k256&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;p256&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;p384&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ahash&lt;/code&gt;, etc.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt; &lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Before&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;After&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Time in Register Stackify, all 134 modules&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;1702.7s&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;26.2s&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Total wasm code generation time, all 134 modules&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;1922.2s&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;67.4s&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ed25519-compact&lt;/code&gt; 2.4.0 alone went from 1683.4s to 23.2s in Register Stackify, and from 1884.9s to 48.2s for total code generation.&lt;/p&gt;

&lt;p&gt;That’s 31 minutes of code generation for one ordinary crate, down to 48 seconds.&lt;/p&gt;

&lt;p&gt;The patched compiler still spends 23 seconds in that pass. Once its field arithmetic is inlined, the crate really is enormous. The quadratic scan is what turned 23 seconds into half an hour.&lt;/p&gt;

&lt;p&gt;Here’s a detailed benchmark for some crates:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Crate&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Register Stackify before&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;after&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Whole code generation&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ed25519-compact&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;1683.4s&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;23.2s&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;39x faster&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;k256&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;5.06s&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0.21s&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;4.8x faster&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;blake2&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;1.43s&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0.21s&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;4.9x faster&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ripemd&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0.61s&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0.064s&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;3.9x faster&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sha2&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;3.01s&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0.83s&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;2.8x faster&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;p256&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;3.22s&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0.44s&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;2.4x faster&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;p384&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0.89s&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0.12s&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;2.3x faster&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;blake3&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0.27s&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0.046s&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;2.3x faster&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;jwt-simple&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0.68s&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0.073s&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;1.6x faster&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curve25519-dalek&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0.14s&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0.021s&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;1.3x faster&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;aegis&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0.017s&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;0.0028s&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;1.2x faster&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;For &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sha2&lt;/code&gt;, 88% of the crate’s entire WebAssembly code-generation time was in Register Stackify before the fix.&lt;/p&gt;

&lt;h3 id=&quot;non-crypto-things&quot;&gt;Non-crypto things&lt;/h3&gt;

&lt;p&gt;I also tried a bunch of compression crates (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;brotli&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;flate2&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;miniz_oxide&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ruzstd&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;zopfli&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;libflate&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lzma-rs&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;snap&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bzip2-rs&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;zune-inflate&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lz4_flex&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Register Stackify went from 0.51s to 0.19s, 2.6x faster.&lt;/p&gt;

&lt;p&gt;I also tried linear algebra crates (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nalgebra&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;glam&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cgmath&lt;/code&gt;), and compilation got 4.3x faster.&lt;/p&gt;

&lt;p&gt;The bug shows up everywhere, but it really gets expensive when a function gets big.&lt;/p&gt;

&lt;p&gt;The target (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wasm32-unknown-unknown&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wasm32-wasip1&lt;/code&gt;, etc.) also doesn’t make any difference.&lt;/p&gt;

&lt;h2 id=&quot;who-pays-for-it-today&quot;&gt;Who pays for it today&lt;/h2&gt;

&lt;p&gt;Rust users targeting any wasm target with debug info enabled are affected, including the default &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dev&lt;/code&gt; profile and release profiles with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;debug = 2&lt;/code&gt; (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;debug = 1&lt;/code&gt; isn’t affected).&lt;/p&gt;

&lt;p&gt;C and C++ users compiling wasm with clang and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-g&lt;/code&gt; are affected too, which is how issue #168326 in LLVM first appeared.&lt;/p&gt;

&lt;p&gt;More codegen units or no debug info work around the problem until the patch lands, but that’s not ideal.&lt;/p&gt;

&lt;p&gt;Is it going to be fixed once Rust updates its LLVM fork to include the fix originally made for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;clang&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;Let’s see:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Module&lt;/th&gt;
      &lt;th&gt;Current Rust&lt;/th&gt;
      &lt;th&gt;Upstream LLVM fix&lt;/th&gt;
      &lt;th&gt;Ours&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;repro RegStackify&lt;/td&gt;
      &lt;td&gt;45.37s&lt;/td&gt;
      &lt;td&gt;6.32s&lt;/td&gt;
      &lt;td&gt;1.17s&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;repro total codegen&lt;/td&gt;
      &lt;td&gt;52.58s&lt;/td&gt;
      &lt;td&gt;7.12s&lt;/td&gt;
      &lt;td&gt;1.99s&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;sha2 RegStackify&lt;/td&gt;
      &lt;td&gt;3.04s&lt;/td&gt;
      &lt;td&gt;2.35s&lt;/td&gt;
      &lt;td&gt;0.83s&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;sha2 total codegen&lt;/td&gt;
      &lt;td&gt;3.46s&lt;/td&gt;
      &lt;td&gt;2.75s&lt;/td&gt;
      &lt;td&gt;1.21s&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;k256 RegStackify&lt;/td&gt;
      &lt;td&gt;5.07s&lt;/td&gt;
      &lt;td&gt;0.67s&lt;/td&gt;
      &lt;td&gt;0.22s&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;k256 total codegen&lt;/td&gt;
      &lt;td&gt;6.79s&lt;/td&gt;
      &lt;td&gt;1.86s&lt;/td&gt;
      &lt;td&gt;1.42s&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Well… no. An LLVM update will definitely help, but not as much as the changes proposed here.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title>An improved attack on 7-round AES</title>
    <link href="https://00f.net/2026/07/30/an-improved-attack-on-7-round-aes/"/>
   <updated>2026-07-30T00:00:00+02:00</updated>
   <id>https://00f.net/2026/07/30/an-improved-attack-on-7-round-aes</id>
   <content type="html">&lt;p&gt;Recently, Milad Nasr and Nicholas Carlini used an AI model to &lt;a href=&quot;https://anthropic.com/document/aes_mobius_bridge.pdf&quot;&gt;improve an earlier attack on seven-round AES-128&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;And &lt;a href=&quot;https://qiita.com/satokan3&quot;&gt;Satoru Kanno (@satokan3)&lt;/a&gt; wrote &lt;a href=&quot;https://qiita.com/satokan3/items/d8530ccb4a93f4ac76d1&quot;&gt;a great explanation of the result in Japanese&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Here’s a walkthrough based on his blog post and my understanding of the results, in English.&lt;/p&gt;

&lt;p&gt;TLDR: Don’t panic. It’s an incremental improvement to an impractical reduced-round attack. Full AES-128 remains unaffected.&lt;/p&gt;

&lt;p&gt;But what’s interesting is how the researchers used an AI model to find the improvement.&lt;/p&gt;

&lt;h2 id=&quot;what-the-result-actually-covers&quot;&gt;What the result actually covers&lt;/h2&gt;

&lt;p&gt;Standard AES-128 has ten rounds.&lt;/p&gt;

&lt;p&gt;But this attack stops after seven. So, this is not the real cipher. But studying reduced-round ciphers is a common practice in order to better understand the security margin of a primitive.&lt;/p&gt;

&lt;p&gt;A better attack on seven rounds is useful research, but it says nothing alarming about the complete cipher.&lt;/p&gt;

&lt;p&gt;The attack also needs about &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;2^105&lt;/code&gt; carefully arranged plaintext blocks, all encrypted under the same unknown key.
This is way outside the recommended (and pratical) limits of AES usage, and just streaming that many 16-byte blocks at 1 TB/s would take roughly 20 trillion years.&lt;/p&gt;

&lt;p&gt;And an attacker would have to collect them from real network traffic. Plus, they’d need access to an encryption oracle and the ability to choose a vast number of specially structured inputs.&lt;/p&gt;

&lt;p&gt;That’s a lot to ask! So to be clear: this scenario is completely unrealistic. There’s no practical attack here. AES remains fine.&lt;/p&gt;

&lt;h2 id=&quot;how-the-research-happened&quot;&gt;How the research happened&lt;/h2&gt;

&lt;p&gt;Nasr and Carlini have long records in security research. In particular, they recently coauthored &lt;a href=&quot;https://www.cybergym.io/exploitgym/&quot;&gt;ExploitGym&lt;/a&gt;, which tests whether AI agents can turn real software vulnerabilities into working exploits.&lt;/p&gt;

&lt;p&gt;For this project, they used a custom research scaffold, sponsored by Anthropic (AI usage cost alone for that task was evaluated to about $100,000)&lt;/p&gt;

&lt;p&gt;It gave the model access to papers, code, and experiments, and let long searches continue between human prompts.&lt;/p&gt;

&lt;p&gt;And the early runs concluded that AES had already been studied too thoroughly to leave an easy improvement.&lt;/p&gt;

&lt;p&gt;So, the team kept the search focused: they changed the instructions, rejected easier targets, and pushed the model to work on a publishable attack against seven rounds.&lt;/p&gt;

&lt;p&gt;Their scaffold even let the model modify parts of its own agent loop.&lt;/p&gt;

&lt;p&gt;And after three days, the output from one run contained the idea that became the Möbius Bridge.&lt;/p&gt;

&lt;p&gt;Finally, a few more days of experiments refined it into the attack described in the paper.&lt;/p&gt;

&lt;p&gt;The paper says the model produced the bridge, the optimization techniques, and the correctness arguments. Pretty cool.&lt;/p&gt;

&lt;p&gt;It’s important to note that the AI model did substantial work inside the system the researchers built, but it didn’t independently decide to study AES or publish a result.&lt;/p&gt;

&lt;p&gt;Nasr and Carlini spent several hundred hours, nearly a month, checking the claims and writing the paper.&lt;/p&gt;

&lt;p&gt;They’re the researchers responsible for the work. The Claude Mythos model was a tool they used.&lt;/p&gt;

&lt;h2 id=&quot;the-expensive-byte-guess&quot;&gt;The expensive byte guess&lt;/h2&gt;

&lt;p&gt;The new attack builds on a &lt;a href=&quot;https://doi.org/10.1007/978-3-642-38348-9_23&quot;&gt;2013 meet-in-the-middle attack&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A meet-in-the-middle attack computes forward from the plaintext and backward from the ciphertext, then looks for a match in the middle. And in this case, a large precomputed table describes a four-round core of AES.&lt;/p&gt;

&lt;p&gt;To reach that core, the older attack had to guess selected bytes from the outer rounds.&lt;/p&gt;

&lt;p&gt;And one of those bytes had 256 possible values. For every candidate at this stage, the attack tried all 256 values and rebuilt the data for a table lookup each time.&lt;/p&gt;

&lt;h2 id=&quot;the-möbius-bridge&quot;&gt;The Möbius Bridge&lt;/h2&gt;

&lt;p&gt;Now, here’s the cool part, the shortcut. The AES S-box made it possible.&lt;/p&gt;

&lt;p&gt;Because the S-box isn’t an arbitrary lookup table: it first inverts each byte in a 256-element finite field, then applies a fixed affine transformation.&lt;/p&gt;

&lt;p&gt;And at this stage, the two sides of the attack see different versions of the same collection.&lt;/p&gt;

&lt;p&gt;If an offline difference is called &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;d&lt;/code&gt;, the paper’s bridge identity is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;g = s^2 * d^-1 XOR s&lt;/code&gt;, where &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;g&lt;/code&gt; is the corresponding online value and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;s&lt;/code&gt; is an unknown byte shared by the entire collection.&lt;/p&gt;

&lt;p&gt;As a function of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;d&lt;/code&gt;, this is a particular Möbius transformation.&lt;/p&gt;

&lt;p&gt;The researchers then take the reciprocals of the offline differences. If &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;v = d^-1&lt;/code&gt;, the relationship becomes the affine map &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;v -&amp;gt; s^2 * v XOR s&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The Möbius Bridge replaces the collection with a fingerprint that stays identical under that shared transformation.
The individual values change, but the fingerprint doesn’t.&lt;/p&gt;

&lt;p&gt;The attack can therefore check the precomputed table without trying all 256 values of the key byte first.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/posts/aes-mobius-bridge.svg&quot; alt=&quot;The previous attack tries all 256 values of one key byte before comparing with the offline table. The Möbius Bridge rewrites a shared Möbius relation as an affine map on reciprocal values, then replaces that loop with an invariant fingerprint and one comparison.&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The formula in the diagram describes the relation between two collections when the surrounding key guesses are correct.
It doesn’t describe complete AES states.&lt;/p&gt;

&lt;p&gt;The bridge doesn’t remove the byte from the final key: it removes the 256-way guess from this table-lookup stage, and a later step recovers the byte.&lt;/p&gt;

&lt;p&gt;Other key guesses remain, and bytes that pass through &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MixColumns&lt;/code&gt; don’t preserve the same convenient relation.&lt;/p&gt;

&lt;p&gt;Removing the loop suggests a 256-fold speedup. But it’s important to note that computing the fingerprint is expensive, so some of that gain disappears.&lt;/p&gt;

&lt;p&gt;After several optimizations, the estimated speedup is about 200 to 800 times. Pretty impressive, but once again, that has no practical impact.&lt;/p&gt;

&lt;h2 id=&quot;the-data-still-dominates&quot;&gt;The data still dominates&lt;/h2&gt;

&lt;p&gt;For the same &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;2^105&lt;/code&gt; chosen plaintexts and a roughly 63 percent chance of success, the estimates are:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Attack&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Chosen plaintext blocks&lt;/th&gt;
      &lt;th style=&quot;text-align: right&quot;&gt;Estimated AES-equivalent work&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Previous attack&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;2^105&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;2^99&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Möbius Bridge&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;2^105&lt;/code&gt;&lt;/td&gt;
      &lt;td style=&quot;text-align: right&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;2^89.3&lt;/code&gt; to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;2^91.4&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;That’s a large reduction but the impossible &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;2^105&lt;/code&gt; data requirement hasn’t changed, so data remains the dominant cost.&lt;/p&gt;

&lt;h2 id=&quot;what-has-actually-been-tested&quot;&gt;What has actually been tested&lt;/h2&gt;

&lt;p&gt;Obviously, the complete attack has never been run because it’s far too expensive. So, the researchers tested its components instead. See the &lt;a href=&quot;https://github.com/anthropics/cryptography-research-demo/tree/main/AES&quot;&gt;code they released&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The researchers also ran an end-to-end attack on a toy cipher based on AES with a 24-bit key.&lt;/p&gt;

&lt;p&gt;For full-size seven-round AES-128, they recovered 50 out of 50 planted keys, but it skipped the two parts that can’t be run at full scale.&lt;/p&gt;

&lt;p&gt;It used the one table entry that should match and to identify the rare plaintext pair that the complete attack would have to find.&lt;/p&gt;

&lt;p&gt;A Lean theorem checks the core bridge identity, while large computational experiments test the fingerprint’s behavior.
Cool, but neither proves the complete attack.&lt;/p&gt;

&lt;p&gt;We should emphasize that Anthropic published the work as a technical report rather than a peer-reviewed paper.
Cryptographers outside Anthropic commented on an early draft, but broad independent review has only just begun.
The full attack is too expensive to validate on actual hardware.&lt;/p&gt;

&lt;p&gt;That being said, the work remains a clever incremental result in reduced-round AES cryptanalysis.&lt;/p&gt;

&lt;p&gt;These results show that a capable AI model can be a useful tool when experienced people choose the problem, give it room to explore, and check every important claim.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title>AES gets swizzled</title>
    <link href="https://00f.net/2026/07/16/aes-with-simd-swizzles/"/>
   <updated>2026-07-16T00:00:00+02:00</updated>
   <id>https://00f.net/2026/07/16/aes-with-simd-swizzles</id>
   <content type="html">&lt;p&gt;There’s an old problem with AES when implemented in software: it’s either slow or insecure.&lt;/p&gt;

&lt;p&gt;AES has a state of sixteen bytes, and a round has four steps:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SubBytes&lt;/code&gt; replaces every byte using the AES S-Box.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ShiftRows&lt;/code&gt; moves bytes to different columns.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MixColumns&lt;/code&gt; combines the four bytes in each column.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AddRoundKey&lt;/code&gt; XORs another sixteen-byte value.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SubBytes&lt;/code&gt; is the annoying part, because it applies a random-looking permutation to every byte:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;SBox(0x00) = 0x63
SBox(0x53) = 0xed
...
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;An obvious way to implement that is by using lookup tables.&lt;/p&gt;

&lt;p&gt;But here’s the problem: the lookup indices are secret data, and accessing a cache line that was just accessed and is still in the cache is slightly faster than accessing other addresses.&lt;/p&gt;

&lt;p&gt;Taking advantage of this, an adversary on the same machine can learn information about the secret indices. For example, DJB &lt;a href=&quot;https://cr.yp.to/antiforgery/cachetiming-20050414.pdf&quot;&gt;recovered AES keys from remote timings&lt;/a&gt;, and Osvik, Shamir, and Tromer demonstrated &lt;a href=&quot;https://eprint.iacr.org/2005/271&quot;&gt;cross-process attacks against OpenSSL and disk encryption&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That was mostly solved on modern mobile, desktop, and server CPUs by adding AES instructions.&lt;/p&gt;

&lt;p&gt;But old is new again. With new platforms such as WebAssembly, even when running on such CPUs, applications can’t use AES instructions, so they have to reimplement AES themselves. Sigh.&lt;/p&gt;

&lt;p&gt;A common way to avoid using lookup tables is bitslicing: using a circuit of logical operations applied to a different representation of the AES state, where all the bits expected to follow the same circuit are packed together in a register.&lt;/p&gt;

&lt;p&gt;It works very well in hardware, but in software, performance is generally still not great, especially compared to what CPU AES instructions can do.&lt;/p&gt;

&lt;p&gt;But there’s a third option that’s surprisingly not well known and was originally described by Mike Hamburg in &lt;a href=&quot;https://www.shiftleft.org/papers/vector_aes/vector_aes.pdf&quot;&gt;Accelerating AES with Vector Permute Instructions&lt;/a&gt;.&lt;/p&gt;

&lt;h2 id=&quot;a-lookup-table-inside-a-register&quot;&gt;A lookup table inside a register&lt;/h2&gt;

&lt;p&gt;Modern CPUs, even when accessed via WebAssembly, include something nice: SIMD registers that contain 16 bytes or more.&lt;/p&gt;

&lt;p&gt;A lot of instructions can be used with such registers, but a very common one, which (oh, joy!) is even accessible in WebAssembly, treats one 16-byte vector as a table and another as sixteen selectors:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;table     = [t0, t1, t2, ... t15]
selectors = [ 3,  9,  0, ...   7]
result    = [t3, t9, t0, ...  t7]
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;A single instruction effectively performs sixteen lookups in parallel. The table is kept in a register, so there are no memory lookups, and (barring microarchitectural vulnerabilities) no side channels.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/posts/simd-aes-lookups.svg&quot; alt=&quot;A secret byte selects an AES S-Box address in memory, while a SIMD byte permutation selects lanes in a register loaded from a fixed address.&quot; /&gt;&lt;/p&gt;

&lt;p&gt;This is something that all CPUs with SIMD instructions support. On x86, it’s called &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PSHUFB&lt;/code&gt;, on ARM, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TBL&lt;/code&gt;, and WebAssembly has a corresponding instruction called &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;i8x16.swizzle&lt;/code&gt;, which any sane compiler can map directly to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PSHUFB&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TBL&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When selectors are between 0 and 15, the behavior is as expected, and the same across all targets.&lt;/p&gt;

&lt;p&gt;Values outside that range are target-specific, which is important for WebAssembly (more about that later).&lt;/p&gt;

&lt;h2 id=&quot;turning-256-values-into-16-by-16&quot;&gt;Turning 256 values into 16 by 16&lt;/h2&gt;

&lt;p&gt;The AES S-Box isn’t actually random. It’s defined as:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;S(b) = A(inverse(b)) XOR 0x63
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The byte is inverted in the AES finite field. Then a fixed linear bit transformation &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;A&lt;/code&gt; and the constant &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0x63&lt;/code&gt; are applied. And the inverse of zero is defined as zero.&lt;/p&gt;

&lt;p&gt;AES uses a finite field with 256 elements, commonly written &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GF(2^8)&lt;/code&gt;. And the same field can be represented as a quadratic extension of a 16-element field:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;GF(2^8) is isomorphic to GF(2^4)[t] / (t^2 + t + zeta)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;With these binary encodings, field addition in both &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GF(2^8)&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GF(2^4)&lt;/code&gt; is bitwise XOR.&lt;/p&gt;

&lt;p&gt;Here &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;zeta&lt;/code&gt; is chosen so that the quadratic polynomial is irreducible.&lt;/p&gt;

&lt;p&gt;And here’s something interesting:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;256 = 16 * 16
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This is just a reversible change of coordinates.&lt;/p&gt;

&lt;p&gt;And in the new representation, one byte becomes a pair of encoded four-bit field components, while addition and multiplication keep working as expected.&lt;/p&gt;

&lt;p&gt;A linear input transformation produces those components:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;lo = input &amp;amp; 0x0f
hi = input &amp;gt;&amp;gt; 4

encoded = lookup16(input_map_lo, lo) XOR lookup16(input_map_hi, hi)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;See? The low and high nibble contributions can be looked up separately because the transform is linear.&lt;/p&gt;

&lt;p&gt;With two permutations, we can transform all sixteen AES state bytes.&lt;/p&gt;

&lt;h2 id=&quot;inversion-with-small-register-tables&quot;&gt;Inversion with small register tables&lt;/h2&gt;

&lt;p&gt;Hamburg’s nested-inversion construction reduces the nonlinear part of the field inverse to five small lookups:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;k = low_nibble(encoded)
i = high_nibble(encoded)
j = k XOR i

ak  = lookup16(inverse_scaled, k)
iak = lookup16(inverse, i) XOR ak
jak = lookup16(inverse, j) XOR ak
io  = lookup16(inverse, iak) XOR j
jo  = lookup16(inverse, jak) XOR i
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The stages use scaled and skewed representations, so treating every value as an ordinary nibble in one fixed &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GF(2^4)&lt;/code&gt; basis gives incorrect results.&lt;/p&gt;

&lt;p&gt;So, two output tables apply the remaining factors, return to the normal AES byte basis, and apply the linear part of the S-Box affine map:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;a = lookup16(output_u, io) XOR lookup16(output_t, jo)

a XOR 0x63 = AES_SBOX[input]
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;With the target-specific infinity handling described below, we get the correct S-Box value for every possible input byte. And we only need 16-element lookup tables. Each fits in a SIMD register. You see where this is going.&lt;/p&gt;

&lt;h2 id=&quot;folding-the-rest-of-the-round&quot;&gt;Folding the rest of the round&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MixColumns&lt;/code&gt; is evaluated on the constant-free S-Box value &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;a = A(inverse(input))&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It needs both &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;a&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;a&lt;/code&gt; multiplied by &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0x02&lt;/code&gt; in the AES field (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;3a&lt;/code&gt; is also needed, but that’s just &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;a + 2a&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Since that operation is linear, a second pair of output tables can produce the doubled value directly:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;a2 = lookup16(output2_u, io) XOR lookup16(output2_t, jo)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Four fixed permutations gather the bytes after &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ShiftRows&lt;/code&gt; and arrange the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MixColumns&lt;/code&gt; contributions:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;m0 = permute(a2,       mix0)
m1 = permute(a XOR a2, mix1)
m2 = permute(a,        mix2)
m3 = permute(a,        mix3)

output = m0 XOR m1 XOR m2 XOR m3
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;How about the affine constant &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0x63&lt;/code&gt; we talked about earlier? Turns out that we can omit it.&lt;/p&gt;

&lt;p&gt;A vector containing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0x63&lt;/code&gt; in every byte is unchanged by &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ShiftRows&lt;/code&gt; and survives &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MixColumns&lt;/code&gt; because every output row’s coefficients XOR to one:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;0x02 XOR 0x03 XOR 0x01 XOR 0x01 = 0x01
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Just one final vector XOR restores &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0x63&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;We can finally &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AddRoundKey&lt;/code&gt;, which is a simple XOR.&lt;/p&gt;

&lt;p&gt;To do all this, we only need 15 byte permutations:&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;Input transformation:          2
Nested inversion:              5
S-Box output and its double:   4
ShiftRows and MixColumns:      4
                              --
Total:                        15
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;swizzling-infinity&quot;&gt;Swizzling infinity&lt;/h2&gt;

&lt;p&gt;The inversion contains denominators that can be zero. So we need some kind of out-of-range selector to represent infinity.&lt;/p&gt;

&lt;p&gt;The inverse tables map a zero denominator to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0x80&lt;/code&gt;. XORing that with a nibble can produce anything from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0x80&lt;/code&gt; through &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0x8f&lt;/code&gt;, but these values all represent the same infinity marker: bit 7 is set, while the low nibble doesn’t matter. When one of them selects from the next 16-byte table, the byte permutation returns zero.&lt;/p&gt;

&lt;p&gt;And as mentioned previously, different targets treat out-of-range selectors differently.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Primitive&lt;/th&gt;
      &lt;th&gt;Out-of-range selector behavior&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;x86 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PSHUFB&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;If selector bit 7 is set, the result is zero; otherwise the low nibble selects&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;AArch64 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TBL&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Every index above 15 returns zero&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;WebAssembly strict SIMD&lt;/td&gt;
      &lt;td&gt;Every index above 15 returns zero&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;WebAssembly relaxed SIMD&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0x10&lt;/code&gt; to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0x7f&lt;/code&gt; may return zero or use the low nibble; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0x80&lt;/code&gt; to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0xff&lt;/code&gt; returns zero&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;A selector between &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;15&lt;/code&gt; or between &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0x80&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0xff&lt;/code&gt; produces the same output everywhere, even in WebAssembly.&lt;/p&gt;

&lt;p&gt;And the circuit described above only produces selectors in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0x00&lt;/code&gt; through &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0x0f&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0x80&lt;/code&gt; through &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0x8f&lt;/code&gt;. So, we’re safe!&lt;/p&gt;

&lt;p&gt;The default WebAssembly “strict” SIMD mode doesn’t map well to x86 instructions, so the generated code is pretty inefficient.&lt;/p&gt;

&lt;p&gt;Fortunately, WebAssembly later introduced a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;relaxed-simd&lt;/code&gt; variant of the instruction that marks selectors in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0x10&lt;/code&gt; through &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0x7f&lt;/code&gt; range as “don’t-care” values and lowers directly to a CPU instruction.&lt;/p&gt;

&lt;p&gt;It’s a little sad that WebAssembly support is fragmented and relaxed SIMD isn’t supported everywhere yet, most notably in Safari. At least server runtimes such as Wasmer, WasmEdge, Wasmtime, and WAVM have supported it by default for a long time. And Safari will eventually catch up.&lt;/p&gt;

&lt;p&gt;Is this technique worth it compared to lookup tables and bitslicing? It is. Compared to lookup tables, it guarantees constant-time execution. And compared to bitslicing, it doesn’t require bit-level representation changes and introduces less register pressure.&lt;/p&gt;

&lt;p&gt;Most importantly, it’s a very good match for how some new AES-based ciphers such as AEGIS and HiAE operate. And we can finally have ciphers with half-decent performance on WebAssembly rumtimes without the WASI-Crypto extensions.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title>The best WebAssembly runtime may still be no runtime at all</title>
    <link href="https://00f.net/2026/07/08/webassembly-compilation-to-c-2026/"/>
   <updated>2026-07-08T00:00:00+02:00</updated>
   <id>https://00f.net/2026/07/08/webassembly-compilation-to-c-2026</id>
   <content type="html">&lt;p&gt;In 2023, I wrote that &lt;a href=&quot;/2023/12/11/webassembly-compilation-to-c/&quot;&gt;the best WebAssembly runtime may be no runtime at all&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;To summarize: if you already have a WebAssembly module, translating it to C and compiling that C with a normal native compiler can be surprisingly hard to beat.&lt;/p&gt;

&lt;p&gt;I expected that argument to get weaker over time, because WebAssembly runtimes keep improving. Their compilers have better register allocators, better lowering, better support for newer WebAssembly instructions, and more deployment polish than a small transpiler can reasonably have.&lt;/p&gt;

&lt;p&gt;Then I ran the 2026 benchmark.&lt;/p&gt;

&lt;p&gt;The 2026 &lt;a href=&quot;/2026/06/23/webassembly-runtimes-2026/&quot;&gt;WebAssembly benchmark&lt;/a&gt; has the full table for other runtimes.&lt;/p&gt;

&lt;p&gt;And the WebAssembly-to-C path performed very well, but was beaten by new runtimes implementing the “wide arithmetic” WebAssembly proposal.&lt;/p&gt;

&lt;p&gt;I was curious to see how the WebAssembly-to-C approach would work with if that proposal was implemented.&lt;/p&gt;

&lt;p&gt;So, let’s compare:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wasmer 7.1.0&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wasmtime 46.0.0&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;WABT &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wasm2c&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wasm2c&lt;/code&gt;, I added support for wide arithmetic, which was surprisingly trivial to implement. You can find my fork &lt;a href=&quot;https://github.com/dip-proto/wabt&quot;&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The generated C code simply uses compiler carry intrinsics and C’s 128-bit integer type.&lt;/p&gt;

&lt;p&gt;Note that I compiled the generated C with WABT’s Segue memory mode enabled, with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;zig cc -O3 -march=native&lt;/code&gt;, on the exact same libsodium benchmark suite as the previous runtime post.&lt;/p&gt;

&lt;p&gt;Every build uses the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lime1+simd128+wide_arithmetic&lt;/code&gt; feature set.&lt;/p&gt;

&lt;p&gt;Oh, and before you ask: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wasm2c&lt;/code&gt; supports memory protection using guard pages, like other runtimes.&lt;/p&gt;

&lt;h2 id=&quot;the-speed-numbers&quot;&gt;The speed numbers&lt;/h2&gt;

&lt;p&gt;The numbers below are slowdowns relative to the native libsodium build. A value of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1.25x native&lt;/code&gt; means the benchmark took 25% more time than native code.&lt;/p&gt;

&lt;p&gt;Lower is better.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/wasm2c2026/speed.png&quot; alt=&quot;wasm2c with wide arithmetic, ranked by geomean slowdown&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The median is a tie with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wasmer&lt;/code&gt;. But the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wasm2c&lt;/code&gt; executable used 0.887x as much time as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wasmer&lt;/code&gt; on the geomean, and 0.814x as much as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wasmtime&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The dumb C path survived the feature upgrade. Once the new arithmetic instructions were added, a normal C compiler could again produce code competitive with dedicated WebAssembly compilers.&lt;/p&gt;

&lt;h2 id=&quot;memory-usage&quot;&gt;Memory usage&lt;/h2&gt;

&lt;p&gt;I also measured peak resident set size with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/usr/bin/time -v&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is cold process RSS for one command invocation, using the median of fifteen runs. It includes the runtime executable, its startup state, JIT or compilation machinery used by that command, and anything else the process maps while running the benchmark.&lt;/p&gt;

&lt;p&gt;Note that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wasmer&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wasmtime&lt;/code&gt; have a fixed cost that can be amortized if a service keeps the runtime alive and runs many modules or many calls.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/wasm2c2026/memory.png&quot; alt=&quot;Cold process memory, median of 15 runs&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Most of that difference is fixed engine overhead. The module itself costs very little.&lt;/p&gt;

&lt;p&gt;If I subtract the RSS of an empty WASI module run through the same path, with a 64 MiB maximum linear memory like the benchmark build, only a couple of MiB remain:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/wasm2c2026/memory-extra.png&quot; alt=&quot;What the module itself costs&quot; /&gt;&lt;/p&gt;

&lt;p&gt;At run time, with the WebAssembly code directly compiled to C, then to executable code, there’s no engine process to bring along.&lt;/p&gt;

&lt;h2 id=&quot;no-runtime-is-still-a-good-choice&quot;&gt;No runtime is still a good choice&lt;/h2&gt;

&lt;p&gt;The generated output is portable C.&lt;/p&gt;

&lt;p&gt;So, the deployment target no longer has to be “a platform with a WebAssembly runtime”. It can be any platform with a C compiler and enough libc support for whatever the module imports.&lt;/p&gt;

&lt;p&gt;It also means the compiler is a choice.&lt;/p&gt;

&lt;p&gt;You can compile the generated C with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;clang&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gcc&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But you can also take advantage of modern C toolchains such as &lt;a href=&quot;https://fil-c.org/&quot;&gt;Fil-C&lt;/a&gt; to get memory safety on wasm64, or on wasm32 without depending on virtual-memory guard pages.&lt;/p&gt;

&lt;p&gt;Or you can use a formally verified compiler such as &lt;a href=&quot;https://compcert.org/&quot;&gt;CompCert&lt;/a&gt; for high assurance code generation.&lt;/p&gt;

&lt;p&gt;I didn’t benchmark those toolchains. The point is that WebAssembly-to-C gives you that option, while a dedicated runtime doesn’t.&lt;/p&gt;

&lt;p&gt;This is also why I remain impressed by &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wasmer&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wasmer&lt;/code&gt; also implements &lt;a href=&quot;https://wasix.org/&quot;&gt;WASIX&lt;/a&gt;, which fills many of the POSIX-shaped gaps that plain WASI still leaves open. So, if your goal is to run existing applications in WebAssembly with fewer rewrites, this is the way to go, not WebAssembly-to-C or other options.&lt;/p&gt;

&lt;p&gt;And &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wasmtime&lt;/code&gt; implements the “Component Model”, that improves code separation and gives applications a typed interface boundary for composing pieces of WebAssembly. I have an intuition that the same thing can be achieved using WebAssembly-to-C by compiling components separately, but I didn’t think about it much.&lt;/p&gt;

&lt;p&gt;Regardless WebAssembly-to-C is the wrong answer if you need dynamic loading of untrusted code without precompilation, runtime-level policy, preemption, fuel, component-model machinery, or one engine reused across many tenants.&lt;/p&gt;

&lt;p&gt;It’s also the wrong answer if your deployment model depends on shipping one portable &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.wasm&lt;/code&gt; artifact and letting the destination machine choose how to run it.&lt;/p&gt;

&lt;p&gt;But if you control the WebAssembly module, can precompile it, and don’t need runtime services, WebAssembly-to-C is very close to a no-brainer, especially with the addition of wide arithmetic.&lt;/p&gt;
</content>
  </entry>
  
  <entry>
    <title>Performance of WebAssembly runtimes in 2026</title>
    <link href="https://00f.net/2026/06/23/webassembly-runtimes-2026/"/>
   <updated>2026-06-23T00:00:00+02:00</updated>
   <id>https://00f.net/2026/06/23/webassembly-runtimes-2026</id>
   <content type="html">&lt;p&gt;I wanted to know if WebAssembly runtimes are getting faster.&lt;/p&gt;

&lt;p&gt;This is a follow-up to the earlier libsodium WebAssembly benchmarks from &lt;a href=&quot;/2019/04/09/benchmarking-webassembly-using-libsodium/&quot;&gt;2019&lt;/a&gt;, &lt;a href=&quot;/2021/02/22/webassembly-runtimes-benchmarks/&quot;&gt;2021&lt;/a&gt; and &lt;a href=&quot;/2023/01/04/webassembly-benchmark-2023/&quot;&gt;2023&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Not “does the newest version beat native code in one microbenchmark?”, and not “which runtime has the prettiest benchmark chart?”, but something more boring and more useful:&lt;/p&gt;

&lt;p&gt;If I take the same C crypto code, compile it to WebAssembly, and run it on the latest runtime, a runtime from one year ago, and a runtime from two years ago, are things actually improving?&lt;/p&gt;

&lt;p&gt;So I benchmarked libsodium on WebAssembly runtimes released around June 2024, June 2025, and June 2026.&lt;/p&gt;

&lt;p&gt;The short version:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wasmer&lt;/code&gt; is the best performer, but &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WAVM&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WAMR&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wasmtime&lt;/code&gt; are close.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WAVM&lt;/code&gt; has the best optimizer, and is able to generate very fast code out from baseline, portable WebAssembly&lt;/li&gt;
  &lt;li&gt;The new WebAssembly &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wide_arithmetic&lt;/code&gt; instructions are a big deal for crypto code when runtimes support them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;what-i-measured&quot;&gt;What I measured&lt;/h2&gt;

&lt;p&gt;The test program is libsodium’s benchmark suite, built from libsodium commit &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;8e3be8615ba6adcd7babaecf5e76f516890ba5fb&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I built one native baseline and several WebAssembly variants:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;native x86-64, compiled with Zig using the local CPU target&lt;/li&gt;
  &lt;li&gt;plain WebAssembly&lt;/li&gt;
  &lt;li&gt;WebAssembly with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lime1&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;WebAssembly with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lime1&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;simd128&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;WebAssembly with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lime1&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;simd128&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wide_arithmetic&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the native reference, libsodium was built with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-Dcpu=native&lt;/code&gt;. For &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wasm2c&lt;/code&gt;, the generated C was compiled with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;zig cc -O3 -march=native&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WAMR&lt;/code&gt;, I used AOT mode: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wamrc&lt;/code&gt; compiled each &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.wasm&lt;/code&gt; file to an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.aot&lt;/code&gt; file, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;iwasm&lt;/code&gt; ran the resulting AOT file. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wamrc&lt;/code&gt; doesn’t accept &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--cpu=native&lt;/code&gt;, so I used &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--target=x86_64 --cpu=x86-64-v4 --opt-level=3&lt;/code&gt;, which matches the host’s available x86-64 feature level and works across the WAMR versions that could compile these modules.&lt;/p&gt;

&lt;p&gt;The native command was:&lt;/p&gt;

&lt;div class=&quot;language-sh highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;zig build &lt;span class=&quot;nt&quot;&gt;-Denable_benchmarks&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-Doptimize&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;ReleaseFast &lt;span class=&quot;nt&quot;&gt;-Dcpu&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;native &lt;span class=&quot;nt&quot;&gt;-Diterations&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;3
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The WebAssembly commands were the same shape, with a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wasm32-wasi&lt;/code&gt; target and the feature-specific CPU strings:&lt;/p&gt;

&lt;div class=&quot;language-sh highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;zig build &lt;span class=&quot;nt&quot;&gt;-Denable_benchmarks&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-Dtarget&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;wasm32-wasi &lt;span class=&quot;nt&quot;&gt;-Doptimize&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;ReleaseFast &lt;span class=&quot;nt&quot;&gt;-Diterations&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;3
zig build &lt;span class=&quot;nt&quot;&gt;-Denable_benchmarks&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-Dtarget&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;wasm32-wasi &lt;span class=&quot;nt&quot;&gt;-Doptimize&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;ReleaseFast &lt;span class=&quot;nt&quot;&gt;-Dcpu&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;lime1 &lt;span class=&quot;nt&quot;&gt;-Diterations&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;3
zig build &lt;span class=&quot;nt&quot;&gt;-Denable_benchmarks&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-Dtarget&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;wasm32-wasi &lt;span class=&quot;nt&quot;&gt;-Doptimize&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;ReleaseFast &lt;span class=&quot;nt&quot;&gt;-Dcpu&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;lime1+simd128 &lt;span class=&quot;nt&quot;&gt;-Diterations&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;3
zig build &lt;span class=&quot;nt&quot;&gt;-Denable_benchmarks&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-Dtarget&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;wasm32-wasi &lt;span class=&quot;nt&quot;&gt;-Doptimize&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;ReleaseFast &lt;span class=&quot;nt&quot;&gt;-Dcpu&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;lime1+simd128+wide_arithmetic &lt;span class=&quot;nt&quot;&gt;-Diterations&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;3
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The host was an AMD Ryzen AI 9 HX 470 with 12 cores and 24 threads. CPU boost was disabled and the maximum CPU frequency was 2 GHz. The OS was Linux 7.1.0-rc7, and Zig was &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0.17.0-dev.948+e949341b7&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The numbers below are the geometric mean of per-benchmark slowdowns relative to the native build. Lower is better. A value of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;2.0&lt;/code&gt; means “twice as slow as native” on this machine.&lt;/p&gt;

&lt;p&gt;I used &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ITERATIONS=3&lt;/code&gt;, so the very small libsodium tests are noisy and quantized. Rows reporting zero time were excluded from the aggregate.&lt;/p&gt;

&lt;h2 id=&quot;versions&quot;&gt;Versions&lt;/h2&gt;

&lt;p&gt;For every runtime except &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WAVM&lt;/code&gt;, I used the latest stable release available on June 23, 2026, plus a stable release from roughly one year earlier and one from roughly two years earlier.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Runtime&lt;/th&gt;
      &lt;th&gt;2024&lt;/th&gt;
      &lt;th&gt;2025&lt;/th&gt;
      &lt;th&gt;2026&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Bun&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/oven-sh/bun/releases/tag/bun-v1.1.16&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1.1.16&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/oven-sh/bun/releases/tag/bun-v1.2.17&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1.2.17&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/oven-sh/bun/releases/tag/bun-v1.3.14&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1.3.14&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Node&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://nodejs.org/dist/v22.3.0/&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;22.3.0&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://nodejs.org/dist/v24.2.0/&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;24.2.0&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://nodejs.org/dist/v26.3.1/&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;26.3.1&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WAMR&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/bytecodealliance/wasm-micro-runtime/releases/tag/WAMR-2.1.0&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;2.1.0&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/bytecodealliance/wasm-micro-runtime/releases/tag/WAMR-2.3.1&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;2.3.1&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/bytecodealliance/wasm-micro-runtime/releases/tag/WAMR-2.4.4&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;2.4.4&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WABT wasm2c&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/WebAssembly/wabt/releases/tag/1.0.35&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1.0.35&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/WebAssembly/wabt/releases/tag/1.0.37&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1.0.37&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/WebAssembly/wabt/releases/tag/1.0.41&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1.0.41&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WasmEdge&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/WasmEdge/WasmEdge/releases/tag/0.14.0&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0.14.0&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/WasmEdge/WasmEdge/releases/tag/0.14.1&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0.14.1&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/WasmEdge/WasmEdge/releases/tag/0.17.0&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0.17.0&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wasmer&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/wasmerio/wasmer/releases/tag/v4.3.2&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;4.3.2&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/wasmerio/wasmer/releases/tag/v6.0.1&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;6.0.1&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/wasmerio/wasmer/releases/tag/v7.1.0&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;7.1.0&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wasmtime&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/bytecodealliance/wasmtime/releases/tag/v22.0.0&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;22.0.0&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/bytecodealliance/wasmtime/releases/tag/v34.0.0&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;34.0.0&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/bytecodealliance/wasmtime/releases/tag/v46.0.0&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;46.0.0&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WAVM&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;n/a&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;n/a&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/WAVM/WAVM/releases/tag/nightly/2026-04-05&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nightly/2026-04-05&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wazero&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/wazero/wazero/releases/tag/v1.7.3&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1.7.3&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/wazero/wazero/releases/tag/v1.9.0&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1.9.0&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
      &lt;td&gt;&lt;a href=&quot;https://github.com/wazero/wazero/releases/tag/v1.12.0&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1.12.0&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WAVM&lt;/code&gt; is awkward to compare historically. The old available nightly collapsed to a 2022 binary for both the 2024 and 2025 slots, and that binary refused to run on this machine. I only kept the 2026 nightly.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WAMR 2.1.0&lt;/code&gt;, the selected 2024 release, installed fine but its AOT compiler failed on these Zig-generated modules with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;invalid WASM stack data type&lt;/code&gt;. I kept the version in the matrix, but didn’t include an aggregate for it.&lt;/p&gt;

&lt;h2 id=&quot;baseline-webassembly&quot;&gt;Baseline WebAssembly&lt;/h2&gt;

&lt;p&gt;This is the plain WebAssembly build, without &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lime1&lt;/code&gt;, SIMD, or wide arithmetic.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/wasm2026/baseline.png&quot; alt=&quot;Baseline WebAssembly slowdown by release year&quot; /&gt;&lt;/p&gt;

&lt;p&gt;There isn’t one universal trend.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wasmtime&lt;/code&gt; steadily improved: 2.67x native in 2024, 2.54x in 2025, 2.41x in 2026. It got faster every year, and the gains land in the tenths place, above the noise.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Node&lt;/code&gt; also improved slowly, from 8.60x native to 7.95x native.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wazero&lt;/code&gt; was basically flat: 4.84x, 4.70x, 4.72x native. No real movement over two years.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WAMR&lt;/code&gt; in AOT mode was already fast in 2025 and stayed there in 2026: 1.59x native, then 1.57x native, the same within this benchmark’s noise. I don’t have a complete 2024 WAMR number because &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WAMR 2.1.0&lt;/code&gt; couldn’t compile these modules.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wasmer&lt;/code&gt; regressed in the 2025 release I tested, then recovered in 2026. The 2026 baseline barely beats 2024.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wasm2c&lt;/code&gt; improved modestly in 2026. It remains one of the best options if ahead-of-time translation to native C is acceptable for your deployment model.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Bun&lt;/code&gt; is the outlier. Its 2024 and 2025 results were far behind, but the 2026 result is about three times faster than the 2025 result. It’s still slower than &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Node&lt;/code&gt; on this benchmark, but the direction is excellent.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WasmEdge&lt;/code&gt; is fast too, but its command-line behavior changed enough to matter. My first &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0.17.0&lt;/code&gt; run accidentally used interpreter mode for compiled modules and looked catastrophically slow. Running the compiled modules with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--run-mode=aot&lt;/code&gt; fixed it: the 2026 baseline was 1.74x native, between the 2024 and 2025 baseline results.&lt;/p&gt;

&lt;h2 id=&quot;best-supported-build-by-year&quot;&gt;Best supported build by year&lt;/h2&gt;

&lt;p&gt;The baseline table is useful because it compares the same WebAssembly target everywhere.&lt;/p&gt;

&lt;p&gt;But if you’re choosing a runtime for your own deployment, you probably care about the fastest build that runtime can actually run.&lt;/p&gt;

&lt;p&gt;So for each runtime and year, I also selected the best complete result among the supported builds: baseline, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lime1&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lime1+simd128&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lime1+simd128+wide_arithmetic&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/wasm2026/best-by-year.png&quot; alt=&quot;Best supported build by release year&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Looks similar to the previous graph, except for &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wasmtime&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wasmer&lt;/code&gt; that really benefit from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wide_arithmetic&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Ranked by the best supported build, the complete current-year results are:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/wasm2026/rank-2026.png&quot; alt=&quot;2026 releases, best supported build, ranked&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;cpu-feature-variants&quot;&gt;CPU feature variants&lt;/h2&gt;

&lt;p&gt;The WebAssembly feature story is more interesting than the year-to-year runtime story.&lt;/p&gt;

&lt;p&gt;For the 2026 releases, these were the aggregate slowdowns:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/wasm2026/features-2026.png&quot; alt=&quot;CPU feature variants across 2026 releases&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lime1&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;simd128&lt;/code&gt; alone aren’t magic here. Sometimes they help, sometimes they hurt, and sometimes the difference is lost in benchmark noise.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wide_arithmetic&lt;/code&gt; is different.&lt;/p&gt;

&lt;p&gt;Only &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wasmtime&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wasmer&lt;/code&gt; could run the full &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wide_arithmetic&lt;/code&gt; build among the complete stable rows I tested. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WAMR&lt;/code&gt; rejected it with unsupported opcode &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0xfc13&lt;/code&gt;. But when &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wide_arithmetic&lt;/code&gt; worked, it was the biggest speedup in the whole experiment:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wasmtime 46.0.0&lt;/code&gt;: 2.41x native without it, 1.46x native with it.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wasmer 7.1.0&lt;/code&gt;: 2.08x native without it, 1.33x native with it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s the kind of change I like. A lot of libsodium’s expensive operations are arithmetic-heavy. If the WebAssembly ISA can express that arithmetic directly, the runtime has much less work to rediscover what the C compiler already knew.&lt;/p&gt;

&lt;h2 id=&quot;failures&quot;&gt;Failures&lt;/h2&gt;

&lt;p&gt;Most runs completed cleanly, but not all of them.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Bun 1.2.17&lt;/code&gt; failed &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;box_easy&lt;/code&gt; in the baseline build. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Bun 1.1.16&lt;/code&gt; failed &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pwhash_argon2i&lt;/code&gt; in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lime1&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lime1+simd128&lt;/code&gt; builds.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Node 22.3.0&lt;/code&gt; originally failed &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pwhash_argon2i&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pwhash_argon2id&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pwhash_scrypt&lt;/code&gt; in the baseline, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lime1&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lime1+simd128&lt;/code&gt; builds. The failures weren’t fixed by increasing Node’s JavaScript heap or stack settings. They were fixed by giving the Wasm modules an explicit maximum linear memory. With the baseline build, a 1024-page maximum, or 64 MiB, made &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pwhash_argon2i&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pwhash_argon2id&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pwhash_scrypt&lt;/code&gt; complete. But &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pwhash_scrypt&lt;/code&gt; failed with 512 pages and segfaulted again at 1536 pages and above, so this appears to be a V8 memory-mode threshold rather than a simple “more memory is better” setting.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WAMR 2.1.0&lt;/code&gt;, the 2024 slot, couldn’t compile even the baseline modules in AOT mode. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WAMR 2.3.1&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;2.4.4&lt;/code&gt; compiled and ran the baseline, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lime1&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lime1+simd128&lt;/code&gt; builds, but not &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wide_arithmetic&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Those failures were excluded from the aggregate. So were benchmark rows with a zero reported median.&lt;/p&gt;

&lt;h2 id=&quot;so-are-runtimes-getting-faster&quot;&gt;So, are runtimes getting faster?&lt;/h2&gt;

&lt;p&gt;Some of them are.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wasmtime&lt;/code&gt; is the cleanest yes: it got faster every year in this benchmark, by a little each time.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Node&lt;/code&gt; is also a yes, but the slope is gentle.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Bun&lt;/code&gt; is a loud yes between 2025 and 2026. It still has a lot of ground to cover for this workload, but the improvement is too large to ignore.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wazero&lt;/code&gt; is mostly flat.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WAMR&lt;/code&gt; is also mostly flat between the versions that worked here, but “flat” at about 1.4x to 1.6x native is a very good place to be.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wasmer&lt;/code&gt; is mixed if you only look at the baseline, but the 2026 release supporting &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wide_arithmetic&lt;/code&gt; changes the practical answer for crypto code. With that feature enabled, it was the fastest complete 2026 result I could compare across a normal current release.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wasm2c&lt;/code&gt; remains good. If you can translate WebAssembly to C ahead of time and compile it for the host, it’s hard to beat.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WAVM&lt;/code&gt; produced the fastest 2026 baseline number, but I don’t have a fair 2024 or 2025 comparison.&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WasmEdge&lt;/code&gt; remains excellent once it’s forced into AOT mode. The accidental interpreter-mode run was a good reminder that command-line defaults are part of the benchmark, too.&lt;/p&gt;

&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;

&lt;p&gt;If you run CPU-heavy cryptography in WebAssembly, runtime choice still matters a lot.&lt;/p&gt;

&lt;p&gt;The spread between the fastest complete current result and the slowest current result is large: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wasmer&lt;/code&gt; with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wide_arithmetic&lt;/code&gt; was 1.33x native, while current &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Bun&lt;/code&gt; baseline was 8.77x native.&lt;/p&gt;

&lt;p&gt;Feature support matters too. The same runtime can move from “pretty good” to “surprisingly close to native” when the WebAssembly module can use better arithmetic instructions.&lt;/p&gt;

&lt;p&gt;The comforting part is that the mainstream runtimes aren’t standing still. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wasmtime&lt;/code&gt; improved steadily. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Bun&lt;/code&gt; made a huge jump. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Wasmer&lt;/code&gt; gained a feature that matters for real crypto workloads. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WasmEdge&lt;/code&gt; remained fast once the AOT run mode was explicit.&lt;/p&gt;

&lt;p&gt;The less comforting part is that WebAssembly performance still isn’t one thing. It depends on the runtime, the release, the enabled WebAssembly features, whether the code goes through WASI from JavaScript, and whether ahead-of-time native compilation is allowed.&lt;/p&gt;

&lt;p&gt;So benchmark your actual workload.&lt;/p&gt;

&lt;p&gt;But if your workload looks like libsodium, the answer in 2026 is: WebAssembly can be close to native, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wide_arithmetic&lt;/code&gt; is worth caring about, and yes, some runtimes really are getting faster.&lt;/p&gt;
</content>
  </entry>
  
</feed>

