statichyper happy ?
back

Measure Before You Rewrite

I almost rewrote the whole Plumeria compiler in Rust.

Not because it was slow. Because I had nothing else to do.

This is a note to myself, so that the next time the same impulse arrives, I have numbers to hand instead of enthusiasm.

The Impulse

The plan sounded reasonable when I said it out loud. Take the parser, the transformer, zss-engine, all of it, and ship one native binary. Everyone else is doing it. Tailwind did it. The tooling world moved to Rust and Go, and staying in JavaScript started to feel like a failure of nerve.

So I wrote it. 8,593 lines of Rust across parser.rs, transform.rs, and a port of zss-engine. A working .node binary. compileCSS went from 1,494 lines of TypeScript down to 35 lines that forward to the native call.

Then I measured, and the measurement ended the project.

What the JavaScript Version Actually Costs

The docs site. 197 pages. Production build, JavaScript path, with the profiler on.

real 18.91s
✓ Compiled successfully in 11.8s
✓ Generating static pages using 7 workers (197/197) in 1519ms

Nineteen seconds. Of those nineteen seconds, here is every millisecond Plumeria spent, summed across all 8 worker processes:

loader.total              5,126ms   169 files   30.3ms/file
├ transform.parse         1,982ms   (this is swc — already Rust)
├ transform.scanAll         939ms
└ own transform logic     ~1,990ms
loader.compileCSS            66ms   (once, whole project)
loader.optimizer             65ms

That is CPU time across eight parallel workers. Divided by eight, Plumeria's contribution to the wall clock is about 0.64 seconds of a 18.91 second build. Around 3%.

And of that 0.64s, a third is swc parsing — which is already Rust, and which I was not going to make faster by writing more Rust around it. My own logic, the part I was actually proposing to rewrite, comes to roughly 0.25 seconds.

I was about to spend 8,593 lines of Rust to compete for a quarter of a second.

The Function I Was Sure Was the Bottleneck

scanAll walks the project, parses every source file, and builds the tables the transformer reads from. It felt heavy. It sounded heavy. It is the kind of function you assume is the problem.

docs/            98 files    cold  74.3ms   warm  1.0ms
synthetic       520 files    cold 525.4ms   warm  2.8ms

Linear. No hidden quadratic waiting to bite me at scale. And the warm number is the one that matters, because scanAll keeps its tables in a Map and invalidates by mtime — a rebuild costs one to three milliseconds.

I had never measured it. I had only felt that it was slow.

What the Rust Version Actually Cost

The rewrite finished the production build too. It just took longer.

| | JavaScript | Rust | | --- | --- | --- | | compile | 11.8s | 27.0s | | total | 18.91s | 34.84s |

In dev mode, with the same profiler:

loader.transform   19,964ms   96 files   208.0ms/file
loader.optimizer        97ms   96 files     1.0ms/file

208 milliseconds per file, inside the native binary. 99.4% of all loader time. The JavaScript path did the same work in 29ms per file.

My first instinct was to blame the FFI — the binary is 1.7MB, maybe crossing the boundary is expensive. It is not. A napi call costs microseconds and the .node is dlopened once per worker. If the binary size were the problem, the per-call cost would be small and flat.

The real reason was in my own code:

  • transform_source is called once per file, and its first act is Project::scan_shared(&root).
  • scan_shared walks the entire source tree and calls fs::canonicalize on every file before it consults its cache. canonicalize is realpath, which resolves every path component. Even on a cache hit, every call pays O(N) syscalls. Across N files that is O(N²).
  • When the incoming source does not byte-match what is on disk — which in a loader chain is most of the time — it deep-clones the entire parsed project and rebuilds the prop-usage index. Another O(N) per file.
  • And then it parses the source a second time.

What napi Actually Costs

Having blamed the FFI and been wrong, I went and measured it, because "napi is slow" is the kind of folklore I did not want to carry around for another year.

The round trip itself is free. Parsing a ten-byte source through swc.parseSync — a real native call, there and back:

swc parseSync (10 bytes) -> AST      6.9 µs/call

Seven microseconds. Across 169 files that is 1.2ms for the entire build. Call overhead is not a thing you need to think about.

What costs is the cargo. Here is the same file parsed two ways — once returning the AST as JavaScript objects, once returning only a string:

| bytes | parseSync (AST out) | transformSync (string out) | difference | share | | --- | --- | --- | --- | --- | | 7,498 | 289µs | 99µs | 191µs | 66% | | 14,997 | 568µs | 174µs | 394µs | 69% | | 29,995 | 1,135µs | 340µs | 795µs | 70% | | 59,991 | 2,254µs | 667µs | 1,587µs | 70% |

About 70% of what I call "parsing" is not parsing. It is materialising the AST as JavaScript objects on the way out, and it scales linearly with source size.

napi is not slow. Handing it an AST is slow.

The same repository already contains the proof of the good shape. compileCSS takes the whole project, does all the work on one side of the boundary, and returns a single CSS string:

loader.compileCSS   66.4ms   1 call   whole project
loader.transform    4,913ms  169 calls  per file

One call with a string result: 66ms. Per-file calls with an AST result: 4,913ms. Same language, same machine, same work. The difference is entirely in how many times the data crosses and what shape it is in when it does.

And the pinpoint native pieces I had already adopted behave exactly as you would hope. @rust-gear/glob charges almost nothing to be called, and charges for the tree it actually walks:

empty tree            47 µs
docs, 48 files       510 µs
monorepo, 447 files  1,970 µs

Forty-seven microseconds of fixed cost — the call, the pattern compilation, the thread pool. Everything above that is directory traversal, which is the work you asked for. Across a whole build that is 25ms out of 5,126ms. There is nothing left to win there.

Which means I owed my own argument a correction

I said the rewrite was competing for a quarter of a second. That was too kind to me and too harsh on the rewrite.

If roughly 70% of transform.parse is boundary cost, then about 1,320ms of the 1,982ms is marshalling — and no amount of pinpoint optimisation can reach it. You only get it back by keeping the AST on the native side, which is to say: by doing the full port, the exact thing I was arguing against.

So the honest ceiling:

| | today | full port, done right | | --- | --- | --- | | parse, actual work | ~660ms | ~660ms | | AST marshalling | ~1,320ms | 0 | | my own transform logic | ~1,990ms | ~600–1,000ms | | scanAll | 939ms | ~300ms | | total CPU | 5,126ms | ~1,600–2,000ms | | wall clock (÷8 workers) | 0.64s | ~0.20–0.25s |

0.40 to 0.45 seconds. Out of 18.91. Around 2.3%.

That is the strongest version of the case for rewriting, argued properly, with the number that favours it. It still loses — and it loses to its own implementation, which came in at +16 seconds instead of −0.45. The gap between the theoretical win and the delivered result is a factor of thirty-five, and every bit of that gap is the architecture I failed to port, not the language I ported to.

The Lesson

None of that is Rust's fault. Rust is fine. The bug was that I ported the functions and not the architecture.

The JavaScript version is fast because of a design decision I made years ago and then forgot I had made: module-scoped mutable tables, mtime invalidation, no copying. Warm rescan, one millisecond. When I rewrote it in Rust I reproduced every function faithfully and quietly dropped the thing that was actually doing the work.

You can port a function. You cannot port a function and expect its incrementality to come along for free.

The speed was never in the language. It was in not doing the work twice.

What I Am Writing Down

Measure the thing before you rewrite the thing. The profiler was already in the repo. It took one command and four minutes to learn that the target was under half a second. I had been ready to spend weeks.

A native boundary is priced by the cargo, not by the crossing. Seven microseconds to call in and out; seventy percent of the cost to hand back an AST. So the rule is not "avoid napi" — it is decide what crosses. One call, whole job, small result. If a design needs to ship a syntax tree across per file, the language on the far side has stopped being the interesting question.

A rewrite deletes the tests, not just the code. 99 of 154 compiler tests failed against the native path, and they were not cosmetic failures — dynamic style objects that the TypeScript path resolved correctly were simply unsupported. Those tests were the specification. Rewriting the implementation threw away the only written record of what the implementation was supposed to do.

Boredom is not a performance problem. I knew this was the motivation while I was doing it. That was not enough to stop me. Numbers were.

"Everyone is rewriting in Rust" is an observation about other people's bottlenecks. Tailwind had a real one. I had 0.45 seconds and a working incremental cache.

The Rust branch is going back on the shelf. Not deleted — the output was verified byte-identical to the JavaScript path, so it makes a decent differential-test oracle if the day ever comes when half a second is the thing standing between me and a shipped build.

That day is not today, and I should stop pretending I can predict when it arrives. I can only measure when it does.