Data-Oriented Design in Yuku's Parser Yuku, a JavaScript and TypeScript parser written in Zig by developer Yuku, parses the 8 MB typescript.js file in about 19 ms on an M3 by replacing pointer-based AST nodes with u32 indices into flat arrays, following the data-oriented design used by the Zig compiler. The parser produces 852,919 nodes for that file, and the index representation halves pointer size, reserves the max u32 value for "no child," and places all nodes, extras, pooled strings and diagnostics in a single arena so 850,000 frees collapse into one deinit call. The author credits Andrew Kelley's data-oriented design talk and the Zig compiler's flat-index AST as the source of the technique, and pins node sizes with compile-time asserts to prevent a new field from growing all 852,919 nodes. Data-Oriented Design in Yuku's Parser Yuku https://yuku.fyi is a JavaScript and TypeScript parser I write in Zig. It gets through typescript.js https://raw.githubusercontent.com/yuku-toolchain/parser-benchmark-files/refs/heads/main/typescript.js , an 8 MB file, in about 19 ms https://github.com/yuku-toolchain/ecmascript-parser-benchmark-native on my M3. The grammar was never the interesting part of that. Recursive descent is in every textbook and you can more or less transcribe it from the ECMAScript spec. What decides whether a parser is fast is a question the spec never asks: what does a node look like in memory, and who allocates it. The answer isn’t mine. The Zig compiler stores its AST as flat arrays of indices, and I learned the technique by reading it, along with Andrew Kelley’s talk on data-oriented design https://vimeo.com/649009599 , where the reasoning is laid out properly. Yuku applies the same idea with a looser grip than Zig does, on purpose. The last section is about exactly where, and why. What the pointer tree costs The AST most of us write first looks like this: js const Node = union enum { binary: struct { op: Op, left: Node, right: Node }, call: struct { callee: Node, args: Node }, identifier: struct { name: const u8 }, // ... }; Nothing wrong with it as a description of the grammar. It’s just bad for the machine, and you don’t feel it until the file gets big. Yuku produces 852,919 nodes for typescript.js , roughly one per ten bytes of source. In the pointer version every one is a separate malloc . At tens of nanoseconds a call, that’s tens of milliseconds spent asking for memory, and a similar bill to give it back, for a parse that should take 19 ms in total. Then the nodes live wherever the allocator felt like putting them. Every later walk is a chain of dependent loads to addresses the CPU can’t predict. A miss to main memory costs around 100 ns; arithmetic costs a fraction of one. A traversal over 850k scattered nodes spends most of its life stalled. The third cost is easy to miss: the tree is full of absolute addresses, so it’s welded to one process. You can’t write it to a file, map it back in, or hand it to another language without rebuilding it object by object. For Yuku that last one matters most, because JavaScript is the main consumer. None of these costs come from the grammar. They all come from the representation. Indices, not pointers Every pointer becomes a u32 index into one flat array: js /// Index into the AST node array. pub const NodeIndex = enum u32 { null = std.math.maxInt u32 , }; Half the size of a pointer, indifferent to which address space it lives in, and the max value is reserved for “no child” so an optional child costs nothing extra. The tree is a handful of arrays plus the arena that owns them: js pub const Tree = struct { /// Index of the root node always a program node . root: NodeIndex = undefined, /// All nodes in the AST. nodes: std.MultiArrayList Node = .empty, /// Backing storage for variadic node children. extras: std.ArrayList NodeIndex = .empty, /// String pool for AST node string fields. strings: StringPool = .{}, /// The original source text passed to the parser. source: const u8 = "", /// Arena allocator owning all the memory. arena: std.heap.ArenaAllocator, pub fn deinit self: const Tree void { self.arena.deinit ; // the whole tree, freed in one call } }; That deinit is the first thing you get for free. Nodes, lists, pooled strings and diagnostics all live in one arena, so 850,000 frees become one. A node is a tagged union plus a source span, and I pin both sizes with compile-time asserts so a field added to one variant can’t quietly grow all 850k nodes. A field is cheap to add; 850,000 copies of it are not. js pub const Node = struct { data: NodeData, // tagged union, one variant per node kind 171 of them span: Span, // { start: u32, end: u32 }, byte offsets into source }; pub const BinaryExpression = struct { left: NodeIndex, right: NodeIndex, operator: BinaryOperator, }; comptime { std.debug.assert @sizeOf NodeData == 44 ; std.debug.assert @sizeOf Node == 52 ; } Here’s what that produces. let x = 1 + 2; , dumped straight out of the parser: js let x = 1 + 2; 0 4 8 12 byte offsets 0 binding identifier 4..5 name=4..5 "x" 1 numeric literal 8..9 raw=8..9 "1" 2 numeric literal 12..13 raw=12..13 "2" 3 binary expression 8..13 left=1 right=2 operator=add 4 variable declarator 4..13 id=0 init=3 5 variable declaration 0..14 kind=let declarators=extras 0..1 6 program 0..14 body=extras 1..2 extras: 4 5 | program body declarator list Four things to notice, and you’ll use every one of them later: - Children come before parents. Node 3 refers to 1 and 2, which already exist, and the root is the last node appended. This falls out of recursive descent for free: a parse function builds its children, appends itself, and hands its index back. - Every edge is a small integer. left=1 , right=2 . Not addresses. - The identifier’s name isn’t a copied string. It’s the byte range 4..5 of the source. - The two variable-length lists live in a shared extras array, and the node keeps a start and a length. Two columns, not one array of structs std.MultiArrayList stores those nodes as separate columns instead of one array of 52-byte structs: array of structs struct of arrays data | span data | span ... data: d0 d1 d2 ... span: s0 s1 s2 ... reading node kinds drags every a kind-only pass reads one span into cache as dead weight dense column and nothing else js pub inline fn data self: const Tree, index: NodeIndex NodeData { return self.nodes.items .data @intFromEnum index ; } pub inline fn span self: const Tree, index: NodeIndex Span { return self.nodes.items .span @intFromEnum index ; } A pass that only switches on node kind, which is most passes, never pulls spans through the cache. A pass that only wants positions never pulls payloads. Appending is a bump Adding a node increments a length. The parser reserves capacity up front from the source size, so the allocator stays off the hot path: js const estimated nodes = if source len < 512 000 @max 256, source len / 2 else source len / 4; try self.tree.nodes.ensureTotalCapacity alloc, estimated nodes ; Those estimates are deliberately fat. Real files land near one node per ten bytes, so source len / 2 asks for several times what gets used. That’s fine: a large allocation is an mmap underneath, and the OS doesn’t hand you a page until you write to it. Reserve generously, pay for what you touch. The bonus nobody advertises Because every child has a lower index than its parent, a plain for loop over the node array from 0 to n is a post-order traversal. No recursion, no explicit stack, and the access is sequential so the prefetcher does the work. Any bottom-up pass can just be a loop. Stripping parenthesized expressions is exactly this: js fn stripParenthesizedNodes tree: ast.Tree void { const datas = tree.nodes.items .data ; const spans = tree.nodes.items .span ; for 0..datas.len |i| { var inner: u32 = switch datas i { .parenthesized expression = |p| @intFromEnum p.expression , .ts parenthesized type = |p| @intFromEnum p.type annotation , else = continue, }; // resolve to the innermost non-paren node while true { switch datas inner { .parenthesized expression = |p| inner = @intFromEnum p.expression , .ts parenthesized type = |p| inner = @intFromEnum p.type annotation , else = break, } } datas i = datas inner ; spans i = spans inner ; } } Variable-length children A binary expression has exactly two children and they fit inside the node. A block has any number of statements. Rather than size every node for the worst case, variadic children go into the shared array and the owner keeps an 8-byte descriptor: js pub const IndexRange = struct { start: u32, len: u32 }; /// Returns the extra node indices for the given range. pub inline fn extra self: const Tree, range: IndexRange const NodeIndex { return self.extras.items range.start.. 0..range.len ; } Building those lists is the interesting part. The parser doesn’t know a block’s statement count until it hits the closing brace, and it must not allocate a growable list per block. So there’s one scratch buffer used as a stack: each list-building function records where it started and truncates back to that point on the way out. pub fn parseBody self: Parser, terminator: ?TokenTag ast.IndexRange { const checkpoint = self.scratch statements.begin ; // current length defer self.scratch statements.reset checkpoint ; // truncate on exit while self.isAtBodyEnd terminator { const statement = try statements.parseStatement self, .{} ; try self.scratch statements.append self.allocator , statement ; } // one bulk copy of scratch checkpoint.. into tree.extras return self.flushToExtras &self.scratch statements, checkpoint ; } Nesting is where that pays off. Here’s { a; { b; c; } d; } : 0 identifier reference "a" 1 expression statement expression=0 2 identifier reference "b" 3 expression statement expression=2 4 identifier reference "c" 5 expression statement expression=4 6 block statement body=extras 0..2 the inner block 7 identifier reference "d" 8 expression statement expression=7 9 block statement body=extras 2..5 the outer block 10 program body=extras 5..6 extras: 3 5 | 1 6 8 | 9 inner outer program Read the extras left to right and you can see the recursion. The inner block finished first, so its two statements flushed first, at 0..2, while the outer block’s statement 1 sat lower in the same buffer, untouched. The outer block then resumed, collected 6 and 8 , and flushed all three at 2..5. The buffer grows a few times per parse and then never again; appending a child is a bounds check and a store. Yuku keeps five of these for contexts that assemble lists at the same time. The same offset-and-length shape shows up everywhere once you have it. Comments attach to nodes through a prefix-sum array of length node count + 1 : node i ’s comments are the slice between offsets i and i + 1 . Backtracking is just truncation This one is a side effect of the layout rather than something I designed in, and it matters more than it sounds. TypeScript needs speculation in a few places: