Skip to content
1 change: 1 addition & 0 deletions docs/src/Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ When releasing a new version, move the "Unreleased" changes to a new version sec
### Performance

- Recoupling matrices are stored such that they can leverage BLAS, also for mixed complex tensor with real recoupling coefficients.
- Constructing a `FusionTreeBlock` only takes the fusion product of the shorter side, and `fusionblocks` groups the cached fusion trees of the space instead of constructing a block for every combination of sectors (internal)

## [0.17.2](https://github.com/QuantumKitHub/TensorKit.jl/compare/v0.17.1...v0.17.2) - 2026-09-20

Expand Down
29 changes: 20 additions & 9 deletions src/fusiontrees/fusiontrees.jl
Original file line number Diff line number Diff line change
Expand Up @@ -140,18 +140,29 @@ function FusionTreeBlock{I}(
end

# note: cs is unique due to ⊗ returning unique fusion results
cs = if N₁ == 0
sort!(collect(filter(isunit, ⊗(uncoupled[2]...))))
elseif N₂ == 0
sort!(collect(filter(isunit, ⊗(uncoupled[1]...))))
# a superset of the coupled sectors suffices, as there are no fusion trees for the others:
# the unit of the other side, or the fusion product of the shorter side, which avoids that of
# the longer side (`vec`: products of `ProductSector`s have a shape)
cs = if N₁ == 0 || N₂ == 0
# the only unit that can couple: on the left of the first and on the right of the last sector
us = N₁ == 0 ? uncoupled[2] : uncoupled[1]
leftunit(first(us)) == rightunit(last(us)) ? [leftunit(first(us))] : I[]
else
sort!(collect(intersect(⊗(uncoupled[1]...), ⊗(uncoupled[2]...))))
sort!(vec(collect(⊗(uncoupled[N₁ <= N₂ ? 1 : 2]...))))
end

for c in cs,
f₂ in fusiontrees(uncoupled[2], c, isdual[2]),
f₁ in fusiontrees(uncoupled[1], c, isdual[1])
push!(trees, (f₁, f₂))
for c in cs
# the shorter side has fusion trees for every candidate, the longer side not necessarily
# (with several units, `c` must also have the units at the ends of the longer side)
us = N₁ <= N₂ ? uncoupled[2] : uncoupled[1]
UnitStyle(I) isa SimpleUnit || N₁ == 0 || N₂ == 0 ||
(leftunit(c) == leftunit(first(us)) && rightunit(c) == rightunit(last(us))) || continue
longer = N₁ <= N₂ ? fusiontrees(uncoupled[2], c, isdual[2]) : fusiontrees(uncoupled[1], c, isdual[1])
isempty(longer) && continue
for f₂ in fusiontrees(uncoupled[2], c, isdual[2]),
f₁ in fusiontrees(uncoupled[1], c, isdual[1])
push!(trees, (f₁, f₂))
end
end
return FusionTreeBlock(trees)
end
Expand Down
18 changes: 12 additions & 6 deletions src/spaces/homspace.jl
Original file line number Diff line number Diff line change
Expand Up @@ -210,13 +210,19 @@ grouped by their uncoupled charges.
function fusionblocks(W::HomSpace)

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.

Do we have an idea how much time this takes? If it is too much, there might be a point in caching the combination of fusiontrees and fusionblocks for non-abelian tensors

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.

After c643ae2: 0.43 s (fℤ₂ × U(1) × SU(3)) and 4.15 s (fℤ₂ × U(1) × SU(2) × U(1)) for all spaces of one CTMRG iteration. With #580 that is 4–8 % of building the braid transformers, and it runs before the threaded part. One iteration calls fusionblocks 550 times on 396 distinct spaces, so caching alone saves about 30 %. Caching it together with sectorstructure would also give the tree indices of each block, which saves the gettoken lookups of the source trees in GenericTreeTransformer.

Do you think it's worth looking into that for a follow-up?

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.

It might actually be worth it, and feels a bit more uniform since very often fusionblocks is actually what you want for the non-abelian things while we are only caching fusiontrees

I = sectortype(W)
N₁, N₂ = numout(W), numin(W)
isdual_src = (map(isdual, codomain(W)), map(isdual, domain(W)))
fblocks = Vector{FusionTreeBlock{I, N₁, N₂, fusiontreetype(I, N₁, N₂)}}()
for dom_uncoupled_src in sectors(domain(W)), cod_uncoupled_src in sectors(codomain(W))
fs_src = FusionTreeBlock{I}((cod_uncoupled_src, dom_uncoupled_src), isdual_src)
isempty(fs_src) || push!(fblocks, fs_src)
F = fusiontreetype(I, N₁, N₂)
# group the (cached) fusion trees of `W` by their uncoupled sectors
groups = Dict{Tuple{NTuple{N₁, I}, NTuple{N₂, I}}, Vector{F}}()
for (f₁, f₂) in fusiontrees(W)
push!(get!(Vector{F}, groups, (f₁.uncoupled, f₂.uncoupled)), (f₁, f₂))
end
return fblocks
# in the order of `sectors(domain(W))` (outer) and `sectors(codomain(W))` (inner), each of
# which runs fastest over its first space
position(V) = Dict{I, Int}(s => i for (i, s) in enumerate(sectors(V)))

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.

Do we need to map every sector to an Int, or can we get away with just calling < on the sectors themselves since they should appear in the same order, thereby avoiding having to allocate the Dicts here?

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.

We could do this, but I checked and the isless approach is actually slower (almost a factor of 2) for the product sectors I was testing with, since that callsfindindex for each factor at every comparison. So I would be inclined to keep the Dict positions since they're faster.

Also note I fixed an issue with the sorting below, where previously the Dict lookups were run at every comparison, whereas now we run the lookup on each block once and then sort on the obtained results.

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.

let me investigate if we can actually speed up the comparisons a bit, I think there are quite a few fast paths that might be able to get this closer together. It's also just about this looking a bit "ugly" or complicated, if we could just use comparison it's a bit easier to read

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.

Is this specifically for the SU(3) case by the way? I would guess the findindex on the other factors is cheap (unless we are missing some fast definitions here)

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.

QuantumKitHub/TensorKitSectors.jl#115

Mind giving it a go with that included?

codpos, dompos = map(position, codomain(W)), map(position, domain(W))
rank((u₁, u₂)) = (reverse(map(getindex, dompos, u₂)), reverse(map(getindex, codpos, u₁)))
us = collect(keys(groups))
return map(i -> FusionTreeBlock(groups[us[i]]), sortperm(map(rank, us))) # each rank computed once
end

function diagonalblockstructure(W::HomSpace)
Expand Down
15 changes: 15 additions & 0 deletions test/symmetries/doubletree.jl
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,21 @@ using TensorKitSectors
end
end

UnitStyle(I) isa SimpleUnit && @testset "Double fusion tree: blocks of a HomSpace" begin
V = Vect[I](Dict(s => 1 for s in (out..., incoming)))
W = V ⊗ V' ← V ⊗ V' # never empty: the unit couples both sides
trees = collect(fusiontrees(W))
blocks = TK.fusionblocks(W)
# every block holds the trees of `W` with its uncoupled sectors, in the same order
for block in blocks
@test fusiontrees(block) == filter(f -> (f[1].uncoupled, f[2].uncoupled) == block.uncoupled, trees)
end
@test sum(length, blocks) == length(trees)
# the blocks are ordered as `sectors(domain(W))` (outer) and `sectors(codomain(W))` (inner)
uncoupled = [(u₁, u₂) for u₂ in sectors(domain(W)) for u₁ in sectors(codomain(W))]
@test map(block -> block.uncoupled, blocks) == filter(u -> any(f -> (f[1].uncoupled, f[2].uncoupled) == u, trees), uncoupled)
end

@testset "Double fusion tree: planar trace" begin
if FusionStyle(I) isa UniqueFusion
f1, f1 = src
Expand Down
Loading