Skip to content

Reduce overhead: cached block access, SumSpace sector hashing, sparse missing entries - #83

Open
lkdvos wants to merge 17 commits into
mainfrom
optimizatio
Open

lkdvos wants to merge 17 commits into
mainfrom
optimizatio

Conversation

@lkdvos

@lkdvos lkdvos commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

Performance pass targeting spurious allocations and repeated TensorKit cache lookups.

Changes

Cache lookups

  • SumSpace gets allocation-free sectorhash/sectorequal with TensorKit's "same set of sectors" meaning: equality checks mutual containment via hassector, and the hash combines the min and max sector hash. The ElementarySpace fallback built and sorted a Set of sectors on every sectorstructure lookup, which hit blocksectors, hasblock, fusiontrees and subblocks.
  • blocks(t) and subblocks(t) return TensorKit's BlockIterator/SubblockIterator carrying each entry's own blocks/subblocks, so the per-entry structure is looked up once instead of once per sector or fusion tree. foreachblock uses the same cached per-entry blocks.
  • Sparse block/subblock return small zero blocks for missing entries instead of materializing full zero tensors.
  • BlockTensorMap/SparseBlockTensorMap(t::AbstractTensorMap, W) iterate each source's subblocks once. Before, they did one structure lookup per (fusion tree, entry) pair.

Generic fallbacks

  • Entry-wise norm for all AbstractBlockTensorMaps (previously dense only), plus entry-wise == and tr.

Copies and allocations

  • copy_blocks! replaces the scalar-indexing copyto!(::BlockMatrix, ::Matrix) in the factorization, truncation and lmul!/rmul! write-backs. It is GPU compatible, and a JLArray test covers it.
  • The truncation write-back no longer copies a destination that is fully overwritten.
  • Missing sparse entries in add!, scale!, tensoradd!, permute!/transpose!/braid! and trace_permute! are allocated uninitialized and passed β = Zero(), instead of being zero-filled and then added into. A shared _addblock!/_scale_untouched! handles this. Dense add! now passes β to each entry's add! instead of first scaling the whole destination, so each entry is traversed once.
  • VI.add allocates with similar instead of zerovector.

Fixes found along the way

  • Changes results for p ≠ 2: norm(t, p) ignored p within each entry, so norm(t, p) differed from norm(TensorMap(t), p) for p ≠ 2.
  • Sparse in-place fillers silently did nothing for missing entries: fill!, one!/id!, isomorphism!/unitary!, isometry! and rand!/randn!/randexp!/randisometry! write through blocks, where missing entries are detached zeros. They now first store exactly the entries they fill:
    • the identity-like fillers store only entries that overlap a block diagonal (9 of 81 for one! on V⊗V←V⊗V);
    • fill!(t, 0) empties the tensor;
    • the other fillers store every nonempty entry.
  • t[f₁, f₂] = v threw a MethodError on dense BlockTensorMaps (getindex! on an Array).
  • The adjoint-source permute!/transpose!/braid! methods referenced undefined p₁/p₂ and used the wrong permutation. They are now merged into the generic methods.
  • I removed the unreachable 8-argument add_transform! methods. TensorKit 0.17 only calls the 9-argument form, and mixed TensorMap/block tensor calls are intercepted earlier, by permute!/transpose!/braid! and TO.tensoradd!.
  • braid!(::TensorMap, ::BlockTensorMap) called the deprecated TK.add_braid!.
  • Smaller fixes:
    • numout/numin(::Type{<:SparseTensorArray}) referenced an undefined variable.
    • axes(::SumSpace, ::Sector) used flatten, which isn't imported.
    • An undefined S was interpolated in the real/imag error message.

Benchmarks

Setup:

  • U1: a sum space with 3 components, V = (0=>2, 1=>2, -1=>2) ⊞ (0=>3, 1=>1, -1=>1) ⊞ (0=>1, 2=>1). tq is V⊗V←V⊗V (81 entries) and stq a sparse version (p = 0.3); t/st are V⊗V←V.
  • SU2 and Trivial: 4-leg tensors on sum spaces with 3 components each.
  • Measurement: single-threaded BLAS on one pinned core. Each number is the minimum over 3 runs, alternating between base and branch, each of up to 200 samples. Base is main at 294d3aa (TensorKit 0.17.2).

Structure and block access

op before after speedup
blocksectors(tq) 1.59 μs / 50 allocs 0.38 μs / 2 4.2×
collect(fusiontrees(tq)) 2.28 μs / 53 0.95 μs / 5 2.4×
collect(blocks(tq)) 336 μs / 3342 81 μs / 554 4.2×
collect(blocks(stq)) 460 μs / 4679 62 μs / 806 7.5×
collect(subblocks(tq)) 952 μs / 5721 432 μs / 1432 2.2×
block(tq, c) 46 μs / 470 46 μs / 469 1.0×
block(stq, c) 64 μs / 661 24 μs / 297 2.7×
subblock(tq, f) 57 μs / 478 53 μs / 478 1.1×

A single block(t, c) or subblock(t, f) still does one structure lookup per entry, so iterate blocks/subblocks when you need more than one.

Reductions

op before after speedup
norm(t) 0.76 μs / 2 0.69 μs / 2 1.1×
norm(st) 77 μs / 1026 0.19 μs / 2 400×
norm(st, 1) 80 μs / 1026 0.11 μs / 2 700×
t == t 117 μs / 1310 65 μs / 836 1.8×
st ≈ st 261 μs / 3162 5.1 μs / 90 51×
tr(tq) 341 μs / 3368 5.2 μs / 46 66×
tr(stq) 466 μs / 4705 3.1 μs / 26 150×

Factorizations

op before after speedup
qr_compact(tq) 1184 μs / 5985 678 μs / 2008 1.7×
qr_compact(stq) 1374 μs / 7138 637 μs / 2076 2.2×
lq_compact(tq) 1162 μs / 5985 634 μs / 2008 1.8×
svd_compact(tq) 1930 μs / 6226 1390 μs / 2231 1.4×
svd_trunc(tq; trunc = truncrank(5)) 2267 μs / 9541 1604 μs / 3150 1.4×
eigh_full(tq + tq') 1419 μs / 5065 1012 μs / 1666 1.4×

Conversions

op before after speedup
BlockTensorMap(TensorMap(tq), space(tq)), U1 1182 μs / 6180 313 μs / 938 3.8×
SparseBlockTensorMap(TensorMap(tq), space(tq)), U1 1037 μs / 7971 327 μs / 949 3.2×
BlockTensorMap(TensorMap(tq), space(tq)), SU2 763 μs / 2823 147 μs / 777 5.2×
BlockTensorMap(TensorMap(tq), space(tq)), Trivial 44 μs / 192 39 μs / 218 1.1×
TensorMap(tq), Trivial 70 μs / 547 40 μs / 55 1.8×
TensorMap(tq), U1 / SU2 / TensorMap(stq) 292 / 126 / 132 μs 293 / 126 / 133 μs 1.0×

Updates and contractions

op before after speedup
add!(x, tq, 1, 1) 7.42 μs / 0 7.52 μs / 0 1.0×
add!(x, tq, 2, 3) 8.76 μs / 0 9.83 μs / 0 0.9×
add!(x, stq′, 2, 3), different sparsity pattern 7.49 μs / 28 7.64 μs / 28 1.0×
dot(stq, stq) 8.07 μs / 8 5.22 μs / 8 1.5×
permute(stq, ((2,1),(4,3))) 127 μs / 575 118 μs / 575 1.1×
@tensor y[b a; d c] := stq[a b; c d] 138 μs / 679 124 μs / 679 1.1×
@tensor y[a; c] := tq[a b; c b] 178 μs / 1688 180 μs / 1688 1.0×
tq * tq / stq * stq 2073 / 417 μs 2047 / 402 μs 1.0×
  • Small blocks: at these sizes, per-entry TensorKit overhead dominates updates and contractions, so they are unchanged within noise. Dense add! with β ≠ 1 is about 1 μs slower here, because it does 81 small fused add! calls instead of one scale! followed by 81 plain adds.
  • Larger blocks: with 4× larger sector dimensions, add!(x, tq, 2, 3) drops from 2441 to 1786 μs, since each entry is traversed once.
  • Sparse in-place updates: with 8× larger sector dimensions, updating a preallocated sparse destination drops from about 475 KB to about 4 KB allocated per call.

Tests

The affected test files and Aqua pass locally. New tests cover:

  • sparse block/blocks/foreachblock/subblock/subblocks against the dense equivalent
  • SumSpace sectorequal/sectorhash: reordered and regrouped components, plus random comparisons against TensorKit's fallback
  • norm for p ∈ {1, 2, 3, Inf}, == across dense/sparse mixes, and tr
  • factorization write-back aliasing, and copy_blocks! on JLArray storage
  • conversion round trips for U1, SU2 and Trivial sectors, including empty blocks
  • permute!/transpose!/braid! into sparse and dense destinations, with adjoint sources and β ∈ {Zero(), 0, One(), random}
  • sparse in-place fillers: the result matches the dense equivalent, and only filled entries are stored

🤖 Generated with Claude Code

lkdvos and others added 11 commits October 9, 2026 13:15
The ElementarySpace fallbacks built and sorted a sector Set on every
structure-cache lookup keyed by a SumSpace HomSpace. The component-wise
key is finer than necessary but sound. axes(S, c) used an unimported
flatten on an untyped vector.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
blocks(t) now builds each sub-tensor's BlockIterator once and assembles
the mortar per sector; foreachblock and == for block tensors go through
it. Missing sparse entries get a zero block of the block size instead of
a full zero tensor, also in subblock. setindex! with a block array uses
a single view per block, and no longer calls getindex! on dense parents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- add copy_blocks! (blockwise dense -> BlockMatrix copy, avoids scalar indexing) and use it for all dense write-backs
- truncate_domain!/truncate_codomain!: do not copy the destination that is fully overwritten
- norm: one AbstractBlockTensorMap method, no temporary; also pass p to the inner norms
- add == and tr for AbstractBlockTensorMap over nonzero keys
- fix undefined S in real/imag error message

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`_copy_subblocks!` looped over destination fusion trees and, for each, over
all source blocks calling `v[f₁, f₂]` (a structure cache lookup) even when the
slice was empty, rebuilding `blockedrange` axes per tree. The reverse
constructors `BlockTensorMap(t, space)` / `SparseBlockTensorMap(t, space)`
indexed the block tensor per fusion tree, building a mortar over all blocks.

Both directions now share one generic body (branching on `issparse`): loop
over the blocks once, iterate each block's `subblocks` once, and copy to/from
the matching slice of the full tensor's subblock, looked up through a single
`subblocks` iterator. Per-leg block offsets are precomputed per uncoupled
sector. Trivial sectors take the cheap `subblock` path to avoid a structure
lookup per block. Sparse targets keep the previous semantics of storing every
block with a nonempty space.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Blockwise add/scale/permute/braid/transpose/tensoradd!/trace_permute! into a
SparseBlockTensorMap used to read missing entries through getindex, which
allocates a zero-filled block that is then added into. Missing entries are now
allocated uninitialized (TO.tensoralloc, honouring the allocator) and the kernel
is called with β = Zero(); present entries receive β directly. Entries of the
destination that the (sparse) source does not touch are scaled up front, or
dropped for a sparse destination when β == 0, so preallocated destinations keep
their buffers.

VI.scale!(ty, tx, α) is now add!(ty, tx, α, Zero()), and VI.add allocates its
destination with similar instead of zerovector, since every entry is overwritten.

Remove the 8-argument TK.add_transform! methods: TensorKit 0.17.2's
add_transform! takes a conjsrc argument, so these were unreachable (and called
kernels that no longer exist). Merge the AdjointTensorMap-source permute!/
transpose!/braid! methods into the generic ones; the adjoint permute!/transpose!
referenced undefined p₁/p₂ and indexed with p instead of the linear permutation.
Fix SparseTensorArray numout/numin on types (referenced undefined A), and route
braid!(::TensorMap, ::BlockTensorMap) through braid! instead of deprecated add_braid!.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cover sparse/dense destinations with missing and present entries,
β ∈ {Zero(), 0, One(), random}, adjoint sources, fermionic sectors, and
that β = 0 overwrites NaN-filled destinations.

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>
…s` iterators

`subblocks(t)` now caches each entry's subblock structure, mirroring `blocks(t)`;
`block`/`subblock` share the same per-entry accessors, and the conversion
code reuses them instead of its own getter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The component-wise versions were sound as a cache key but finer than TensorKit's
"same set of sectors" meaning, reporting e.g. `⊞(A, B)` and `⊞(B, A)` as unequal.
Equality now checks mutual sector containment via `hassector`, and the hash
combines the min and max sector hash, both set functions, without allocating.

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

codecov Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.93478% with 13 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...c/tensors/abstractblocktensor/abstracttensormap.jl 87.67% 9 Missing ⚠️
src/auxiliary/sparsetensorarray.jl 33.33% 2 Missing ⚠️
src/linalg/linalg.jl 95.65% 1 Missing ⚠️
src/tensors/indexmanipulations.jl 85.71% 1 Missing ⚠️
Files with missing lines Coverage Δ
src/auxiliary/blockarrays.jl 84.61% <100.00%> (+4.61%) ⬆️
src/linalg/factorizations.jl 96.22% <100.00%> (ø)
src/tensors/abstractblocktensor/conversion.jl 84.74% <100.00%> (+6.17%) ⬆️
src/tensors/blocktensor.jl 66.66% <100.00%> (-3.13%) ⬇️
src/tensors/sparseblocktensor.jl 68.18% <100.00%> (-0.30%) ⬇️
src/tensors/tensoroperations.jl 85.55% <100.00%> (+0.32%) ⬆️
src/tensors/vectorinterface.jl 97.82% <100.00%> (+0.45%) ⬆️
src/vectorspaces/sumspace.jl 68.29% <100.00%> (+6.60%) ⬆️
src/linalg/linalg.jl 88.53% <95.65%> (+5.80%) ⬆️
src/tensors/indexmanipulations.jl 53.42% <85.71%> (+29.89%) ⬆️
... and 2 more

... and 2 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 and others added 5 commits October 12, 2026 09:23
…leaner sparse updates

- Rename the per-entry block/subblock caches (`mapentries`, `blockcache`/`getblock`,
  `subblockcache`/`getsubblock`).
- `zeroblock` builds only the d₁×d₂ zero matrix for any element type (adjoints via
  their parent), instead of allocating a full zero tensor for non-`TensorMap` entries.
- Inline `_newblock` and `_prescale!`; `_addblock!` is `@inline`.
- `_scale_untouched!` takes the permutation tuple and checks the source with `haskey`
  instead of building a `Set`; adjoint block tensors get a matching `haskey`.
- Document the trade-offs of the `SumSpace` sector hash.

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

`fill!`, `one!`/`id!`, `isomorphism!`/`unitary!`, `isometry!` and the random fillers write
through `blocks`, whose missing sparse entries are detached zeros, so these writes were lost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`one!`, `isomorphism!` and `isometry!` only store the entries that overlap a block diagonal,
`fill!(t, 0)` empties the tensor, and zero-dimensional entries are never stored.

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

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

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.

1 participant