Skip to content

Performance and Concurrency

Eric San edited this page Jun 14, 2026 · 2 revisions

Performance and Concurrency

Who this is for: performance engineers. These are design facts drawn from the source — not benchmark numbers. File-level hot-path mechanics are in Internals.

This page collects the performance-relevant design decisions in one place.

Data layout

  • Struct-of-arrays throughout. Nodes, tokens, scopes, symbols, and references are all std.MultiArrayList. A pass that needs one field streams a dense column without dragging the others through cache — e.g. SymbolTable.allNames() / allFlags() return single columns, and ReferenceTable can scan all symbol_ids to count unresolved refs without touching node/scope data. See AST and Memory Layout.
  • Flat side tables, index identity. Variable-arity children live in one extra_data: []u32; nodes reference children by u32 index, never by pointer. No per-node heap allocation; the tree is contiguous and trivially serializable.
  • Cached column pointers on hot paths. The parser caches raw pointers into the token and node columns (tags_ptr, tok_starts_ptr, node_tags_ptr, …) so peek()/nodeTag skip MultiArrayList.items(...) reconstruction; the lexer caches token columns similarly. The resolver hoists the event write cursor (ev_ptr/ev_len).
  • Packed events. Each semantic event is a packed struct(u64) — eight per 64-byte line. The CFG splits the hot "next" adjacency into a 16-byte SegNextInfo sidecar so markUsed/markLooped touch a quarter cache line.

Pre-sizing

The parser sizes nodes and extra_data to @max(sizing_count * 3 / 4, 1) (sizing_count = tokens.len, or the caller's capacity_hint in streaming mode) — about 0.75 entries per token — so the hot addNode/event-push paths hit appendAssumeCapacity. The lexer pre-sizes the token list to @max(src.len / 2 + 16, 64) and grows geometrically (cap*2 + 16). max_nodes is a hard ceiling of sizing_count * 16, checked in addNode. Recursion is bounded at max_recursion_depth = 400 to turn deep nesting into a diagnostic rather than a stack overflow.

SIMD

The lexer and line-index code use 16-wide byte vectors (@Vector(16, u8)):

  • asciiIdentEnd scans an identifier run 16 bytes at a time, classifying [A-Za-z0-9_$] with a lowercase fold and range compares, and jumping to the first non-match with @ctz. Pure-ASCII identifiers (the common case) never decode UTF-8.
  • lineTerminatorScan (comment/regex line scanning) and computeLineStarts (the lazy line index) skip 16-byte windows that contain no \n/\r/0xE2 candidate, falling to scalar handling only on windows that do.

Keyword recognition

keywordLookup is a hand-rolled perfect hash, not a hash map: a length gate, an O(1) comptime first-char bitset (KW_FC_MASK[len]), then switch(len)switch(first_char) with each candidate compared as one packed u64 word — at most two comparisons. After ./?. the keyword test is skipped entirely (the next identifier is a property name).

Lazy work

  • Line starts are not produced by the lexer. span.LineIndex builds the line-start table on first location lookup and caches it, so a clean file that emits no diagnostics never pays for it.
  • Comments are opt-in (a CommentSink must be supplied).
  • Parents are on demand (build_parents), built in one forward pass.
  • CFG is toggleable (need_cfg) — skipping it removes a large fraction of semantic work when no flow-dependent rule is active.
  • Ref ranges are toggleable (build_ref_ranges); when on, grouping is a counting sort (O(n + k)), not a comparison sort.
  • Redeclaration checks are opt-in (diagnose_redeclare).

Resolver hot structures

Reference resolution fronts a scope_map HashMap with a 512-entry direct-mapped ref_cache (hash & 511) to absorb repeated lookups of the same identifier; hoist_map gives O(1) per-level ancestor probing in the unresolved retry pass; undo_stacks restore visibility in O(1) per shadowed name on scope close. See Semantic Analysis.

Streaming pipelines (scaffolding — not wired in-tree)

Status: the streaming machinery below is designed but not driven by any in-tree code. Parsing and analysis are single-threaded in every path this library ships. Verify before relying on it: the lexer never publishes a token count (TokenizeOptions.publish_to / publish_batch_mask have zero consumers in scalar_lexer.zig), nothing constructs StreamingHooks, and the only std.Thread.spawn in the library is the resolver's scope/CFG split — which is itself opt-in and uncalled by analyze (see below). Treat this section as a description of the plumbing a downstream driver could use, not of a working default.

The parser side of a lex→parse pipeline is implemented: ParseOptions.streaming (StreamingHooks) carries published_len / lex_done atomics, and the parser's pump (refreshParsedLen, the peekSlow/peekAtSlow slow paths) blocks until more tokens are published or EOF is signaled. The 3-stage hooks (events_publish_to, ast_view_out, ast_ready) and the parser-side publish at statement boundaries (event stream PUBLISH_BATCH = 4096 in scope_events.zig) are also present, as is the lexer-side token batch size (PUBLISH_BATCH = 1024 in lexer_helpers.zig). What is missing is a producer: no in-tree code spawns a concurrent lexer or wires publish_to, so the pump never sees a concurrently growing token stream.

The design constraint these hooks are built around is real and worth knowing: shared buffers (nodes, scope_events, …) are pre-sized to safe upper bounds and must not realloc mid-parse, because a consumer thread would hold raw pointers into them — which is why streaming would size from capacity_hint and the parser uses appendAssumeCapacity throughout.

The resolver also exposes a scope/CFG parallel split (ScopeCfgParallel.start/.join + combineParts) — the library's only actual Thread.spawn. It too is opt-in: the default analyze/analyzeWithOptions path calls the single-threaded resolveFull (or resolveFullScope when need_cfg is off), never ScopeCfgParallel. So even the implemented parallelism is not on by default.

The zeroing-allocator contract

The analyzer reads sentinel-initialized buffers assuming fresh memory is zero. Use an ArenaAllocator over std.heap.page_allocator (zeroed OS pages) or wrap with semantic.ZeroingAllocator. A raw GPA, or an arena over c_allocator, can crash in ReleaseFast. This is a caller contract (no runtime probe is possible — see Semantic Analysis).


Next: API Reference · Architecture

Clone this wiki locally