Skip to content
2 changes: 1 addition & 1 deletion src/TensorKit.jl
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export infimum, supremum, isisomorphic, ismonomorphic, isepimorphic
export sectortype, sectors, hassector
export unit, rightunit, leftunit, allunits, isunit, otimes, deligneproduct, timereversed
export Nsymbol, Fsymbol, Rsymbol, Bsymbol, frobenius_schur_phase, frobenius_schur_indicator, twist, fusiontensor
export sectorscalartype, fusionscalartype, braidingscalartype
export sectorscalartype, fusionscalartype, braidingscalartype, dimscalartype

# Export methods for fusion trees
export fusiontrees, braid, permute, transpose
Expand Down
65 changes: 57 additions & 8 deletions src/auxiliary/dicts.jl
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,14 @@ end
Base.empty(::SortedVectorDict, ::Type{K}, ::Type{V}) where {K, V} = SortedVectorDict{K, V}()
Base.empty!(d::SortedVectorDict) = (empty!(d.keys); empty!(d.values); return d)

# _searchsortedfirst(v::Vector, k) = searchsortedfirst(v, k)
function _searchsortedfirst(v::Vector, k)
i = 1
@inbounds while i <= length(v) && isless(v[i], k)
i += 1
end
return i
end
_searchsortedfirst(v::Vector, k) = searchsortedfirst(v, k)
# function _searchsortedfirst(v::Vector, k)

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 this be gotten rid of?

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 is definitely a non-trivial change here though that might actually require a bit of benchmarking in various cases, as the question is basically "do we use linear or binary search".
I seem to recall @Jutho saying that he measured this and found that the linear search is overall faster, although I can definitely see how this has to depend on the length of the vector.

I do however agree with the point that it could be great to just get rid of a bunch of code in general, and if we find something that actually maintains a competitive dictionary type it would be wonderful if we could outsource this in its entirety. I can understand that for standard MPS it is probably more relevant to focus on the small length cases, while for tensors with more legs or symmetries with more sectors it probably ends up flipping, and at some point there might actually be a case for just switching to either Dict or Dictionary, deleting this code here. Unfortunately it is really hard to measure the total effect this will have since there is soo many different usecases and regimes 😢

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.

I admit I wasn't very clear in my benchmarks, and I don't have tests literally showing the benefit to switching back to binary search, but it was noticeable. I'll see to showing this more transparently. I'm predicting a crossover at fairly small number of keys though where linear search starts better, but then binary takes over, but I believe even with the smallest case I tested of 13, binary already outperformed.

At some point I was indeed looking at starting from Dict and then converting to SectorDict, which ended up working in e.g. fuse. For things like this, I did decide based on global improvement, so no cutoff where something else started working better. Indeed, at some point I was losing my mind about all these cutoffs, so I just stuck to what always worked best, not necessarily the best case depending on the scenario.

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.

If we any way are going to restrict tuples to very short size, and use vectors otherwise, can we choose that cutoff value to coincide with switching from linear search (for tuples) to binary search (for vectors)?

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.

I'd also like to see the performance compared to a regular Dict with hashing to be honest, it seems to me that for more complicated product types like the ones @borisdevos is using the actual operations are quite a bit more involved than simply getting a hash function

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.

At least within SectorDicts, the crossover from linear to binary outperforming is somewhere in the 16-32 key range, which is not too unexpected complexity-wise. Functions that care about this do equally good within 5% for small N, where for N = 16 they're about equal, and for N>16 get steadily outperformed by binary search. So I think at least within the context of the changes of this PR, binary search is better.

If we any way are going to restrict tuples to very short size, and use vectors otherwise, can we choose that cutoff value to coincide with switching from linear search (for tuples) to binary search (for vectors)?

I'm a little confused though, because we're never really "densely" searching through NTuples, and wouldn't also through Vectors, no? Every lookup within this is done with TensorKitSectors' findindex, which specialises for every sector type to be instant (the fallback is indeed linear, but the docs label this method as "required"). So this cutoff would be dependent on other things, like allocation and compile time, and other factors I have yet to discover.

# i = 1
# @inbounds while i <= length(v) && isless(v[i], k)
# i += 1
# end
# return i
# end

function Base.delete!(d::SortedVectorDict{K}, k) where {K}
key = convert(K, k)
Expand Down Expand Up @@ -186,6 +186,55 @@ function Base.:(==)(d1::SortedVectorDict, d2::SortedVectorDict)
return true
end

# merge over two SORTED vector pairs representing keys and values
# - combine(v1,v2): value for a key present in both operands
# - only1(v1) / only2(v2): value for a key present in only one operand;
# pass `nothing` to drop such keys entirely (e.g. for an intersection)
# zero results are dropped (either from `combine` or `only1`/`only2`), matching how GradedSpace never stores an explicit zero dimension
# k1 and k2 originate from GradedSpace.dims.keys, which are guaranteed to be sorted
function _sortedmerge(k1::Vector{I}, v1::Vector{Int}, k2::Vector{I}, v2::Vector{Int}, combine, only1, only2) where {I}
n1, n2 = length(k1), length(k2)
ks, vs = Vector{I}(), Vector{Int}()
sizehint!(ks, n1 + n2)
sizehint!(vs, n1 + n2)
i, j = 1, 1
@inbounds while i <= n1 && j <= n2
if k1[i] == k2[j]
d = combine(v1[i], v2[j])
if !iszero(d)
push!(ks, k1[i])
push!(vs, d)
end
i += 1
j += 1
elseif k1[i] < k2[j]
_mergeonly!(ks, vs, k1[i], v1[i], only1)
i += 1
else
_mergeonly!(ks, vs, k2[j], v2[j], only2)
j += 1
end
end
@inbounds while i <= n1
_mergeonly!(ks, vs, k1[i], v1[i], only1)
i += 1
end
@inbounds while j <= n2
_mergeonly!(ks, vs, k2[j], v2[j], only2)
j += 1
end
return ks, vs
end
@inline _mergeonly!(ks, vs, k, v, ::Nothing) = nothing
@inline function _mergeonly!(ks, vs, k, v, f)
d = f(v)
if !iszero(d)
push!(ks, k)
push!(vs, d)
end
return nothing
end

"""
Hashed(value, hashfunction = Base.hash, isequal = Base.isequal)

Expand Down
2 changes: 1 addition & 1 deletion src/factorizations/factorizations.jl
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ module Factorizations
export copy_oftype, factorisation_scalartype, one!, truncspace

using ..TensorKit
using ..TensorKit: AdjointTensorMap, SectorDict, SectorVector,
using ..TensorKit: AdjointTensorMap, SectorDict, SectorVector, findindex,
blocktype, foreachblock, one!,
similar_diagonal, similarstoragetype

Expand Down
25 changes: 25 additions & 0 deletions src/factorizations/truncation.jl
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,31 @@ _blocklength(ax::Base.OneTo, ind::AbstractVector{Bool}) = count(ind)
function truncate_space(V::ElementarySpace, inds)
return spacetype(V)(c => _blocklength(dim(V, c), ind) for (c, ind) in pairs(inds))
end
function truncate_space(V::GradedSpace{I, NTuple{N, Int}}, inds) where {I <: Sector, N}
vals = values(I)
dualV = isdual(V)
newdims = zeros(Int, N)
for (c, ind) in pairs(inds)
n_read = findindex(vals, dualV ? dual(c) : c) # dual-adjusted index for reading V.dims
n_write = findindex(vals, c) # output is never dual, so c is fine as-is
newdims[n_write] = _blocklength(V.dims[n_read], ind) # dim(c) = dim(dual(c))
Comment thread
borisdevos marked this conversation as resolved.
end
return typeof(V)(NTuple{N, Int}(newdims), false)
end
function truncate_space(V::GradedSpace{I, <:SectorDict}, inds) where {I <: Sector}
dualV = isdual(V)
ks, vs = Vector{I}(), Vector{Int}() # accumulate and sort once at the end
for (c, ind) in pairs(inds)
d = get(V.dims, dualV ? dual(c) : c, 0)
len = _blocklength(d, ind)
if !iszero(len)
push!(ks, c)
push!(vs, len)
end
end
perm = sortperm(ks)
return typeof(V)(SectorDict{I, Int}(ks[perm], vs[perm]), false)
end

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 all of this code purely for more performance? The original truncate_space (L37-39) works with general GradedSpace objects, right?

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.

Yes, admittedly quite modest (1-3x speedup) and N-dependent, but it's never a regression.


function truncate_domain!(tdst::AbstractTensorMap, tsrc::AbstractTensorMap, inds)
for (c, b) in blocks(tdst)
Expand Down
152 changes: 105 additions & 47 deletions src/spaces/gradedspace.jl
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,17 @@ end
sectortype(::Type{<:GradedSpace{I}}) where {I <: Sector} = I

function GradedSpace{I, NTuple{N, Int}}(dims; dual::Bool = false) where {I, N}
d = ntuple(n -> 0, N)
isset = ntuple(n -> false, N)
d = zeros(Int, N)
isset = falses(N)
for (c, dc) in dims
k = convert(I, c)
i = findindex(values(I), k)
k = dc < 0 && throw(ArgumentError(lazy"Sector $k has negative dimension $dc"))
dc < 0 && throw(ArgumentError(lazy"Sector $k has negative dimension $dc"))
isset[i] && throw(ArgumentError(lazy"Sector $c appears multiple times"))
isset = TupleTools.setindex(isset, true, i)
d = TupleTools.setindex(d, dc, i)
isset[i] = true
d[i] = dc
end
return GradedSpace{I, NTuple{N, Int}}(d, dual)
return GradedSpace{I, NTuple{N, Int}}(NTuple{N, Int}(d), dual)
end

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 really want to allocate here, if we are anyway going to restrict to short tuples?

function GradedSpace{I, NTuple{N, Int}}(dims::Pair; dual::Bool = false) where {I, N}
return GradedSpace{I, NTuple{N, Int}}((dims,); dual = dual)
Expand Down Expand Up @@ -89,9 +89,20 @@ GradedSpace(g::AbstractDict; dual::Bool = false) = GradedSpace(g...; dual = dual
field(::Type{<:GradedSpace}) = ℂ
InnerProductStyle(::Type{<:GradedSpace}) = EuclideanInnerProduct()

function dim(V::GradedSpace)
init = 0 * dim(first(allunits(sectortype(V))))
return sum(c -> dim(c) * dim(V, c), sectors(V); init = init)
function dim(V::GradedSpace{I, <:AbstractDict}) where {I <: Sector}
init = zero(dimscalartype(I))
return sum(((c, d),) -> dim(c) * d, V.dims; init)
end
function dim(V::GradedSpace{I, NTuple{N, Int}}) where {I <: Sector, N}
init = zero(dimscalartype(I))
D = init
vals = values(I)
@inbounds for n in 1:N
d = V.dims[n]
iszero(d) && continue
D += dim(vals[n]) * d # dim(c) = dim(dual(c))
end
Comment thread
borisdevos marked this conversation as resolved.
return D
end
function dim(V::GradedSpace{I, <:AbstractDict}, c::I) where {I <: Sector}
return get(V.dims, isdual(V) ? dual(c) : c, 0)
Expand Down Expand Up @@ -126,54 +137,101 @@ function unitspace(S::Type{<:GradedSpace{I}}) where {I <: Sector}
end
zerospace(S::Type{<:GradedSpace}) = S()

# TODO: the following methods can probably be implemented more efficiently for
# `FiniteGradedSpace`, but we don't expect them to be used often in hot loops, so
# these generic definitions (which are still quite efficient) are good for now.
function ⊕(V₁::GradedSpace{I}, V₂::GradedSpace{I}) where {I <: Sector}
function ⊕(V₁::GradedSpace{I, <:SectorDict}, V₂::GradedSpace{I, <:SectorDict}) where {I <: Sector}
dual1 = isdual(V₁)
dual1 == isdual(V₂) || throw(SpaceMismatch("Direct sum of a vector space and a dual space does not exist"))
ks, vs = _sortedmerge(V₁.dims.keys, V₁.dims.values, V₂.dims.keys, V₂.dims.values, +, identity, identity)

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 we route this through Base.mergewith(+, V₁.dims, V₂.dims), and then implement a specialized version of that for the sorted vector dict? I do like the idea of keeping this a bit closer to the AbstractDict functionality, such that if we ever end up switching out the dictionary type this is not too much work.

return typeof(V₁)(SectorDict{I, Int}(ks, vs), dual1)
end
function ⊕(V₁::GradedSpace{I, <:Tuple}, V₂::GradedSpace{I, <:Tuple}) where {I <: Sector}
dual1 = isdual(V₁)
dual1 == isdual(V₂) ||
throw(SpaceMismatch("Direct sum of a vector space and a dual space does not exist"))
dims = SectorDict{I, Int}()
for c in union(sectors(V₁), sectors(V₂))
cout = ifelse(dual1, dual(c), c)
dims[cout] = dim(V₁, c) + dim(V₂, c)
newdims = map(+, V₁.dims, V₂.dims)
return typeof(V₁)(newdims, dual1)
end
function ⊖(V::GradedSpace{I, <:Tuple}, W::GradedSpace{I, <:Tuple}) where {I <: Sector}
dualV = isdual(V)
V ≿ W && dualV == isdual(W) || throw(SpaceMismatch("$(W) is not a subspace of $(V)"))
newdims = map(-, V.dims, W.dims)
return typeof(V)(newdims, dualV)
end
function ⊖(V::GradedSpace{I, <:SectorDict}, W::GradedSpace{I, <:SectorDict}) where {I <: Sector}
dualV = isdual(V)
V ≿ W && dualV == isdual(W) || throw(SpaceMismatch("$(W) is not a subspace of $(V)"))
ks, vs = _sortedmerge(V.dims.keys, V.dims.values, W.dims.keys, W.dims.values, -, identity, nothing)
return typeof(V)(SectorDict{I, Int}(ks, vs), dualV)
end

function fuse(V₁::GradedSpace{I, <:SectorDict}, V₂::GradedSpace{I, <:SectorDict}) where {I <: Sector}
dual1, dual2 = isdual(V₁), isdual(V₂)
acc = Dict{I, Int}() # SectorDict `get` within the double for loop accumulates O(N^2) ` findindex` calls -> sort afterwards

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 the main purpose of this specialization bypassing the bad scaling of repeated insertion into a SectorDict being slow, or is the point to avoid calling dim(V, c), or is it the combination?

For the latter and readability, it might actually make sense to introduce a new function, similar to Base.pairs that produces something that iterates like "zip(sectors(V), dims(V))" without having to rehash a bunch of things. (Which looks like it might be useful in a bunch of these functions).

For the former, do you think we could get away with a specialized SectorDict(generator) constructor that does precisely this, or alternatively try out the approach where we just accumulate everything into vectors, and then "sort+merge"?

k1, k2 = V₁.dims.keys, V₂.dims.keys
v1, v2 = V₁.dims.values, V₂.dims.values
@inbounds for na in eachindex(k1)
a₀, da = k1[na], v1[na]
a = dual1 ? dual(a₀) : a₀
for nb in eachindex(k2)
b₀, db = k2[nb], v2[nb]
b = dual2 ? dual(b₀) : b₀
dab = da * db
for c in a ⊗ b
acc[c] = get(acc, c, 0) + Nsymbol(a, b, c) * dab
end
end
end
return typeof(V₁)(dims; dual = dual1)
end
function ⊖(V::GradedSpace{I}, W::GradedSpace{I}) where {I <: Sector}
dual = isdual(V)
V ≿ W && dual == isdual(W) ||
throw(SpaceMismatch("$(W) is not a subspace of $(V)"))
return typeof(V)(c => dim(V, c) - dim(W, c) for c in sectors(V); dual)
end

function fuse(V₁::GradedSpace{I}, V₂::GradedSpace{I}) where {I <: Sector}
dims = SectorDict{I, Int}()
for a in sectors(V₁), b in sectors(V₂)
for c in a ⊗ b
dims[c] = get(dims, c, 0) + Nsymbol(a, b, c) * dim(V₁, a) * dim(V₂, b)
ks0 = collect(keys(acc))
vs0 = collect(values(acc))
perm = sortperm(ks0)
return typeof(V₁)(SectorDict{I, Int}(ks0[perm], vs0[perm]), false)
end
function fuse(V₁::GradedSpace{I, NTuple{N, Int}}, V₂::GradedSpace{I, NTuple{N, Int}}) where {I <: Sector, N}
vals = values(I)
dual1, dual2 = isdual(V₁), isdual(V₂)
newdims = zeros(Int, N)

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.

I do think that probably here we want to restrict to tuple as well, and use Base.setindex instead. (worst case scenario, there is also this: https://github.com/Jutho/TupleTools.jl/blob/a57ef2a1189604bba10ac08b2e2bae02d29d1a3e/src/TupleTools.jl#L37-L43

@inbounds for na in 1:N
da = V₁.dims[na]
iszero(da) && continue
a₀ = vals[na] # avoid call to sectors(V₁)
a = dual1 ? dual(a₀) : a₀
for nb in 1:N
db = V₂.dims[nb]
iszero(db) && continue
b₀ = vals[nb] # idem for V₂
b = dual2 ? dual(b₀) : b₀
dab = da * db
for c in a ⊗ b
nc = findindex(vals, c)
newdims[nc] += Nsymbol(a, b, c) * dab
end
end
end
return typeof(V₁)(dims)
return typeof(V₁)(NTuple{N, Int}(newdims), false)
end

function infimum(V₁::GradedSpace{I}, V₂::GradedSpace{I}) where {I <: Sector}
function infimum(V₁::GradedSpace{I, <:Tuple}, V₂::GradedSpace{I, <:Tuple}) where {I <: Sector}
Visdual = isdual(V₁)
Visdual == isdual(V₂) || throw(SpaceMismatch("Infimum of space and dual space does not exist"))
newdims = map(min, V₁.dims, V₂.dims)
return typeof(V₁)(newdims, Visdual)
end
function infimum(V₁::GradedSpace{I, <:SectorDict}, V₂::GradedSpace{I, <:SectorDict}) where {I <: Sector}
Visdual = isdual(V₁)
Visdual == isdual(V₂) ||
throw(SpaceMismatch("Infimum of space and dual space does not exist"))
return typeof(V₁)(
(Visdual ? dual(c) : c) => min(dim(V₁, c), dim(V₂, c))
for c in intersect(sectors(V₁), sectors(V₂)); dual = Visdual
)
end
function supremum(V₁::GradedSpace{I}, V₂::GradedSpace{I}) where {I <: Sector}
Visdual == isdual(V₂) || throw(SpaceMismatch("Infimum of space and dual space does not exist"))
ks, vs = _sortedmerge(V₁.dims.keys, V₁.dims.values, V₂.dims.keys, V₂.dims.values, min, nothing, nothing)
return typeof(V₁)(SectorDict{I, Int}(ks, vs), Visdual)
end
function supremum(V₁::GradedSpace{I, <:Tuple}, V₂::GradedSpace{I, <:Tuple}) where {I <: Sector}
Visdual = isdual(V₁)
Visdual == isdual(V₂) || throw(SpaceMismatch("Supremum of space and dual space does not exist"))
newdims = map(max, V₁.dims, V₂.dims)
return typeof(V₁)(newdims, Visdual)
end
function supremum(V₁::GradedSpace{I, <:SectorDict}, V₂::GradedSpace{I, <:SectorDict}) where {I <: Sector}
Visdual = isdual(V₁)
Visdual == isdual(V₂) ||
throw(SpaceMismatch("Supremum of space and dual space does not exist"))
return typeof(V₁)(
(Visdual ? dual(c) : c) => max(dim(V₁, c), dim(V₂, c))
for c in union(sectors(V₁), sectors(V₂)); dual = Visdual
)
Visdual == isdual(V₂) || throw(SpaceMismatch("Supremum of space and dual space does not exist"))
ks, vs = _sortedmerge(V₁.dims.keys, V₁.dims.values, V₂.dims.keys, V₂.dims.values, max, identity, identity)
return typeof(V₁)(SectorDict{I, Int}(ks, vs), Visdual)
end

hassector(V::GradedSpace{I}, s::I) where {I <: Sector} = dim(V, s) != 0
Expand Down
Loading