Skip to content

Cached treetwister for twist! - #599

Open
lkdvos wants to merge 4 commits into
mainfrom
twist-transformer
Open

lkdvos wants to merge 4 commits into
mainfrom
twist-transformer

Conversation

@lkdvos

@lkdvos lkdvos commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Addresses the standalone twist! part of #595.

twist! looped over fusiontrees(t) and called scale! on every subblock, so its cost grew with the number of subblocks rather than with the data size, and a twist of a GPU tensor launched one kernel per subblock. This PR routes twist!/twist through the existing index manipulation kernel with a cached tree transformer.

Changes

  • twist(src::Union{FusionTreePair, FusionTreeBlock}, inds; inv = false) -> (src, θ): fusion-tree level twist, next to permute/braid, with inds given as indices or as an NTuple{N, Bool} mask (indices are converted to a fixed-length mask, so all forms are inferable).
  • A public twist!(tdst, tsrc, inds, α = 1, β = 0, [backend], [allocator]; inv = false) computing tdst = β * tdst + α * twist(tsrc, inds; inv), laid out like braid!: it unwraps adjoints through unwrap_adjoints and calls a _twist! kernel entry mirroring _braid!. twist!(t, inds) and twist(t, inds) are thin wrappers around it, and twist now also accepts backend/allocator keywords.
  • Cached treetwister(A, Vdst, Vsrc, p, conjsrc, twistmask, inv), with the same arguments as treebraider (the mask taking the place of the levels): always a UniqueTreeTransformer, also for non-abelian sectors, since a twist maps every tree pair onto itself with a scalar factor (no recoupling matrices / pack-matmul-unpack needed).
  • The in-place twist!(t, inds) runs through add_transform! (aliasing is allowed for trivial permutations in TensorOperations), and twist is a single out-of-place pass instead of copy! followed by twist!.
  • All combinations of TensorMap/AdjointTensorMap destinations and sources go through the twister. The twist mask is relabeled like the levels of braid!, and an adjoint destination flips inv (just as it conjugates α).
  • AD: a twist_pb pullback with Mooncake and Enzyme rules for the three-argument twist!, plus Enzyme rules for the in-place twist!(t, inds). The existing ChainRules twist rrule already forwards keywords and is now also tested with inv and backend/allocator.
  • The shortcut for trivial permutations moved from add_transform! into _braid!/_transpose! (which also skips the transformer lookup there), so add_transform! always applies its transformer.
  • GPUArrays extension: cached device treetwister, registered and sized like the other device caches, so a GPU twist! is a single batched kernel launch.

Benchmarks (CPU, single thread)

fℤ₂ × U(1) tensors Vχ ⊗ VD ⊗ VD' ← Vχ ⊗ VD ⊗ VD', twisted on indices (2, 3, 5, 6), minimum of 20 runs:

subblocks entries twist! before twist! after speedup copy
134,688 538,752 344 ms 39.5 ms 8.7× 1.1 ms
214,688 13,740,032 686 ms 86 ms 8.0× 77 ms
1,058,848 1,058,848 3157 ms 91 ms 35× 3.3 ms

Building the twister on a cache miss costs ~3× a cached call in the smallest case. There is still a per-subblock cost from the individual tensoradd! calls on the CPU, which is left for a follow-up. GPU timings were not measured.

Testing

  • Locally: tensors/indexmanipulations.jl, tensors/contractions.jl, chainrules/linalg.jl, mooncake/indexmanipulations.jl, enzyme/flip.jl and cuda/tensors.jl (RTX A6000) pass.
  • New "Twists: test via CPU" testset in test/cuda/tensors.jl comparing twist/twist! on CuTensorMaps (and their adjoints) against the host results, and checking that the cached device transformer is used.
  • New "Twists: adjoint operands" testset in test/tensors/indexmanipulations.jl, covering adjoint sources, adjoint destinations and in-place twists of adjoints, plus α/β accumulation.
  • AMD tests were not run locally.

Follow-up

Folding the twist into the permutations that precede a contraction (the second part of #595) will be done on top of this, through a cached contraction plan holding the transformers.

🤖 Generated with Claude Code

lkdvos and others added 3 commits October 28, 2026 09:41
…ernel

`twist!` looped over all fusion trees and scaled every subblock separately,
with a cost that grows with the number of subblocks rather than the data size,
and one kernel launch per subblock for GPU tensors (#595).

- Add `twist(src, inds; inv)` for fusion tree pairs and blocks, returning the
  scalar twist factor, with `inds` given as indices or as a `Bool` mask.
- Add the cached `treetwister`, a `UniqueTreeTransformer` for all sectors since
  a twist maps every tree pair onto itself, keyed on a fixed-length mask.
- `twist!` and `twist` on a `TensorMap` go through `add_transform!` (in place
  for `twist!`, a single pass for `twist`); other tensor types keep the loop.
- Move the shortcut for trivial permutations from `add_transform!` into
  `_braid!` and `_transpose!`, so `add_transform!` always applies the
  transformer.
- Cache a device `treetwister` in the GPUArrays extension.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adjoint destinations and sources are unwrapped as in the other index
manipulations: the twist mask of an adjoint destination is relabeled onto the
legs of its parent with the inverse twist, and `treetwister` takes a `conjsrc`
flag, with the destination space and the permutation of the source legs
following from the source space. An in-place twist of an adjoint therefore
uses the same transformer as the equivalent twist of its parent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… α, β, ...)`

`twist!(t, inds)` and `twist(t, inds)` are now thin wrappers around the
three-argument form, which goes through `unwrap_adjoints` and a `_twist!`
kernel entry like `_braid!`. Adds `twist_pb` with Mooncake and Enzyme rules,
and backend/allocator keywords for `twist`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# and store that instead of repeating the twist in the pullback each time.
# effectively, we replace `twist!` by `add ∘ twist`.
Ap = if !isa(α, Const)
Ap = twist(A.val, inds.val; inv, copy = true)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similar to permute and transform, is there any chance this could secretly be aliasing A.val?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's what the copy=true is for right?

shadow = EnzymeRules.needs_shadow(config) ? C.dval : nothing
return EnzymeRules.AugmentedReturn(primal, shadow, cache)
end
function EnzymeRules.reverse(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was Enzyme not able to make its own rules for these? Kind of sad if so...

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These changes make twist go through the same path as permute and transpose etc, so even if it was able to do this I think this path is way more friendly on both compile time and possibly runtime?

…ist!` on Julia 1.10

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

2 participants