Skip to content

perf(output): remove the dump walk's quadratic prefix and parent lookups - #1092

Merged
dekobon merged 3 commits into
mainfrom
fix/1054-dump-prefix-quadratic
Jul 27, 2026
Merged

perf(output): remove the dump walk's quadratic prefix and parent lookups#1092
dekobon merged 3 commits into
mainfrom
fix/1054-dump-prefix-quadratic

Conversation

@dekobon

@dekobon dekobon commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Fixes #1054

bca dump was quadratic in AST nesting depth. The issue is already
closed with the full write-up; this PR is the code.

Root cause: two quadratic terms, not one

  1. The prefix, as the issue describes. Every queued node carried an
    owned String copy of its ancestors' box-drawing prefix, rebuilt with
    format! per node and clone()d per child. A tree that is wide and
    deep holds O(depth²) resident bytes plus an O(depth) copy per node.

  2. Node::parent, which the issue does not mention. branch_glyphs
    called node.parent().is_none() once per node for the flush-left
    check, and tree_sitter resolves parent() by descending from the
    root — the same shape perf(metrics/tokens): O(leaves x depth^2) ancestor walk is a DoS vector #1052 and perf(metrics): Node::parent lookups make three walks quadratic in nesting depth #1084 hit. This term dominates on the
    wide-and-deep fixture.

What changed

The walk keeps one shared prefix buffer, extended on descent and
truncated at the start of the next visit; each stack frame carries a
prefix_len instead of a String. Truncating on visit rather than on
the way back up is what makes a bare length sufficient — prefixes only
grow as the walk descends, so the first prefix_len bytes are still
this node's prefix when it is popped.

The flush-left decision moved onto the stack as a Connector enum
(Flush / Last / Inner) derived when a node is pushed, so
Node::parent is called exactly once per walk — for the start node, the
only node that can lack a parent. (bca find starts at a matched node
that does have one; it renders as a last child, as before.)

The mirrored dump_metrics / dump_ops walks carried the same per-entry
owned prefix and got the same treatment. Their measured cost is unchanged
on the fixtures tried — a nested-closure chain keeps one stack entry alive
at a time, so the quadratic term needs a tree that is wide and deep —
but the O(depth) copy per rendered line is gone and the three walks no
longer differ in shape.

The last commit (87da2b98) is a follow-up cleanup landed after the issue
write-up: push_children now consumes the child iterator directly and
reverses the new stack tail in place, dropping the reused Vec<Node>
staging buffer so each node is copied once rather than twice.

Measurements

Release binary, the issue's fixture int main(){return ((((…1…))));}:

depth before after peak RSS before after
1000 84 ms 10 ms 15.5 MB 11.6 MB
2000 284 ms 10 ms 25.4 MB 12.0 MB
4000 1,181 ms 50 ms 66.2 MB 13.0 MB

Wide-and-deep JavaScript fixture (1500 nested functions, two extra
siblings per level), where the parent() term bites hardest:

command before after
bca dump 4.00 s 0.05 s
bca metrics 1.33 s 1.29 s

What this does not fix

The emitted byte count, which is what the issue's "~34,000x
amplification" headline measures. A depth-d tree drawing carries d
glyphs of indentation on every line, so output is inherently
O(nodes × depth); 546 MB at depth 8000 is what the format costs. Bounding
the output is a CLI resource-limit decision tracked in #1053--depth
is the existing lever. This PR removes the time and the resident memory
spent producing those bytes, not the bytes.

Evidence

  • Byte-identity over 300 files spanning src/,
    tests/repositories/pdf.js, tests/repositories/DeepSpeech, and
    tests/repositories/serde, against a binary built from main:
    dump 300/300 and metrics 300/300 byte-identical; ops 300/300 after
    normalising the positional connector and sorting, because bca ops
    output is already nondeterministic run to run on main — filed
    separately as fix(ops): operator/operand order is nondeterministic across runs #1091.
  • make pre-commit green end to end (MAKE_EXIT=0) on the tip
    commit: cargo trio, doc warnings, udeps, the markdown/TOML/shell/
    Makefile/Actions lint families, man-page drift, both self-scan tiers,
    and the Python ruff/mypy/pyright/pytest/stubtest stages.
  • Every prefix.truncate is covered by a perturbation-verified test.
  • No snapshot churn and no submodule bump: the rendered text is unchanged,
    so a clean run leaves no .snap.new.

Not covered by make bench-scaling. Its probes measure the metric
walk, not the dump walk, so no probe exercises this path — the numbers
above are direct release-binary timings on the two fixtures, not harness
output.

Spun out

dekobon added 3 commits July 26, 2026 23:03
The AST dump gave every queued node an owned copy of its ancestors'
box-drawing prefix — a string that grows ~3 bytes per nesting level —
so a wide-and-deep tree held O(depth^2) resident bytes and copied
O(depth) per node. Separately, the flush-left check called
Node::parent once per node, which tree-sitter resolves by descending
from the root.

The walk now keeps one shared prefix buffer, extended on descent and
truncated on the next visit, and carries each node's connector glyph
on the work stack so only the node the walk starts from needs a
parent lookup. On the issue's fixture bca dump goes from 1.18s to
0.05s at depth 4000 (peak RSS 66 MB -> 13 MB); on a wide-and-deep
JavaScript fixture, 4.00s to 0.05s.

Rendered text is byte-identical, verified across 300 files from the
corpus submodules for dump, metrics, and ops. The mirrored metrics
and ops dumps carried the same per-entry owned prefix and get the
same treatment. Every truncate is covered by a test that fails when
the line is removed, including the previously untested rail of
cyclomatic.modified's siblings. The three deep-nesting tests now
write to io::sink instead of buffering their output, which took the
metrics one from 10 GB peak RSS to 23 MB.

Fixes #1054
last_emitted_metric_group_uses_closing_connector filtered group lines
at a three-column rail, but groups sit six columns in (three for the
root space's connector, three for the metrics line's). The filter
matched only the "metrics" line itself, so the test passed with every
metric group rendering a dangling "|-" and with every group rendering
the closing glyph. Fix the rail and assert both directions.

dump_node_non_utf8_source_does_not_panic asserted only is_ok(), which
held with the raw-bytes fallback stubbed out to Ok(()) — the snippet
it exists to render could vanish silently. It now renders to an
in-memory sink and asserts the non-UTF-8 byte reaches the output,
which also keeps the test off the process's real stdout, as does the
same change to the empty-ops test.

Both repaired tests were verified by perturbing the production line
each one guards and confirming the failure.
push_children now takes the child iterator directly, appends the
frames in source order, and reverses the new stack tail in place.
This removes the reused Vec<Node> that every non-leaf visit cleared
and refilled, so each node is copied once instead of twice.

Last-child detection still keys off the child actually walked last,
now the frame at the bottom of the reversed tail, so a cursor/
child_count disagreement on a malformed tree keeps rendering the
real last child as the closing connector.
@dekobon dekobon added output Report / serialization output surface refactor Code maintainability / tech-debt cleanup (no behaviour change) labels Jul 27, 2026
@dekobon
dekobon merged commit 3351efb into main Jul 27, 2026
37 checks passed
@dekobon
dekobon deleted the fix/1054-dump-prefix-quadratic branch July 27, 2026 17:05
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.75281% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.04%. Comparing base (edbad0a) to head (87da2b9).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
src/output/dump_metrics.rs 97.16% 0 Missing and 3 partials ⚠️
src/output/dump_ops.rs 95.91% 0 Missing and 2 partials ⚠️
src/output/dump.rs 99.08% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1092      +/-   ##
==========================================
- Coverage   98.06%   98.04%   -0.02%     
==========================================
  Files         270      271       +1     
  Lines       67117    67235     +118     
  Branches    66687    66805     +118     
==========================================
+ Hits        65816    65922     +106     
- Misses        860      871      +11     
- Partials      441      442       +1     
Flag Coverage Δ
rust 98.03% <97.75%> (-0.02%) ⬇️

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

Files with missing lines Coverage Δ
src/output/mod.rs 100.00% <100.00%> (ø)
src/output/dump.rs 92.15% <99.08%> (-3.16%) ⬇️
src/output/dump_ops.rs 82.19% <95.91%> (+2.35%) ⬆️
src/output/dump_metrics.rs 87.10% <97.16%> (+4.31%) ⬆️
🚀 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

output Report / serialization output surface refactor Code maintainability / tech-debt cleanup (no behaviour change)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(output/dump): quadratic prefix growth amplifies output ~34000x

1 participant