Skip to content

Keep the trees of fusion tree block manipulations in production order - #580

Open
leburgel wants to merge 13 commits into
mainfrom
lb/fsbraid_production_order
Open

leburgel wants to merge 13 commits into
mainfrom
lb/fsbraid_production_order

Conversation

@leburgel

@leburgel leburgel commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Fusion tree block manipulations (bendright/bendleft, foldright/foldleft, artin_braid and
everything built on them: repartition, transpose, braid, permute) no longer construct the
canonical destination block and a Dict index to place the trees of the result. They keep the
trees in the order in which they are produced, which is all the order-agnostic consumers (the tree
transformers, through gettoken) need. For non-abelian sectors this bookkeeping dominated the cache
misses of braid/permute.

  • Destination trees are collected by a small internal TreeRows helper: a linear search for blocks
    of at most 32 trees, a Dict beyond that. Benchmarked against an always-Dict lookup, the linear
    search is 1.5–2× faster for braids of SU(2) and Fibonacci blocks; recycling the tree vectors
    between swaps gave no measurable gain and was dropped, the matrices are still reused (mul!).
  • Blocks with a single tree are braided as their tree pair, through the tree-pair manipulations
    themselves (artin_braid/bendright/bendleft of a single tree now also work for
    non-UniqueFusion sectors whenever the result is a single tree).
  • artin_braid filters the braid channels by Nsymbol and computes the R-symbols once per tree.
  • canonicalize, treeindex_map and repartition_noncanonical are gone; tests compare blocks up to
    the order of their trees (_inorder in test/setup.jl).

Behaviour change: manipulating a block requires it to be complete (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. Since #605, planar_trace also works on
complete blocks, so nothing on main builds incomplete ones. Results of block manipulations are no
longer in canonical order: == between blocks is order-sensitive, the hash (since #605) is not.

Tested: the full doubletree suite over all sectors, a new test for single-tree blocks, and the
tensor index manipulation, planar, braiding tensor, Aqua and JET tests; every transformer of a CTMRG
iteration (fℤ₂ × U(1) × SU(3) and fℤ₂ × U(1) × SU(2) × U(1), D = 6–9) matched v0.17.2 to 9e-16 on an earlier iteration of this PR.

Cold construction of the recoupling data for one permutation of a tensor from a PEPS CTMRG step
(scripts below), main → this PR (measured on an earlier iteration of this PR, which still
canonicalized the final result):

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.

@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.

@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.

@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?

@lkdvos
lkdvos force-pushed the planartrace branch 3 times, most recently from f6dca43 to 76da813 Compare November 3, 2026 16:19
Base automatically changed from planartrace to main November 4, 2026 14:51
leburgel and others added 7 commits November 4, 2026 18:23
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>
leburgel and others added 5 commits November 4, 2026 18:23
…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>
…easurable speedup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@lkdvos lkdvos changed the title Keep the intermediate blocks of fsbraid in production order Keep the trees of fusion tree block manipulations in production order Nov 5, 2026
@lkdvos
lkdvos force-pushed the lb/fsbraid_production_order branch from d397cb2 to 238ee04 Compare November 5, 2026 03:56
@lkdvos
lkdvos marked this pull request as ready for review November 5, 2026 13:57
…of large blocks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
trees::Vector{F}
index::Union{D, Nothing} # `nothing`: few trees, the rows are found by a linear search
n::Int
# no default outer constructor, of which `D` would be unbound if `index === nothing`

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.

this seems kind of extraneous

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.

can you clarify what "this" refers to? The need for the struct, or the comment about defining an inner constructor to avoid the unbound type parameter?

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.

sorry, the comment

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.

3 participants