Skip to content

perf(kv-router): allocate CRTC child storage only on a node's first child - #17259

Merged
jthomson04 merged 2 commits into
mainfrom
jthomson04/perf-28-find-which-crtc-lane-drain-miss-path-lea
Oct 26, 2026
Merged

jthomson04 merged 2 commits into
mainfrom
jthomson04/perf-28-find-which-crtc-lane-drain-miss-path-lea

Conversation

@jthomson04

@jthomson04 jthomson04 commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Overview:

Every CRTC node holds its children in an ArcSwap, and dropping one runs arc_swap's Debt::pay_all, which walks a debt node for every thread that ever loaded an ArcSwap (about 140 here). Most freed nodes are dead leaves that never had a child, so reclaiming each cost about 5 µs in that walk. This PR allocates the ArcSwap only when a node gets its first child, and seeds it with that child. That raises CRTC write capacity by 15% under glibc and 49% under jemalloc.

write capacity (M block ops/s) base this PR Δ [95% CI]
glibc 71.27 82.24 +15.2% [+14.1, +15.7]
jemalloc 108.52 159.32 +49.1% [+39.0, +68.0]

Write capacity ranged from 69.8–73.0 M block ops/s for base to 80.9–83.0 for this PR under glibc, and from 94.3–114.8 to 154.1–163.3 under jemalloc.

Keep-up runs on this node land in one of two states whatever the arm: fast (find_matches p50 about 2.5 µs) or slow (about 3.9 µs). Slow runs were 3/10 for base and 2/10 for this PR under glibc, and 3/10 and 5/10 under jemalloc. A median across both states mostly reflects that split, so this table compares medians within the fast state:

allocator (fast runs, base / PR) metric base this PR Δ
glibc (7 / 8) find_matches p50 (µs) 2.50 2.46 -1.3%
glibc find_matches p99 (µs) 11.65 11.54 -1.0%
glibc update p50 (µs) 34.02 31.10 -8.6%
glibc update p99 (µs) 1,345.7 1,326.2 -1.4%
jemalloc (7 / 5) find_matches p50 (µs) 2.56 2.56 +0.0%
jemalloc find_matches p99 (µs) 12.70 12.74 +0.3%
jemalloc update p50 (µs) 18.37 16.64 -9.4%
jemalloc update p99 (µs) 524.9 493.1 -6.1%

Slow-state runs show the same thing: find_matches is unchanged or up to 3% faster, and update p50 falls 7–9% under both allocators.

Benchmark: mooncake_bench replays the full Mooncake trace (128 workers, duplication factor 20, length factor 4: 472k requests, 2.0M KV events, 320M block operations) through ThreadPoolIndexer over CRTC with 8 event workers, on one 144-core Grace node. Overload replays the trace in 750 ms, so the achieved rate is the write capacity. Keep-up replays it in 12 s and measures find_matches and update latency. Each allocator ran 10 rounds, as fresh processes in rotated order. The overload Δ is the median per-round ratio with a bootstrap 95% CI. The keep-up Δ compares unpaired medians, so it has no CI.

Details:

  • NodeChildren::state is now a OnceLock<ArcSwap<ChildrenState>> that stays unset until the first insert, which seeds it with that child. Readers see an unset cell as Empty. remove, clear and transfer_for_split return early on an unset cell, so a split that starts from a leaf saves one swap as well.
  • A node that never had a child now drops without the debt walk, and without allocating an Arc<ChildrenState>. In the overload profile, pay_all falls from 9.8% to 3.3% of CPU under glibc and from 21.6% to 5.9% under jemalloc.
  • Results are unchanged. An unset cell reads exactly like the Empty state it replaces, so a reader racing the first insert sees what it saw before. Shape gates, versions and unlink rules are untouched. Keep-up node splits (23.8k) and lookup repairs (87.2k) match between arms.
  • Cost: each child lookup adds one acquire load on the OnceLock, and each node grows by 8 bytes, while each leaf saves one heap allocation.
  • The numbers above are for 68f04e3043. The second commit was not re-measured:
    • The first insert used to create the cell holding Empty and then swap in the real state. It now seeds the cell with the child directly, which saves one Arc allocation and one debt walk per leaf that gains a child. That is about 24k walks in this trace, so the expected gain is under 1%.
    • It also folds values_snapshot and extend_values into for_each_child.
  • Adds the test a_node_without_children_holds_no_state_cell and a README note on why the cell is lazy. The existing concurrent-insert tests race the first insert into an unset cell.
Reproduce

mooncake_bench (lib/bench/kv_router) on the full Mooncake trace, -n 128 --trace-duplication-factor 20 --trace-length-factor 4, CRTC with 8 event workers, fresh processes in rotated order on one Grace node:

crtc-bench run --site tyche --arm base=7924fd9133020c61dd45dd6435f21b5818489262 --arm lazy-children=7924fd9133020c61dd45dd6435f21b5818489262:lazy-children.patch --loads overload,keepup --rounds 10 --allocators glibc,jemalloc

A keep-up run is in the fast state when its find_matches p50 is under 3.2 µs.

Where should the reviewer start?

concurrent_radix_tree_compressed/children.rs: NodeChildren, Loaded and cell_or_seed().

Related Issues

None.

🤖 Generated with Claude Code

…hild

Every CRTC node held its children in an ArcSwap, and dropping an ArcSwap
runs arc_swap's Debt::pay_all, which walks one debt node per thread that
has ever loaded one. Most nodes the indexer frees are dead leaves that
never had a child, so reclaiming each one paid that walk.

NodeChildren now keeps its ArcSwap in a OnceLock that stays unset until
the first child is inserted; readers see an unset cell as Empty. A node
that never had a child drops without the walk and without allocating an
Arc<ChildrenState>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jthomson04 <jwillthomson19@gmail.com>
@copy-pr-bot

copy-pr-bot Bot commented Oct 25, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@github-actions github-actions Bot added perf documentation Improvements or additions to documentation labels Oct 25, 2026
@jthomson04
jthomson04 marked this pull request as ready for review October 25, 2026 03:25
@jthomson04
jthomson04 requested a review from a team as a code owner October 25, 2026 03:25
@coderabbitai

coderabbitai Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 1ecc870 and 68f04e3.

Files selected for processing: 2
  • lib/kv-router/src/indexer/concurrent_radix_tree_compressed/README.md
  • lib/kv-router/src/indexer/concurrent_radix_tree_compressed/children.rs

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 11 remain after this review.

Walkthrough

NodeChildren now stores its ArcSwap in a OnceLock. Empty construction and reads do not initialize the cell. Insertion initializes it on demand, and removal, clearing, and split transfer handle an unset cell.

Changes

Lazy child-state allocation

Layer / File(s) Summary
Lazy state representation and reads
lib/kv-router/src/indexer/concurrent_radix_tree_compressed/children.rs
NodeChildren stores its ArcSwap in a OnceLock. Empty construction leaves the cell unset, and read methods use a shared empty state when it is unset.
Mutation paths and validation
lib/kv-router/src/indexer/concurrent_radix_tree_compressed/children.rs, lib/kv-router/src/indexer/concurrent_radix_tree_compressed/README.md
Insertion initializes the cell on demand. Removal, clearing, and split transfer handle unset state. A test checks unset and initialized states. The README describes allocation and cleanup.

Priority: Low

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: Minimal · up to 68f04

The lazy child-state change has no identified issue that needs to be resolved before merge.

Pre-merge checks: Passed 3 · Failed 2

Failed Failed checks: 2 warnings

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 24 functions across 1 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check Warning The description includes the required Overview, Details, reviewer starting point, benchmark data, reproduction steps, and implementation context. However, the required Related Issues section contains … Add the implemented issue reference in the Related Issues section, such as “Closes #1234” or “Closes DYN-1234”. Use “Relates to” if the pull request does not fully resolve the issue.
Passed Passed checks: 3 passed
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: lazy allocation of CRTC child storage on first child insertion.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 24 functions across 1 files. (1 skipped: 1 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

Full details: Description check

Explanation

The description includes the required Overview, Details, reviewer starting point, benchmark data, reproduction steps, and implementation context. However, the required Related Issues section contains “None” and does not reference an issue.

Resolution

Add the implemented issue reference in the Related Issues section, such as “Closes #1234” or “Closes DYN-1234”. Use “Relates to” if the pull request does not fully resolve the issue.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

The first insert created the cell holding `Empty`, then swapped in the
real state, paying an `Arc` allocation and a full debt walk for a value
nobody reads. Seed the cell with the first child instead.

Also pass the cell into `compare_and_swap` so nothing but an insert can
create it, return early from `from_map` on an empty map, and fold
`values_snapshot`, `extend_values` and `entries_snapshot` into
`for_each_child`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jthomson04 <jwillthomson19@gmail.com>
@jthomson04

Copy link
Copy Markdown
Contributor Author

/ok to test 27157a9

@devin-ai-integration

Copy link
Copy Markdown
Contributor

✅ Dynamo PR CI passed — run 40159577891 (attempt 1) on 27157a9a47

Gate checks: ✅ backend-status-check · ✅ deploy-status-check · ✅ dynamo-status-check

Posted automatically by Devin for run 40159577891. Updated on every full-CI run of this PR.

@jthomson04
jthomson04 merged commit d1d61fc into main Oct 26, 2026
131 checks passed
@jthomson04
jthomson04 deleted the jthomson04/perf-28-find-which-crtc-lane-drain-miss-path-lea branch October 26, 2026 16:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation perf size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants