Skip to content

Keep the intermediate blocks of fsbraid in production order - #580

Draft
leburgel wants to merge 11 commits into
planartracefrom
lb/fsbraid_production_order
Draft

leburgel wants to merge 11 commits into
planartracefrom
lb/fsbraid_production_order

Conversation

@leburgel

Copy link
Copy Markdown
Member

This is a draft illustrating some potential performance improvements; the actual implementations are
very much up for discussion.

fsbraid bends and swaps a FusionTreeBlock step by step, and every step built the canonical
destination block and a Dict index, although only the result needs canonical order. For
non-abelian sectors this bookkeeping dominates the cache misses of braid/permute.

  • Intermediate blocks keep their trees in production order; only the result is canonicalized.
    Small blocks use a linear search instead of a Dict.
  • Tree vectors and matrices are reused between swaps (mul!).
  • Single-tree blocks are braided as a product of scalars (private per-tree helpers; the public
    methods are unchanged).
  • artin_braid filters channels by Nsymbol and computes the R-symbols once per tree.

Behaviour change: braiding requires a complete block (all tree pairs for its uncoupled sectors
and duality flags, as built by FusionTreeBlock{I}(uncoupled, isdual) and fusionblocks); an
incomplete one raises an ArgumentError. On main, only planar_trace builds incomplete blocks, and
it only transposes them, which is unaffected. Easy to lift if needed.

Tested: a new test for single-tree blocks; every transformer of a CTMRG iteration
(fℤ₂ × U(1) × SU(3) and fℤ₂ × U(1) × SU(2) × U(1), D = 6–9) matches v0.17.2 to 9e-16.

Cold construction of the recoupling data for one permutation of a tensor from a PEPS CTMRG step
(scripts below), main → this PR:

main this PR
fℤ₂ × U(1) × SU(3), 1 thread 5.29 s 2.13 s (2.5×)
fℤ₂ × U(1) × SU(3), 8 threads 1.58 s 0.76 s (2.1×)
fℤ₂ × U(1) × SU(2) × U(1), 1 thread 4.99 s 1.95 s (2.6×)
fℤ₂ × U(1) × SU(2) × U(1), 8 threads 3.45 s 2.88 s (1.2×)

SU(2) gains little from threads: Fsymbol for SU2Irrep goes through WignerSymbols, which takes
a lock on every call. Removing that lock (caching the F-symbols in TensorKitSectors) is scheduled
as a follow-up.

TensorKit main (ae8abfc), SUNRepresentations c02a3f3, TensorKitSectors v0.3.10, Julia 1.12.5; shared 80-core
machine, two interleaved rounds agree within 15 %.

fℤ₂ × U(1) × SU(3) reproducer (julia -t8)
using SUNRepresentations, TensorKit
SUNRepresentations.use_disk_cache(false; persist = false)

# the spaces of one index permutation in a CTMRG step for a fℤ₂ × U(1) × SU(3)-symmetric PEPS
const I = FermionParity ⊠ U1Irrep ⊠ SU3Irrep
s(f, q, a, b) = FermionParity(f) ⊠ U1Irrep(q) ⊠ SU3Irrep(a, b)
V1 = Vect[I](s(0, 0, 1, 0) => 1, s(1, 1, 0, 1) => 1, s(1, -1, 0, 0) => 1, s(0, 2, 0, 0) => 1)
V2 = Vect[I](s(0, 0, 1, 0) => 1, s(1, -1, 0, 0) => 1, s(0, -2, 0, 1) => 1)
V3 = Vect[I](s(0, 0, 1, 0) => 1, s(1, 1, 0, 1) => 1, s(1, -1, 0, 0) => 1)
V4 = Vect[I](s(0, 0, 0, 0) => 1, s(1, -1, 0, 1) => 1, s(0, -2, 1, 0) => 1)
V5 = Vect[I](s(0, 0, 0, 0) => 2, s(0, 0, 1, 1) => 2, s(1, 1, 1, 0) => 2, s(1, -1, 0, 1) => 2, s(1, 1, 0, 2) => 1, s(0, 2, 0, 1) => 1, s(0, -2, 1, 0) => 1, s(1, -1, 2, 0) => 1)
V6 = Vect[I](s(0, 0, 0, 0) => 3, s(0, 0, 1, 1) => 2, s(1, 1, 1, 0) => 2, s(1, -1, 0, 1) => 2, s(0, 2, 0, 1) => 1, s(0, -2, 1, 0) => 1, s(0, -2, 0, 2) => 1, s(0, 2, 2, 0) => 1)
V7 = Vect[I](s(0, 0, 0, 0) => 1, s(1, 1, 1, 0) => 1, s(1, -1, 0, 1) => 1)
W = (V1 ⊗ V2 ⊗ V3) ← (V4 ⊗ V5 ⊗ V6' ⊗ V7)
p = ((1, 7, 4), (2, 3, 5, 6))

t = randn(W)

# cold construction of the cached recoupling data of the permutation (TensorKit's caches emptied,
# the F- and R-symbols warm), with 1 and with all threads
function cold_permute(t, p, n)
    TensorKit.set_num_manipulation_threads(n)
    TensorKit.set_num_transformer_threads(n)
    TensorKit.empty_globalcaches!()
    return @elapsed permute(t, p)
end
cold_permute(t, p, Threads.nthreads()) # compilation, and the symbols
for n in unique((1, Threads.nthreads()))
    ts = [cold_permute(t, p, n) for _ in 1:3]
    println("cold permute, $n thread(s): ", round(minimum(ts); digits = 2), " s (min of 3)")
end
fℤ₂ × U(1) × SU(2) × U(1) reproducer (julia -t8)
using TensorKit

# the spaces of one index permutation in a CTMRG step for a fℤ₂ × U(1) × SU(2) × U(1)-symmetric PEPS
const I = FermionParity ⊠ U1Irrep ⊠ SU2Irrep ⊠ U1Irrep
s(f, q, j, q′) = FermionParity(f) ⊠ U1Irrep(q) ⊠ SU2Irrep(j) ⊠ U1Irrep(q′)
V1 = Vect[I](s(0, 0, 1/2, 1) => 1, s(1, -1, 0, 0) => 1, s(0, 2, 0, 0) => 1, s(0, 0, 0, -2) => 1, s(1, 1, 1/2, -1) => 1, s(1, 1, 0, 2) => 1)
V2 = Vect[I](s(0, 0, 1/2, 1) => 1, s(0, 2, 0, 0) => 1, s(0, 0, 0, -2) => 1, s(1, 1, 1/2, -1) => 1, s(1, 1, 0, 2) => 1)
V3 = Vect[I](s(1, 1, 0, 0) => 1, s(0, 0, 1/2, -1) => 1, s(0, 0, 0, 2) => 1, s(0, 2, 1/2, 1) => 1, s(0, 2, 0, -2) => 1)
V4 = Vect[I](s(1, 1, 0, 0) => 1, s(0, 0, 1/2, -1) => 1, s(0, 0, 0, 2) => 1, s(1, -1, 1/2, 1) => 1, s(1, -1, 0, -2) => 1)
V5 = Vect[I](s(0, 0, 0, 0) => 5, s(0, 0, 1, 0) => 1, s(1, 1, 1/2, 1) => 4, s(1, -1, 1/2, -1) => 4, s(0, 0, 1/2, 3) => 1, s(1, 1, 0, -2) => 3, s(1, -1, 0, 2) => 3, s(0, 0, 1/2, -3) => 1, s(1, 1, 1, -2) => 1, s(1, -1, 1, 2) => 1, s(1, 1, 0, 4) => 1, s(1, -1, 0, -4) => 1)
V6 = Vect[I](s(0, 0, 0, 0) => 5, s(0, 0, 1, 0) => 2, s(1, 1, 1/2, 1) => 4, s(1, -1, 1/2, -1) => 3, s(0, 0, 1/2, 3) => 2, s(0, 2, 1/2, -1) => 1, s(0, -2, 1/2, 1) => 1, s(1, 1, 0, -2) => 3, s(1, -1, 0, 2) => 3, s(0, 0, 1/2, -3) => 2, s(0, 2, 0, 2) => 1, s(1, 1, 1, -2) => 1, s(0, -2, 0, -2) => 1, s(1, 1, 0, 4) => 1)
V7 = Vect[I](s(0, 0, 1/2, 1) => 1, s(1, -1, 0, 0) => 1, s(0, 0, 0, -2) => 1, s(1, 1, 1/2, -1) => 1, s(1, 1, 0, 2) => 1)
W = (V1 ⊗ V2' ⊗ V3) ← (V4' ⊗ V5 ⊗ V6' ⊗ V7)
p = ((1, 7, 4), (2, 3, 5, 6))

t = randn(W)

# cold construction of the cached recoupling data of the permutation (TensorKit's caches emptied,
# the symbols warm), with 1 and with all threads
function cold_permute(t, p, n)
    TensorKit.set_num_manipulation_threads(n)
    TensorKit.set_num_transformer_threads(n)
    TensorKit.empty_globalcaches!()
    return @elapsed permute(t, p)
end
cold_permute(t, p, Threads.nthreads()) # compilation, and the symbols
for n in unique((1, Threads.nthreads()))
    ts = [cold_permute(t, p, n) for _ in 1:3]
    println("cold permute, $n thread(s): ", round(minimum(ts); digits = 2), " s (min of 3)")
end

🤖 Generated with Claude Code

@codecov

codecov Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.30508% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/fusiontrees/braiding_manipulations.jl 96.82% 2 Missing ⚠️
Files with missing lines Coverage Δ
src/fusiontrees/duality_manipulations.jl 97.20% <100.00%> (+0.07%) ⬆️
src/fusiontrees/fusiontrees.jl 97.07% <100.00%> (+0.96%) ⬆️
src/fusiontrees/braiding_manipulations.jl 94.96% <96.82%> (-0.59%) ⬇️

... and 20 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@lkdvos lkdvos left a comment

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.

As a separate comment, there is some redundant computations in the repartition that are there mostly to avoid having to figure out the braids for mixed splitting-fusion pairs. It might also make sense to have Claude figure out if we can braid without repartition.

Comment thread src/fusiontrees/braiding_manipulations.jl Outdated
Comment thread src/fusiontrees/braiding_manipulations.jl Outdated
Comment thread src/fusiontrees/braiding_manipulations.jl Outdated
@leburgel

Copy link
Copy Markdown
Member Author

I looked into it. repartition is about a fifth to a quarter of the build in our workload, and about a fifth of the braids keep every leg on its side, so those could skip it entirely. That's a larger change, so I'd do it in a separate PR, starting with those side-preserving permutations.

@leburgel

Copy link
Copy Markdown
Member Author

Follow-up on braiding without repartition: I tried it for permutations that keep every leg on its side, braiding the splitting trees directly and the fusion trees as the splitting trees of the adjoint block with every crossing inverted. The results are identical, but there is no measurable speed-up in our workload, so I won't open a PR for it. The cost of repartition must sit in the braids that move legs between sides.

@lkdvos lkdvos left a comment

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.

Looking at this a bit more, I'm actually wondering if there is a point to never have destinations in canonical order, since these are only ever used in combination with the block they return, so nothing is actually assuming any particular order there. This might both simplify the code slightly and also speed it up because there is no need for a final sorting, but the one thing to be mindful of is hashing and equality, which might need some checking.

Comment thread src/fusiontrees/braiding_manipulations.jl Outdated
Comment thread src/fusiontrees/braiding_manipulations.jl Outdated
Comment thread src/fusiontrees/braiding_manipulations.jl Outdated
Comment thread src/fusiontrees/braiding_manipulations.jl
@leburgel

Copy link
Copy Markdown
Member Author

On never bringing the destinations into canonical order: I tried it by making the final canonicalize in fsbraid a no-op. The transformers are the same up to row order, but building them is not measurably faster (same run, 1 thread: SU(3) 10.4–11.0 s vs 10.7–11.3 s, SU(2) 26.6–27.7 s vs 27.1–30.1 s). So there is no performance reason, only the simpler code. Since block equality compares the trees in order, the round-trip tests would need an order-independent comparison, and braid, transpose and repartition on a FusionTreeBlock would return their trees in a different order than now. Happy to do it here or in a follow-up, whichever you prefer.

leburgel and others added 11 commits October 29, 2026 17:14
The bends and Artin braids of a fusion tree block used to construct their destination block in canonical order, together with a dictionary from trees to rows, at every step. The intermediate blocks of `fsbraid` now keep their trees in the order in which they are produced (`TreeRows`, `canonical = Val(false)` for `bendleft`, `bendright` and `artin_braid`, `repartition_noncanonical`), and only the result is brought into canonical order (`canonicalize`). Small blocks find rows by a linear search. In `artin_braid`, the channels are obtained by filtering `a ⊗ d` instead of intersecting two fusion products, and the symbols that do not depend on the channel are computed once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Artin braids keep the size of a block, so `fsbraid` passes the tree vector and the matrices of the previous step on to `artin_braid` and multiplies in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A block with a single tree stays a single tree under every step of `fsbraid`, so its braid is computed with the per-tree manipulations, without blocks or matrices. Their bodies move into private `_artin_braid_single` and `_bendright_single`, which take the single coefficient of each symbol with `only`; the public methods keep their `UniqueFusion` assertion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…annel by dispatch

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…channel with `only`, and filter the braid channels for every unit style

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… loops over the multiplicities

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`bendright` and `bendleft` of a tree pair no longer assert `UniqueFusion`, but
rely on `only` to throw if the bent vertex has a multiplicity, which replaces
their `_single` variants. The shortcut of `braid` for a symmetric braiding is
restricted to `UniqueFusion`, where the `Fsymbol`s are trivial, such that a
block with a single tree is braided as its tree pair.

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

The destination blocks of bends, folds and braids are only used together with
their transformation matrix, so they no longer construct and index the canonical
destination block, but keep their trees in the order in which they are produced.
Since `planar_trace` now also works on complete blocks, the canonical mode of
`TreeRows`, `canonicalize`, `repartition_noncanonical` and `treeindex_map` are
removed. The tests compare the results of manipulations up to the order of
their trees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@lkdvos
lkdvos marked this pull request as draft October 29, 2026 21:29
@lkdvos
lkdvos force-pushed the lb/fsbraid_production_order branch from d92e2f8 to d397cb2 Compare October 29, 2026 21:40
@lkdvos
lkdvos changed the base branch from main to planartrace October 29, 2026 21:40
@lkdvos

lkdvos commented Oct 29, 2026

Copy link
Copy Markdown
Member

Thanks for checking. My numbers agree that there is little to gain in speed (at most ~5–14% for SU2/Fibonacci transposes, nothing for A4), but I'd still like to do it for the code: with #605, planar_trace works on complete blocks, so the incomplete-block support can go entirely (the canonical mode of TreeRows, canonicalize, repartition_noncanonical, the max(length(rows.trees), length(src)) workaround in the bends, treeindex_map), and the folds no longer construct the destination block, about −65 lines.

So I stacked this PR on #605 and force-pushed: your branch including today's two commits, rebased onto planartrace, plus two commits on top:

  • the single-tree simplification (bending and braiding a single tree pair without the _single helpers),
  • production order for all block manipulations, with the doubletree round-trip tests comparing blocks up to the order of their trees (_inorder in test/setup.jl).

I dropped my @tensor variant in favour of your loop. The full doubletree suite over all sectors, singletree, spaces, JET, and the planar, index manipulation and braiding tensor tests pass. The PR base is now planartrace; once #605 is rebased onto main this needs another small rebase.

Comment on lines +87 to +89
_artin_channel(a, b, d, e) = _artin_channel(FusionStyle(a), a, b, d, e)
_artin_channel(::UniqueFusion, a, b, d, e) = only(a ⊗ d)
_artin_channel(::FusionStyle, a, b, d, e) = only(_artin_channels(a, b, d, e))

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.

Suggested change
_artin_channel(a, b, d, e) = _artin_channel(FusionStyle(a), a, b, d, e)
_artin_channel(::UniqueFusion, a, b, d, e) = only(a ⊗ d)
_artin_channel(::FusionStyle, a, b, d, e) = only(_artin_channels(a, b, d, e))
_artin_channel(a, b, d, e) = only(_artin_channels(a, b, d, e))

In this case, we might also just inline this and get rid of the helper?

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