How much faster is Rust than Node? I measured seven scenarios

frontendrustbenchmarkperformancev8

I wrote a whole post, a history of JavaScript build tools being replaced by Rust one after another: SWC replacing Babel, Turbopack replacing Webpack. That post keeps saying Rust is faster. Faster by how much?

So let's run some benchmarks, all on the same machine.

Seven scenarios, from Node 16% faster to Rust 35x faster

At most, Rust is about 35 times faster than Node.

Adding up ten million floating point numbers: Node 9.78ms, Rust 9.65ms, nearly identical. Every step waits for the previous addition (s = s + this number), one floating point addition takes about 3 cycles, and both sides run at that speed. Changing the language does not help.

Allocating lots of small objects that are thrown away right after use, Node is actually faster: a median of 4.34ms per round against Rust's 5.05ms. V8's generational GC is built for exactly this. New objects go into one contiguous block of memory (the young generation), so allocating is just moving a pointer forward; when the block is full, the program stops, the few objects still alive are moved, and the rest of the block is cleared at once. In Rust, each object asks the allocator for its own memory and is freed one by one.

But in the rounds where GC stops the program, Node gets slower. In the slowest 1% (p99), Node is 10.34ms and Rust 5.74ms. In one run with --trace-gc, GC stopped 275 times over the 200 rounds, the longest stop 4.29ms.

The 35x at the right end is "multiply each number by two and add it up if it passes a threshold", written as map().filter(). Node's map builds an array of ten million items, filter builds another, and the whole chain takes 578ms; the same work as a hand written for loop takes 62ms. A Rust iterator builds no intermediate array: each number is doubled, checked against the threshold and added in one pass, and it compiles to the same thing as the hand written loop, 16.1ms.

Small arrays: Node is faster. Large arrays: Rust is faster

Same code, only the array length changes.

for each number in the array:
    y = number * 2
    if y > 1:  total = total + y    # add it
    else:      total = total        # skip, unchanged

The data is random, so the if is true about half the time. The total work is fixed at reading two hundred million elements, so a shorter array is walked more times; the chart shows the time per element.

As the array grows, Node slows down and Rust does not, because the two compile that if into different machine code.

Node keeps the branch. The CPU does not wait for the comparison; it guesses a path from which way this branch went the last few times and keeps running. This is branch prediction. A right guess costs nothing; a wrong guess throws away what was computed on that path and starts over. In the chart, as the array grows from a thousand to a million items, Node goes from 1.02ns to 5.59ns per element. Another run uses an array of ten million items and changes the condition to one that is almost always true, so the CPU always guesses right: Node drops from 6.26ns to 3.64ns per element, while Rust stays at about 1.57ns both times.

Rust has no branch. LLVM computes both results, with and without the addition, and picks one with fcsel, a select instruction. Each element costs one extra addition, but the CPU has nothing to guess.

Here are the inner loops both sides actually compile to, written as assembly, one CPU instruction per line:

; Rust: no jump, fcsel picks one of two
ldr   d1, [x10], #8      ; load one number
fadd  d1, d1, d1         ; y = number * 2
fadd  d2, d0, d1         ; compute total + y first
fcmp  d1, d12            ; compare y with 1.0
fcsel d0, d2, d0, gt     ; y > 1: take total + y, otherwise keep total
; Node (V8's TurboFan): b.hs jumps over the add
ldr   d0, [x4, x6]       ; load one number
fadd  d0, d0, d0         ; y = number * 2
fcmp  d1, d0             ; compare 1.0 with y
b.hs  #+0x8              ; y not above 1: skip the next line
fadd  d2, d2, d0         ; total = total + y

With a short array, the sequence of "add / skip" keeps repeating and fits within what the branch predictor records, so it guesses right almost every time. Once the array is too long for that, about half the guesses are wrong. On this M1 Pro the boundary falls between four thousand and sixteen thousand elements. Rust has no branch to guess, so at any length each element takes about 1.58ns, the flat line in the chart.

SWC claims 20 to 70x. How much of that is Rust?

SWC claims to be 20 times faster than Babel on one thread and 70 times faster on four cores.

The test closest to a bundler builds an AST (abstract syntax tree) of 1.78 million nodes and walks it: Node 39.62ms, Rust 14.79ms, a gap of 2.68x. That is how much the language alone accounts for; 20 divided by 2.7 leaves about 7x to explain.

esbuild, written in Go, is the comparison: like SWC it is a native binary designed from scratch, only the language is not Rust.

Compiling the same 280KB file to ES2019: Babel 134ms, SWC 19.6ms, esbuild 25.6ms. Each tool ran six times, and esbuild was 27 to 38% slower than SWC every time, but both were more than five times faster than Babel. That 5x is not something only Rust has.

Compiling to ES5: Babel 4385ms, SWC 78.1ms, a gap of 56x. esbuild does not support ES5.

Dividing SWC's two multiples over Babel, 6.8x for ES2019 and 56x for ES5, by the language's 2.7x leaves 2.5x for ES2019 and 21x for ES5. That remainder comes from Babel and SWC being written differently.

The four cores in the 70x are not all Rust's doing either. Running the same work on eight threads, Node gets 6.2 times faster and Rust 7.6 times. Rust gains a bit more, but Node still gets six times faster with its built in worker threads splitting the work across cores; Babel simply does not use them.

What SWC's multiple is made of

  • About 3x, the language: moving from JavaScript to a language that compiles to a native binary. The AST walk is 2.7x; for a job that runs once and exits, such as reading a 4.8MB file, rewriting it and writing it back, it is 2.7 to 3.7x including startup, and most of the extra is Node's startup: node -e 0, which does nothing, still takes 34.8ms.
  • 2.5 to 21x, how the program is written: this part grows with the work. Switching the target from ES2019 to ES5 makes Babel's time 33 times longer and SWC's only 4 times.
  • About 3.5x, multiple cores (SWC's own 70 divided by 20): Node has worker threads too; Babel does not use them.

Only the first part has to do with the language, and esbuild, written in Go, has that part too.

The toolchain post is here.