perf(output): remove the dump walk's quadratic prefix and parent lookups - #1092
Merged
Conversation
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.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ 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
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1054
bca dumpwas quadratic in AST nesting depth. The issue is alreadyclosed with the full write-up; this PR is the code.
Root cause: two quadratic terms, not one
The prefix, as the issue describes. Every queued node carried an
owned
Stringcopy of its ancestors' box-drawing prefix, rebuilt withformat!per node andclone()d per child. A tree that is wide anddeep holds O(depth²) resident bytes plus an O(depth) copy per node.
Node::parent, which the issue does not mention.branch_glyphscalled
node.parent().is_none()once per node for the flush-leftcheck, and
tree_sitterresolvesparent()by descending from theroot — 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_leninstead of aString. Truncating on visit rather than onthe way back up is what makes a bare length sufficient — prefixes only
grow as the walk descends, so the first
prefix_lenbytes are stillthis node's prefix when it is popped.
The flush-left decision moved onto the stack as a
Connectorenum(
Flush/Last/Inner) derived when a node is pushed, soNode::parentis called exactly once per walk — for the start node, theonly node that can lack a parent. (
bca findstarts at a matched nodethat does have one; it renders as a last child, as before.)
The mirrored
dump_metrics/dump_opswalks carried the same per-entryowned 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 issuewrite-up:
push_childrennow consumes the child iterator directly andreverses 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…))));}:Wide-and-deep JavaScript fixture (1500 nested functions, two extra
siblings per level), where the
parent()term bites hardest:bca dumpbca metricsWhat this does not fix
The emitted byte count, which is what the issue's "~34,000x
amplification" headline measures. A depth-
dtree drawing carriesdglyphs 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 —
--depthis the existing lever. This PR removes the time and the resident memory
spent producing those bytes, not the bytes.
Evidence
src/,tests/repositories/pdf.js,tests/repositories/DeepSpeech, andtests/repositories/serde, against a binary built frommain:dump300/300 andmetrics300/300 byte-identical;ops300/300 afternormalising the positional connector and sorting, because
bca opsoutput is already nondeterministic run to run on
main— filedseparately as fix(ops): operator/operand order is nondeterministic across runs #1091.
make pre-commitgreen end to end (MAKE_EXIT=0) on the tipcommit: 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.
prefix.truncateis covered by a perturbation-verified test.so a clean run leaves no
.snap.new.Not covered by
make bench-scaling. Its probes measure the metricwalk, not the
dumpwalk, so no probe exercises this path — the numbersabove are direct release-binary timings on the two fixtures, not harness
output.
Spun out
bca opsentry order is nondeterministic run to run onmain, found while establishing byte-parity.