Skip to content

perf(metrics): bound the cognitive nesting map - #1377

Merged
dekobon merged 2 commits into
mainfrom
perf/1375-nesting-map-lifetime
Aug 30, 2026
Merged

perf(metrics): bound the cognitive nesting map#1377
dekobon merged 2 commits into
mainfrom
perf/1375-nesting-map-lifetime

Conversation

@dekobon

@dekobon dekobon commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Fixes #1375.

What the peak actually was

#1375 was filed from a measurement with the attribution explicitly not
done, and two of its three hypotheses turned out to be wrong. The 5.1 GB
is not worker fan-out over ordinary files: the registry carries a dozen
tree-sitter-* crates whose generated src/parser.c runs 8–28 MB and
slips past the generated-file heuristic (the header opens with
#include "tree_sitter/parser.h", no marker). At -j 1 the whole
100k-file tree peaks at 1.31 GB — exactly the largest single file, so
nothing accumulates across files.

Splitting one 28 MB file's 1,265 MB by selection:

selection (-j 1) peak RSS
dump — parse only 722 MB
--metrics cyclomatic / halstead / abc / nargs / nexits 723–725 MB
--metrics cognitive 1,264 MB
all metrics, stdout 1,265 MB
all metrics, --output-dir 1,266 MB

So ~720 MB is the parse and cognitive alone added ~540 MB. The
reorder buffer and the wire clone are ruled out by the last two rows
(--output-dir has neither and matches stdout); bca check, which has
no reorder buffer at all, peaked at 4.5 GB on the same tree.
MALLOC_ARENA_MAX=1 gives 3.4 GB at 5× the wall time — real, but
secondary and not ours.

The change

NestingMap was reserved at descendant_count and never freed a slot,
so it converged on one entry per node. Each slot is now freed by its
last reader, and the map grows from empty, so the live set is the
seeded-but-unvisited nodes — bounded by the traversal stack.
propagate_nesting_to_children reserves per sibling set so a wide
generated table still costs one allocation.

run before after
objc parser.c (28 MB), -j 1 1,265 MB / 5.1 s 735 MB / 3.3 s
cargo registry (100k files), -j 16 5.1 GB 3.6 GB
DeepSpeech, -j 1 / -j 16 125 / 488 MB 103 / 439 MB

make bench-scaling unchanged, all 27 probes linear.

Why this needs its own guard

No metric value moves — the retained entries were already dead — so
nothing in the output can distinguish the fix from its absence. Two
guards, deliberately overlapping:

  • walk_frees_every_cognitive_nesting_slot reads the
    nesting_slots_retained counter over the four paths that seed a slot:
    a plain nested walk, the two overrides that write a child's slot ahead
    of the walk (Python comprehension clauses, Tcl try handler bodies),
    and an exclude_tests prune. Both freeing sites were verified by
    reverting them individually and watching only this test fail.
  • A debug_assert! generalises that to every walk the suite runs —
    twenty languages of corpus fixtures rather than the four the test
    names. This matters because the freeing rule now has two owners, and a
    third continue added later would otherwise leak in silence.

Incidental

  • Cognitive::SEEDS_NESTING and Node::descendant_count existed only
    to size the reserve; removed with their tests.
  • parser_cache's module doc asserted a per-thread retention bound of
    "tens of KiB". The pinned tree-sitter caps those pools at fixed counts
    (TS_MAX_TREE_POOL_SIZE, MAX_NODE_POOL_SIZE) and the surrounding
    scratch arrays track parse depth rather than input size, so the figure
    was unsupported in both directions. The doc now states what the source
    actually shows.
  • The CI recipe gains the --jobs memory sizing rule: bca is
    cgroup-CPU-aware but not memory-aware, and a parse-only run measures
    25–70× the source size.

Deliberately not done

A --max-file-size gate, a memory-aware --jobs auto, a bounded
OrderedStdout, and an allocator swap all address terms the measurement
shows are small. The remaining ~720 MB on a 28 MB input is the parse
tree itself.

The walk's `NestingMap` grew to one entry per AST node and was
reserved at `descendant_count` up front. Each slot is seeded by the
parent, read once by the node's own `compute`, read once more when its
children are seeded, and was then kept until the walk ended, so the map
converged on the tree's size whether or not anything still read it.

Free the slot in its last reader — `propagate_nesting_to_children`,
plus the `exclude_tests` prune arm, which pops a seeded node without
computing or propagating it — and drop the up-front reserve. The live
set is then the seeded-but-unvisited nodes, bounded by the traversal
stack. `propagate_nesting_to_children` reserves per sibling set, so a
generated parser table hanging half a million children off one
`initializer_list` still costs one allocation rather than a doubling
chain.

Measured on a 28 MB generated `tree-sitter` `parser.c`, release,
`-j 1`: 1,265 MB -> 735 MB peak RSS, 5.1 s -> 3.3 s. `metrics -O json`
over a 100k-file cargo registry at `-j 16`: 5.1 GB -> 3.6 GB.
`tests/repositories/DeepSpeech` at `-j 16`: 488 MB -> 439 MB. Attributed
by selection: `--metrics cognitive` alone accounted for 540 MB of the
old peak, while every other metric and a parse-only `dump` sat at
~722 MB. `make bench-scaling` is unchanged, all 27 probes linear.

No metric value moves, so nothing in the output can show the map was
retaining dead entries. A `debug_assert!` covers every walk the suite
runs, and `nesting_slots_retained` plus
`walk_frees_every_cognitive_nesting_slot` pin the four paths that seed
a slot: a plain nested walk, the two overrides that write a child's
slot ahead of the walk (Python comprehension clauses, Tcl `try` handler
bodies), and an `exclude_tests` prune. Each freeing site was verified
by reverting it and watching that test fail.

`Cognitive::SEEDS_NESTING` and `Node::descendant_count` existed only to
size the reserve and are removed with their tests. `parser_cache`'s
module doc claimed a retention bound of "tens of KiB" per thread; the
pinned tree-sitter caps those pools at fixed counts and the surrounding
scratch arrays track parse depth rather than input size, so the doc now
states what the source supports instead.

Fixes #1375
@codecov

codecov Bot commented Aug 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 72.72727% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.46%. Comparing base (97c9ac1) to head (6424a98).

Files with missing lines Patch % Lines
src/spaces/compute.rs 72.72% 1 Missing and 2 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1377      +/-   ##
==========================================
- Coverage   98.47%   98.46%   -0.01%     
==========================================
  Files         278      278              
  Lines       75812    75772      -40     
  Branches    75382    75342      -40     
==========================================
- Hits        74653    74610      -43     
- Misses        755      756       +1     
- Partials      404      406       +2     
Flag Coverage Δ
rust 98.45% <72.72%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/macros/mod.rs 95.09% <ø> (ø)
src/metrics/cognitive.rs 99.85% <ø> (-0.01%) ⬇️
src/node.rs 98.32% <ø> (-0.03%) ⬇️
src/node/parser_cache.rs 100.00% <ø> (ø)
src/spaces/compute.rs 98.23% <72.72%> (-0.75%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

remains a debugging knob, not a default. It is *not*
memory-aware: each worker holds one whole file in memory at a
time, and a parse-only run measures 25–70× the source size, so
peak RSS is roughly `jobs × 70 × the largest source file` in

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Location: big-code-analysis-book/src/recipes/ci.md:874

MEDIUM -- Scope the RSS sizing rule to output modes that do not retain completed results

The new formula is presented as generic CI guidance, but it is not a process-wide bound for every documented metrics destination. metrics --output <FILE> deliberately sends every FuncSpace over an unbounded channel and only collects/writes them after the walk (big-code-analysis-cli/src/commands/analyze.rs:159-184), so peak memory in that mode also grows with the full result set. Structured stdout has a similar extra term: OrderedStdout documents that its reorder buffer can degrade toward the whole tree when an early file dominates (big-code-analysis-cli/src/ordered_stdout.rs:28-38). Please qualify this as the parse/in-flight term (or scope it to --output-dir/check) rather than telling memory-limited CI users that lowering --jobs alone bounds RSS.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 6424a983. Verified both terms before editing, and both hold:

  • metrics --output <FILE> builds a crossbeam::channel::unbounded() and only drains it with rx.into_iter().collect::<Vec<_>>() after run_walk returns (commands/analyze.rs:161-183), so the whole result set is live at once — the same shape as the perf(cli): diff --since round-trips both sides through temp JSON files #1116 regression, deliberate here.
  • OrderedStdout documents the degradation itself (ordered_stdout.rs:32-34): the buffer approaches the whole tree "when one early file dominates the run". Worth noting that is precisely the tree this PR measures — a 28 MB parser.c among 100k small files — so it is the live case, not a hypothetical one.

The bullet now states the formula as the in-flight term and says --jobs bounds that term only, then splits the destinations: --output-dir writes per document and bca check reduces each file to its violations, so both stay at the in-flight bound; --output <FILE> and structured stdout add a result-set term. It closes by pointing a memory-limited runner at --output-dir.

The CHANGELOG sentence that referred to this rule was making the same overreach by reference, so it now says "the in-flight term in its --jobs sizing guidance" and names the two destinations that add to it.

make pre-commit passes.

@dekobon dekobon left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Code Review: perf(metrics): bound the cognitive nesting map

Verdict: APPROVE WITH COMMENTS

Severity Count
BLOCKER 0
CRITICAL 0
HIGH 0
MEDIUM 1

Findings

# Severity File:Line Title Category
1 MEDIUM big-code-analysis-book/src/recipes/ci.md:874 Scope the RSS sizing rule to output modes that do not retain completed results correctness

Review passes executed

  • Correctness and logic bugs
  • Security review of the changed traversal state lifecycle
  • Performance and scalability
  • Test coverage

Files reviewed: 8

I did not find a metric-value or traversal-correctness regression in the slot reclamation itself. The only comment is on the new generic memory-sizing guidance, which omits existing output-buffer terms.

The `--jobs` sizing guidance read as a process-wide bound, and it is
one only where the destination does not retain finished results.
`metrics --output <FILE>` sends every space over an unbounded channel
and collects it after the walk, and structured stdout holds completed
documents while an earlier file is still being analyzed — worst
exactly when one large file sorts early, which is the shape this
change was measured on. `--output-dir` writes per document and
`bca check` reduces each file to its violations, so those two stay at
the in-flight bound.

State the formula as the in-flight term, name the destinations that
add a result-set term, and point a memory-limited runner at
`--output-dir`.
@dekobon
dekobon merged commit 6424a98 into main Aug 30, 2026
41 checks passed
@dekobon
dekobon deleted the perf/1375-nesting-map-lifetime branch August 30, 2026 23:33
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.

perf(cli): peak RSS scales with jobs × largest file (5.1 GB over a 100k-file tree)

1 participant