Make the resolution stage 34% faster - #983
Closed
dersam wants to merge 30 commits into
Closed
Conversation
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
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.
Summary
Makes the resolution stage of Rubydex faster on a hyper scale codebase. Measured
against the Shopify monolith,
resolvegoes 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_ancestorsreturned 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 smallChainState, and a thin wrapper that copies only for the callers that need anowned chain.
ancestors_state_ofgives 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
Resolveris enough.Parent chains, mixin lists, the work deques and the result chain all come from a
pool now, and the
LinearizationContextgets reused instead of rebuilt. Achanged 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:
autoresearch.mdholds the audit recipe and the reasoning.One test changed
DeclarationTest#test_descendantscompared the descendants of a declaration asan 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 theoptimization session.
autoresearch.mdholds 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:
docs/and drop the machineryDead ends worth knowing
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.
Arcaround the ancestor chain is slower than a copy, because the chainsare short.
linearize_mixinsis slower than thelinear 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_intolost a step that the code itreplaced 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_chaincovers it. The class hasto sit outside the cycle, because a class inside the cycle gets a cyclic chain
from the cycle itself.
The smaller faults.
resolve_alias_chainsreturned a dangling id as a validtarget for a missing declaration, where the code it replaced panicked.
search_ancestorsand its slow twin disagreed about a missing declaration, oneskipping and one panicking; both skip now, through a shared helper. The
benchmark script built a
python3program by string interpolation of a path,restored its config on
EXITonly, and reported a parse failure asvalue=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_ifclippy errors were already onmain. That was wrong.The check behind it used
git stash, which leavesHEADon the branch, thusboth runs measured the same code. Base
108c3638reports no clippy output atall. Both errors came from this work and are now fixed.
Testing
cargo test --workspace: 1144 tests passbundle exec rake test: 303 Ruby tests, 1365 assertions, 0 failuresbundle exec rubocop: no offensescargo clippy --workspace: no errors and no warnings, which matches base108c3638cargo fmt --check: cleanThe suite gained a
convergence_testsmodule. Almost every existing fixturesettles 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 descendantbookkeeping keeps the old semantics.
Co-authored-by: Claude Opus 5 noreply@anthropic.com
Orchestrated-by: ae noreply@shopify.com