DEV Community

COMMENTERTHE9
COMMENTERTHE9

Posted on • Originally published at cx-lang.com

Cx Dev Log — 2026-08-03

Eliminating Overhead for Non-recursive Code in Cx: The Cycle-Only Call-Depth Guard

Two host callbacks in every compiled call might sound minor, but they clocked in at about 11 ns per call in debug mode and around 14.5 ns debug-side. That's how our previous uniform call-depth guard in Cx worked—until today. It was effective but indiscriminate. Functions that couldn’t possibly recurse still took the performance hit.

Today’s change makes that a thing of the past: non-recursive code won't face any overhead for stack-depth protection now.

How cycle-only guarding works

So here's what's changed: a function that can't call itself through the call graph won't need a depth counter. Our solution starts by building a static call graph from the IR. This step is crucially done post-lowering to avoid a class of bugs tracked as C1-C4. Using Tarjan's strongly connected components algorithm, we've isolated functions that exist in call cycles. These are the only functions the guard touches.

We've added about 130 lines of code to host_boundary.rs. The core entry point—functions_that_can_recurse()—trims the call graph to module-internal edges only. Here's the catch: host intrinsics like cx_* end chains rather than completing cycles, so they're left out. A function gets the guard if it's part of an SCC larger than one or if it loops back to itself.

An insightful touch: Tarjan's algorithm here runs iteratively, not recursively, ensuring our compiler doesn't eat its own stack while guarding against stack overflows in user code.

Soundness? It’s all about the call graph’s completeness. The absence of indirect calls in Cx secures this. With IrInst::Call.callee being a String, the backend doesn't emit call_indirect, and there's no function or callable type in the language—ensuring every possible call edge is accounted for.

Performance results

Ran the numbers. Measured a few times, took the minimums:

Recursive calls like fib(30) (which stay guarded) showed negligible changes—141 ms to 139 ms in debug and 104 ms to 112 ms in release. Noise. The guard remains for recursives, so no surprise there.

Non-recursive code is where it shines: 6 million calls shifted from 200 ms to 110 ms in debug and from 128 ms to 106 ms in release. The cycle-only guard puts us on par with unguarded calls—103 ms release, 110 ms debug. Non-recursive performance? Back to business as usual.

For guarded functions, a call wears about 3.7 ns. The debug overhead that originally sparked this optim (11 ns/call) was nearly four times what ships.

Inline counter deferred on evidence

Yesterday, swapping the two host callbacks for a declare_data load/add/store sequence was flagged as a follow-up optimization. Not anymore. Cycle-only cuts the guard cost from non-recursive routines, which covers most code. For recursive code, at about 3.7 ns/call in release, it doesn’t merit more fiddling.

Consistently, we measure first, decide second, and document the reasoning afterward.

Test coverage

Our latest fixture, t_mutual_recursion_guard, digs into scenarios beyond self-loops alone—A to B and B to A kind of calls need complete SCC analysis. We've got new tests on self-loops, length-2 and length-3 cycles, non-recursive calls dodging guards, and host intrinsics not being treated as graph edges. Boundary checks hold steady at 255/256 across recursion shapes and backends.

The corpus climbed to 414 from 413 fixtures. Parity ticks up from 373/40/0 to 374/40/0. JIT tests climb to 426 from 425. Clippy stays perfect at 110/110.

State of the repo

Submain sees an advance of 26 commits over main, up from 24. The merge is one step closer, yet not done. The gap's there, and growing.

Merge that to the front, and two 0.3.3 milestones await: array-return lowering and expression receivers. They're on the horizon.


Follow the Cx language project:

Originally published at https://cx-lang.com/blog/2026-08-03

Top comments (0)