In Part 2, we unlocked Tail Call Optimization and hit 2.1 billion recursive calls before slamming into C#'s maximum array size limit. That should have been the end of the experiment.
Then it hit me: the call stack wasn't stopping us anymore—only our data structures were. So we stripped those away too, down to a single bit per node. When we ran it again, the recursion didn't stop at 10 billion, or 100 billion. It just kept eating through our 192 GB of DDR5 RAM until the machine literally had no memory left to give. Yes, that's right—recursion all the way through. No iterations, no loops in C#—just a pure, elegant recursive expression. Welcome to Hyper-Dimensional Depth.
| Stage | Max Recursive Calls | Run Time | Memory Used | What Stopped It |
|---|---|---|---|---|
| Part 1 | 30 Million | 350 ms | ~2 GB | ❌ 2 GB Thread Stack Limit |
| Part 2 | 2.1 Billion | 2.6 sec | ~19 GB |
❌ C# 2.1B Array & int Limit
|
1,520,000,000,000 stack frames deep — no loops, no iterations
Gen 0/1/2: 0 / 0 / 0How Is This Even Possible? (Where We Left Off)
In C#, recursion is supposed to crash with a StackOverflowException after ~10,000 calls.
You're told that if you want to scale, you have to rewrite your recursive function into an iterative while loop.
Why? Because in standard recursion, every time Dfs() calls itself, the CPU has to pause the current call,
save its state, and wait for the child call to return. Those paused frames pile up on the Thread Stack—which
is only 1 MB by default. Once that 1 MB fills up, it overflows and crashes.
Instead of giving up on recursion, we chipped away at every bottleneck step by step:
-
In Part 1: We shrank our nodes from classes to
struct, replacedHashSet<T>withbool[], and expanded the Thread Stack to ~2 GB. That took us from ~10,000 calls to 30 million. -
In Part 2: We stripped out
List<int>, generics, andforeachso each stack frame took only ~48 bytes (return nodes.DfsRecursiveLinear(next, visited) + 1;). That pushed us to 44.72 million calls—where 44.72M × 48 bytes completely filled the 2 GB Thread Stack and threwStackOverflowExceptionagain. -
The TCO Breakthrough (Part 2): Why was the CPU still stacking frames? Only because of that
+ 1at the end—every call had to stay paused on the Thread Stack waiting to add1when the child returned. When we removed the return value, the hatch released:
// Part 2: 2.1 Billion (limited by array size)
public static void DfsRecursiveLinear(
this NodeLinear[] nodes,
int index,
bool[] visited)
{
if ((uint)index >= (uint)nodes.Length || visited[index])
return;
visited[index] = true;
int next = nodes[index].NextIndex;
if (next >= 0)
nodes.DfsRecursiveLinear(next, visited); // <-- Tail position: nothing left to do after!
}
Because the recursive call was now the very last instruction, the compiler triggered Tail Call Optimization (TCO)—which
is technically an anomaly! Officially, C# does not support or document TCO (the Roslyn C# compiler doesn't even emit .tail IL instructions, so you're not supposed to be able to trigger it from C#).
Yet under these exact conditions, the JIT/AOT compiler secretly rewrote the recursion into a jmp instruction in CPU registers—reusing the same single stack frame forever instead of pushing a new frame onto the Thread Stack.
Suddenly, the Thread Stack stopped growing. We tested 60 million... 100 million... 500 million... all the way to 2.1 billion calls.
Shifting From the Stack to the Heap
As we pushed past hundreds of millions of calls with TCO, we noticed something interesting: we were now able to use more and more system memory (the Heap).
With the Thread Stack out of the way (0 bytes added per call), the only memory growing was our nodes[] and visited[] arrays on the Heap.
And at 2.1 billion, we didn't stop because of recursion—we stopped because of three data-structure limits on the Heap:
NodeLinear[]- C# arrays on the Heap cap at ~2.1B elements (and already used 17 GB of RAM)bool[]- Another 2.1B element array on the Heapintindices - Max value of ~2.1B
But here's the thing: TCO itself has no limit. It's just a jmp instruction.
Once we realized the limit was no longer the call stack—the limit was in the memory itself—we stripped our Heap data structures down to the bone.
The Stripping: Piece by Piece 🔪
To break past 2.1 billion calls and let TCO run until it exhausted all 192 GB of physical RAM, we had to eliminate every one of those three bottlenecks from our Part 2 implementation (NodeLinear[] nodes, int index, and bool[] visited). Here is how we stripped them away one by one:
1. Kill the NodeLinear[] Array on the Heap
In Part 1 and Part 2, to force Depth-First Search into its worst-case recursion depth (where visiting N nodes requires N deep recursive calls with zero early backtracking), our benchmark graph was a single continuous linear chain (Node 0 → Node 1 → Node 2 → … → Node N-1).
In Part 2, we represented that chain by allocating a massive NodeLinear[] array on the Heap, where each struct was 8 bytes (two 4-byte int fields) and every slot i simply stored NextIndex = i + 1:
-
At
2.14 billionnodes (Part 2):2,147,483,591 nodes × 8 bytes = 17.18 GB of Heap RAM—AND it slammed straight into C#'s maximum single-array limit (Array.MaxLength). -
At
1.52 trillionnodes (Part 3): Even if C# allowed it, storing aNodeLinearstruct with 64-bitlongfields (16 bytesper node) would require1.52T × 16 bytes = 24.32 Terabytes of RAM!
Look closely at what nodes[index].NextIndex was actually doing on every recursive call: we were reading 8 bytes from DRAM just to look up slot index and get back index + 1.
For a linear chain, we don't need to store edges in a Heap array at all—the next node's address is always index + 1, computed on the fly in a single CPU register instruction (inc rdi) with 0 bytes of RAM:
// ❌ BEFORE (Part 2): 8 bytes per node on the Heap (17.18 GB at 2.14B, capped at Array.MaxLength)
public struct NodeLinear
{
public int Value; // 4 bytes
public int NextIndex; // 4 bytes (always initialized to i + 1)
}
public static void DfsRecursiveLinear(this NodeLinear[] nodes, int index, bool[] visited)
{
if ((uint)index >= (uint)nodes.Length || visited[index]) return;
visited[index] = true;
int next = nodes[index].NextIndex; // <-- 17 GB Heap lookup just to read (index + 1)!
if (next >= 0)
nodes.DfsRecursiveLinear(next, visited);
}
// ✅ AFTER (Part 3): NodeLinear[] array is GONE (0 bytes of RAM).
// The edge transition (index + 1) happens directly inside CPU register RDI:
public static void DfsLinear(int index, int maxDepth, bool[] visited)
{
if (index >= maxDepth || visited[index]) return;
visited[index] = true;
DfsLinear(index + 1, maxDepth, visited); // <-- Procedural edge: 17 GB of RAM eliminated!
}
2. Upgrade from 32-Bit int to 64-Bit long Indices
Even with NodeLinear[] gone, passing int index and int maxDepth still traps us at the 32-bit signed integer ceiling: int.MaxValue = 2,147,483,647 (231 − 1). The moment index + 1 increments past 2,147,483,647, a 32-bit signed int wraps around to -2,147,483,648.
By upgrading index and maxDepth from 32-bit int (Int32) to 64-bit long (Int64), our node counter widens to a full 64-bit CPU register (RDI and RSI on x86-64) with a maximum value of 9,223,372,036,854,775,807 (9.22 Quintillion nodes)—over 4.29 billion times larger than int.MaxValue:
// ❌ 32-bit signed int: caps at 2,147,483,647 (~2.14 Billion)
// ✅ 64-bit signed long: caps at 9,223,372,036,854,775,807 (~9.22 Quintillion)
public static void DfsLinearLong(long index, long maxDepth, ChunkedBitArray visited)
3. Borrowing the Heap & Mapping ChunkedBitArray Bit-by-Bit
Now only visited remains on the Heap. Our goal here was to push as close to our 192 GB maximum system memory as physically possible.
When we initially ran this on Windows, desktop OS overhead limited us to 1 trillion stack frames (~119 GB)—but when we switched to headless Linux, we were able to go even further, pushing all the way to 1.52 trillion stack frames and getting much closer to the 192 GB of memory we had available (more details on that later).
To reach that scale, we had to solve two fundamental memory problems:
Problem 1: Byte Addressing vs. Bit Addressing (1.52 TB vs. ~177 GiB / 190 GB)
Even though a visited flag is just 0 or 1, CPUs cannot address individual bits in RAM—the smallest addressable unit of memory is 1 byte (8 bits).
Because of that, a standard C# bool[] uses 1 full byte per node, wasting the other 7 bits:
bool[] on the Heap (1 Byte = 8 Bits per Node — 87.5% Wasted Zeros)
1.52 Trillion × 1 Byte = 1.52 TB RAM (Impossible!)
ulong Word (64 Nodes Packed Into 1 Single 8-Byte Word — 100% Density)
1.52T ÷ 8 = 190 GB (176.96 GiB) — Fits in 192 GB!
By packing 64 nodes into each 64-bit ulong (1 bit per node), we get an 8x compression—shrinking
1.52 trillion nodes from an impossible 1.52 TB down to 190 GB decimal (176.96 GiB binary), getting us as close as physically possible to our 192 GB of system memory once the OS kernel and .NET runtime are accounted for.
Problem 2: You Cannot Allocate One Continuous Array Across ~190 GB in Managed C#
Even after packing 64 nodes into each ulong, 1.52 trillion nodes still requires:
1,520,000,000,000 bits ÷ 64 bits/ulong = 23,750,000,000 ulong elements (23.75 Billion).
In C#, every managed array is indexed by a signed 32-bit int and hard-capped at Array.MaxLength = 2,147,483,591 elements (~2.14B).
You cannot allocate a single continuous new ulong[23_750_000_000] on the .NET Heap!
The Solution: 3-Level Bit-Addressing Across 1,416 Heap Chunks (ulong[][])
To span nearly all of our 192 GB of system memory without hitting C#'s single-array cap, we built ChunkedBitArray (ulong[][] _chunks)—a 2D jagged array that slices the Heap into 128 MiB chunks:
- Each chunk holds
1 << 30(230 = 1,073,741,824) bits (~1.07 billion nodes). - Divided by
64(26) bits perulong, each chunk is aulong[16,777,216]array =134,217,728bytes (128 MiB)—safely below .NET's 2 GB per-array limit. - On Windows (
1.00 trillionnodes),_chunksallocated932chunks (~119 GB). On headless Linux (1.52 trillionnodes), it scaled all the way to1,416chunks (_chunks[0]through_chunks[1415]=176.96 GiB/190 GB).
Normally, converting a flat index into a 2D chunk, word, and bit position requires integer division (/) and modulo (%)—and hardware integer division (idiv) is one of the slowest instructions on an x86 CPU (~15–40 cycles).
Because we sized our chunks (230 bits) and words (26 bits) to exact powers of two, the binary bits of long index ARE the address!
We slice the 64-bit integer into three bit-fields using 1-cycle shifts (>>) and bitwise masks (&):
long index Maps Across 1,416 Heap Slabs
-
Find the
128 MiBChunk (int chunk = (int)(index >> 30)): Shiftingindexright by30bits discards the lower 30 bits (0..29). In binary,>> 30is exact integer division by 230 (1,073,741,824). Every timeindexcrosses another1,073,741,824nodes,chunkincrements by1—stepping from_chunks[0]all the way to_chunks[1415]. -
Mask the Offset Inside That Chunk (
int bitIndex = (int)(index & 0x3FFFFFFF)):ChunkMask = (1 << 30) - 1 = 0x3FFFFFFF, which in binary is thirty1s. BitwiseANDingindex & 0x3FFFFFFFstrips off the chunk number in bits63..30and leaves only the bottom 30 bits—the exact remainderindex % 1,073,741,824(0to1,073,741,823). -
Find the 64-Bit
ulongWord (int word = bitIndex >> 6): Eachulongholds 64 = 26 bits. ShiftingbitIndexright by6bits (>> 6) divides by64, extracting Bits29..6—giving us the exact array slot0 .. 16,777,215inside_chunks[chunk]. -
Flip the Exact Bit Inside That
ulong(int bit = bitIndex & 63; ... |= (1UL << bit)):63in binary is six1s (111111). MaskingbitIndex & 63extracts Bits5..0(0to63). Then1UL << bitshifts a single1into that exact bit slot, and|=flips that bit inside_chunks[chunk][word]without disturbing the other 63 nodes sharing the sameulong!
🔍 Concrete Example: Tracing Node 1,073,741,900 in Binary
Suppose the recursion reaches index = 1,073,741,900 (just 76 nodes past the end of Chunk #0):
- Level 1 (
chunk):1,073,741,900 >> 30=1→ Selects_chunks[1](the second128 MiBchunk on the Heap). - Intra-chunk offset (
bitIndex):1,073,741,900 & 0x3FFFFFFF=76(the 76th bit insideChunk #1). - Level 2 (
word):76 >> 6(76 / 64) =1→ Selects_chunks[1][1](the secondulonginChunk #1, which holds bits64..127). - Level 3 (
bit):76 & 63(76 % 64) =12→ Computes1UL << 12(0x1000) and sets Bit12of_chunks[1][1]!
// ChunkedBitArray: 1 bit per node, scales right up to max system memory with 0 GC
public class ChunkedBitArray
{
private const int BitsPerChunk = 1 << 30; // 1,073,741,824 bits (128 MiB) per chunk
private const int ChunkMask = BitsPerChunk - 1; // 0x3FFFFFFF (lowest 30 bits)
private readonly ulong[][] _chunks;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool Get(long index)
{
int chunk = (int)(index >> 30); // Level 1: Which 128 MiB chunk? (Bits 63..30)
int bitIndex = (int)(index & ChunkMask); // Intra-chunk offset (Bits 29..0)
int word = bitIndex >> 6; // Level 2: Which 64-bit ulong? (Bits 29..6)
int bit = bitIndex & 63; // Level 3: Which bit (0..63)? (Bits 5..0)
return (_chunks[chunk][word] & (1UL << bit)) != 0;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Set(long index)
{
int chunk = (int)(index >> 30);
int bitIndex = (int)(index & ChunkMask);
int word = bitIndex >> 6;
int bit = bitIndex & 63;
_chunks[chunk][word] |= (1UL << bit);
}
}
Memory savings on the Heap:
bool[]at 1.52 trillion: ~1.52 TB (impossible ❌)ChunkedBitArrayat 1.00 trillion (Windows): ~119 GB (932chunks ×128 MiB, 0 GC triggered ✅)ChunkedBitArrayat 1.52 trillion (Headless Linux): ~190 GB decimal / 176.96 GiB binary (1,416chunks ×128 MiB—pushing right up to our 192 GB physical RAM limit with 0 GC triggered ✅)
The Hyper-Dimensional Algorithm: Bare Metal 🔩
// Hyper-Dimensional DFS: 1.52 TRILLION recursive calls
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
public static void DfsLinearLong(
long index,
long maxDepth,
ChunkedBitArray visited)
{
if (index >= maxDepth || visited.Get(index))
return;
visited.Set(index);
// Linear graph: next = index + 1
// This is in tail position - TCO kicks in
DfsLinearLong(index + 1, maxDepth, visited);
}
That's it. No node array. No unnecessary allocations.
Just pure recursion hitting the metal.
(Full C# benchmark source: LargeStackBenchmark_BonusRound.cs & LargeStackBenchmark_Program.cs on GitHub.)
🚀 C# AOT Results: Hyper-Dimensional Depth
Running C# 14 (.NET 10 Native AOT) on a minimal Headless Ubuntu Server 24.04.5 LTS environment to get as much of our 192 GB of system memory as possible:
| Depth | Time | Ops/sec | Memory | GC Wake-Ups | |
|---|---|---|---|---|---|
| 🎯 | 3 Billion | 1.34s | 2.24B | 357 MB | 0 |
| 🔥 | 5 Billion | 2.23s | 2.24B | 596 MB | 0 |
| 🚀 | 10 Billion | 4.47s | 2.24B | 1.2 GB | 0 |
| 🤯 | 16 Billion | 7.14s | 2.24B | 1.9 GB | 0 |
| 💥 | 50 Billion | 22.2s | 2.25B | 5.9 GB | 0 |
| 🌟 | 100 Billion | 44.4s | 2.25B | 11.9 GB | 0 |
| ⚡ | 500 Billion | 3.72 min | 2.24B | 59.6 GB | 0 |
| 🔥 | 1 TRILLION | 7.41 min | 2.25B | 119 GB | 0 |
| 🌌 | 1.52 TRILLION | 11.27 min | 2.25B | 190 GB | 0 |
Let that sink in for a second: it took nearly 11 and a half minutes (11.27 min / 676.08 seconds) of continuous, flat-out execution on a 5.7 GHz Ryzen 9 9950X3D to complete 1.52 trillion recursive calls—which is still insane when you realize the CPU was executing 2.25 billion recursive calls every single second without a single loop or a single Garbage Collector pause!
And on Windows 11, our initial run was twice as slow—taking 15.20 minutes just to reach 1 Trillion calls (compared to 7.41 minutes on Headless Ubuntu) before Windows' ~38 GB desktop OS overhead ran out of physical RAM.
Depth Comparison Analysis
| Compared Against | Previous Depth | New Depth (Part 3) | Depth Improvement |
|---|---|---|---|
| vs. Part 1 (2 GB Thread Stack) | 30,000,000 |
1,520,000,000,000 |
50,666x Deeper |
vs. Part 2 (TCO + int Array Ceiling) |
2,140,000,000 |
1,520,000,000,000 |
710x Deeper |
The Key Insights
1. TCO Has No Depth Limit (Zero Stack Frames Accumulated)
Tail Call Optimization transforms a recursive call into a register jmp instruction.
Instead of pushing a new activation record onto the Thread Stack on every step, the CPU reuses the exact same stack frame Trillions of times.
At 1.52 trillion calls, net Thread Stack growth is literally 0 bytes. The only limits left are:
- Physical RAM to record which nodes have been visited
- Memory bandwidth & CPU clock cycles (patience!)
2. Data Structures & 32-Bit Indexing Were the Real Bottleneck
We didn't hit a wall at 2.1 billion in Part 2 because of recursion—we hit it because C# arrays are indexed by a 32-bit signed int (Array.MaxLength = 2,147,483,591) and because our pre-allocated graph array filled RAM.
By computing edges procedurally (index + 1), upgrading node indices to 64-bit long, and slicing the Heap into 128 MiB chunks, recursion scales until physical DIMMs are full.
3. Bit-Level Packing Is an 8x Memory Multiplier with Zero GC Pressure
Because CPUs cannot address below 1 byte, a standard bool[] wastes 7 out of every 8 bits—meaning 1.52 trillion nodes would require an impossible 1.52 TB of RAM.
Packing 64 nodes into each ulong word (1 bit per node) with power-of-two bit-shifting (>> 30, >> 6, & 63) compresses 1.52 trillion nodes into 190 GB decimal (176.96 GiB binary) with zero hardware division instructions (idiv) and 0 Garbage Collector wake-ups (0 / 0 / 0 across Gen 0, Gen 1, and Gen 2).
4. Rock-Solid Linear Performance (O(N) Across Trillions of Calls)
From 3 billion to 1.52 trillion calls, throughput remained completely flat—~1.10 billion ops/sec (0.91 ns/call) on Windows 11 and ~2.25 billion ops/sec (0.445 ns/call, just 2.39 CPU cycles per recursive call!) on Headless Ubuntu Linux.
Double the depth, double the time. No cache thrashing, no GC pauses, no degradation.
5. The OS Tax & Page Tables Matter at Terabit Scale (Windows 11 vs. Headless Linux)
When you're trying to use every last gigabyte of 192 GB of DDR5 RAM, the operating system itself becomes a primary variable:
- Reclaiming ~38 GB of RAM: Windows 11's desktop compositor and background services consumed ~35–42 GB at idle, capping us at
1.00 trillioncalls (119 GB). Switching to Headless Ubuntu Server 24.04.5 LTS dropped user-space idle memory to just54 MiB—freeing the headroom needed to reach1.52 trillionstack frames (176.96 GiB/190 GB). - Cutting Execution Time in Half (
15.2m→7.41mat 1T): On Linux, by stripping redundant bounds checks and backing our bitset with2 MiBTransparent Huge Pages (THP) instead of 45 million separate4 KiBpage table entries, we eliminated TLB page-walk stalls and shrunk the hot loop to just 9 x86-64 instructions (32 bytes)—running 2.05x faster than our original Windows run!
⚔️ Cross-Language Showdown: How Does C# Stack Up Against 11 Other Languages?
After pushing C# to 1.52 trillion recursive calls, the obvious next question was: "How do every other major systems, managed, functional, and scripting language perform on the exact same 192 GB Headless Ubuntu server?" We wrote and benchmarked the exact same recursive traversal across 12 languages, grouped into three architectural tiers (you can find the source code for all the benchmarks we ran on GitHub at github.com/rbtLong/hpc-dfs-iii).
Tier 1: Bare-Metal Systems Languages (Fortran 2008, Rust, C++20, C (C23), Zig)
Why They Are Tier 1 & Their Stack / Memory Allocation Advantage: Tier 1 languages compile directly to native machine code with zero virtual machine and zero Garbage Collector built in. That gives them two hardware advantages when pushing recursion to the limit of physical RAM:
-
Unrestricted Stack & 64-Bit Flat Addressing: Unlike managed runtimes (where single arrays and
Span<T>are capped at 32-bitint.MaxValue= ~2.14B elements and thread stacks are locked down), Tier 1 languages can address all 192 GB of RAM as a single flat 64-bit pointer held directly on the stack with0 bytesof object headers. Even more remarkably, if you unlock the Linux stack limit (ulimit -s unlimited) and disable TCO in C (clang -O2 -fno-optimize-sibling-calls), the Linux kernel lets C dynamically grow its actual hardware Thread Stack (VmStk) to156.46 GiB—pushing10.50 Billionreal16-bytecallqstack frames directly onto the Thread Stack! -
Zero Runtime Footprint (+35 Billion Extra Nodes at the Ceiling): Because Tier 1 binaries have only
~2 MiBof process overhead (no CLR or JVM host reserve), under TCO (0 bytes/call) they were able to push slightly past C#'s1.520 Trillion(176.96 GiB) all the way to the physical kernel ceiling of1.555 Trillionrecursive calls (181.03 GiB) before the Linux kernel's~3 GiBsafety watermark stepped in.
Tier 1 Quirks Uncovered:
- Fortran 2008 &
unsafeRust Tied for #1 (1.90–1.91 cycles/call): Bothgfortran -O3andrustcemitted a 7-instruction (25-byte) loop that hoisted the constant1outside the loop and incrementedindex++before the memoryor, hitting2.82 Billion ops/sec(9.00–9.03 minat1.52T;9.20–9.22 minat1.555T). - The LLVM 2x
btsUnrolling Quirk (Where C# Beat Default-O3C, C++, and Zig): Out of the box at-O3, Clang/LLVM unrolled the tail call 2x using back-to-backbts(Bit Test and Set) instructions that stalled AMD Zen 5's Store-to-Load Forwarding buffer—dropping C, C++, and Zig to2.04–2.10B ops/sec(slower than C# Native AOT's2.25B ops/sec). Disabling LLVM's 2x unroller (unroll(disable)/.never_inline) restored all three to2.76–2.80B ops/sec. - Safe Rust's Bounds-Check Tax: Idiomatic safe Rust (
&mut [u64]slice) could not elide its slice bounds check inside the recursive loop, running at2.39B ops/sec(10.59 minat1.52T)—just 40 seconds ahead of C#.
| Language & Compiler | Mode / Kernel | Max Depth | 1.00T Time | 1.52T Time | 1.555T Max | Ops/sec | Stack / Call | GC |
|---|---|---|---|---|---|---|---|---|
| 🥇 Fortran 2008 (gfortran 13.3 -O3) | RECURSIVE + ibset (7 inst) |
1.555T | 5.94 min | 9.00 min | 9.20 min | 2.82B | 0 B (TCO) | None |
| 🥈 Rust 1.99 (rustc -O3) | unsafe *mut u64 (7 inst) |
1.555T | 5.93 min | 9.03 min | 9.22 min | 2.81B | 0 B (TCO) | None |
| 🥉 C++20 (clang++ 18.1 -O3) | [[clang::musttail]] 1x (7 inst) |
1.555T | 5.97 min | 9.08 min | 9.33 min | 2.80B | 0 B (TCO) | None |
| C (C23) (clang 18.1 -O3 Tuned) | [[clang::musttail]] 1x (7 inst) |
1.555T | 6.01 min | 9.15 min | 9.32 min | 2.78B | 0 B (TCO) | None |
| Zig 0.13 (-OReleaseFast Tuned) | @call(.always_tail) (7 inst) |
1.555T | 6.03 min | 9.17 min | 9.42 min | 2.76B | 0 B (TCO) | None |
| Rust 1.99 (Safe) (rustc -O3) | Safe &mut [u64] slice (9 inst) |
1.520T | 6.96 min | 10.59 min | — | 2.39B | 0 B (TCO) | None |
| ⚡ C# 14 (.NET 10 AOT) | TCO + ulong* THP (9 inst) |
1.520T | 7.41 min | 11.27 min | CLR ~4 GiB | 2.25B | 0 B (TCO) | 0 (Undisturbed) |
| C (C23) / C++ / Zig (Default LLVM -O3) | Default 2x Unrolled bts Stalled |
1.520T+ | ~8.18 min | ~12.4 min | — | 2.04–2.10B | 0 B (TCO) | None |
| C (C23 Non-TCO Stack) (ulimit -s unlimited) | Hardware callq on 156 GiB Stack |
10.50B | Exhausts 192 GB RAM at ~11.4B real stack frames | 0.10B | 16 B/call | None | ||
How Did C# Hold Its Own Against Bare-Metal C, C++, and Rust?
Looking at the Tier 1 results above, it is remarkable that C#—a fully managed, garbage-collected language—was able to go toe-to-toe with bare-metal systems languages like C, C++, Rust, Zig, and Fortran across 1.52 Trillion recursive calls. Dissecting the hardware counters and compiler disassembly (objdump -d -M intel) reveals three key insights into how C# held its own—and why C itself wasn't in 1st place:
-
How C# Competed with Zero GC Overhead (9 Instructions vs. 7 Instructions):
Every other language in Tier 1 (Fortran, Rust, C++, C, Zig) has no Garbage Collector at all. C# runs inside the .NET runtime with a live Garbage Collector—yet because our traversal allocates zero managed objects and eliminates stack frames via TCO, the GC stayed completely asleep (
0collections across 1.52 trillion calls).- It finished right on the heels of Safe Idiomatic Rust (
10.59 minfor Safe Rust vs.11.27 minfor C# at1.52T—separated by just0.15 CPU cyclesper call, since Safe Rust's&mut [u64]slice emits a 2-instruction bounds check inside the loop). - And why did tuned Fortran,
unsafeRust, C++, and C run ~20% faster than C# (~9.0–9.15 minvs.11.27 min, or1.90–1.94 cycles/callvs.2.39 cycles/call)? Disassembly showed the entire gap came down to just 2 extra x86 instructions in C#'s loop (9 instructions vs. 7 instructions): .NET's compiler reloaded the constant1(mov ecx, 1) inside the loop instead of hoisting it into a register beforehand, and used a separatelea rax, [r8+rax*8]instruction to calculate the memory address beforeor QWORD PTR [rax], rcxinstead of folding the address math directly into theorinstruction. - To prove the .NET runtime itself added zero hidden tax, we pasted C#'s exact 9-instruction assembly loop into a C program and compiled it with
clang -O3: C executed those 9 instructions at2.27B ops/sec(2.37 cycles/call)—matching C#'s2.25B ops/sec(2.39 cycles/call) to within1.2%!
- It finished right on the heels of Safe Idiomatic Rust (
-
Why Out-of-the-Box
clang -O3C, C++, and Zig Actually Ran Slower Than C# (The LLVM 2xbtsUnrolling Trap): Notice the second C row near the bottom of the Tier 1 table: when compiled with standardclang -O3out of the box, C (2.04B ops/sec,~8.18 minat 1T) actually ran slower than C# (2.25B ops/sec,7.41 minat 1T)! Why?- In a normal 1x loop, each call sets one bit in memory using a bitwise shift (
shlx) and a direct memory read-modify-write (or QWORD PTR [mem], reg). - However, at default
-O3, LLVM's optimizer tried to be "too clever": after turning the tail call into a loop, it unrolled the loop 2x (handling 2 nodes per iteration) and replacedshlx + orwith x86'sbts(Bit Test and Set) instruction. Specifically, Clang loaded the 64-bit word from cache into registerr9(mov r9, [rsi+r8*8]), executed two back-to-backbts r9, rdiinstructions on that same registerr9, and wroter9back to memory (mov [rsi+r8*8], r9). - Why did that hurt performance? Because
btsboth modifies registerr9and writes the old bit into the CPU Carry Flag (EFLAGS.CF), the secondbtscannot execute until the firstbtsfinishes, and the memory write cannot enter the CPU's Store Queue until both serialbtsinstructions complete. Because 64 consecutive recursive calls all hit the exact same 64-bit word in L1 cache, delaying the memory write stalls AMD Zen 5's Store-to-Load Forwarding (STLF) buffer on the next iteration—dropping default-O3C, C++, and Zig from1.94 cycles/calldown to2.56–2.64 cycles/call(2.04–2.10B ops/sec). - Once we told Clang not to unroll the loop (adding
#pragma clang loop unroll(disable)in C,-mllvm -unroll-count=1in C++, and@call(.never_inline, ...)in Zig), LLVM stopped usingbts, emitted the clean 7-instructionshlx + orloop, and C jumped by+32%to2.78B ops/sec(9.15 minat1.52T).
- In a normal 1x loop, each call sets one bit in memory using a bitwise shift (
-
Why Tuned C Landed in 4th Place Behind Fortran and
unsafeRust (Instruction Scheduling Inside the 7-Instruction Loop): Even after fixing the unroller, why wasn't C in 1st place? In truth, the top five tuned bare-metal languages—Fortran (9.00 min),unsafeRust (9.03 min), C++20 (9.08 min), C23 (9.15 min), and Zig (9.17 min)—finished in a virtual photo finish across 1.52 trillion calls, separated by less than0.04 CPU cycles(~6 picoseconds) per call! The tiny~9–15 secondedge held by Fortran (gfortran -O3) andunsafeRust (rustc -O3) over C came down to instruction ordering inside their 7-instruction loops: both GCC's Fortran backend and Rust scheduled the index increment (inc rdi) before the memoryor QWORD PTR [rdx+rax*8], rcxinstruction, allowing the CPU to increment the counter and prepare the loop branch in parallel while the Load-Store Unit executed the memory update—whereas Clang scheduledadd rax, 1after the memoryor.
Best of all, our C# solution isn't some exotic dialect or foreign low-level DSL. It's the same C# we all know and love—complete with rich high-level language features—just using hot-path HPC primitives like ref struct, scoped Span<T>, Unsafe, and TCO that any C# developer can reach for when performance matters.
Tier 2: Managed VMs, Functional Actor Runtimes & Goroutines (Scala 2.13, Elixir / BEAM, Java 21, Go 1.24)
Tier 2 languages run on managed runtimes with Garbage Collectors—and when pushed to hyper-dimensional recursion depth, they expose some of the wildest runtime quirks in computer science:
-
Scala 2.13 (
@tailrecon OpenJDK 21): Even though the JVM has no TCO bytecode instruction, Scala's compiler (scalac) rewrites@tailrecself-recursion at compile time into a JVM bytecode backward jump (goto 0). Quirk: A 1DArray[Long]hits the JVM's 32-bit array index cap at137.44 Billionbits (70.77s). A 2DArray[Array[Long]]on the JVM heap reaches1.00 Trillionin12.50 min, but crashes withOutOfMemoryErrorat1.52TbecauseParallelGCreserves Young Generation space inside-Xmx182g. Only by bypassing the JVM heap with off-heapsun.misc.Unsafedid Scala reach1.52 Trillionin13.58 min—with C# AOT finishing 2.31 minutes (+20.8%) faster! -
Elixir 1.14 / Erlang OTP 25 (BEAM JIT): As a pure functional VM without loops, BEAM guarantees
O(1)Last Call Optimization—holding its stack at just54 words(432 bytestotal!) across1.00 Billioncalls in8.07s. Quirk (The 60-BitFixnumOverflow): Because the 64-bit BEAM VM uses 4 tag bits per word, immediate integers (Fixnum) are only 60 bits signed! Shifting1 <<< biton bit positions59..63(7.8%of calls) overflowsFixnumand allocates heapBignumobjects—triggering35,623Minor GCs per 1 Billion calls! -
Java 21 (OpenJDK HotSpot C2 — Standard Un-Optimized Recursion): Because Java intentionally omits automatic TCO so stack-walking APIs preserve every frame (
32 bytes/call), a standard, un-optimized recursive call burns stack memory on every step. Quirk (The176 GiBThread Constructor Escape Hatch): Default-Xss1mcrashes at23,607calls, and the JVM CLI parser rejects any flag above-Xss1g(26.84Mcalls). By passing a 64-bitlong(176 GiB) to Java's 4-argumentnew Thread(group, target, name, stackSize)constructor, we bypassed the CLI parser and mapped a176 GiBnative OS thread stack—allowing standard recursive Java 21 to push5.50 Billionreal stack frames in68.05s(164.99 GiBRAM,0GC) before exhausting our 192 GB RAM (276x shallower and 27.9x slower per call than C#). Note: While Java can easily adopt our zero-GC 1-bit slab memory map (long[][]), replicating our jump-based execution in Java is much harder (since Java has neither TCO nor a workinggotokeyword), though not strictly impossible! -
Go 1.24 (
gcDynamic Goroutine Stack — Standard Un-Optimized Recursion): Everyone expects standard recursion in Go to beat Java because Go goroutines dynamically double their stacks on the heap. Quirk (Go's Hardcoded 32-Bitmaxstackceiling = 2 GBWall): On defaults (1 GBdecimal limit), standard recursive Go panics at8,388,399calls when its512 MiBstack tries to double to1,024 MiB(1,073,741,824 > 1,000,000,000!). Even when we calleddebug.SetMaxStack(180 GiB), standard recursive Go still crashed at exact call33,554,004(0.57s,1.00 GiBstack) because Go's runtime source (runtime/stack.go:336&runtime/proc.go:166) uses a 32-bituint32infunc stackalloc(n uint32)and hardcodesmaxstackceiling = 2,000,000,000bytes! However, Go can do much better: if you apply our exact same 1-bit slab memory layout ([][]uint64) and jump-based execution approach (using Go's nativegoto), Go completely bypasses the goroutine stack limit and can achieve the same1.52 Trilliondepth and match—or even beat—C#'s execution speed!
| Language & Runtime | Configuration / Mode | TCO? | Max Depth Reached | Time at Max Depth | Ops/sec | Stack / Call | GC Wake-Ups |
|---|---|---|---|---|---|---|---|
| ⚡ C# 14 (.NET 10 AOT / JIT) | TCO + ChunkedBitArray / ulong* |
✅ Yes | 1.52 TRILLION | 11.27 min (7.41m @ 1T) | 2.25B | 0 B | 0 |
| Scala 2.13.15 (JVM 21 C2) | @tailrec Off-Heap Unsafe |
✅ Yes (goto 0) |
1.52 TRILLION | 13.58 min (8.98m @ 1T) | 1.87B | 0 B | 0 |
| Scala 2.13.15 (JVM 21 C2) | @tailrec On-Heap 2D long[][] |
✅ Yes (goto 0) |
1.00 TRILLION (OOM @ 1.52T) | 12.50 min | 1.33B | 0 B | 0 |
| Elixir 1.14 / OTP 25 (BEAM JIT) | Pure Tail Call + :atomics |
✅ Yes (BEAM LCO) | 1.00 Billion+ (∞ stack) | 8.07s (@ 1B) | 0.12B | 0 B (432 B tot) | 35,623 (60-bit Bignum!) |
| Java 21.0.12 (HotSpot C2) | Standard Recursion (176 GiB Thread Stack) |
❌ No | 5.50 Billion (165 GiB Stack!) | 68.05s | 0.081B | 32 B/call | 0 |
| Java 21.0.12 (HotSpot C2) | Default -Xss1m → Max CLI -Xss1g |
❌ No | 23,607 → 26.84 Million ❌ | 1.08ms → 1.35s | 0.02B | ~40 B/call | 0 |
| Go 1.24.1 (gc Goroutine) | Standard Recursion (SetMaxStack(180 GiB)) |
❌ No | 8.39M → 33,554,004 ❌ | 0.57s (Panics @ 1 GiB!) | 0.052B | 32–64 B/call | 0 |
Breaking Down Tier 2: C# vs. Scala, C# vs. Java & Go, and How They Can Be Optimized
Before diving into the numbers below, an important fairness note: for Java and Go, the table above measures a standard, un-optimized implementation (just as a naive explicit Stack<T> without TCO caps out far shallower at ~134M–268 Million due to the 2 GB T[] heap object limit and heap double-dipping, and even Part 2's TCO with a naive 1D bool[] memory map stopped at 2.14 Billion). We have not yet optimized Java or Go with custom control-flow jumps—and as we break down below, both languages can perform dramatically better when given the same memory map and execution approach (with the exception of Java's control flow, which is much harder to pull off, though not strictly impossible):
-
C# vs. Scala (
11.27 minvs.13.58 minat 1.52T): Scala is the only JVM language here that reached the trillions out of the box becausescalacrewrites@tailrecself-recursion at compile time into a bytecode backward jump (goto 0), completely avoiding stack frames. However, when using a 2D chunked array (Array[Array[Long]]) on the JVM heap, Scala hit1.00 Trillionin12.50 min(1.33B ops/sec) and then crashed withOutOfMemoryErrorat1.52TbecauseParallelGCreserves Young Generation space inside-Xmx182g. Even after we rewrote Scala to bypass the JVM heap using off-heapsun.misc.Unsafe, Scala took13.58 min(1.87B ops/sec) to reach1.52T—meaning C# finished2.31 minutes(+20.5%) faster while keeping its GC completely idle. -
C# vs. Standard Java 21 (
276xDeeper &27.9xFaster — And Why Optimizing Java Is Harder, Though Not Impossible): Becausejavacand OpenJDK HotSpot intentionally omit automatic TCO, every standard recursive call in Java pushes a32-byte(256-bit) activation record onto the thread stack. Even after we bypassed the JVM CLI parser's-Xss1gcap using the 4-argumentnew Thread(..., 176 GiB)constructor to map a176 GiBnative OS thread stack, standard Java burned256 bitsof stack memory for every1 biton the heap—maxing out at5.50 Billioncalls in68.05s(80.8M ops/sec), or276xshallower and27.9xslower per call than C#.
Can Java do better? Yes—on memory layout immediately, though execution is much harder: Java can adopt our exact same zero-GC 1-bit slab memory map on the heap (long[1485][16777216]) with0GC pauses because mutating primitivelongbits allocates zero objects. However, applying our jump-based execution approach in Java is much harder because Java lacks TCO and banned thegotokeyword in.javasource code—meaning you either have to flatten your algorithm into a state-machine loop (while+switch/ labeledcontinue) or use a post-compile bytecode weaver to inject raw0xA7(goto) opcodes into the.classbinary (which is exactly whatscalacdoes under the hood!). Hard—yes—but not impossible. -
C# vs. Standard Go 1.24 — Why Un-Optimized Go Performed Worse Than Java (Tested Twice!), and How Optimized Go Can Match or Beat C#:
On paper, everyone expects Go to crush Java in deep recursion because Go goroutines allocate dynamically growable stacks on the heap instead of fixed OS thread stacks. When we tested a standard, un-optimized recursive implementation in Go against Java twice, we uncovered a surprising reversal:
-
Test 1 (Out-of-the-Box Defaults): On default settings, Go (
8.39 Millioncalls under its default1 GBstack limit) easily beat default Java (23,607calls under its default1 MiB-Xss1mlimit), just as most engineers would predict. -
Test 2 (Unlocked Runtimes — Where Standard Go Collapsed at
33.55M!): When we unlocked both runtimes—giving Java a176 GiBthread stack vianew Thread(..., 176 GiB)and callingdebug.SetMaxStack(180 GiB)in Go—standard Java soared to5.50 Billioncalls (80.8M ops/sec), while standard Go panicked and died at exact call33,554,004(52.3M ops/sec)—meaning unlocked Java beat un-optimized Go by164xin depth and1.55xin speed!
33.55M? First, auditing Go's runtime source (runtime/proc.go:165andruntime/stack.go:336) revealed thatmaxstackceilingis hardcoded to2,000,000,000bytes (2 GB) andfunc stackalloc(n uint32)uses an unsigned 32-bituint32—so when Go's power-of-two stack doubler (256 MiB→512 MiB→1.00 GiBat33,554,004calls) tries to double to2.00 GiB(2,147,483,648bytes), it exceeds2,000,000,000and panics regardless ofdebug.SetMaxStack(). Second, Go had to pause execution 19 separate times to allocate a 2x larger heap stack and copy every frame over (runtime.copystack).
Can Go do better? Absolutely—in fact, Go can achieve the exact same optimization and performance (or even better!): Unlike Java, Go compiles directly to native machine code, supports unboxed 64-bit primitive slices ([][]uint64), and has a real, first-classgoto LABELstatement built right into the language! By adopting our exact 1-bit slab memory layout and replacing the recursive call with a directgotojump (or an AST pre-build weaver viago generate), Go uses0 bytesof goroutine stack, never triggersruntime.copystackor GC pauses, and can reach the full1.52 Trilliondepth at native machine speed—matching or potentially even edging out C# in raw execution! -
Test 1 (Out-of-the-Box Defaults): On default settings, Go (
Tier 3: Dynamic Scripting & Bytecode Runtimes (Node.js v22 V8, Python 3.12 CPython)
What happens when you throw hyper-dimensional recursion at the world's two most popular dynamic scripting engines? We uncovered a hilarious reversal between Node.js (Google V8) and Python 3.12 (CPython):
-
Node.js v22.14 (Google V8 12.4 JIT — The Signed 32-Bit Flag Overflow):
Even though ES6 (ES2015) mandated Proper Tail Calls (PTC), V8 refused to ship it. Every recursive call pushes
80 to 136 bytesonto the V8 stack, crashing at7,384calls on the default984 KiBstack. Quirk: When we passed--stack-size=180000000(180 GB), Node.js threwRangeError: Maximum call stack size exceededon line 0 beforemain()even started! Why? Because V8 multipliesv8_flags.stack_size * KB(int32_t * 1024) using signed 32-bit integer arithmetic—so any value above2,097,151 KiB(2.00 GiB) overflowsint32_tinto a negative number! At its hard 32-bit ceiling (--stack-size=2097151), Node.js permanently dies at26,843,490calls. -
Python 3.12.3 (CPython — The PEP 652 Heap Frame Plot Twist That Beat Go and Node.js!):
Python has no TCO and stops at
997calls by default (sys.getrecursionlimit() = 1000). In older Python (≤ 3.10), raisingsys.setrecursionlimit()caused a fatal C stack overflow (SIGSEGV) around ~30,000 calls. Quirk: Starting in Python 3.11/3.12 (PEP 652), CPython stopped pushing Python-to-Python calls onto the C hardware stack (VmStkstays flat at132 kB!) and instead allocates linked_PyInterpreterFramestructs (168.66 bytes/frame) on the 64-bit process Heap! Withsys.setrecursionlimit(1_000,001,000), Python 3.12 completed1,000,000,000(1.00 Billion) recursive calls in117.52 seconds(157.20 GiBRAM,0GC)—outlasting Node.js by 37x and Go by 30x in recursion depth! (Though reaching1.52 Trillionin Python would still require an impossible238.7 TiBof RAM!)
| Language & Runtime | Configuration / Mode | TCO? | Max Depth Reached | Time at Max Depth | Ops/sec | Memory / Call | GC Wake-Ups |
|---|---|---|---|---|---|---|---|
| ⚡ C# 14 (.NET 10 AOT) | TCO + ulong* THP |
✅ Yes | 1.52 TRILLION | 11.27 min | 2.25B | 0 B stack (1 bit heap) | 0 |
| Python 3.12.3 (CPython PEP 652) | setrecursionlimit(1B) Heap Frames |
❌ No | 1.00 Billion (157.2 GiB Heap!) | 117.52s (1.96 min) | 0.0085B (8.5M/s) | 168.7 B/call (on Heap) | 0 |
| Python 3.12.3 (CPython Default) | Default getrecursionlimit()=1000 |
❌ No | 997 ❌ (RecursionError) |
0.16 ms | 0.0062B | 185.2 B/call | 0 |
| Node.js v22.14.0 (Google V8 12.4) | Max 32-Bit --stack-size=2097151 |
❌ No | 26,843,490 ❌ (2 GiB int32_t cap) |
0.46s (@ 25M) | 0.054B (54.3M/s) | 80.0 B/call (on Stack) | 0 |
| Node.js v22.14.0 (Google V8 12.4) | Default --stack-size=984 (984 KiB) |
❌ No | 7,384 ❌ (RangeError) |
0.72 ms | 0.010B | 136.5 B/call | 0 |
Breaking Down Tier 3: C# vs. Node.js, C# vs. Python, and Node.js vs. Python
-
Why Node.js (V8) Cannot Do TCO Here:
Wait—didn't the ECMAScript 6 (ES2015) specification officially mandate Proper Tail Calls (PTC) in strict mode (
"use strict")? Yes, it did! However, Google's V8 team (which powers Node.js and Chrome) refused to ship it in production—briefly putting it behind an experimental--harmony-tailcallsflag before ripping that flag out of V8 entirely. V8's engineers objected that implicit tail call elimination silently drops stack frames, breakingError.stacktraces, Chrome DevTools profiling, and call-stack debugging (leaving Apple's JavaScriptCore in Safari/WebKit as the only major JS engine that ever shipped ES6 PTC). -
C# vs. Node.js v22 (
56,625xDeeper Unlocked &41.5xFaster per Call): Even though V8 compiles our recursive DFS with its Maglev/TurboFan JIT, refusing to emit TCO forces Node.js to push an80 to 136.5 bytenative stack frame onto the hardware thread stack on every single call—crashing at just7,384calls out of the box (--stack-size=984). Worse yet, when you try to unlock Node.js's stack on a 192 GB server, V8 parses--stack-size(in KiB) by multiplyingv8_flags.stack_size * 1024inside a signed 32-bit integer (int32_t)—which overflows into a negative number above2,097,151 KiB(2.00 GiB) and crashes beforemain()even starts! That hard-caps Node.js at26,843,490calls (0.46s@ 25M =54.3M ops/sec). Because V8 has to push80-byte(640-bit) stack frames to RAM and execute stack-guard checks on every call while C# executes a0-byteregisterjmpand flips1 bit, C# (1.52 Trillionat2.25B ops/sec) ran41.5xfaster per call, used640xless memory per node, and went56,625xdeeper than unlocked Node.js (and205,850,487xdeeper than default Node.js)! -
C# vs. Python 3.12 (
1,520xDeeper,265xFaster, and1,349xLess Memory per Node): Out of the box, CPython stops at997calls (RecursionError). When unlocked (sys.setrecursionlimit(1_000_001_000)), Python 3.12 reached1.00 Billioncalls—but because Python has no TCO and runs inside a bytecode evaluation loop, every recursive step still allocates168.7 bytes(1,349 bits) on the Heap and crawls at8.5M ops/sec. At the1.00 Billionmark where Python needed117.52 secondsand157.20 GiBof RAM, C# traverses those same1.00 Billionnodes in0.44 secondsusing just119 MBof RAM—running265xfaster per call, using1,349xless memory per node, and scaling1,520xdeeper (1.52 Trillionvs.1.00 Billion). -
Node.js vs. Python 3.12 — How "Slow" Interpreted Python Crushed JIT-Compiled Node.js by
37xin Depth: This was the biggest upset in Tier 3. In raw execution speed, Node.js's V8 JIT (54.3M ops/sec) is6.4xfaster than Python 3.12's bytecode interpreter (8.5M ops/sec), and on default settings Node.js reaches7.4xdeeper (7,384vs.997). Normally, you would bet everything that V8-powered Node.js would obliterate Python when unlocked. Yet when we unlocked both runtimes, Python 3.12 (1.00 Billioncalls) beat Node.js (26.84 Millioncalls) by37.25xin recursion depth! Why? Because Node.js binds JavaScript recursion directly to the C++ hardware thread stack (RSP) and traps that stack behind a 32-bit2 GiBflag ceiling—leaving 98.9% of our 192 GB of RAM completely unreachable. Python 3.12 (PEP 652), by contrast, decoupled Python-to-Python calls from the C hardware stack (keeping its OS thread stack flat at just132 kB) and moved its_PyInterpreterFrameactivation records onto the 64-bit process Heap. Even though Python's Heap frames were more than twice as large as V8's stack frames (168.7 Bvs.80.0 B) and6.4xslower per call, placing them on the unrestricted 64-bit Heap allowed Python to utilize157.20 GiBof system memory and outlast both Node.js (37x) and Go (30x)!
But Wait—Isn't Using the Heap "Cheating"? 🤔
You might be thinking: "Hold on—you allocated a ChunkedBitArray on the Heap. Doesn't using the Heap violate the rules of recursion vs. iteration?"
No. In fact, this question exposes the single biggest misconception in the entire "Recursion vs. Iteration" debate. Every graph traversal—whether recursive or iterative, and whether written in C#, Java, Go, or bare-metal C—has to manage two completely separate kinds of state:
- Control Flow (Where am I in the traversal?): Which node am I currently visiting, and how do I advance to the next one?
-
Visited State (Which nodes have I already seen?): The
visitedlookup structure that prevents infinite cycles. Every approach storesvisitedon the Heap because a multi-gigabyte graph cannot fit inside a default 1–8 MB Thread Stack.
1. The "Iterative Stack" Is ALSO on the Heap—And Without TCO, It Doesn't Even Come Close to 2.14 Billion!
When mainstream engineering advice tells you, "Convert your recursive DFS into a while loop using an explicit Stack<T> to avoid a StackOverflowException," what is new Stack<T>() actually doing under the hood?
Stack<T> is a Heap-allocated object wrapping a Heap-allocated T[] array! Converting recursion to an explicit Stack<T> does not eliminate stack memory—it merely moves the call stack off the CPU's Thread Stack and onto the Heap.
Remember: in Part 2, 2.14 Billion (2,147,483,591) was the TCO Recursive limit with a naive 1D bool[] / NodeLinear[] memory mapping (where TCO used 0 bytes of stack, and our linear Stack<int> test only survived to 2.14B because it immediately Pop()ped the current node before Push()ing the next one—holding just 1 element = 4 bytes in the stack, which is functionally manual TCO!).
When a naive Stack<T> is NOT using TCO and actually has to push and hold active frames on the Heap as it descends (just like a real call stack in backtracking or post-order DFS), it is double-dipping on the Heap and crashes far shallower than 2.14 Billion:
-
Memory Double-Dip (
64–128+ bitsvs.0 bitsper node): Pushing 64-bitlongnode indices (8 bytes) or(long node, int state)frames (16 bytes) onto an explicitStack<T>burns64 to 128+ bitsper node on the Heap on top of the graph andvisitedstate. At1.52 trillionnodes, an explicitStack<long>would require an impossible12.16 to 24.32 Terabytesof Heap RAM just for the traversal stack—whereas TCO recursion uses0 bytesof control-flow memory! -
Why Naive
Stack<T>(No TCO) Crashes at~134M – 268 Million(or1.07BMax): By default in .NET, no single array object on the Heap can exceed2 GB(2,147,483,591bytes)—the exact same 2 GB wall as the Thread Stack! BecauseStack<long>(orStack<Node>references) stores8 bytesper element (or16 bytesper state frame) inside a single contiguousT[]array on the Heap,2 GB / 8 bytescapsStack<long>at just~268 Million(268,435,456) elements—and16-byteframes cap out at~134 Million(or~15–30 Millionwhen paired with Part 1's naive Heap graph)! Even if you enable<gcAllowVeryLargeObjects>on 64-bit .NET,Stack<T>grows its backingT[]array by powers-of-two doubling (2x) (134M → 268M → 536M → 1,073,741,824=1.07 Billion)—where the very next2xresize overflows 32-bit signedintcapacity math while simultaneously demanding both the old multi-gigabyte array and the new2xarray on the Large Object Heap and thrashing the GC!
| Traversal Architecture | Control Flow Storage | Visited State Storage | Memory Cost per Node | Max Depth on 192 GB RAM |
|---|---|---|---|---|
| Standard / Non-TCO Recursive (Part 1 & 2) | Hardware Thread Stack (~1 MB → 2 GB max) | Heap (HashSet<T> or bool[]) |
48–104 B stack + Heap node |
~10K–19K (1 MB) → 30M–44.7M (2 GB Stack Overflow) |
Naive Iterative Stack<T> (No TCO — Pushes Frames on Heap) |
Heap (Stack<T> backing T[]) |
Heap (bool[] or BitArray) |
8–16 B heap stack + Heap visited |
~134M – 268 Million (2 GB T[] Object Cap; 1.07B max 2x resize) |
| Part 2 TCO Recursive (Naive 1D Heap Map) | CPU Registers (jmp — 0 Bytes!) |
Heap (1D NodeLinear[] + bool[]) |
0 B stack + 9 B heap |
2.14 Billion (1D bool[] Array.MaxLength & int Cap) |
| Non-TCO + Giant/Heap Stack (Go, Java, Python) | Heap-Allocated Stack (Go/Python) or 176 GiB Thread Stack (Java) | Heap (ChunkedBitArray) |
32–168 B frame + 1 bit heap |
33.5M (Go) • 1.00B–3.09B (Py) • 5.50B (Java) |
| Part 3 TCO Recursive (C#, Tier 1, Scala) | CPU Registers (jmp — 0 Bytes!) |
Heap (ChunkedBitArray / Slab) |
0 B stack + 1 bit heap |
1.52 – 1.555 TRILLION (100% RAM) |
2. Why "Using the Heap" Alone Couldn't Save Standard Recursion in Java, Go, Python, or Node.js
If putting visited on the Heap were a "cheat code," then standard recursive Java, Go, Python, and Node.js—all of which also used the Heap for their visited state (and in Go and Python, even used the Heap for their call stacks!)—would have effortlessly reached 1.52 trillion calls out of the box too. Instead, when running a standard recursive function call without manual control-flow rewriting, look at what happened to them on Control Flow (#1):
-
Go Literally Allocates Its Call Stack on the Heap—And Standard Recursion Still Died at
33.55 Million: A Go goroutine's stack is not a fixed OS thread stack; it is a dynamic buffer allocated directly on Go's managed Heap. Yet because the Go compiler does not perform automatic Tail Call Optimization on a standard recursive call, every step pushed a32-byteframe onto that Heap-backed goroutine stack, forced 19 stop-and-copy Heap reallocations (runtime.copystack), and hit Go's hardcoded2 GBper-goroutine stack ceiling (maxstackceiling) at just33.55 millioncalls! (Whereas if you adopt our[][]uint641-bit slab map in Go and manually replace the recursive call with Go's nativegotostatement, Go bypasses the goroutine stack entirely and can achieve the exact same zero-GC1.52Tlayout and match or even beat C#'s speed!) -
Java Had the Exact Same Heap
ChunkedBitArray—And Standard Recursion Died at5.50 Billion: We gave Java the exact same1-bit-per-nodeChunkedBitArrayon the JVM Heap (which also runs with0GC pauses since mutating pre-allocated primitivelong[][]arrays allocates zero objects) and even mapped a massive176 GiBcustom thread stack (-Xss176g). Yet because Java lacks TCO—and even bannedgotoin.javasource code—standard recursion in Java burned256 bitsof stack RAM for every1 bitof Heap bitmap, exhausting171.68 GiBof stack memory at5.50Bcalls (276xshallower than C#), unless you resort to complex loop-switch rewriting or raw JVM bytecode hacking. -
Python 3.12 Stores Stack Frames on the Heap—And Exhausted 192 GB at
3.09 Billion: Since PEP 652, CPython allocates its interpreter stack frames (_PyInterpreterFrame) on the Heap rather than the C hardware stack. Even with Heap-allocated frames and a Heap-allocated bitset, the lack of TCO meant Python burned~56 bytesof Heap per recursive call—running out of all 192 GB of physical RAM at3.09 billioncalls. -
Even With TCO, Heap Architecture Matters (Scala & Elixir):
Elixir has TCO, yet its immutable functional Heap had to copy
1 KiBsub-binaries and run GC sweeps, collapsing to13.4M ops/sec. Even Scala 2.13 (which has@tailrec) paid a ~5 GiB JVM GC reservation penalty on its 2D Heap array (long[][]) and finished over2.3 minutesslower than C#.
3. Non-GC Languages (C, C++, Rust, Zig) vs. Managed C#: Manual Release & RAII
What about the bare-metal Tier 1 languages (C, C++, Rust, Zig, and Fortran)? They also have to allocate the ~181 GiB (190 GB) visited bitset on the Heap (via mmap, calloc, or std::alloc::alloc_zeroed).
The difference is what happens over the lifetime of that allocation:
-
In Non-GC Systems Languages (Manual Deallocation & RAII):
Because C, C++, Rust, Zig, and Fortran have no Garbage Collector, you are 100% responsible for the lifecycle of those
181 GiBof physical pages. In C, Zig, and Fortran, you must explicitly release the memory back to the OS kernel (free()/munmap()/defer allocator.free()/deallocate()) or risk leaking the entire system's RAM. In C++ and Rust, this is enforced deterministically through RAII (Resource Acquisition Is Initialization)—using destructors (~BitSet()/std::unique_ptr) in C++ or theDroptrait in Rust to automatically unmap the Heap slabs the instant the traversal scope exits. -
In C# (.NET 10) — Deterministic RAII-Style Cleanup Plus Zero-GC Execution:
In standard managed code, allocating
177 GiBacross1,416Heap arrays would normally make developers terrified of Garbage Collector pauses. Yet because we pre-allocated our fixed-size128 MiBslabs up front and allocated0 bytesinside the recursive hot loop, .NET's GC never woke up once (Gen 0/1/2: 0 / 0 / 0) across all1.52 trillioncalls. And when the traversal finishes, ourChunkedBitArrayandFlatNativeBitArrayimplementIDisposable(usingusing var visited = ..., C#'s deterministic disposal pattern) and invokemadvise(..., MADV_DONTNEED)/NativeMemory.AlignedFreeto zap and return all177 GiBof physical pages to the OS kernel immediately—giving us deterministic RAII-grade resource cleanup without giving up a managed language!
The Verdict: Hardware Register TCO + Bit-Mapped Heap > Abstraction
- Did we crash with
StackOverflowException? No—control flow stayed in CPU registers (0 bytesper call). - Did we use pure recursion with zero loops and zero
Stack<T>middlemen? Yes. - Did we execute 1.52 Trillion recursive calls in C# while the Garbage Collector slept (
0 / 0 / 0)? Yes.
Using the Heap for visited state isn't cheating—every graph algorithm on Earth uses the Heap for state. What made 1.52 trillion possible was eliminating the call stack from RAM entirely via Tail Call Optimization (jmp) and compressing the Heap down to 1 bit per node.
Test Environment
| OS (Initial 1.00T Run) | Windows 11 Pro (x86-64) |
|---|---|
| OS (Max 1.52T Run) | Headless Ubuntu Server 24.04.5 LTS (Linux Kernel 6.8.0-107-generic, swapoff -a) |
| Language & Runtime | C# 14 on .NET 10 (.NET SDK 10.0.402 — tested in both Native AOT & Standard RyuJIT) |
| CPU | AMD Ryzen 9 9950X3D 16-Core / 32-Thread Processor (Zen 5, up to 5.7 GHz) |
| RAM | 192 GB DDR5 (186.365 GiB OS-visible physical memory) |
| Benchmark Source Code | github.com/rbtLong/hpc-dfs-iii (All 12 languages & runner scripts) |
Closing Thoughts (For Now...)
Graph Traversal Is the Bedrock of Modern Computing
Depth-First Search is one of the very first, most basic algorithms we learn in computer science. So why go back to it and push it to such an extreme? Because graphs and trees are the cornerstone—the invisible bedrock—of modern computing. Whichever direction you look, graphs are the underpinning that drives everything we build:
- AI & Deep Learning: Neural networks, computation graphs, backpropagation (which is literally reverse post-order DFS), transformers, and Tree-of-Thoughts (ToT) reasoning.
- UI / UX & Rendering Engines: React's Fiber tree, Angular's component hierarchy, browser DOM and layout trees, and desktop UI scene graphs.
- The Internet, Networks & Maps: Packet routing, network topologies, GPS navigation and map routing graphs, distributed service meshes, and dependency graphs.
- File Systems, Storage & USB / Device Peripherals: Directory hierarchies, storage B-trees, OS hardware device trees, and USB / PCIe bus and peripheral topology trees.
- Circuit Boards & Robotics: PCB trace routing and circuit netlist graphs, chip layout, robot kinematic chains, and motion-planning / pathfinding state graphs.
- Call Graphs, Execution Paths & Concurrency: Function call chains, compiler ASTs and control-flow graphs, thread execution trees, deadlock cycle detection, and garbage collector mark-sweep reachability graphs.
What 1.52 Trillion Calls Taught Me
Even though this result was entirely unexpected, the irony is that tail call optimization and bit-mapped memory layouts have been around since the very beginning.
Neither of them is a new idea. Yet pushing this one fundamental recursive algorithm all the way to 1.52 trillion calls gave me a much deeper, first-principles understanding of the HPC memory layouts of each language—and proved that under the hood, they are not all the same.
What stood out most is that C# is genuinely unique in its versatility. It held its own right alongside the heavyweight Tier 1 systems languages—C, C++, Rust, and Fortran—at the absolute edge of our 192 GB hardware ceiling. That doesn't mean C# is a replacement for bare-metal systems languages everywhere, but it demonstrates something remarkable: even in a language that carries a full-featured Garbage Collector, you can write code that does not touch the GC at all (0 wake-ups across 1.52 trillion calls).
Of course, that doesn't take anything away from the real advantages of a language like Rust. In fact, I am a big fan of Rust and have built a couple of systems in Rust specifically because it offers a deterministic, rock-solid runtime profile with strong compile-time guarantees that C# wasn't designed to match when you need long-running, pause-free determinism (though even Rust can still leak memory if reference cycles or allocations are misused).
At the same time, Rust has its own real trade-offs: the borrow checker has multiple layers of complexity that often force your architecture itself to become complex—and as we saw in our benchmark (where safe idiomatic Rust ran 17.6% slower than unchecked Rust due to slice bounds checks), if you aren't careful you can still end up with sub-optimal machine code while fighting the borrow checker at the same time.
For example, in one of my recent Rust projects, I built a controller whose sole job was to talk to a physical hardware controller driving a device. I deliberately avoided bringing in tokio or async network I/O because yielding to network traffic would have made execution unpredictable and non-deterministic—defeating the entire reason I chose Rust! Instead, I kept the Rust controller strictly deterministic, wrote directly to a Unix Domain Socket (UDS) as a clean architectural seam, and handled all the network orchestration at the C# layer, which was built for it. It was a fantastic solution—and this deep-dive into how different runtimes behave at their physical limits makes it crystal clear why choosing the right seam between languages works so well.
I still cannot believe we reached a point where trillion-scale recursion is truly possible. What I once assumed was a hard, inescapable limit of recursion turned out not to be a hardware limit at all—the solution to this problem wasn't some new invention. It was always there... I just never knew...
More than anything, discovering that a basic algorithm we all learned in school was hiding this kind of scale right in plain sight shows just how much I still do not know. Once you see it though, your world changes and you cannot unsee it. And there is still much more work to do—stay tuned for Part 4, where we go even deeper! :)