Skip to content

perf(einsum): memoize the symmetry-aware path rebuild instead of redoing it per call - #251

Merged
spMohanty merged 1 commit into
mainfrom
claude/issue-26-relevance-0b6c45
Aug 21, 2026
Merged

perf(einsum): memoize the symmetry-aware path rebuild instead of redoing it per call#251
spMohanty merged 1 commit into
mainfrom
claude/issue-26-relevance-0b6c45

Conversation

@spMohanty

Copy link
Copy Markdown
Member

Rescoped from #26. The original issue targeted pre-cache extraction work; that ask is stale (_symmetry_fingerprint and inner_sym no 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, by 8d2005fc23 / d0891da8bb.

The bug

_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 anyway.

Ordinary code reaches it: fnp.zeros((n, n)) returns a SymmetricTensor carrying SymmetryGroup([1, 0], axes=(0, 1)), and x @ x enters via identity_pattern. It reaches every entry point routing through einsum, including matmul and @.

The fix

The rebuild is a pure function of the _path_cache key — the dummy operands it contracts are materialized from shapes alone, and the aliasing comes from identity_pattern — so it is 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.

build_path_info is the expensive half (162 us vs 6 us for contract_path at 64x64), so caching only the path search would not have helped.

Measured, at a verified 100% cache-hit rate (64x64)

case before after
dense (a, b) 65.8 us 65.9 us
aliased x @ x 242.5 us 68.4 us
zeros((n, n)) operand 268.7 us 72.4 us

Both 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, which docs/reference/cost-model.md:913-922 requires to stay bounded and not caller-inflatable. _resolve_optimize_for_k could not see it: it gates on k >= 8 and watches contract_path, while this fired at k=2 inside build_path_info.

Behavior preservation

  • Billed FLOPs and result values 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 — unchanged.
  • Sharing the memoized PathInfo matches the dense path, which already shares its StepInfo objects across calls; FlopscopePathInfo.from_inner rewrites step.flop_cost on 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.

…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.
@spMohanty
spMohanty merged commit 5924691 into main Aug 21, 2026
24 checks passed
@spMohanty
spMohanty deleted the claude/issue-26-relevance-0b6c45 branch August 21, 2026 00:35
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.
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