← Back to Good Intentions

NTNT 0.4.12: Cutting the Repeated Work Out of the Hot Path

Most programmers carry around a rough speed hierarchy for languages. C and Rust are fast. Python, Ruby, and PHP are slower but productive. JavaScript was a punchline until V8 and other JITs turned it into one of the fastest dynamic runtimes people ship every day. NTNT sits near the start of that same runtime story: interpreted, useful, and still young enough that the biggest wins are often hiding in repeated work.

That hierarchy is useful for picking a language. It is almost useless for understanding why a particular program is slow, because the speed of a language is really the speed of its implementation, and implementations differ in ways the one-line ranking hides. When is the code parsed? When are names resolved? Are values boxed on the heap or packed into registers? Do hot paths get specialized after the runtime sees them a few thousand times? Are templates cached or re-read? Does a function keep redoing the same setup every time it runs?

Those questions sort languages into rough families. Native compiled languages like Rust, Go, C, and C++ turn your source into machine code ahead of time, so by the time it runs there is no interpreter in the loop. Bytecode and VM languages like Java and C# compile to an intermediate form and run on mature virtual machines. Dynamic languages like Python, Ruby, PHP, and JavaScript live in a messier but familiar middle: some runtimes interpret bytecode, some specialize hot paths, and the best of them have spent years teaching the runtime which costs it can avoid. And then there are direct or tree-walking interpreters, which walk the parsed program and execute it node by node. That is where a lot of young languages start, because it is the most direct path from "the parser works" to "the language runs real programs."

NTNT is in that last group today. It is interpreted, and it has not pretended otherwise. That placement is the whole context for this release, because the highest-leverage performance work for an interpreted language at this stage is not a JIT. It is finding the spots where the interpreter does the same work over and over when it already knows the answer, and not doing that.

So let me be precise about what 0.4.12 is, because performance posts love to overpromise. NTNT 0.4.12 does not make NTNT as fast as Rust, Go, or a mature JIT runtime. It removes several concrete performance cliffs from the code paths real NTNT apps already use. That is the entire claim. The good news is that the cliffs were steep, and the code that fell off them was ordinary app code.

Where interpreters quietly spend your time

The thing that surprises people about slow interpreted code is that there is usually no single villain. You profile expecting one dramatic bottleneck and instead you find a thin film of overhead spread across everything: a name looked up in a scope chain on every reference, a value boxed and cloned because the runtime cannot prove it is safe not to, a generic helper invoked for an operation that had a cheap special case, a template parsed again because nobody remembered the last parse, setup work redone around every database call.

None of those is fatal on its own. They become visible when they stack and repeat, which is exactly what happens in the shapes web apps are made of: templates rendered per request, loops over rows, helper-heavy routes, pages backed by a database. The cost is real, it is just distributed, and distributed costs are the ones that survive longest because no single flame in the profile is tall enough to demand attention.

Worse, some of them are not flat overhead at all. A few are algorithmic. Code that looks linear can quietly behave like O(n²), and that is where an interpreter goes from "a bit slow" to "this request never finishes." Two of the fixes in this release are exactly that case, and to see why, it helps to know one thing about how NTNT holds a value.

One design decision that explains half the release

NTNT has value semantics. A Value is a plain Rust enum, and an array is just an owned Vec<Value> inside it, a string just an owned String. There is no Rc, no shared backing buffer, no copy-on-write cell. When you write b = a, the array behind a is deep-copied into b, and the two never alias again. The environment is a HashMap<String, Value> per scope, so a binding owns its value outright.

That choice buys a lot of predictability. There is never a spooky-action-at-a-distance bug where mutating one list silently changes another, because no two bindings share storage. But it has a sharp edge, and the edge is the binary + operator. When the runtime evaluates items + [row], it does the honest thing the value model demands: it clones the whole left array, appends the new element to the copy, and binds the result back. Do that in a loop and the cost is brutal.

items = items + [row]   # in a loop of N iterations
# iteration 1 copies 1 element
# iteration 2 copies 2 elements
# iteration 3 copies 3 elements
# ...  total work = 1 + 2 + ... + N  =  N(N+1)/2  =  O(n²)

Nothing about the source says "quadratic." It reads like an append. It is secretly a triangular number of element copies, and the same trap sits under s = s + piece for strings. So the same design decision that makes NTNT values safe and easy to reason about is what put the cliff there. The interesting part of the fix is that the same design decision is what makes the fix safe.

What 0.4.12 actually changed

Seven optimizations, each one a place where the interpreter was repeating work it did not need to repeat. The pattern is the same every time: figure out what was being recomputed, and stop recomputing it.

Array self-append, back to linear. Start with the one that matters most, because the mechanics are the prettiest. The line everyone writes is:

items = items + [row]

In a loop body, NTNT now checks for a very specific shape before evaluating the +: the assignment target is a bare identifier, the same identifier appears on the left of the +, the right side is an array literal, and the binding currently holds an array. When all of that holds, instead of routing through the cloning + operator it reaches into the binding and calls Vec::append on the array in place. No copy of the existing elements. The growing array is mutated where it already lives, walking up the scope chain if the binding is defined in an outer scope.

Here is the part a language person will appreciate. This in-place mutation is observably identical to the slow path because of value semantics, not in spite of them. There is no other binding that could be aliasing this array, because assignment always copies, so there is nothing for the mutation to corrupt. The optimization is safe precisely because NTNT made the conservative choice everywhere else. A test in the suite pins this down: bind alias = rows, then append to rows, and alias still shows the old contents. Same semantics, minus the quadratic copy.

Two more guards keep it honest. The right side is required to be side-effect-free (no calls, lambdas, blocks, matches, nested assignments, templates, or awaits), so taking the fast path can never skip or reorder an observable effect. And the new elements are fully evaluated into a staging buffer before anything is mutated, so a failure halfway through the right-hand side leaves the binding untouched. The mutation is the last thing that happens, only once the result is known good.

The payoff is the difference between a hang and an instant response. A 20,000-element append fixture went from 7.11 seconds to 0.0122 seconds. After the fix, 50,000 elements ran in 0.0238s and 100,000 in 0.0439s, which is the clean linear scaling you expected from reading the code.

String self-concat, same shape, one extra subtlety. Building a string by hand has the identical trap:

html = html + piece

Same fast path, same guards, with one more thing to get right. Strings are often built across several terms at once, like s = s + piece + "y", and the parser hands that to the interpreter as a left-leaning tree of + nodes. To append in order, the fast path has to flatten that tree and collect the terms left to right. The helper that does it carries a documented invariant (with a debug_assert) that it must be called with an empty accumulator, because it only starts pushing terms after it finds the leftmost target + rhs base case. Get that backwards and s + "a" + "b" would produce "ba". The fast path also threads TypeMode through, so strict mode still rejects an implicit int-to-string concat exactly as the slow path would.

The numbers track the array case. s = s + "x" over 100k iterations went from 0.770s to 0.030s, and over 200k from 4.760s to 0.060s. The multi-term s = s + piece + "y" shape went from 5.370s to 0.040s at 100k, and from 26.100s to 0.070s at 200k. Twenty-six seconds to seven hundredths is the gap between a route that times out and one that does not.

len(identifier) fast path. Asking for the length of a big collection bound to a name was paying the value tax again.

let count = len(items)

The old path evaluated the argument expression first, which for an identifier means cloning the whole array or map out of the environment, then handed that copy to len only to read one number off it and throw the copy away. The fast path matches the AST shape len(<identifier>) directly and reads the length straight off the binding without materializing a copy. It still has to respect shadowing, so before short-circuiting it confirms that len actually resolves to the builtin native function and not a user-defined len in scope. If you shadowed it, you get your version, no surprises. On a helper-heavy /native/calls route that calls little functions in a tight loop, this was 350 to 1,575 RPS, a 4.5x jump, all of it from not cloning collections to count them.

Template parsing, cached. A route that calls template(path, data) was reparsing the external template and its partials on every request.

template("views/page.html", data)

Now each interpreter keeps a cache of parsed templates keyed by resolved path, and the parsed form is shared cheaply as an Rc<Vec<TemplatePart>> rather than re-walked from source. The cache stores the parsed AST, not the file text, so a hit skips the whole parse. Correctness around hot reload comes from a deliberate split: in development the lookup does one stat and compares the file's mtime against the cached one, so editing a template invalidates it immediately, while in production that stat is skipped entirely. The choice to compare mtime rather than hash the file content is the right cheap-versus-expensive call, since a stat is nearly free and a reparse is the thing you are trying to avoid. A layout-plus-partial render went from 15,441 to 22,635 RPS (+46.6%), and a 100-row template loop from 3,035 to 5,776 RPS (+90.3%).

Template loops, less scope churn. Row-heavy loops were allocating a fresh Rc<RefCell<Environment>> per row to hold the loop variable and its metadata, then throwing it away. The loop renderer now creates the loop scope once and reuses it, clearing its bindings between iterations with a single values.clear() instead of building a new scope object each time. Clearing matters: it is what stops a binding from one row leaking into the next, and it keeps the parent-scope fallback intact because the reused scope still points at its parent. Smaller win on its own, +3.8% on layout-plus-partial and +6.1% on the 100-row loop, but it stacks directly on top of the parse cache where loops already spend their time.

PostgreSQL prepared statements, cached. Pooled Postgres routes were re-preparing the same SQL on every query. The fix is prepare_cached() from deadpool-postgres, and the detail worth knowing is where that cache lives: it is per-connection, not per-pool. A connection pool by itself hands you a warm socket, but each checkout still re-sent the parse and plan for a statement it had run a thousand times. prepare_cached keeps the prepared Statement on the client so the second use of a query skips the server round trip. It also handles the one nasty case correctly: if the schema changes under a cached plan, Postgres raises "cached plan must not change result type," and the code catches exactly that, evicts the stale entry, and retries on the direct path. Measured on a local PostgreSQL 16 container with wrk -t4 -c32 -d4s, a single pooled query at one worker went from 7,549 to 15,625 RPS (2.07x) and dropped latency from 4.19ms to 2.01ms. At eight workers, a single query went from 32,004 to 62,198 RPS, and a twenty-query route from 2,092 to 4,736 RPS (2.26x).

Shared Postgres pools for repeated connect. The recommended pattern has always been a module-scope handle:

let db = connect(database_url)

But real code, including the Larri Dashboard and a lot of benchmark and demo code, sometimes called connect(url) per request, and each call stood up a whole connection pool only to drop it moments later. The fix splits the idea of a connection into two layers. There is a process-global registry of physical pools keyed by a normalized connection string, where normalization canonicalizes the host, folds the default 5432 port, sorts query parameters, and hashes the password so two spellings of the same target share one pool. Separately, each connect() returns a lightweight logical handle carrying an opaque id and an unforgeable token. Many logical handles can sit on top of one physical pool. close(handle) validates the token and invalidates that one handle, so a closed handle correctly errors with Invalid or closed database connection if reused, but it leaves the shared pool standing for the next caller. A smoke test of 50 repeated connect(url) calls held at a single underlying pool.

One honest caveat: each distinct normalized URL can create its own shared pool for the ntnt process, so dynamic or user-controlled connection strings should be bounded. A max-pool-count or LRU-style eviction registry for that case is tracked as follow-up (#142), not something this release ships.

Slow-but-useful is a real strategy

It is worth saying plainly that being interpreted is not a flaw to apologize for. Python, Ruby, PHP, and JavaScript all started slower than the compiled languages of their day and won anyway, because they were useful first and got faster later. CPython's 3.11 adaptive interpreter specializes bytecode based on the types it actually sees at runtime. Ruby's YJIT compiles hot methods to native code. Those came after years of the languages being valuable in their plain interpreted form, not before.

Node and Express are the sharpest version of this. Express is not the fastest server stack you can benchmark, and it has never claimed to be. It became one of the most-deployed ways to put an app on the internet because the ecosystem was there, the deployment story was simple, the I/O model fit web work, and the thing was familiar. Raw requests-per-second was rarely the deciding factor.

The lesson NTNT takes from that is not "ignore performance." It is that the useful target right now is fast enough and predictable in the paths real apps actually exercise, rather than topping a synthetic benchmark. 0.4.12 is aimed squarely at that target, and every change in it shipped with a measurement so the claim stays honest. The release also added a benchmark harness (DD-061) so future runtime work has to keep clearing the same bar.

Why this, and not a bytecode VM or a JIT

Bigger runtime changes are on the table for later. A bytecode compiler or a specializing JIT would move NTNT into a different performance family, and that work may well happen. It did not happen now, on purpose.

A JIT is a large, invasive change with its own bugs, and it pays off most once the cheap wins are already gone. The cheap wins were not gone. Every optimization in 0.4.12 fixed a measured cliff without touching the language model or asking anyone to rewrite their app. The same items = items + [row] you already wrote is now fast. Local, targeted changes like these also keep the interpreter easy to debug and reason about, which is worth a lot while the language is still young and the runtime is still something a person can hold in their head.

The internal performance roadmap (DD-061) is blunt about the discipline here. It rules out a "broad rewrite of Value or Environment without benchmark evidence," and it says the next move toward a slot-based binder or a bytecode VM should be "instrumentation and evidence, not a speculative rewrite." There is a literal "no feels-faster commits" rule: every change runs the benchmark suite at least three times and reports a consistent median or best-of-three, on workloads that mirror real apps rather than synthetic arithmetic. The other line worth repeating is "do not fossilize bad semantics for speed." That is the reason the array and string fast paths are hedged with so many guards. The point was never to make the language a little quicker by quietly changing what the code means. It was to make the existing meaning cheaper to compute.

The honest version of the claim

NTNT 0.4.12 is faster where current NTNT apps spend their time. Template rendering, length checks, list and string building, and Postgres queries all got measurably cheaper, and a couple of accidental O(n²) traps in completely ordinary code are now linear.

The language still has larger runtime work ahead, and this release does not pretend to have solved interpreter performance in general. What it does is take the obvious app paths off the several avoidable costs they were quietly paying. For where NTNT is in its life, that is the right work to do first, and it is the kind of work that comes with benchmarks rather than adjectives.

← Introducing the NTNT Language-Native Job SystemNTNT 0.5.0: The Verification Release →