Skip to content

perf(ast): exclude_tests is quadratic in depth x one-attribute-item count #1447

Description

@dekobon

Summary

--exclude-tests over Rust is quadratic in depth × attributed-item count when
each attributed item carries a run of one attribute. PR #1446 fixed the
sibling shape (one long run, many attributes) by reusing a verdict across the
run; runs of length 1 have nothing to reuse, so this axis is untouched.

Roughly half of the cost predates #1446's attribute arm: it is
rust_outer_attr_marks_test restarting at the parent's first child once per
item, i.e. the #1100 budget design rather than any recent regression.

Reproduction

D nested fns, then 3*D separate one-attribute items at the innermost
level:

lines  = ["fn f%d() {" % i for i in range(D)]
lines += ["#[cfg(test)]\nfn t%d() {}" % i for i in range(3 * D)]
lines += ["}"] * D
$ bca metrics --no-config --jobs 1 --metrics nom --exclude-tests -p dN.rs
D --exclude-tests flag off with #1446's attribute arm disabled
250 0.20 s 0.00 s 0.09 s
500 0.75 s 0.01 s 0.37 s
1000 3.07 s 0.03 s 1.38 s

~4× per doubling in all three columns that grow, so the class is quadratic with
and without the attribute arm.

Root cause

forward_attribute_scan_budget is max(6, 3 * depth)
(big-code-analysis-ast/src/checker.rs). At depth D it permits a width-3D
parent onto the forward reading, and rust_attribute_run_under then walks the
parent's children from index 0 for every item it is asked about. With 3D
items under one parent that is O(D²).

The budget scales with depth because a previous_sibling climb costs O(depth)
while a cursor step is flat (#1100's measurements, recorded in the constants'
doc comments). That reasoning is sound per call; what it does not price is
being called O(width) times on the same parent.

Fix sketch

The shape of #1446's fix does not transfer directly — its reach works because
every attribute in a run shares one answer, and here each item has its own.
Options:

  1. Reach on the other axis. A verdict for item i could report that it
    answers for the run boundary it already walked past, letting the next item
    start where the last stopped. Needs the walker to carry a per-parent cursor
    rather than a single reach, which is more state than fix(metrics/loc): six LOC fixes from the 2026-09-10 residue #1446 added.
  2. Cap the forward budget by absolute width, not just depth. A parent wider
    than some constant takes the backward reading regardless of depth. Cheap, but
    it re-opens the O(depth²) case perf(checker): the exclude_tests attribute scan is O(attributes x depth) #1100 closed for deep-and-narrow trees, so it
    needs that fixture re-measured.
  3. Memoize per parent. One (parent_id, run_verdict) slot on the walk would
    collapse the repeat asks. Adds walk state and a cache-invalidation question.

Measure before choosing: the shape is pathological, and real Rust is a few
percent attributed. This is filed because the class is real and now named, not
because it has been seen in practice.

Found while fixing the #1446 review's HIGH finding, by checking whether the fix
covered the adjacent diagonal. It does not, and the new nom/deep-attribute-run
probe does not cover it either.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions