Skip to content

perf(router): copy new tree nodes from one empty node - #196

Merged
dinwwwh merged 1 commit into
mainfrom
claude/project-thread-m32ra9
Oct 29, 2026
Merged

dinwwwh merged 1 commit into
mainfrom
claude/project-thread-m32ra9

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Oct 29, 2026

Copy link
Copy Markdown
Member

Requested by dinwwwh · project thread

Summary

createNode returned an object literal whose seven fields are undefined. V8 does not treat undefined (or void 0) as a constant in an object literal, so the interpreter creates the object and then runs DefineNamedOwnProperty for each field, seven ICs per node (node --print-bytecode shows it; null values would be one CreateObjectLiteral). A cold start creates a node for most routes while building the tree.

Nodes are now copies of one module-level EMPTY_NODE ({ ...EMPTY_NODE }), which V8 makes in one step with a single clone IC, and createNode is gone. The fields and their undefined values stay the same, so every read of them, and the optimized code, is unchanged.

I also tried switching the fields to null: about the same cold gain, but 10 to 20 more instructions per request in steady state (+0.3%), so not that.

Measurements

Callgrind instructions, --single-threaded, hash seeds 1 and 2 (averaged), main vs this PR:

Stage main PR Change
Router import (any set) 2.118M 2.178M +60k
RPC add, 250 statics 2.325M 2.235M -90k
REST add, 703 routes 4.227M 4.088M -3.3%
REST, 3 requests 3.757M 3.579M -4.7%
REST, 3 requests on 3 methods 5.487M 5.199M -5.2%
REST, 10 requests 6.454M 6.161M -4.5%
100 real nouns, 10 requests 6.426M 6.126M -4.7%
RPC, 10 requests 420k 420k same

The import grows because EMPTY_NODE is now created there; the first add used to compile createNode and build the first literal, so RPC import plus add is still 30k lower. REST cold start plus 3 requests is about 258k lower overall.

  • Size (esbuild --minify, gzip -9): 5076 B to 5071 B minified, 2114 B to 2105 B gzip.
  • Steady state (static, param, two params, catch-all, tail, miss, encoded, three mixes; seeds 1 and 2): identical to within 2 instructions.

Checks

  • pnpm run check green; router tests 73/73 with 100% coverage.
  • All fuzzers at 3 seeds: 0 diffs.
  • Stryker: 68 survivors (64 on main). The new ones replace a node copy, or EMPTY_NODE itself, with {}, which reads the same undefined fields and only gives nodes other shapes; they replace the one return {} survivor createNode had.

Generated by Claude Code

An object literal with `undefined` values is not a constant V8 can clone:
it creates the object, then defines each property with its own IC, seven
per node. Spreading one empty node copies it in one step instead. The
nodes keep their fields and `undefined`, so the code that reads them, and
the steady state, stay the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7Sutbw6xMTMyDFeNfw
@dinwwwh dinwwwh self-assigned this Oct 29, 2026
@codecov

codecov Bot commented Oct 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Oct 29, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

1 warning

⚠️ 1 benchmark is dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

✅ 29 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing claude/project-thread-m32ra9 (11f7697) with main (7a4e0bd)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes — reviewed the single-commit change to packages/router/src/index.ts (the only file touched), which replaces per-node object-literal construction with copies of one shared empty node:

  • createNode removed, EMPTY_NODE added — the module-private createNode<T>() is gone; a module-level const EMPTY_NODE: Readonly<Node<never>> with the same seven undefined fields replaces it (packages/router/src/index.ts:99).
  • All four construction sites now spread EMPTY_NODE — routesOf root (index.ts:247), the expand param child (index.ts:439), and both literalChild paths (index.ts:489, index.ts:503). grep createNode returns no hits, so no construction path was missed.

The spread produces the same seven own enumerable properties with the same undefined values in the same order, so every read of them and the optimized code paths are unchanged. EMPTY_NODE is only ever spread, never returned or mutated, and its fields are all undefined (no nested objects to alias), so the shared singleton introduces no aliasing hazard. I re-ran the router suite (73/73, 100% coverage) and uncheck packages/router/src/index.ts (oxlint 0/0, oxfmt, tsc) — both green. The new Stryker survivors the author notes (replacing a node copy or EMPTY_NODE with {}) are equivalent mutants, since a plain {} reads the same undefined fields; they are expected, not a coverage gap.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@dinwwwh
dinwwwh merged commit 2b6c549 into main Oct 29, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants