Skip to content

perf(metrics): retire the remaining Node::parent climbs (#1088) - #1097

Open
dekobon wants to merge 2 commits into
mainfrom
perf/1088-remaining-parent-climbs
Open

perf(metrics): retire the remaining Node::parent climbs (#1088)#1097
dekobon wants to merge 2 commits into
mainfrom
perf/1088-remaining-parent-climbs

Conversation

@dekobon

@dekobon dekobon commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Closes #1088.

Retires the four Node::parent climbs #1084 deferred — and one cause
the issue did not name, which turned out to be the one that mattered.

What changed

perf(node): scan a node's children with a cursorNode::wraps_any,
reached from is_child and has_sibling and so from most languages'
checkers and getters, walked a node's children with child(0) +
next_sibling(). #217 chose that form over the cursor iterator to avoid
a TreeCursor allocation, on the stated premise that a sibling step is
O(1). It is not: ts_node_next_sibling resolves the parent first, and
tree_sitter stores no parent pointer — it descends from the root. The
scan was O(children × depth), making every caller O(depth) per node.

perf(metrics): thread the chain through the last climbs — three of
the four listed sites take an Ancestors parameter (JS-family
is_func / is_closure and the JS space-name getters, Elixir's
get_func_space_name, the suppression scan), plus Ruby's is_closure,
which the first pass missed. ops_inner, act_on_node, and
suppression_markers maintain chains of their own. All widened items
are pub(crate); the published API is unchanged.

The fourth site wanted deleting, not threading. Elixir's Npm / Npa
compute opened with is_func_space_with_code, whose answer the
defmodule keyword check immediately below always discarded —
elixir_is_class_macro is exactly defmodule, so the classifier
admits precisely what that check admits, and every node whose
classification consulted the quote-template ancestor walk is rejected
by it regardless. Removing the call removes the work rather than making
it cheaper, and leaves both signatures untouched.

Measurements

The issue's "worth measuring first" instinct is what caught the real
cause: threading the chain into the JS predicates left the probe at
k = 1.97. The walk is two steps long on that shape, so it should have
flattened — the remaining O(depth) was one level down, in
wraps_any. A chain-fed predicate is only as linear as the
primitives it calls.

Probe before after
nom/nested-arrow (JS, one arrow per level) k = 1.97, 17.6 s @ depth 4000 k = 1.03, 6.3 ms
nom/nested-declared-function (shape control) k = 1.08 k = 1.08

Ordinary input benefits too: a metric walk over the 384-file pdf.js
corpus drops from ~443 ms to ~370 ms.

The probe deliberately uses block-bodied arrows, where the walk stops
in two steps. A chain of expression-bodied arrows
(a => b => c => …) is the one shape no chain can flatten — no stop
kind intervenes, so the walk itself is O(depth) long. Documented at
the macro rather than silently left.

Metric values

Unchanged for every language. Full workspace suite green with no
.snap.new under the big-code-analysis-output submodule, so no
submodule bump is needed.

Tests

Chain-vs-climb parity for every newly threaded consumer, each verified
by perturbation
to fail against a wrong ancestor read: is_func /
is_closure across all four JS grammars plus Ruby, get_func_space_name
across all four JS grammars plus Elixir, has_sibling, a JS fixture
added to the existing a_known_chain_answers_exactly_what_climbing_answers,
the bca function span walk, and act_on_node on a subtree.
wraps_any's equivalence test keeps its node-by-node comparison with the
reference flipped — the retired sibling chain is now the reference, so
what it pins is that the swap did not change the set of children
enumerated.

Two Npm / Npa parity tests written during the first pass were
vacuous and were replaced: they asserted that a known chain and a
climb agree, over a fixture where the ancestor answer never reached the
output — which is the redundancy described above, showing up as a test
that could not fail. The replacements pin counts over a defmodule
nested inside a quote.

Follow-up

#1096 tracks the remaining per-node node.parent() calls (Halstead
get_op_type getters, src/metrics/abc/, the loc / npm / npa /
cyclomatic bodies). docs/development/benchmarking.md is updated to
point there, and the Ancestors::unknown() call sites that remain are
deliberate and documented at each.

Validation

make pre-commit green, including the self-scan gate at both tiers.
.bca-baseline.toml net shrank — removing the redundant Elixir
classifier and reverting the unused Npm / Npa parameter took several
functions back below their recorded values.

dekobon added 2 commits July 27, 2026 21:23
`Node::wraps_any` — reached from `is_child` and `has_sibling`, and so
from most languages' checkers and getters — walked a node's children
with `child(0)` + `next_sibling()`. #217 chose that form over the
cursor iterator to avoid a `TreeCursor` heap allocation per call, on
the stated premise that a sibling step is O(1).

It is not. `ts_node_next_sibling` resolves the parent before it can
find a sibling, and `tree_sitter` stores no parent pointer — it
descends from the root. Each step therefore cost O(depth) and the scan
O(children x depth), which is the same defect #1084 removed from the
predicates that ask for an ancestor outright, one level further down.

The cursor iterator is O(children) after one allocation and measures
faster on ordinary input as well as pathological input: a metric walk
over the 384-file pdf.js corpus drops from ~443 ms to ~370 ms.

The equivalence test keeps its node-by-node comparison but flips which
side is the reference: the retired sibling chain is now the reference
implementation, so what it pins is that the swap did not change the set
of children enumerated.
#1084 gave the metric walk an ancestor chain and threaded it through
three hot predicates; #1062 took the fourth. Four call sites stayed on
`Ancestors::unknown()`, each correct but paying `Node::parent`'s
O(depth) per step, and each therefore a latent quadratic on a deeply
nested file. Three are threaded here:

- the JS-family name-binding walk behind `Checker::is_func` /
  `is_closure`, plus the JS-family space-name getters, which climbed to
  read the `pair` or `variable_declarator` that names an anonymous
  function;
- Elixir's `Getter::get_func_space_name`;
- the suppression-marker scan.

`is_func`, `is_closure`, `get_func_name`, `get_func_space_name`, and
`NArgs::compute` gain an `Ancestors` parameter. All are `pub(crate)`, so
the published API is unchanged. Ruby's `is_closure` reads it too: its
`{ … }` block is a closure only when its parent is not the `Lambda`
that already counted it, which makes it the one non-JS predicate of
that pair that asks for an ancestor.

The three walks that reached these predicates without a chain —
`ops_inner`, `act_on_node` (behind `bca function`), and
`suppression_markers` — now maintain one by the same truncate/push rule
`metrics_inner` uses, constructed through `Ancestors::checked` so a
bookkeeping slip fails a debug build rather than feeding a predicate a
wrong ancestor in silence. `act_on_node` seeds its chain with the
subtree root's own ancestry rather than empty, so it stays correct if
it is ever called below the tree root.

The fourth site, Elixir's `Npm` / `Npa`, wanted deleting rather than
threading. Both `compute` bodies opened with `is_func_space_with_code`,
which cost a source-text keyword scan per node and, for `def`-shaped
calls, the `quote`-template ancestor walk. Its answer was always
discarded: `elixir_is_class_macro` is exactly `defmodule`, so the
keyword check immediately below admits precisely the nodes the
classifier would have and rejects every node the walk was consulted
for. Removing the call removes the work instead of making it cheaper,
and leaves `Npm::compute` / `Npa::compute` with the signature they had.

Guarded by a new probe pair. `nom/nested-arrow` fits `time ~ depth^k` at
1.97 before and 1.03 after, and at depth 4000 `nom` over nested arrow
functions drops from ~17.6 s to ~6.3 ms;
`nom/nested-declared-function` is the same nesting written with
`function` declarations, which `is_func` answers from the node's own
kind, and holds at 1.08 either side.

Most of that win came from the child scan retired in the previous
commit: threading the chain alone left the probe at 1.97, because the
walk is two steps long on that shape and the O(depth) term had moved
into `Node::wraps_any`. A chain-fed predicate is only as linear as the
primitives it calls.

Metric values are unchanged for every language. The remaining per-node
`node.parent()` calls in the Halstead getters, `src/metrics/abc/`, and
the `loc` / `npm` / `npa` / `cyclomatic` bodies are tracked in #1096.

Fixes #1088
@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.71689% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.08%. Comparing base (0e630cd) to head (1d6ff63).

Files with missing lines Patch % Lines
src/checker.rs 92.15% 4 Missing ⚠️
src/getter.rs 96.22% 2 Missing ⚠️
src/node.rs 97.40% 2 Missing ⚠️
src/suppression.rs 93.75% 1 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1097      +/-   ##
==========================================
- Coverage   98.10%   98.08%   -0.03%     
==========================================
  Files         272      272              
  Lines       67527    67776     +249     
  Branches    67097    67346     +249     
==========================================
+ Hits        66249    66477     +228     
- Misses        868      888      +20     
- Partials      410      411       +1     
Flag Coverage Δ
rust 98.07% <97.71%> (-0.03%) ⬇️

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

Files with missing lines Coverage Δ
src/checker/bash.rs 58.82% <100.00%> (ø)
src/checker/c.rs 76.92% <100.00%> (ø)
src/checker/cpp.rs 77.77% <100.00%> (ø)
src/checker/csharp.rs 90.90% <100.00%> (ø)
src/checker/elixir.rs 62.06% <100.00%> (-25.87%) ⬇️
src/checker/go.rs 76.92% <100.00%> (ø)
src/checker/groovy.rs 100.00% <100.00%> (ø)
src/checker/irules.rs 70.00% <100.00%> (ø)
src/checker/java.rs 86.95% <100.00%> (ø)
src/checker/kotlin.rs 69.56% <100.00%> (ø)
... and 38 more
🚀 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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(metrics): thread the ancestor chain through the four remaining Node::parent climbs

1 participant