Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
4e25615
Initial steps to CUDA support
kshyatt Jan 29, 2026
0586f57
Restore singular value distance
kshyatt May 19, 2026
030543a
Cleanup and some actual tests
Jul 29, 2026
c7a5502
Try densitymatrices tests
Jul 29, 2026
14bc735
Add a simple timestep example
Jul 29, 2026
c851493
More progress
Jul 31, 2026
58d4bbc
refactor `_fliptwist_s`
lkdvos Jul 31, 2026
d1b661b
Working CTMRG contractions tests
Aug 3, 2026
0e92892
Partially support cluster projectors tests
Aug 4, 2026
d1ceeb4
Working tf_ising_finiteT
Aug 4, 2026
fed1e8a
Sitedep truncation also working
Aug 5, 2026
8001dd0
Working ctmrg/partition_function (except C4v)
Aug 5, 2026
f827b37
Working BP tests
Aug 5, 2026
b1c8ea1
Fixed iteration appears to be working
Aug 5, 2026
5d19879
Flavors working too
Aug 5, 2026
bbc9249
Working ctmrg/unitcell
Aug 5, 2026
51edc34
Working CTMRG/pepo
Aug 5, 2026
a651dd9
Try simplifying the test script
kshyatt Aug 6, 2026
0b1ddf7
Adjust paths
kshyatt Aug 6, 2026
87e4a42
Fix runtests.jl
Aug 6, 2026
2400b78
Soothe VUMPS on 1.12
kshyatt Aug 6, 2026
1233758
Bondenv tests
Aug 6, 2026
8362562
Fix product_peps
kshyatt Aug 6, 2026
50b9bdc
BP-Gaugefix test for CUDA
Aug 9, 2026
144589d
More working tests with downstream fixes
Aug 11, 2026
76d6cd8
Don't use QRIteration for C4V + CUDA
kshyatt Aug 12, 2026
dbc8d85
Get cuda flavors working
kshyatt Aug 12, 2026
986776a
Make the gaugefix loop GPU-friendly
Aug 13, 2026
0d16b5c
CUDA expvals test works
kshyatt Aug 14, 2026
ac026db
bp/expvals tests working
Aug 14, 2026
e6bb5f0
GPUified SVD and passing tests
Aug 14, 2026
f0fb4eb
Working eigh
Aug 14, 2026
9db52dd
Switch to CUDACore and more tests working
Aug 15, 2026
69446b4
Gradient tests
Aug 15, 2026
92324c7
Fix for c4v
Aug 18, 2026
7e90dd8
Use the new braiding branch over at TK
Aug 19, 2026
329acae
Use alloc_caches from GPUArrays
Aug 19, 2026
1db3cb7
Leave a note about which GPU algo to use
Aug 19, 2026
ba932e9
Use DivideAndConquer for ROCm tests
Aug 19, 2026
2e4651a
Let the cache depth vary by algo
Aug 19, 2026
884d427
Fix AMDGPU rev
Aug 20, 2026
984d046
CUDA/CUDACore badness STRIKES AGAIN
kshyatt Aug 20, 2026
0b9c813
Make sure buffers aren't overwritten in rrule_via_ad
kshyatt Aug 20, 2026
e71da5c
use Jacobi for the fixed_iterscheme tests for speeeeeeeed
Aug 20, 2026
f0b8425
Fixes for holding cache memory for too long
kshyatt Aug 20, 2026
2fd64ab
Try turning off the most memory intensive part
kshyatt Aug 21, 2026
a289d49
Add a quick check to make sure no empty spaces were created
Aug 21, 2026
464ae0c
Batched SVD support and more caching improvements
Aug 22, 2026
afe36d6
Force real-valued accumulator
kshyatt Aug 24, 2026
cb7476d
Move batching logic into MAK as much as possible
kshyatt Sep 15, 2026
d039daa
Incremental trimming of duplicated tests
kshyatt Sep 14, 2026
87baf8e
Use new TO version
kshyatt Sep 21, 2026
788fc8d
Fix the c4v gradients
kshyatt Sep 22, 2026
57a5113
Update caching strategy
kshyatt Sep 23, 2026
5630847
Soothe CI a bit more
kshyatt Sep 24, 2026
345a864
Try GPUArrays 12
kshyatt Oct 7, 2026
814e61c
Fixups for Project files
Oct 9, 2026
0abb633
Drop some useless changes
kshyatt Oct 12, 2026
c2ef00d
Remove duplicated storage type methods
kshyatt Oct 12, 2026
4d56a4c
Try the handle handler fix branch
kshyatt Oct 12, 2026
fae2ba1
Update Project.toml
kshyatt Oct 14, 2026
0fedc0e
Cleanup some rebase dupes
kshyatt Oct 14, 2026
cdd7b8b
Rebase issues and other fixes
kshyatt Oct 16, 2026
88d6f8b
Cleanup some dead lines
kshyatt Oct 19, 2026
dc44c7a
Remove more dead lines
kshyatt Oct 19, 2026
0ecf653
More dead stuff removal
kshyatt Oct 19, 2026
19e2e3a
GPU specific extensions for SVD stuff
kshyatt Oct 20, 2026
cc2c5d9
Clean up project files a bit
kshyatt Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .buildkite/pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ steps:
- "1.13"
queue:
- "cuda"
#- "rocm" # disable for now as these are timing out
- "rocm"
group:
- "boundarymps"
- "bondenv"
Expand Down
5 changes: 4 additions & 1 deletion Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,21 @@ Zygote = "e88e6eb3-aa80-5325-afca-941959d7151f"
Adapt = "79e6a3ab-5dfb-504d-930d-738a2a938a0e"
GPUArrays = "0c68f7d7-f131-5f86-a1c3-88cf8149b2d7"

[sources]
TensorKit = {rev = "main", url = "https://github.com/QuantumKitHub/TensorKit.jl"}

[extensions]
PEPSKitAdaptExt = "Adapt"
PEPSKitGPUArraysExt = "GPUArrays"

[compat]
Accessors = "0.1"
Adapt = "4"
GPUArrays = "11, 12"
ChainRulesCore = "1.0"
Compat = "3.46, 4.2"
DocStringExtensions = "0.9.3"
FiniteDifferences = "0.12"
GPUArrays = "11, 12"
KrylovKit = "0.9.5, 0.10"
LinearAlgebra = "1"
MPSKit = "0.14"
Expand Down
126 changes: 124 additions & 2 deletions ext/PEPSKitGPUArraysExt.jl
Original file line number Diff line number Diff line change
@@ -1,15 +1,137 @@
module PEPSKitGPUArraysExt

using GPUArrays
using PEPSKit, MatrixAlgebraKit
using GPUArrays: AnyGPUArray, AllocCache
using PEPSKit
using LinearAlgebra
using TensorKit
using TensorKit: MatrixAlgebraKit as MAK
import PEPSKit: _column_vectors, _materialize_block, _indexable_diagview

PEPSKit._materialize_block(m::Union{Adjoint{T, A}, Transpose{T, A}}) where {T, A <: AnyGPUArray} = copyto!(similar(m, size(m)), m)
function PEPSKit._column_vectors(::Type{A}, m::AbstractMatrix) where {A <: AnyGPUArray}
m̃ = PEPSKit._materialize_block(m)
return A[m̃[:, j] for j in axes(m̃, 2)]
end
PEPSKit._indexable_diagview(m::AnyGPUArray) = collect(MatrixAlgebraKit.diagview(m))
PEPSKit._indexable_diagview(m::AnyGPUArray) = collect(MAK.diagview(m))

# Each caller (such as `su_iter`) gets a pair of caches. This makes sense to do on a per-caller basis
# because what is being cached varies between algorithms.
# For each caller we also store several caches, for SimultaneousCTMRG and SU,
# one for even iterations and one for odd, and for SequentialCTMRG, 5 (one "round" plus one extra)
# This has to be done because we can't reuse a cache from iteration `i`
# until iteration `i+n` is completely finished and its result handed off.
const ALLOC_CACHES = Dict{Tuple{Symbol, Int}, Vector{AllocCache}}()
const ALLOC_CACHES_LOCK = ReentrantLock()

function _caches(site::Symbol, depth::Int)
return Base.@lock ALLOC_CACHES_LOCK begin
get!(() -> [AllocCache() for _ in 1:depth], ALLOC_CACHES, (site, depth))
end
end

function PEPSKit._with_alloc_cache(f, ::Type{<:AnyGPUArray}, site::Symbol, iter::Int, depth::Int)
cache = @inbounds _caches(site, depth)[mod1(iter + 1, depth)]
return GPUArrays.@cached cache f()
end

# Reduce into a 0-dimensional device array instead of returning a host scalar. `sdiag_pow` only
# feeds this into a broadcast, and a 0-dim array broadcasts as a scalar, so the value never has to
# come back to the host. Returning a number here would force a device sync on every call, and
# `sdiag_pow` runs once per bond per weight absorption in simple update.
function PEPSKit._maxabs(data::AnyGPUArray)
T = real(eltype(data))
acc = similar(data, T, ())
fill!(acc, zero(T))
Base.mapreducedim!(abs, max, acc, data)
return acc
end

PEPSKit._uncache(x, ::Type{<:AnyGPUArray}) = deepcopy(x)

function PEPSKit.free_alloc_caches!(::Type{<:AnyGPUArray}, caller::Symbol)
Base.@lock ALLOC_CACHES_LOCK begin
# collect first: freeing mutates ALLOC_CACHES
stale = [key for key in keys(ALLOC_CACHES) if first(key) === caller]
for key in stale
for cache in ALLOC_CACHES[key]
GPUArrays.unsafe_free!(cache)
end
delete!(ALLOC_CACHES, key)
end
end
return nothing
end

function PEPSKit.free_alloc_caches!(::Type{<:AnyGPUArray})
Base.@lock ALLOC_CACHES_LOCK begin
for caches in values(ALLOC_CACHES), cache in caches
GPUArrays.unsafe_free!(cache)
end
empty!(ALLOC_CACHES)
end
return nothing
end

"""
_batched_spectra_alg(proto) -> alg or nothing
Default batched SVD algorithm this backend offers, or `nothing` if it has none.
"""
function _batched_spectra_alg(proto)
# TODO BAD MAKE THIS A MAK CALL
alg = try
MAK.default_svd_algorithm(typeof(similar(proto, 0, 0, 0)))
catch
return nothing
end
return alg isa MAK.AbstractAlgorithm ? alg : nothing
end

# Hook into the collection-level convergence API. Deliberately restricted to the generic
# CTMRG algorithms: `C4vCTMRG` overrides `corner_spectrum` to `eigh_vals` (its corners are
# diagonal), so a blanket override here would silently switch it back to `svd_vals`.
function PEPSKit.corner_spectra(
Cs::AbstractArray{<:AbstractTensorMap},
::Union{PEPSKit.SequentialCTMRG, PEPSKit.SimultaneousCTMRG},
)
return _batched_spectra(Cs)
end
function PEPSKit.edge_spectra(
Ts::AbstractArray{<:AbstractTensorMap},
::Union{PEPSKit.SequentialCTMRG, PEPSKit.SimultaneousCTMRG},
)
return _batched_spectra(Ts)
end

# `calc_convergence` decomposes every corner and every edge of the environment
# for regular CTMRG, which is expensive, at *least* 8 separate `svd_vals` calls.
# Across *multiple tensors* the situation is much better than within *one*,
# because the corners have to all share a space,
# so for a given sector their blocks have identical sizes and batch with no padding at all.
function _batched_spectra(ts::AbstractArray{T}) where {T <: AbstractTensorMap}
# TODO BAD FIND A BETTER DISPATCH HERE
(isempty(ts) || !(TensorKit.storagetype(T) <: AnyGPUArray)) && return map(svd_vals, ts)
items = [(i, c) for i in eachindex(ts) for c in blocksectors(ts[i])]
isempty(items) && return map(svd_vals, ts)
alg = _batched_spectra_alg(block(ts[first(items)[1]], first(items)[2]))
isnothing(alg) && return map(svd_vals, ts)

Ss = map(
t -> MAK.initialize_output(
MAK.svd_vals!, t,
MAK.default_algorithm(
MAK.svd_vals!, typeof(t)
)
), ts
)
# `batched_svd_vals!` destroys the blocks it has to decompose one at a time, but `ts` is the
# live environment, and computing the convergence spectra must not damage the environment
# it is measuring. Copying whole tensors costs one copy per tensor instead of one per block.
ts′ = map(copy, ts)
As = [block(ts′[i], c) for (i, c) in items]
MAK.batched_svd_vals!(As, [block(Ss[i], c) for (i, c) in items], alg)
return Ss
end

end
1 change: 1 addition & 0 deletions src/PEPSKit.jl
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ include("Defaults.jl") # Include first to allow for docstring interpolation wit
include("utility/util.jl")
include("utility/contraction_labels.jl")
include("utility/tensor_traces.jl")
include("utility/alloc_cache.jl")
include("utility/indexing.jl")
include("utility/diffable_threads.jl")
include("utility/twistdual.jl")
Expand Down
26 changes: 25 additions & 1 deletion src/algorithms/ctmrg/ctmrg.jl
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ Perform a single CTMRG iteration in which all directions are being grown and ren
"""
function ctmrg_iteration(network, env, alg::CTMRGAlgorithm) end

# Signature of the buffer sizes a CTMRG run allocates: the corner and edge spaces fix them,
# and the edges carry the network bond dimension as well as the environment dimension.
function _ctmrg_cache_signature(env::CTMRGEnv)
return hash((alloc_cache_signature(env.corners), alloc_cache_signature(env.edges)))
end

"""
leading_boundary(env₀, network; kwargs...) -> env, info
# expert version:
Expand Down Expand Up @@ -109,6 +115,11 @@ function leading_boundary(
env₀::CTMRGEnv, network::InfiniteSquareNetwork, alg::CTMRGAlgorithm
)
check_input(leading_boundary, network, env₀, alg)
# cached buffer sizes are set by the corner/edge spaces, and the edges carry the network
# bond dimension too, so a change here means every pooled buffer has gone stale
ignore_derivatives() do
free_stale_alloc_caches!(storagetype(env₀), :ctmrg, :enter, _ctmrg_cache_signature(env₀))
end
log = ignore_derivatives(() -> MPSKit.IterLog("CTMRG"))
return with_verbosity(; alg.verbosity) do
env = deepcopy(env₀)
Expand All @@ -120,7 +131,10 @@ function leading_boundary(
local info_iter
converged = false
for iter in 1:(alg.maxiter)
env, info_iter = ctmrg_iteration(network, env, alg)
env, info_iter = with_alloc_cache(storagetype(env), :ctmrg, iter, alloc_cache_depth(alg)) do
ctmrg_iteration(network, env, alg)
end
env = uncache(env, storagetype(env))
η, CS, TS = calc_convergence(env, CS, TS, alg)

if η ≤ alg.tol && iter ≥ alg.miniter
Expand All @@ -134,6 +148,15 @@ function leading_boundary(
ctmrg_logiter!(log, iter, η, network, env)
end
end
env = uncache(env, storagetype(env))
# a truncation that is not fixed-space grows the corner/edge spaces as the loop runs,
# leaving one pooled buffer set per intermediate shape. `env` is copied out by now, so
# nothing live is backed by the cache and it is safe to drop those here. Re-checking
# the signature keeps the pool warm for the repeated fixed-space calls of an
# optimization loop, where the spaces do not move.
ignore_derivatives() do
free_stale_alloc_caches!(storagetype(env), :ctmrg, :exit, _ctmrg_cache_signature(env))
end
info = (;
converged,
convergence_error = η,
Expand Down Expand Up @@ -242,6 +265,7 @@ edge_spectra(Ts, alg) = map(T -> edge_spectrum(T, alg), Ts)
The spectrum of a corner, used to measure CTMRG convergence.
"""
corner_spectrum(C, alg) = svd_vals(C)

"""
edge_spectrum(T, alg)
Expand Down
6 changes: 5 additions & 1 deletion src/algorithms/time_evolution/simpleupdate.jl
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,11 @@ end
function Base.iterate(it::TimeEvolver{<:SimpleUpdate}, state = it.state)
iter, t = state.iter, state.t
(iter == it.nstep) && return nothing
psi, env, ϵ = su_iter(state.psi, it.circuit, it.alg, state.env)
storage = storagetype(state.psi)
psi, env, ϵ = with_alloc_cache(storage, :su, iter) do
su_iter(state.psi, it.circuit, it.alg, state.env)
end
psi, env = uncache(psi, storage), uncache(env, storage)
# update internal state
iter += 1
t += it.dt
Expand Down
131 changes: 131 additions & 0 deletions src/utility/alloc_cache.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""
with_alloc_cache(f, storage, caller::Symbol, iter::Int) -> f()
Run one iteration `f()` of an iterative algorithm using a memory cache.
**This is a no-op unless `GPUArrays` is loaded _and_ `storage` is a GPU array.**
There seems to be no benefit to caching for CPU-side memory, but for the device,
using a warm memory pool which recycles memory avoids everything being blocked
while the GPU device driver allocates.
`caller` ids the calling algo (`:su`, `:ctmrg`) so that algorithms allocating different
buffer sizes avoid stepping on each others' caches. Not every algorithm needs an allocation cache,
only the ones with repeated iterations.
Belief propagation seems to not benefit from the caching as much, so it's currently unused there.
`iter` selects between `depth` alternating caches per `caller`. A buffer allocated during
iteration `i` can't become reusable until later iterations have completely used and discarded
its output, so with too few caches it would be possible to overwrite state that is still in
use. A caller that copies its result out with [`uncache`](@ref) before the next iteration
begins needs only a single cache; simple update keeps 2, since it hands its result off after
the following step.
!!! note
Buffers are only returned to the cache when the enclosing block exits, so a single
block retains *everything* it allocated rather than just its maximum live block.
Keeping more caches in the rotation multiplies this effect, which matters for
algorithms like (esp. sequential) CTMRG whose iterations allocate many
short-lived temporaries.
Caching is skipped while `Zygote.jl` is differentiating, because the reverse-mode tape holds references
to intermediates, and recycling those could silently corrupt gradients.
"""
function with_alloc_cache(f, storage::Type, caller::Symbol, iter::Int, depth::Int = 2)
Zygote.isderiving() && return f()
return _with_alloc_cache(f, storage, caller, iter, depth)
end

"""
uncache(x, storage::Type) -> x
Copy `x` out of any allocation-cache region, so that it stays live and is not overwriten
once the enclosing [`with_alloc_cache`](@ref) block has exited. This is necessary to
ensure `leading_boundary` and other functions which call back into AD handle cached
memory correctly.
Buffers allocated inside a cache block are handed back to the pool when the block exits,
and the next cached call may hand them out again, which *silently overwrites* a result the
caller is still holding. Anything that escapes such a block must be copied out.
**This is a no-op unless `GPUArrays` is loaded _and_ `storage` is a GPU array**, and also
while Zygote is differentiating, since caching is skipped in both of those cases.
"""
function uncache(x, storage::Type)
Zygote.isderiving() && return x
return _uncache(x, storage)
end
_uncache(x, ::Type) = x

"""
alloc_cache_depth(alg)
How many iterations a buffer must go unused for before it may be recycled.
"""
alloc_cache_depth(alg) = 1
_with_alloc_cache(f, ::Type, ::Symbol, ::Int, ::Int) = f()

"""
free_alloc_caches!(storage)
free_alloc_caches!(storage, caller::Symbol)
Release memory held by the allocation caches for storage type `storage`, either for every
`caller` (if none is provided) or only for the given one. Does nothing unless the
`PEPSKitGPUArraysExt` extension is loaded and `storage` is a GPU array type.
This should be called when the bond dimension changes, because the cache keys depend on
buffer size and the caches can't be reused when the bond dimension has changed.
!!! warning
This releases the underlying device memory, so it is only safe to call when nothing
still references a buffer that was allocated inside a [`with_alloc_cache`](@ref) block.
Results that escape such a block must have been copied out with [`uncache`](@ref)
first, otherwise freeing the cache leaves them undefined.
"""
free_alloc_caches!(::Type) = nothing
free_alloc_caches!(::Type, ::Symbol) = nothing

# last buffer-shape signature seen per caller, used to detect when cached buffer sizes
# have gone stale because a bond dimension changed
const ALLOC_CACHE_SIGNATURES = Dict{Tuple{Symbol, Symbol}, UInt}()
const ALLOC_CACHE_SIGNATURES_LOCK = ReentrantLock()

"""
free_stale_alloc_caches!(storage, caller::Symbol, phase::Symbol, signature::UInt)
Release `caller`'s allocation caches when `signature` differs from the one seen at the same
`phase` of the previous call, and record `signature` as the current one for that phase.
The caches are keyed by buffer size, so that when the size changes the old, unusable caches
can be freed and new ones allocated, corresponding to the new size. This avoids the cache
size growing without bound.
`phase` distinguishes the points a caller checks from. For example, CTMRG grows the
corner and edge spaces as it converges, so its incoming and outgoing environments differ
whenever the truncation is *not* fixed-space. Recording both under one key makes the stored
signature alternate between them, so every check reports stale and the pool is freed on every
call. Comparing each phase only against itself keeps the pool across repeated calls while
still invalidating it when the spaces genuinely change.
Like [`free_alloc_caches!`](@ref) this is a no-op without a GPU storage type, and it's
skipped while `Zygote.jl` is differentiating, since caching is disabled there anyway.
"""
function free_stale_alloc_caches!(storage::Type, caller::Symbol, phase::Symbol, signature::UInt)
Zygote.isderiving() && return nothing
stale = Base.@lock ALLOC_CACHE_SIGNATURES_LOCK begin
key = (caller, phase)
previous = get(ALLOC_CACHE_SIGNATURES, key, nothing)
ALLOC_CACHE_SIGNATURES[key] = signature
!isnothing(previous) && previous != signature
end
stale && free_alloc_caches!(storage, caller)
return nothing
end

"""
alloc_cache_signature(tensors) -> UInt
Hash the spaces of `tensors`, for use as a [`free_stale_alloc_caches!`](@ref) signature.
Two states whose tensors live in the same spaces allocate the same buffer sizes.
"""
alloc_cache_signature(tensors) = hash(map(space, tensors))
Loading
Loading