perf(einsum): memoize the symmetry-aware path rebuild instead of redoing it per call - #251
Merged
Merged
Conversation
…ing it per call `_get_path_info` fetches `path_info` from `_path_cache`, then — whenever any operand carries symmetry or two operand positions alias the same array — discards it and re-runs a full `opt_einsum.contract_path` plus `build_path_info` oracle group enumeration. That rebuild fired on every call, so the path cache reported a 100% hit rate while the work it exists to cache was redone regardless. The rebuild is a pure function of the `_path_cache` key: the dummy operands it contracts are materialized from `shapes` alone, and the aliasing it applies comes from `identity_pattern`. It is now memoized on exactly that key. The optimizer label is threaded in as a key component rather than recomputed, so `optimize=False` still falls through to the upstream `_path_type` as before. `build_path_info` is the expensive half (162us vs 6us for `contract_path` at 64x64), so caching only the path search would not have helped. Measured at 64x64 with a verified 100% cache-hit rate: x @ x 242 -> 68 us/call zeros((n, n)) operand 269 -> 72 us/call Both land within ~10% of the dense call instead of 3.7-4.1x it. The dense path is untouched (65.8 -> 65.9 us, within noise). The removed wall time was billed to `flopscope_overhead_time_s`, which `docs/reference/cost-model.md` requires to stay bounded and not caller-inflatable; `_resolve_optimize_for_k` could not see it, since it gates on k >= 8 and watches `contract_path` while this fired at k=2 inside `build_path_info`. Billed FLOPs and result values are bit-identical across dense, aliased, symmetric-2D, rank-3, 3-op chain and `matmul` cases. Per-step `input_groups` / `output_group` / `inner_group` — what the rebuild is load-bearing for — are unchanged. Sharing the memoized `PathInfo` across calls matches what the dense path already does: `FlopscopePathInfo.from_inner` writes `step.flop_cost` onto the inner object on every call, and dense calls with the same key already share their `StepInfo` objects. Tests: two driving tests assert a warm aliased/symmetric contraction performs no further path search; three guard tests pin that the memo key still separates symmetric from dense and aliased from distinct, and that per-step groups survive. All five fail if the key is weakened. Rescoped from #26, whose original text predates the rebuild.
This was referenced Aug 21, 2026
Closed
spMohanty
added a commit
that referenced
this pull request
Aug 21, 2026
Bumps all 8 version locations 0.11.0 → 0.12.0 and writes the release notes. **The minor is forced.** `cz bump` reports PATCH from these commits — every one since `v0.11.0` is `fix`/`test`/`docs`/`style`/`perf`, with no `feat:` and no `BREAKING CHANGE`. The release nonetheless raises what the grader charges for several operations and opens a new phase, which a patch digit would understate. **The notes are assembled, not generated.** `update_changelog_on_bump = true`, so `cz bump` rewrites `## Unreleased` into a flat bullet list — which would have dropped the measured billing-impact prose entirely. The prose for this release lived in three disjoint places and all three are now merged into one section: - the hand-written section on the abandoned `release-v0.12.0` branch (#238–#243), which was cut at #243 and never opened as a PR; - `main`'s `## Unreleased` (#247–#251); - #244 and #245, whose prose existed nowhere. Billing-impact items are split into **costs more** and **costs less** rather than one undifferentiated list, with a separate **no billed amount changes** group for the neutral work. The header states plainly that the release ships as a new version opening a new phase, so no submission is re-evaluated against it. `cz`'s generated commit lists are kept below the prose, per the existing format. Both stale release branches are deleted (`release-v0.11.1` `f55c72611d`, `release-v0.12.0` `5a3f4e56ed`, recorded here in case anything needs recovering). Verified: all 8 version locations at 0.12.0, three lockfiles refreshed and consistent, 10,286 tests pass, ruff and pyright clean. **No tag is pushed by this PR.** Tagging triggers the PyPI publish workflow and is a separate, deliberate step.
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.
Rescoped from #26. The original issue targeted pre-cache extraction work; that ask is stale (
_symmetry_fingerprintandinner_symno longer exist, and the measured win there is ~1.7 us/call, below this hardware's A/B noise floor). The real cost on the same lines was introduced a month after #26 was filed, by8d2005fc23/d0891da8bb.The bug
_get_path_infofetchespath_infofrom_path_cache, then — whenever any operand carries symmetry or two operand positions alias the same array — discards it and re-runs a fullopt_einsum.contract_pathplusbuild_path_infooracle group enumeration. That rebuild fired on every call, so the path cache reported a 100% hit rate while the work it exists to cache was redone anyway.Ordinary code reaches it:
fnp.zeros((n, n))returns aSymmetricTensorcarryingSymmetryGroup([1, 0], axes=(0, 1)), andx @ xenters viaidentity_pattern. It reaches every entry point routing through einsum, includingmatmuland@.The fix
The rebuild is a pure function of the
_path_cachekey — the dummy operands it contracts are materialized fromshapesalone, and the aliasing comes fromidentity_pattern— so it is memoized on exactly that key. The optimizer label is threaded in as a key component rather than recomputed, sooptimize=Falsestill falls through to the upstream_path_type.build_path_infois the expensive half (162 us vs 6 us forcontract_pathat 64x64), so caching only the path search would not have helped.Measured, at a verified 100% cache-hit rate (64x64)
(a, b)x @ xzeros((n, n))operandBoth symmetric cases land within ~10% of the dense call instead of 3.7-4.1x it. The dense path is untouched.
The removed wall time was billed to
flopscope_overhead_time_s, whichdocs/reference/cost-model.md:913-922requires to stay bounded and not caller-inflatable._resolve_optimize_for_kcould not see it: it gates onk >= 8and watchescontract_path, while this fired atk=2insidebuild_path_info.Behavior preservation
matmulcases.input_groups/output_group/inner_group— what the rebuild is load-bearing for — unchanged.PathInfomatches the dense path, which already shares itsStepInfoobjects across calls;FlopscopePathInfo.from_innerrewritesstep.flop_coston every call either way.Tests
Two driving tests assert a warm aliased/symmetric contraction performs no further path search — both watched failing first, with exactly 5 extra searches over 5 calls. Three guard tests pin that the memo key still separates symmetric from dense and aliased from distinct, and that per-step groups survive. All five fail if the key is weakened.
Full suite: 10171 passed. The 13 failures and 3 errors are pre-existing on
main— identical set with this change stashed.