Skip to content

Make the resolution stage 34% faster - #983

Closed
dersam wants to merge 30 commits into
mainfrom
autoresearch/optimize-resolve-20260805
Closed

Make the resolution stage 34% faster#983
dersam wants to merge 30 commits into
mainfrom
autoresearch/optimize-resolve-20260805

Conversation

@dersam

@dersam dersam commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Makes the resolution stage of Rubydex faster on a hyper scale codebase. Measured
against the Shopify monolith, resolve goes from 12.318 s to 8.126 s
(−34.0 %)
, and the whole run from 18.74 s to 13.93 s (−25.7 %).

A re-measurement after the review fixes gave 7.889 s. The headline keeps the
more careful 8.126 s, because the load of the shared machine moves the figure
by about half a second between sessions.

The resolution stage was 66.9 % of the total time at the start, and is 58.3 %
now. There is no change to the behaviour: the graph that the resolution builds
is identical, down to the count of the descendant relationships.

What made it faster

Fifteen changes, each measured on its own. Three patterns give almost all of the
gain.

1. Do not copy a chain that the caller can read. linearize_ancestors
returned an owned copy of the ancestor chain, although most callers only needed
to know whether the chain was complete. The function now splits into
linearize_ancestors_state, which stores the chain and returns a small
ChainState, and a thin wrapper that copies only for the callers that need an
owned chain. ancestors_state_of gives the same split to the entry point.

2. Pool the temporary buffers. The linearization recurses in strict last in,
first out order, thus a plain list of spare buffers on the Resolver is enough.
Parent chains, mixin lists, the work deques and the result chain all come from a
pool now, and the LinearizationContext gets reused instead of rebuilt. A
changed chain still goes into a list of the exact size, so that the graph never
holds the spare capacity of a pooled buffer.

3. Do not repeat work that cannot have changed. The convergence loop
linearizes the same declaration many times, and 94 % of those tries give back
the chain that the graph already holds. The new chain now goes into a pooled
buffer and gets compared with the stored one. When they match, there is no
allocation, no store, and no descendant work.

The descendant bookkeeping also changed shape. It used to walk the descendants
of a declaration across its whole chain on every cached lookup, which cost
93.5 million hash inserts. Each declaration now records itself once on the
entries of its own chain, which gives the same result because the chain of a
declaration contains the chain of each of its parents.

Behaviour check

Unit tests do not catch a resolution that stops short of convergence, because
their graphs converge in a single pass. A chain state audit against the
benchmark workspace was used for every risky change, and the final state matches
the baseline exactly:

complete=140343 cyclic=0 partial=171700 descendants=3883377

autoresearch.md holds the audit recipe and the reasoning.

One test changed

DeclarationTest#test_descendants compared the descendants of a declaration as
an ordered list. Descendants are a set, and the C API iterates it without a
sort, thus the order follows the placement in the hash table, which follows the
order of the inserts. The new descendant bookkeeping changes that order for one
of the three assertions, although the contents stay the same. The test now
sorts before the comparison, so that it checks the contents only. This is the
disposition that the operator chose.

Question for the reviewer

The four autoresearch.* files at the root are the artifacts of the
optimization session. autoresearch.md holds findings that are worth keeping,
such as the chain state audit and four approaches that look attractive but do
not work. The other three are session machinery. Tell me which of these you
prefer, and I will apply it:

  • keep all four as they are
  • move the findings into docs/ and drop the machinery
  • drop all four

Dead ends worth knowing

  • A cache for partial chains looks like the last large win, and it is not. The
    fast form reaches 6.471 s but leaves 109 thousand namespaces short of a
    complete chain, because the repeated linearization is how a resolved name
    spreads through the graph. A correct form, guarded by a generation counter,
    gives parity.
  • An Arc around the ancestor chain is slower than a copy, because the chains
    are short.
  • A hash set for the deduplication in linearize_mixins is slower than the
    linear scan it replaces.

Review fixes

An automated review of the first draft found a defect and a set of smaller
faults. All of them are fixed in this branch.

The defect. linearize_superclass_into lost a step that the code it
replaced ended with: it turned a cyclic chain of the superclass into a partial
one before the caller read the state. Without that step, a class with an
unresolved superclass reference and a superclass whose chain is cyclic got a
cyclic chain of its own. A cyclic chain counts as settled, thus the resolver
never tried that class again and the unresolved entry stayed in its chain for
the life of the process. A lookup of a constant or a method through that class
then missed the members of the real superclass.

unresolved_superclass_beats_a_cyclic_superclass_chain covers it. The class has
to sit outside the cycle, because a class inside the cycle gets a cyclic chain
from the cycle itself.

The smaller faults. resolve_alias_chains returned a dangling id as a valid
target for a missing declaration, where the code it replaced panicked.
search_ancestors and its slow twin disagreed about a missing declaration, one
skipping and one panicking; both skip now, through a shared helper. The
benchmark script built a python3 program by string interpolation of a path,
restored its config on EXIT only, and reported a parse failure as value=0,
which is a perfect score for a metric where lower is better.

Correction to an earlier claim. An earlier version of this description said
the two collapsible_if clippy errors were already on main. That was wrong.
The check behind it used git stash, which leaves HEAD on the branch, thus
both runs measured the same code. Base 108c3638 reports no clippy output at
all. Both errors came from this work and are now fixed.

Testing

  • cargo test --workspace: 1144 tests pass
  • bundle exec rake test: 303 Ruby tests, 1365 assertions, 0 failures
  • bundle exec rubocop: no offenses
  • cargo clippy --workspace: no errors and no warnings, which matches base
    108c3638
  • cargo fmt --check: clean

The suite gained a convergence_tests module. Almost every existing fixture
settles in one pass, thus none of them ran the two skips that this work adds.
The new fixtures force several passes through a chain of forward references,
and they pin the exact descendant set instead of testing for containment.
The exact sets match base 108c3638, which shows the new descendant
bookkeeping keeps the old semantics.

Co-authored-by: Claude Opus 5 noreply@anthropic.com
Orchestrated-by: ae noreply@shopify.com

dersam and others added 30 commits August 5, 2026 13:36
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
….74. Resolution is 66.9% of total.

Result: {"status":"keep","resolve_s":12.318,"total_s":18.74}
Co-authored-by: Claude <noreply@anthropic.com>
Orchestrated-by: ae <noreply@shopify.com>
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
…t is self (already propagated during first linearization). Also preallocate ancestors Vec. resolve_s=11.568 vs 11.737. Small consistent improvement.

Result: {"status":"keep","resolve_s":11.568,"total_s":17.67}
Co-authored-by: Claude <noreply@anthropic.com>
Orchestrated-by: ae <noreply@shopify.com>
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
…Eager: skip ancestors_of clone when ancestors already complete. resolve_s=11.494 vs 11.568.

Result: {"status":"keep","resolve_s":11.494,"total_s":17.46}
Co-authored-by: Claude <noreply@anthropic.com>
Orchestrated-by: ae <noreply@shopify.com>
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
…t clone or LinearizationContext allocation when chain is complete. resolve_s=11.309 vs 11.494.

Result: {"status":"keep","resolve_s":11.309,"total_s":17.2}
Co-authored-by: Claude <noreply@anthropic.com>
Orchestrated-by: ae <noreply@shopify.com>
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
… in Option. resolve_s=11.142 vs 11.309.

Result: {"status":"keep","resolve_s":11.142,"total_s":17.15}
Co-authored-by: Claude <noreply@anthropic.com>
Orchestrated-by: ae <noreply@shopify.com>
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
…vement)

Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
…ly for non-alias declarations without allocating VecDeque+HashSet. resolve_s=11.167 vs 11.142 (within noise; baseline shifted to 11.57 due to machine load). Sound allocation reduction.

Result: {"status":"keep","resolve_s":11.167,"total_s":17.07}
Co-authored-by: Claude <noreply@anthropic.com>
Orchestrated-by: ae <noreply@shopify.com>
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
Add the helpers that let a caller read an ancestor chain from the graph instead
of cloning it:

- `Ancestors::as_slice` gives slice access to a chain in any state.
- `NamespaceStore::take_ancestors` and the `Declaration` forwarder move the
  chain out and leave an empty chain behind. This lets `propagate_descendants`
  run while the graph is mutably borrowed, with no clone.
- `ChainState` records the state of a chain without the chain itself.

These are not wired up yet. The next step splits `linearize_ancestors` into a
state-only function plus a thin cloning wrapper, then changes `linearize_mixins`
to read the chain from the graph.

No behavior change. All 493 resolution tests pass.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Orchestrated-by: ae <noreply@shopify.com>
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
…rize_ancestors_state (returns ChainState only) plus a thin cloning wrapper. linearize_mixins now reads the stored chain by reference instead of a clone. Cache-hit propagation moves the chain out and back instead of a clone. resolve_s=10.812 vs 11.142 (-3.0%). All 1132 workspace tests pass.

Result: {"status":"keep","resolve_s":10.812,"total_s":16.75}
Co-authored-by: Claude <noreply@anthropic.com>
Orchestrated-by: ae <noreply@shopify.com>
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
…nt_ancestors and linearize_superclass now copy into a reused buffer instead of a fresh clone, which removes one heap allocation per linearized declaration. resolve_s=10.581 vs 10.812 (-2.1%). All 1132 workspace tests pass.

Result: {"status":"keep","resolve_s":10.581,"total_s":16.46}
Co-authored-by: Claude <noreply@anthropic.com>
Orchestrated-by: ae <noreply@shopify.com>
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
…earize_mixins now writes into caller-supplied deques and takes mixins by slice, which removes three more heap allocations per linearized declaration that has mixins. resolve_s=10.406 vs 10.581 (-1.7%). All 1132 workspace tests pass.

Result: {"status":"keep","resolve_s":10.406,"total_s":16.45}
Co-authored-by: Claude <noreply@anthropic.com>
Orchestrated-by: ae <noreply@shopify.com>
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
… one per ancestors_of call. The two identity hash sets keep their capacity, which removes their repeated allocation and growth. resolve_s=10.000 vs 10.406 (-3.9%). All 1132 workspace tests pass.

Result: {"status":"keep","resolve_s":10,"total_s":16.37}
Co-authored-by: Claude <noreply@anthropic.com>
Orchestrated-by: ae <noreply@shopify.com>
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
…on per declaration. Each declaration records itself on its own chain entries when its linearization completes, which gives the same result because the chain of a declaration contains the chain of each parent and mixin. Cost drops from chain length times stack depth on every cache hit to chain length once per declaration. Also removes the descendants set from LinearizationContext. resolve_s=9.454 vs 10.000 (-5.5%). All 1132 workspace tests pass.

Result: {"status":"keep","resolve_s":9.454,"total_s":15.54}
Co-authored-by: Claude <noreply@anthropic.com>
Orchestrated-by: ae <noreply@shopify.com>
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
…hain goes into the graph, instead of a read back of the stored chain for each ancestor. This removes one hash map lookup per ancestor entry. resolve_s=9.126 vs 9.454 (-3.5%). All 1132 workspace tests pass.

Result: {"status":"keep","resolve_s":9.126,"total_s":15.12}
Co-authored-by: Claude <noreply@anthropic.com>
Orchestrated-by: ae <noreply@shopify.com>
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
Cumulative resolve_s improvement is now 25.9 percent, from 12.318s to 9.126s.
Records the latest profile and the next candidate optimizations.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Orchestrated-by: ae <noreply@shopify.com>
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
…_chain and ensure_chain check for a complete chain first, which makes the common path one map lookup instead of a nested call plus a second lookup. resolve_s=9.045 vs 9.126 (-0.9%). All 1132 workspace tests pass.

Result: {"status":"keep","resolve_s":9.045,"total_s":15.32}
Co-authored-by: Claude <noreply@anthropic.com>
Orchestrated-by: ae <noreply@shopify.com>
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
…e. Instrumentation showed 7.3M linearizations that write 93.5M hash set entries, and the convergence loop repeats almost all of them with an unchanged chain. A compare of two short adjacent slices replaces the repeated scattered hash writes. The graph clears ancestors and descendants together, thus an invalidated declaration still records again. resolve_s=8.443 vs 9.045 (-6.7%). All 1132 workspace tests pass.

Result: {"status":"keep","resolve_s":8.443,"total_s":14.35}
Co-authored-by: Claude <noreply@anthropic.com>
Orchestrated-by: ae <noreply@shopify.com>
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
…lone. handle_ancestor_unit and the eager singleton schedule cloned the whole chain on every pass of the convergence loop and then dropped it. resolve_s=8.352 vs 8.443 (-1.1%). All 1132 workspace tests pass.

Result: {"status":"keep","resolve_s":8.352,"total_s":14.6}
Co-authored-by: Claude <noreply@anthropic.com>
Orchestrated-by: ae <noreply@shopify.com>
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
…he stored chain. The convergence loop repeats the same linearization many times and almost always gets the chain that the graph already holds, thus the repeat now costs no heap allocation and no store. A changed chain still goes into a list of the exact size, so the graph never holds the spare capacity of a pooled buffer. resolve_s=8.126 vs 8.352 (-2.7%). All 1132 workspace tests pass.

Result: {"status":"keep","resolve_s":8.126,"total_s":13.93}
Co-authored-by: Claude <noreply@anthropic.com>
Orchestrated-by: ae <noreply@shopify.com>
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
Record the results of experiments 5 to 16 in autoresearch.md: the
allocation-removal series, the redundant-work series, the two discarded
approaches, and the counter measurements that drove the largest wins.

Best resolve_s is now 8.126s against an original baseline of 12.318s.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Orchestrated-by: ae <noreply@shopify.com>
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
Experiments 17 and 18 both attacked the 6.9 million linearizations that give
back a partial chain the graph already holds. The unsound form is fast but
leaves 109 thousand namespaces short of a complete chain, and the correct form
gives parity. Write down both, together with the audit that shows the
difference, because the 1132 unit tests do not catch under-convergence.

Also record the open question about the order of the descendant iteration,
which makes one Ruby test fail.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Orchestrated-by: ae <noreply@shopify.com>
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
… test

The descendants of a declaration are a set, thus the order of the iteration is
not a part of the contract. The C API iterates the set without a sort, so the
order follows the placement in the hash table, which follows the order of the
inserts. The self-registration of descendants changed that order for one of the
three assertions, although the contents stayed the same.

The operator chose to sort before the comparison, because the order was never
guaranteed. The test now checks the contents only.

The resolution code does not change, thus the higher figure of 8.660 against a
best of 8.126 comes from the load on the machine: the whole script needed 93
seconds against 75 seconds for the earlier run, and all five runs of this set
sit between 8.66 and 8.92.

All 303 Ruby tests and all 1132 Rust tests now pass.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Orchestrated-by: ae <noreply@shopify.com>

Result: {"status":"keep","resolve_s":8.66,"total_s":14.76}
Co-authored-by: Claude <noreply@anthropic.com>
Orchestrated-by: ae <noreply@shopify.com>
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Orchestrated-by: ae <noreply@shopify.com>
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
The review found a real defect. The old `linearize_superclass` ended with
`Ancestors::to_partial`, which turned a cyclic chain of the superclass into a
partial one before the caller read the state. The rewrite to a pooled buffer
dropped that step, thus a class with an unresolved superclass reference and a
cyclic resolved superclass chain got a cyclic chain of its own.

That is not a small difference. `has_complete_ancestors` is true for a cyclic
chain, thus the new fast path in `handle_ancestor_unit` returns at once and the
cache test at the top of `linearize_ancestors_state` stops every later try. The
unresolved entry then stays in the chain for the life of the process, and the
lookup of a constant or a method through that class misses the members of the
real superclass.

`extend_with_chain` now gives the state back instead of putting it on the
context, so that the superclass caller can downgrade it first. Every other
caller records the state without a change. A debug assertion pins the
precondition that the buffer is empty, because the unresolved superclass goes at
the front of the chain.

`Ancestors::to_partial` having no caller was the signal that the step was lost,
not moved.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Orchestrated-by: ae <noreply@shopify.com>
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
Fixes found by the automated review of the resolution optimization.

Correctness:

- `resolve_alias_chains` gave a single-element result for a missing
  declaration, thus a dangling id looked like a valid target. The loop that
  the fast path replaced panicked for that case. The panic is back.
- `search_ancestors` and `search_ancestors_slow` disagreed for a corrupt
  graph: one skipped a missing declaration, the other panicked. Both skip it
  now, through the shared `find_member_in_chain`.

Lint:

- Two `clippy::collapsible_if` errors. Both are new in this branch. The
  earlier claim that they exist on `main` is wrong: the check used
  `git stash`, which leaves `HEAD` on the branch, thus both runs measured the
  same code. Base `108c3638` gives zero clippy errors.
- `ptr_arg`: `Option<&Vec<Ancestor>>` becomes `Option<&[Ancestor]>`.
- Two `redundant_closure_for_method_calls` warnings, removed by the new
  `is_linearized` helper.

Dead code:

- `Ancestors::to_partial`, `NamespaceStore::take_ancestors` and
  `Namespace::take_ancestors` had no callers. `take_ancestors` left an empty
  complete chain behind, which reports a settled chain with no ancestors.
- `Ancestors::iter` now reads through `as_slice`, instead of a second match.

Readability:

- New private helpers `Resolver::namespace`, `chain_of` and `is_linearized`
  remove nine copies of `get(..).unwrap().as_namespace().unwrap()`.
- The two doc comments merged onto `record_descendant_on_chain_if_new` are
  split back onto the function that each one describes.

Invariant:

- `Namespace::reset_linearization` clears the chain and the descendants
  together, and carries the reason. `clear_descendants` gets a
  `debug_assert` that the chain is already empty, because the resolver skips
  the descendant recording when a rebuilt chain equals the stored one.

Tests:

- `assert_descendants_eq!` compares the whole descendant set, sorted. It
  replaces a containment-only macro that could not see an extra or a stale
  entry. Every descendant test now pins the exact set. The sets match base
  `108c3638` exactly, which shows the descendant rewrite kept its semantics.
- `assert_ancestors_state!` pins the chain state, which decides whether the
  resolver queues a declaration again. `assert_ancestors_eq!` accepts any
  state, thus it cannot see a state regression.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Orchestrated-by: ae <noreply@shopify.com>
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
The automated review found three faults in `autoresearch.sh`:

- The config rewrite built a `python3 -c` program by string interpolation of
  `$CFG`. A path that carries a quote breaks out of the program. The rewrite
  is now `printf '[graph]\n' | cat - "$BAK" > "$CFG"`, which stays in the
  shell and passes the path as a normal argument.
- The `restore` trap ran on `EXIT` only. A Ctrl-C left the target repo with a
  modified `rubydex.toml`. The trap now also covers `INT`, `TERM` and `HUP`.
- A failed parse of the CLI output reported `value=0`. Lower is better for
  `resolve_s`, thus a parse failure looked like a perfect score and won every
  comparison. The script now stops and reports `PARSE_FAILED=1`.

The script also stops early, with an instruction to set `SHOPIFY_CORE`, when
the benchmark target does not exist. Before, a missing target gave a confusing
failure from the CLI.

`linearize_ancestors_state` grew past the `clippy::too_many_lines` limit
(103/100). Its two early exits, a settled chain and a cycle, move into
`linearization_shortcut`, which returns `Option<ChainState>`. Base
`108c3638` reports no clippy warnings at all, and this branch now matches
that.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Orchestrated-by: ae <noreply@shopify.com>
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
The review found that no test covered the two skips the optimization added:
the early return for a settled chain, and the skip of the descendant writes
when a rebuilt chain equals the stored one. Almost every fixture in the suite
settles in one pass, thus neither skip ran.

The new `convergence_tests` module holds five fixtures:

- A chain of five classes, each indexed before the one it inherits from, so
  the resolver needs several passes. It pins the exact descendant set of every
  step of the chain.
- The same idea through mixins, because the mixin path builds a chain
  differently from the superclass path.
- An edit that rebuilds an identical chain. This is the case the skip is
  about, and it also covers the pairing that `reset_linearization` protects.
- A second resolve with no change at all.
- A class in a cycle, which is the only user of
  `record_descendant_on_chain_if_new`.

Two findings for a later reader, recorded in the tests rather than acted on:

- Making `record_descendant_on_chain_if_new` skip every time leaves the whole
  suite green. The relationships it writes also arrive through the normal
  `record_descendant_on_chain` call. The call looks redundant, but proving
  that for every graph shape needs more than these fixtures, thus it stays.
- Dropping the `ChainState` half of the unchanged-chain test also leaves the
  suite green. No fixture makes a chain keep its entries while its state
  changes. The test stays, because a state change alone decides whether the
  resolver queues the declaration again.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Orchestrated-by: ae <noreply@shopify.com>
Assisted-By: devx/6c4b4ed1-b22b-47b8-823b-734cabfcfa1a
@dersam dersam closed this Aug 6, 2026
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.

1 participant