Skip to content

Unnecessary Set allocation in sectorequal/issetequal for Trivial sectors #475

Description

@VinceNeede

sectorequal/issetequal for Trivial-sector spaces materializes a full hash Set to compare "0 or 1 elements"

Summary

TensorKit.sectorequal, for Trivial-sector spaces, falls through to
Base.issetequal's fully generic fallback. That fallback materializes a
full hash Set (a Dict plus 3 backing Memory arrays) on each side,
just to compare two TensorKit.OneOrNoneIterator{Trivial} objects — each
of which can hold at most one element. This shows up just from
constructing ordinary TensorMaps (no contraction, no TensorOperations
even required — see reproducer below), and in our own downstream package
accounted for roughly 35-40% of total allocations in two structurally
different algorithms built on TensorKit.

Minimal reproducer

Bisected down to the simplest trigger found: no contraction is
needed at all
. Repeatedly constructing two structurally-distinct
(transposed-shape) TensorMaps — nothing done with them beyond that —
is enough, with fixed (not even varying) dimensions:

using TensorKit

function build_pair()
    A = randn(ComplexF64, ℂ^3 ^4)
    B = randn(ComplexF64, ℂ^4 ^3)
    return A, B
end

for _ in 1:100
    build_pair()
end

Allocation-by-type breakdown over these 100 iterations (full script,
including the Profile.Allocs setup, in the collapsible section below):

800 allocs  (62.5 KiB)  <-  Dict{Trivial, Nothing}
800 allocs  (25.0 KiB)  <-  Memory{UInt8}
800 allocs  (25.0 KiB)  <-  Vector{Tuple{Any, Any}}
800 allocs  (12.5 KiB)  <-  Memory{Trivial}
800 allocs  (12.5 KiB)  <-  Memory{Nothing}
600 allocs  (14.1 KiB)  <-  TensorKit.DegeneracyStructure{2}
200 allocs  (9.4 KiB)   <-  TensorKit.Hashed{TensorMapSpace{ComplexSpace, 1, 1}, typeof(TensorKit.sectorhash), typeof(TensorKit.sectorequal)}

(That last line is worth noting on its own — sectorequal appears
directly in the type name of another frequently-allocated object here,
independent confirmation alongside the stack trace below.)

Full script (includes the one-vs-two-structures comparison referenced below)
using TensorKit
using Profile

"Build ONE structure repeatedly -- if caching explains the pattern, this should be cheap (same HomSpace every time)."
function build_one_repeatedly(n::Int)
    for _ in 1:n
        randn(ComplexF64, ℂ^3 ^4)
    end
end

"Build TWO distinct structures repeatedly (the confirmed-reproducing case) -- for direct comparison against build_one_repeatedly."
function build_pair()
    A = randn(ComplexF64, ℂ^3 ^4)
    B = randn(ComplexF64, ℂ^4 ^3)
    return A, B
end

"Construct `n` such pairs in a row -- nothing done with them beyond that."
function build_many_pairs(n::Int)
    for _ in 1:n
        build_pair()
    end
end

"Profile allocations made while running `f`, returning the raw Profile.Allocs results."
function profile_allocations(f)
    Profile.Allocs.clear()
    Profile.Allocs.@profile sample_rate = 1.0 f()
    return Profile.Allocs.fetch()
end

"Summarize allocation results as (count, bytes) per allocated type."
function count_allocations_by_type(results)
    counts = Dict{String,Int}()
    bytes = Dict{String,Int}()
    for alloc in results.allocs
        tname = string(alloc.type)
        counts[tname] = get(counts, tname, 0) + 1
        bytes[tname] = get(bytes, tname, 0) + alloc.size
    end
    return counts, bytes
end

"Print the `top` most frequently allocated types, by count."
function print_top_allocations(counts, bytes; top=10)
    for (tname, cnt) in sort(collect(counts); by=last, rev=true)[1:min(top, end)]
        kib = round(bytes[tname] / 1024, digits=1)
        println(cnt, " allocs  (", kib, " KiB)  <-  ", tname)
    end
end

"Print the deepest stack frames for one `Dict{Trivial,...}` allocation, if any occurred."
function print_dict_trivial_stacktrace(results)
    dict_allocs = filter(a -> occursin("Dict{Trivial", string(a.type)), results.allocs)
    isempty(dict_allocs) && return println("No Dict{Trivial,...} allocations found.")
    println("Stack trace for a Dict{Trivial,...} allocation:")
    for frame in first(dict_allocs).stacktrace[2:min(9, end)]
        println("    ", frame)
    end
end

# --- Run it: compare ONE distinct structure vs TWO, repeated many times ---

build_one_repeatedly(5)  # warm up
build_many_pairs(5)

println("=== ONE structure, repeated 100x ===")
results_one = profile_allocations(() -> build_one_repeatedly(100))
counts_one, bytes_one = count_allocations_by_type(results_one)
print_top_allocations(counts_one, bytes_one)
println()
print_dict_trivial_stacktrace(results_one)

println()
println("=== TWO distinct structures, each repeated 100x ===")
results_two = profile_allocations(() -> build_many_pairs(100))
counts_two, bytes_two = count_allocations_by_type(results_two)
print_top_allocations(counts_two, bytes_two)
println()
print_dict_trivial_stacktrace(results_two)

Where it comes from

Stack trace for one of the Dict{Trivial,Nothing} allocations (captured
via Profile.Allocs on the MWE above):

ijl_gc_small_alloc at gc-stock.c:777
Dict at dict.jl:80 [inlined]
Set at set.jl:45 [inlined]
Set at set.jl:47 [inlined]
_Set at set.jl:59 [inlined]
Set at set.jl:58 [inlined]
issetequal(a::TensorKit.OneOrNoneIterator{Trivial}, b::TensorKit.OneOrNoneIterator{Trivial}) at abstractset.jl:527
sectorequal at vectorspaces.jl:368 [inlined]

Confirmed to also reproduce (with essentially the same allocation
pattern) via a plain @tensor contraction of the two operands, and via
a larger 5-operand @tensoropt contraction with drifting dimensions
(mimicking a real MPS/MPO environment-tensor construction) — the
above is simply the simplest case found, not the only one.

Likely mechanism: competing entries in TensorKit's own structural cache

We don't have full visibility into TensorKit's internals, but a direct
comparison points at a specific trigger. TensorKit's own changelog
(v0.13) describes structural information for a TensorMap/HomSpace
being "cached in a global (or task local) dictionary" so it doesn't need
recomputing on every new TensorMap. Testing this directly:

  • Repeatedly constructing one unchanging HomSpace (ℂ^3←ℂ^4, same
    shape every call, 100 calls in a row) — no Dict{Trivial,...}
    allocations at all.
  • Repeatedly constructing two distinct HomSpaces (ℂ^3←ℂ^4 and
    ℂ^4←ℂ^3, each individually unchanging, 100 calls in a row) — the
    full Dict{Trivial,...} pattern shown above.

This suggests the expensive path is specifically hit when two or more
distinct structures compete
for entries in that cache (e.g. to
disambiguate a hash collision, or to confirm a lookup key is/isn't
already present) — a single, repeatedly-reused structure apparently
never needs this comparison at all. We haven't confirmed this against
the actual cache implementation, so please treat it as a lead rather
than a diagnosis — you'll know in a few minutes of looking at the source
whether this is right.

Suggested fix direction

OneOrNoneIterator{G} can only ever hold 0 or 1 elements, so equality is
expressible without any allocation:

function Base.issetequal(a::TensorKit.OneOrNoneIterator{G}, b::TensorKit.OneOrNoneIterator{G}) where {G}
    ea = isempty(a) ? nothing : only(a)
    eb = isempty(b) ? nothing : only(b)
    return ea == eb
end

We prototyped this (as a local, deliberately-temporary type-piracy patch
in our own package, since it's not something we can fix from downstream)
and confirmed it eliminates the Dict{Trivial,Nothing} allocation
pattern almost entirely (~480x reduction in our own benchmark, from
~50,000 to ~100 allocations for a representative workload). We're not
proposing this exact snippet as the final fix -- you'll know better than
we do where it belongs / whether OneOrNoneIterator itself should
implement this more directly rather than specializing issetequal -- but
wanted to share a working confirmation that it's fixable cheaply.

Impact observed

  • Real-world effect on wall-clock time was modest in our testing (a few
    percent) -- the dominant costs in our workload were FLOP-bound
    (BLAS/LAPACK), so this overhead is a smaller fraction of total time at
    larger problem sizes. We'd expect the relative impact to be larger for
    workloads with many small operations, or in memory-constrained/
    multi-threaded settings where GC pressure matters more than raw pause
    time.
  • This is triggered by core space-comparison logic (sectorequal,
    used internally whenever Trivial-sector HomSpaces need to be
    compared/cached), not anything specific to our own package or usage
    pattern -- so we'd expect it to be structural to any Trivial-sector
    TensorKit workload with more than one distinct tensor shape in play,
    not particular to us.

Environment

  • TensorKit.jl version: v0.17.0
  • Julia version: 1.12.6
  • OS: Linux (x86_64-linux-gnu)

Happy to help however is useful -- turn this into a PR, test against a
different fix approach, etc.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions