-
Notifications
You must be signed in to change notification settings - Fork 0
Performance and Concurrency
This page is the set of techniques the implementation uses to keep allocation and cache misses down, each traceable to source. They are design choices rather than benchmarked outcomes: es-parser ships no benchmark harness, so the page makes no comparative speed claim.
Nodes, tokens, scopes, symbols, and references are each a std.MultiArrayList, so
every field is its own contiguous column. A pass that needs one field — every
reference's symbol_id, every symbol's name, every token's
has_newline_before — streams a dense array and pulls nothing else through cache.
The AST's children live in a single flat extra_data: []u32 rather than
per-node heap lists. See AST and Memory Layout.
The semantic phase does not walk the AST; it replays a flat array of 8-byte events the parser emitted, eight to a cache line, skipping every literal and operator that has no scoping meaning. This is the single biggest structural decision and it is covered in Architecture.
The parser sizes nodes and extra_data to tokens.len * 3/4 before parsing
(parser.zig:657) — roughly 0.75 nodes per token — so the common case writes through
cached raw pointers with no growth. It is an estimate, not a hard bound: an
overflow takes a cold path that doubles the buffer and refreshes the cached
pointers (refreshNodePtrs). The token columns are the exception — see below.
peek and friends read tokens through cached raw column pointers — tags_ptr and
tok_starts_ptr (parser.zig:224, :232) — rather than re-deriving the
MultiArrayList slices each time. These stay valid because the token columns are
the lexer's finalized output — the parser borrows them and never grows them (the
node and event pointers are re-cached if their buffers grow). Those stable token
pointers are what let the TypeScript layer rewrite >> into > in place (O(1),
visible to the next peek); see
TypeScript and JSX.
The lexer vectorizes the inner scans that dominate its time — identifier runs, line-terminator hunts, comment and template bodies — all 16 bytes wide with a scalar tail (scalar_lexer.zig:223). Whitespace skipping stays scalar. Line-start computation for diagnostics is the same 16-wide scan (span.zig:85). Details in Lexer and Tokens.
Keyword recognition gates on a comptime first-char bitset and a packed-u64
compare (no hash, no table), so an identifier exits in a couple of comparisons and the hot path skips
the lookup entirely after . / ?. (lexer.zig:210, :178). See
Lexer and Tokens.
Several costs are paid only when asked for:
line starts built on the first diagnostic, via span.LineIndex
parent links built only when build_parents is set (semantic.zig:146)
CFG + reachability skipped when need_cfg = false
comment trivia collected only when a CommentSink is supplied
Reference resolution fronts the name-hash map (scope_map) with a 512-entry
direct-mapped L1 cache — {hash: u64, sym: SymbolId}, 8 KB total
(event_resolver.zig:409) — so the common "resolve to an already-visible binding"
case is a single indexed probe. Two-phase resolution (immediate, then a retry over
the unresolved queue) means the resolver never re-walks the tree to close forward
references. See Semantic Analysis.
The binder and CFG builder read sentinel-initialized buffers where 0 means
"none", so they assume fresh memory is zeroed. The analyzer therefore requires a
zero-filling allocator — an arena over page_allocator, or the bundled
ZeroingAllocator (semantic.zig:94). This is a deliberate trade: skipping
explicit initialization of those buffers in exchange for a caller contract. See
Semantic Analysis.
Every path es-parser executes is single-threaded. The stable-token-pointer property above is the load-bearing invariant on the parse path; nothing the library executes spawns a thread.
es-parser — MIT licensed. This wiki documents the implementation under src/; when a detail matters, the source is authoritative.