It seems the in-place permute! of an AdjointTensorMap with a plain TensorMap parent is much slower than it should be compared to the time and allocations it takes to permute! a plain TensorMap. This issue is specific to the in-place method, since an out-of-place permute seems to just unwrap the parent and follow the same path as a TensorMap permute. It also seems that the slowdown is much worse for TensorMaps with non-Abelian sectortypes, becoming increasingly severe with increasing complexity of the symmetry.
I first encountered this on TensorKit.jl v0.16.5, but it can be reproduced on the current main (v0.17.1). Running the following script,
Reproducer script
using TensorKit, BenchmarkTools
using TensorKit.VectorInterface: One, Zero
p = ((2, 3), (4, 1))
function compare(name, V)
t = randn(ComplexF64, V ⊗ V ← V ⊗ V)
adj = t' # AdjointTensorMap
mat = copy(t') # identical space and contents, but a genuine TensorMap
Vdst = TensorKit.permute(space(mat), p)
dst_adj = similar(mat, Vdst)
dst_mat = similar(mat, Vdst)
# NB: permute(::AdjointTensorMap, p) unwraps the adjoint and is therefore NOT affected.
# The slow path is the in-place one, which is what `tensoradd!`/`blas_contract!` use.
f_adj() = permute!(dst_adj, adj, p, One(), Zero())
f_mat() = permute!(dst_mat, mat, p, One(), Zero())
f_adj(); f_mat()
@assert convert(Array, dst_adj) ≈ convert(Array, dst_mat)
b_adj = @benchmark $f_adj() samples = 500 seconds = 10
b_mat = @benchmark $f_mat() samples = 500 seconds = 10
println(rpad(name, 14),
" adjoint ", round(minimum(b_adj).time / 1e6; digits = 4), " ms (", b_adj.allocs, " allocs)",
" tensormap ", round(minimum(b_mat).time / 1e6; digits = 4), " ms (", b_mat.allocs, " allocs)",
" ratio ", round(minimum(b_adj).time / minimum(b_mat).time; digits = 2), "x")
end
compare("fZ2", Vect[FermionParity](0 => 8, 1 => 8))
compare("fZ2xU1", Vect[FermionParity ⊠ U1Irrep]((0,0) => 4, (1,1) => 4, (0,2) => 3, (1,-1) => 3))
compare("SU2", Vect[SU2Irrep](0 => 4, 1//2 => 4, 1 => 3, 3//2 => 2))
# needs SUNRepresentations
using SUNRepresentations
compare("fZ2xU1xSU3", Vect[FermionParity ⊠ U1Irrep ⊠ SU3Irrep](
(0, 0, SU3Irrep(0,0,0)) => 2, (1, 1, SU3Irrep(1,0,0)) => 1,
(0, 2, SU3Irrep(1,1,0)) => 1, (1, 3, SU3Irrep(1,1,1)) => 1))
on a single thread using Julia 1.12.5 and TensorKit v0.17.1 (main) gives timings:
| sector type |
adjoint |
TensorMap |
ratio |
allocs (adjoint / TensorMap) |
fℤ₂ |
0.0717 ms |
0.0539 ms |
1.33× |
122 / 58 |
fℤ₂ ⊠ U(1) |
0.1232 ms |
0.0319 ms |
3.86× |
794 / 310 |
SU(2) |
2.4282 ms |
0.3382 ms |
7.18× |
27 094 / 2 840 |
fℤ₂ ⊠ U(1) ⊠ SU(3) |
3.8169 ms |
0.0465 ms |
82.08× |
43 529 / 645 |
Diagnosis
(Updated from the original issue description after some more digging)
There seem to be two independent issues that cause this.
The first is the fact that an in-place permutation on an AdjointTensorMap falls back on a generic AbstractTensorMap path which doesn't use the same cached TreeTransformer as the regular plain TensorMap permute path. The generic path seems to only hit a cached method at a later stage (the GLOBAL_FSBRAID_CACHE), really performing repeated calls for every fusion tree pair on every call, rather than caching the whole transformer once per (Vdst, Vsrc, p, levels) specification (as contained in the GLOBAL_TREEBRAIDER_CACHE used on the fast TensorMap path).
The second is an issue with the keys used to access this GLOBAL_FSBRAID_CACHE, causing all queries to miss and rendering the cache effectively useless. This turned out to be due to the fact that FusionTreeBlock doesn't have a hash implementation. This in contrast to FusionTree which does have a hash, and is used to create keys for the GLOBAL_TREEBRAIDER_CACHE. This is the reason the cost is so much worse for non-Abelian sector types, because there the same symmetry data is being recomputed on every call and nothing is reused.
Potential solutions
The second issue is very easy to solve, just adding a Base.hash implementation for FusionTreeBlock resolves the GLOBAL_FSBRAID_CACHE misses and removes the additional overhead for non-Abelian sector types. This is done in #518.
To resolve the first issue, we should avoid AdjointTensorMap wrappers around plain TensorMaps that go through the fully generic path.
This could be done by just not instantiating an AdjointTensorMap in tensoradd! when conjA = true, and carrying the conj flag all the way into the eventual kernel. Some version of this is suggested in #520.
Another option is to just write a dedicated fast cached treebraider for an AdjointTensorMap wrapper of a plain TensorMap. Some version of this is suggested in #519.
I'm not quite sure which of these, if any, would be the best option. Any opinions would be very welcome, @Jutho and @lkdvos.
It seems the in-place
permute!of anAdjointTensorMapwith a plainTensorMapparent is much slower than it should be compared to the time and allocations it takes topermute!a plainTensorMap. This issue is specific to the in-place method, since an out-of-placepermuteseems to just unwrap the parent and follow the same path as aTensorMappermute. It also seems that the slowdown is much worse forTensorMaps with non-Abelian sectortypes, becoming increasingly severe with increasing complexity of the symmetry.I first encountered this on TensorKit.jl v0.16.5, but it can be reproduced on the current
main(v0.17.1). Running the following script,Reproducer script
on a single thread using Julia 1.12.5 and TensorKit v0.17.1 (
main) gives timings:fℤ₂fℤ₂ ⊠ U(1)SU(2)fℤ₂ ⊠ U(1) ⊠ SU(3)Diagnosis
(Updated from the original issue description after some more digging)
There seem to be two independent issues that cause this.
The first is the fact that an in-place permutation on an
AdjointTensorMapfalls back on a genericAbstractTensorMappath which doesn't use the same cachedTreeTransformeras the regular plainTensorMappermute path. The generic path seems to only hit a cached method at a later stage (theGLOBAL_FSBRAID_CACHE), really performing repeated calls for every fusion tree pair on every call, rather than caching the whole transformer once per(Vdst, Vsrc, p, levels)specification (as contained in theGLOBAL_TREEBRAIDER_CACHEused on the fastTensorMappath).The second is an issue with the keys used to access this
GLOBAL_FSBRAID_CACHE, causing all queries to miss and rendering the cache effectively useless. This turned out to be due to the fact thatFusionTreeBlockdoesn't have a hash implementation. This in contrast toFusionTreewhich does have a hash, and is used to create keys for theGLOBAL_TREEBRAIDER_CACHE. This is the reason the cost is so much worse for non-Abelian sector types, because there the same symmetry data is being recomputed on every call and nothing is reused.Potential solutions
The second issue is very easy to solve, just adding a
Base.hashimplementation forFusionTreeBlockresolves theGLOBAL_FSBRAID_CACHEmisses and removes the additional overhead for non-Abelian sector types. This is done in #518.To resolve the first issue, we should avoid
AdjointTensorMapwrappers around plainTensorMaps that go through the fully generic path.This could be done by just not instantiating an
AdjointTensorMapintensoradd!whenconjA = true, and carrying the conj flag all the way into the eventual kernel. Some version of this is suggested in #520.Another option is to just write a dedicated fast cached
treebraiderfor anAdjointTensorMapwrapper of a plainTensorMap. Some version of this is suggested in #519.I'm not quite sure which of these, if any, would be the best option. Any opinions would be very welcome, @Jutho and @lkdvos.