From 5aa01e22c0a289855d26c8579619d91ae53f0962 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Fri, 9 Oct 2026 11:20:46 -0400 Subject: [PATCH 01/65] symbolic: device construction of the pattern and assembly maps On a GPU backend the analysis builds three data-parallel pieces through KernelAbstractions kernels with element-identical results to the host originals: the SymmetricPattern adjacency (packed-key sort + dedup), the assembly map (binary search per stored entry), and the unsigned amap grouping (packed-key sort reproducing the host (abs, neg, p) order). Gated by device_maps_supported (symmetric structures, triangular stored views, packed-key size bounds); everything else keeps the host route. Measured on a 674k condensed ACOPF KKT (GV100): assembly map 0.37 -> 0.03 s, grouping 0.68 -> 0.04 s, pattern build 0.83 -> 0.21 s; MadNLP SparseCondensedKKTSystem init 6.4 -> 3.3 s with a given permutation, same iterations and objective. Tests: test/test_symbolic_device.jl asserts host/device equality for all three routes on every backend (sizes 0..1500, views L/U, structures SPD/S) plus an end-to-end analyze/factorize/solve. CPU suite 41389 pass; CUDA leg of the new file 121 pass locally (GV100). Co-Authored-By: Claude Fable 5 --- src/SparseDirectSolver.jl | 1 + src/solver.jl | 10 +- src/symbolic/device.jl | 239 +++++++++++++++++++++++++++++++++++ src/symbolic/maps.jl | 23 +++- src/symbolic/pattern.jl | 8 +- test/test_symbolic_device.jl | 68 ++++++++++ 6 files changed, 337 insertions(+), 12 deletions(-) create mode 100644 src/symbolic/device.jl create mode 100644 test/test_symbolic_device.jl diff --git a/src/SparseDirectSolver.jl b/src/SparseDirectSolver.jl index cd9e3d85..d358b05f 100644 --- a/src/SparseDirectSolver.jl +++ b/src/SparseDirectSolver.jl @@ -49,6 +49,7 @@ include("symbolic/pairs.jl") include("symbolic/supernodes.jl") include("symbolic/schedule.jl") include("symbolic/layout.jl") +include("symbolic/device.jl") include("symbolic/maps.jl") # matching and scaling (PLAN §1.3, milestone M9), host diff --git a/src/solver.jl b/src/solver.jl index 3d70c811..9bfbf986 100644 --- a/src/solver.jl +++ b/src/solver.jl @@ -454,6 +454,10 @@ function _check_analysis_supported(solver::DirectSolver{T}) where {T} return nothing end +# the backend when the symbolic maps may be built on it (device.jl), else nothing +_device_backend(solver::DirectSolver) = + solver.backend isa KernelAbstractions.GPU ? solver.backend : nothing + function _reorder!(solver::DirectSolver{T}) where {T} _check_analysis_supported(solver) _poll_interrupt(solver.options.user_host_interrupt) @@ -468,7 +472,7 @@ function _reorder!(solver::DirectSolver{T}) where {T} solver.analysis_colval = m === nothing || m.symmetric ? solver.host_colval : matched_colval(m, solver.host_colval, A.index) P = SymmetricPattern(solver.host_rowptr, solver.analysis_colval, A.nrows, solver.structure; - view = _stored_view(solver), index = A.index) + view = _stored_view(solver), index = A.index, device = _device_backend(solver)) if solver.options.schur_mode == 1 # Schur complement mode: the Schur rows and columns last, no 2×2 pivot pairs flags = schur_flags(solver.options.user_schur_indices, A.nrows) @@ -535,7 +539,7 @@ function _symbolic!(solver::DirectSolver{T, INT}) where {T, INT} A = solver.A ord = solver.ordering P = SymmetricPattern(solver.host_rowptr, solver.analysis_colval, A.nrows, solver.structure; - view = _stored_view(solver), index = A.index) + view = _stored_view(solver), index = A.index, device = _device_backend(solver)) if solver.schur === nothing sp = supernode_partition(factor_pattern(P, ord), ord.perm, opts) schur = 0 @@ -548,7 +552,7 @@ function _symbolic!(solver::DirectSolver{T, INT}) where {T, INT} max_local = max_local_bytes(solver.backend)) layout = build_layout(sp, sc; ldlt = _is_ldlt_structure(solver.structure)) Sh = Symbolic(sp, sc, layout, solver.host_rowptr, solver.analysis_colval, A.nrows, solver.structure; - view = _stored_view(solver), index = A.index) + view = _stored_view(solver), index = A.index, device = _device_backend(solver)) nrhs = solver.workspace === nothing ? solver.nbatch : max_rhs(solver.workspace) Sd = adapt(solver.backend, Sh, INT) solver.host_symbolic = Sh diff --git a/src/symbolic/device.jl b/src/symbolic/device.jl new file mode 100644 index 00000000..5f2d8293 --- /dev/null +++ b/src/symbolic/device.jl @@ -0,0 +1,239 @@ +# Device construction of the symbolic maps (PLAN §2.3 step 6 hot spots). +# The host analysis stays the reference; a GPU solver routes three +# data-parallel pieces through KernelAbstractions kernels with element-exact +# results (the tests assert equality against the host originals): +# +# * `device_symmetric_pattern` — the both-triangle adjacency of +# `SymmetricPattern` by packed-key sort and deduplication; +# * `device_assembly_map` — `assembly_map` for the symmetric +# structures, one thread per CSR row, binary search per entry; +# * `device_group_amap` — `_group_amap` (unsigned) as a packed-key +# sort reproducing the host `(abs, neg, p)` order exactly. +# +# The packed 64-bit keys need `n`, `nnz` < 2^32 and factor offsets < 2^31; +# [`device_maps_supported`](@ref) checks the bounds and the callers fall back +# to the host route when it returns `false` (a larger-than-2^31-entry factor +# is a > 16 GiB panel buffer, beyond a single-GPU analysis anyway). + +""" + device_maps_supported(backend, structure, view, n, nnz, factor_len) -> Bool + +Whether the device symbolic-map routes apply: a GPU backend, a symmetric +structure (`"SPD"`, `"HPD"`, `"S"`, `"H"`), a triangular stored view, and +sizes that fit the packed 64-bit sort keys. +""" +function device_maps_supported(backend, structure, view, n::Integer, nnz::Integer, + factor_len::Integer) + backend isa KernelAbstractions.GPU || return false + s = _structure(structure) + (s == STRUCTURE_SPD || s == STRUCTURE_HPD || s == STRUCTURE_SYMMETRIC || + s == STRUCTURE_HERMITIAN) || return false + _matrix_view(view) == VIEW_FULL && return false + return n < 2^32 && nnz < 2^32 && factor_len < 2^31 +end + +# device vector of `x` as `I` +function _device_vec(backend, ::Type{I}, x) where {I <: Integer} + y = KernelAbstractions.allocate(backend, I, length(x)) + isempty(x) || copyto!(y, I.(x)) + return y +end + +# --------------------------------------------------------------------------- +# symmetric pattern + +@kernel function _pattern_keys_kernel!(keys, @Const(rowptr), @Const(colval), base::Int32, + upper::Bool) + r = @index(Global) + @inbounds for p in (rowptr[r] + (one(eltype(rowptr)) - base)):(rowptr[r + 1] - base) + c = Int64(colval[p]) + (1 - Int64(base)) + q = Int64(p) + # the host `_entry_filter`: diagonal dropped by the pattern, entries + # outside the declared triangle dropped by the filter + if c == r || (upper ? c < r : c > r) + keys[2q - 1] = typemax(UInt64) + keys[2q] = typemax(UInt64) + else + keys[2q - 1] = (UInt64(r) << 32) | UInt64(c) + keys[2q] = (UInt64(c) << 32) | UInt64(r) + end + end +end + +""" + device_symmetric_pattern(backend, rowptr, colval, n, view, index) -> (colptr, rowval) + +The `SymmetricPattern` arrays (1-based, sorted, deduplicated, no diagonal) +of the stored entries, built on `backend`. `rowptr`/`colval` are host or +device vectors with base `index`. +""" +function device_symmetric_pattern(backend, rowptr, colval, n::Integer, view, index) + m = length(colval) + base = _index_base(index) == INDEX_ONE ? Int32(1) : Int32(0) + upper = _matrix_view(view) == VIEW_UPPER + I = m + 1 <= typemax(Int32) && n + 1 <= typemax(Int32) ? Int32 : Int64 + drp = _device_vec(backend, I, Array(rowptr)) + dcv = _device_vec(backend, I, Array(colval)) + keys = KernelAbstractions.allocate(backend, UInt64, 2m) + n > 0 && _pattern_keys_kernel!(backend)(keys, drp, dcv, base, upper; ndrange = Int(n)) + KernelAbstractions.synchronize(backend) + isempty(keys) || sort!(keys) # empty: device sorts reject length 0 + hk = Array(keys) + nvalid = searchsortedfirst(hk, typemax(UInt64)) - 1 + colptr = zeros(Int, n + 1) + rowval = Vector{Int}(undef, nvalid) + colptr[1] = 1 + nv = 0 + prev = typemax(UInt64) + @inbounds for i in 1:nvalid + k = hk[i] + k == prev && continue # duplicate of a 'F' view + prev = k + nv += 1 + rowval[nv] = Int(k & 0xffffffff) + colptr[Int(k >> 32) + 1] += 1 + end + resize!(rowval, nv) + for j in 1:n + colptr[j + 1] += colptr[j] + end + return colptr, rowval +end + +# --------------------------------------------------------------------------- +# assembly map + +@kernel function _amap_kernel!(amap, @Const(rowptr), @Const(colval), @Const(iperm), + @Const(col2sn), @Const(snrowptr), @Const(snrowval), + @Const(superptr), @Const(panelptr), base::Int32, + upper::Bool, neg::Bool) + r = @index(Global) + T = eltype(amap) + @inbounds for p in (rowptr[r] + (one(eltype(rowptr)) - base)):(rowptr[r + 1] - base) + c = Int(colval[p]) + (1 - Int(base)) + if upper ? r <= c : r >= c + i = Int(iperm[r]) + j = Int(iperm[c]) + flip = i < j + if flip + i, j = j, i + end + s = Int(col2sn[j]) + lo = Int(snrowptr[s]) + hi = Int(snrowptr[s + 1]) - 1 + f = hi - lo + 1 + a = lo # binary search for i + b = hi + while a < b + mid = (a + b) >> 1 + if Int(snrowval[mid]) < i + a = mid + 1 + else + b = mid + end + end + off = Int(panelptr[s]) + (j - Int(superptr[s])) * f + (a - lo + 1) - 1 + amap[p] = T(flip && neg ? -off : off) + else + amap[p] = zero(T) + end + end +end + +""" + device_assembly_map(backend, sp, layout, rowptr, colval, n, structure; view, index) + -> amap::Vector{Int} + +[`assembly_map`](@ref) on `backend` for the symmetric structures with a +triangular stored view; identical output. The caller checks +[`device_maps_supported`](@ref). +""" +function device_assembly_map(backend, sp::SupernodePartition, layout::Layout, + rowptr, colval, n::Integer, structure; + view = VIEW_FULL, index = INDEX_ONE) + s = _structure(structure) + v = _matrix_view(view) + base = _index_base(index) == INDEX_ONE ? Int32(1) : Int32(0) + I = Int32 # bounds checked by the caller + drp = _device_vec(backend, I, Array(rowptr)) + dcv = _device_vec(backend, I, Array(colval)) + amap = KernelAbstractions.allocate(backend, I, length(colval)) + n > 0 && _amap_kernel!(backend)(amap, drp, dcv, _device_vec(backend, I, sp.iperm), + _device_vec(backend, I, sp.col2sn), + _device_vec(backend, I, sp.rowptr), + _device_vec(backend, I, sp.rowval), + _device_vec(backend, I, sp.super_ptr), + _device_vec(backend, I, layout.panel_ptr), + base, v == VIEW_UPPER, + _is_hermitian(s); ndrange = Int(n)) + KernelAbstractions.synchronize(backend) + return Vector{Int}(Array(amap)) +end + +# --------------------------------------------------------------------------- +# amap grouping + +@kernel function _group_keys_kernel!(keys, @Const(amap)) + p = @index(Global) + @inbounds begin + a = Int(amap[p]) + if a == 0 + keys[p] = typemax(UInt64) # dropped entries sort last + else + off = UInt64(a < 0 ? -a : a) + # the host order: (abs(amap[p]), amap[p] < 0, p) + keys[p] = (off << 33) | (UInt64(a < 0) << 32) | UInt64(p) + end + end +end + +@kernel function _group_count_kernel!(cnt, @Const(keys), @Const(panelptr), nvalid::Int64) + gi = @index(Global) + @inbounds if gi <= nvalid + off = Int((keys[gi] >> 33) & 0x7fffffff) + a = 1 # searchsortedlast(panel_ptr, off) + b = length(panelptr) + while a < b + mid = (a + b + 1) >> 1 + if Int(panelptr[mid]) <= off + a = mid + else + b = mid - 1 + end + end + KernelAbstractions.@atomic cnt[a + 1] += one(eltype(cnt)) + end +end + +""" + device_group_amap(backend, amap, layout, ns) -> (amap_ptr, order) + +[`_group_amap`](@ref) (unsigned) on `backend`: identical order and per-owner +counts. The signed (`"G"`) grouping stays on the host. +""" +function device_group_amap(backend, amap::Vector{Int}, layout::Layout, ns::Int) + m = length(amap) + damap = _device_vec(backend, Int32, amap) + keys = KernelAbstractions.allocate(backend, UInt64, m) + m > 0 && _group_keys_kernel!(backend)(keys, damap; ndrange = m) + KernelAbstractions.synchronize(backend) + isempty(keys) || sort!(keys) # empty: device sorts reject length 0 + hk = Array(keys) + nvalid = searchsortedfirst(hk, typemax(UInt64)) - 1 + order = Vector{Int}(undef, nvalid) + @inbounds for i in 1:nvalid + order[i] = Int(hk[i] & 0xffffffff) + end + cnt = KernelAbstractions.allocate(backend, Int32, ns + 1) + fill!(cnt, Int32(0)) + m > 0 && _group_count_kernel!(backend)(cnt, keys, _device_vec(backend, Int32, layout.panel_ptr), + Int64(nvalid); ndrange = m) + KernelAbstractions.synchronize(backend) + hc = Array(cnt) + amap_ptr = Vector{Int}(undef, ns + 1) + amap_ptr[1] = 1 + for s in 1:ns + amap_ptr[s + 1] = amap_ptr[s] + Int(hc[s + 1]) + end + return amap_ptr, order +end diff --git a/src/symbolic/maps.jl b/src/symbolic/maps.jl index 19f30132..7821b91c 100644 --- a/src/symbolic/maps.jl +++ b/src/symbolic/maps.jl @@ -193,10 +193,19 @@ Assemble the host [`Symbolic`](@ref) from the supernodes, schedule and layout of a pattern and its user CSR arrays (only the pattern is read). """ function Symbolic(sp::SupernodePartition, sc::Schedule, layout::Layout, rowptr::AbstractVector{<:Integer}, - colval::AbstractVector{<:Integer}, n::Integer, structure; view = VIEW_FULL, index = INDEX_ONE) + colval::AbstractVector{<:Integer}, n::Integer, structure; view = VIEW_FULL, index = INDEX_ONE, + device = nothing) ns = nsupernodes(sp) - amap = assembly_map(sp, layout, rowptr, colval, n, structure; view, index) - amap_ptr, amap_src = _group_amap(amap, layout, ns, _structure(structure) == STRUCTURE_GENERAL) + signed = _structure(structure) == STRUCTURE_GENERAL + use_device = device !== nothing && !signed && + device_maps_supported(device, structure, view, n, length(colval), layout.factor_len) + if use_device + amap = device_assembly_map(device, sp, layout, rowptr, colval, n, structure; view, index) + amap_ptr, amap_src = device_group_amap(device, amap, layout, ns) + else + amap = assembly_map(sp, layout, rowptr, colval, n, structure; view, index) + amap_ptr, amap_src = _group_amap(amap, layout, ns, signed) + end child_ptr = zeros(Int, ns + 1) child_ptr[1] = 1 for s in 1:ns @@ -229,7 +238,7 @@ function Symbolic(sp::SupernodePartition, sc::Schedule, layout::Layout, rowptr:: end """ - symbolic_analysis(A::CSR, structure, view = 'F'; opts = Options(), T = eltype(A), max_local = typemax(Int)) + symbolic_analysis(A::CSR, structure, view = 'F'; opts = Options(), T = eltype(A), device = nothing, max_local = typemax(Int)) -> Symbolic{Int, Vector{Int}} The whole host analysis of PLAN §2.3: [`SymmetricPattern`](@ref), @@ -244,18 +253,18 @@ which bound the regime-A budgets ([`resolve_subtree_budgets`](@ref)); the default leaves them unbounded (host analyses, the CPU backend). """ function symbolic_analysis(A::CSR, structure, view = VIEW_FULL; opts::Options = Options(), T::Type = eltype(A), - max_local::Integer = typemax(Int)) + max_local::Integer = typemax(Int), device = nothing) A.nrows == A.ncols || throw(InvalidValueError("the matrix must be square, got $(A.nrows) × $(A.ncols)")) rowptr = Array(A.rowptr) colval = Array(A.colval) - P = SymmetricPattern(rowptr, colval, A.nrows, structure; view, index = A.index) + P = SymmetricPattern(rowptr, colval, A.nrows, structure; view, index = A.index, device) pp = analysis_pairs(P, rowptr, colval, A.nzval, A.nrows, structure, opts; view, index = A.index) ord = compute_ordering(P, opts; T, pp.pairs, pp.candidates) sp = supernode_partition(factor_pattern(P, ord), ord.perm, opts) sc = build_schedule(sp, opts, T; reserve = subtree_local_reserve(structure), elsize = schedule_elsize(structure, T), max_local) layout = build_layout(sp, sc; ldlt = _is_ldlt_structure(_structure(structure))) - return Symbolic(sp, sc, layout, rowptr, colval, A.nrows, structure; view, index = A.index) + return Symbolic(sp, sc, layout, rowptr, colval, A.nrows, structure; view, index = A.index, device) end function _index_vector(backend::KernelAbstractions.Backend, x::AbstractVector{<:Integer}, ::Type{INT}, diff --git a/src/symbolic/pattern.jl b/src/symbolic/pattern.jl index 59d97c1e..b88f895c 100644 --- a/src/symbolic/pattern.jl +++ b/src/symbolic/pattern.jl @@ -24,7 +24,7 @@ present). SymmetricPattern(n, colptr, rowval) SymmetricPattern(A::CSR, structure; view = 'F') - SymmetricPattern(rowptr, colval, n, structure; view = 'F', index = 'O') + SymmetricPattern(rowptr, colval, n, structure; view = 'F', index = 'O', device = nothing) The first form wraps arrays that already satisfy the invariants (checked). The others build the pattern from a CSR pattern, following PLAN §2.3 step 1: @@ -148,9 +148,13 @@ function _selected_entries(rp, cv, n, structure, view) end function SymmetricPattern(rowptr::AbstractVector{<:Integer}, colval::AbstractVector{<:Integer}, n::Integer, - structure; view = VIEW_FULL, index = INDEX_ONE) + structure; view = VIEW_FULL, index = INDEX_ONE, device = nothing) s = _structure(structure) v = _matrix_view(view) + if device !== nothing && device_maps_supported(device, s, v, n, length(colval), 0) + colptr_d, rowval_d = device_symmetric_pattern(device, rowptr, colval, n, v, index) + return SymmetricPattern(Int(n), colptr_d, rowval_d) + end rp, cv = _host_pattern(rowptr, colval, n, index) rows, cols, _ = _selected_entries(rp, cv, n, s, v) # off-diagonal entries in both orientations, then column-sort and deduplicate diff --git a/test/test_symbolic_device.jl b/test/test_symbolic_device.jl new file mode 100644 index 00000000..b34a6fc7 --- /dev/null +++ b/test/test_symbolic_device.jl @@ -0,0 +1,68 @@ +# Device construction of the symbolic maps (src/symbolic/device.jl): the +# kernels must produce ELEMENT-IDENTICAL results to the host originals, on +# every backend (the CPU backend runs the same kernels through KA). + +random_pattern_csr(n, density, view) = begin + A = random_spd(Float64, n, density) + T = view == 'U' ? triu(A) : tril(A) + C = SDS.CSR(SparseMatrixCSC{Float64, Int}(T)) + (Vector{Int}(C.rowptr), Vector{Int}(C.colval)) +end + +@testset "device symbolic maps ($(backend_name(backend)))" for backend in BACKENDS + for (n, density) in ((0, 0.0), (1, 0.0), (40, 0.15), (300, 0.02), (1500, 0.004)), + view in ('L', 'U'), structure in ("SPD", "S") + + rowptr, colval = random_pattern_csr(n, density, view) + # pattern: host route vs device route + Ph = SDS.SymmetricPattern(rowptr, colval, n, structure; view, index = 'O') + Pd = SDS.SymmetricPattern(rowptr, colval, n, structure; view, index = 'O', device = backend) + @test Ph == Pd # GPU: device route; CPU: gated to host + n == 0 && continue + # maps: build the partition once, then both map routes on it + opts = Options() + ord = SDS.compute_ordering(Ph, opts; alg = :amd) + sp = SDS.supernode_partition(SDS.factor_pattern(Ph, ord), ord.perm, opts) + sc = SDS.build_schedule(sp, opts, Float64; + reserve = SDS.subtree_local_reserve(structure), + elsize = SDS.schedule_elsize(structure, Float64)) + layout = SDS.build_layout(sp, sc; ldlt = structure == "S") + amap_h = SDS.assembly_map(sp, layout, rowptr, colval, n, structure; view, index = 'O') + ptr_h, src_h = SDS._group_amap(amap_h, layout, SDS.nsupernodes(sp)) + # the kernels run on every backend here (the solver gate excludes the + # CPU backend only as a performance choice; correctness is universal) + amap_d = SDS.device_assembly_map(backend, sp, layout, rowptr, colval, n, structure; + view, index = 'O') + ptr_d, src_d = SDS.device_group_amap(backend, amap_h, layout, SDS.nsupernodes(sp)) + cp_d, rv_d = SDS.device_symmetric_pattern(backend, rowptr, colval, n, view, 'O') + @test length(amap_h) > 0 # the comparisons are not vacuous + @test amap_d == amap_h + @test ptr_d == ptr_h + @test src_d == src_h + @test cp_d == Ph.colptr + @test rv_d == Ph.rowval + end + # end-to-end: an analyzed solver's stored maps equal the host originals + # recomputed from its own state (on GPU backends the stored maps came + # through the device route; on the CPU backend this is a host identity) + for T in eltypes_among((Float64,)) + A = random_spd(T, 200, 0.03) + solver = DirectSolver(api_matrix(backend, tril(A), Int32), spd_structure(T), 'L') + execute!("analysis", solver, nothing, nothing) + Sh = solver.host_symbolic + amap_ref = SDS.assembly_map(Sh.partition, Sh.layout, solver.host_rowptr, solver.analysis_colval, + 200, spd_structure(T); view = 'L', index = solver.A.index) + @test length(amap_ref) > 0 + @test Sh.amap == amap_ref + ptr_ref, src_ref = SDS._group_amap(amap_ref, Sh.layout, SDS.nsupernodes(Sh.partition)) + @test Sh.amap_ptr == ptr_ref + @test Sh.amap_src == src_ref + # the factorization still works through the device-built maps + execute!("factorization", solver, nothing, nothing) + b = rand(T, 200) + bd = to_device(backend, b) + xd = similar(bd) + execute!("solve", solver, xd, bd; asynchronous = false) + @test relres(A, to_host(xd), b) <= tol(T) + end +end From f4df2e43221727fcb7373c54b01d2e97228d6126 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Fri, 9 Oct 2026 15:08:02 -0400 Subject: [PATCH 02/65] docs: plain backticks for the undocumented _group_amap cross-reference _group_amap has no docstring, so the internals @autodocs page has no target for it; Documenter cannot resolve a [`_group_amap`](@ref). Per AGENTS.md, @ref is for documented names only, plain backticks otherwise. Co-Authored-By: Claude Opus 4.8 --- src/symbolic/device.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/symbolic/device.jl b/src/symbolic/device.jl index 5f2d8293..5aa77a63 100644 --- a/src/symbolic/device.jl +++ b/src/symbolic/device.jl @@ -208,7 +208,7 @@ end """ device_group_amap(backend, amap, layout, ns) -> (amap_ptr, order) -[`_group_amap`](@ref) (unsigned) on `backend`: identical order and per-owner +`_group_amap` (unsigned) on `backend`: identical order and per-owner counts. The signed (`"G"`) grouping stays on the host. """ function device_group_amap(backend, amap::Vector{Int}, layout::Layout, ns::Int) From 77f935f8e5b4d7ef0f95ffff0528dcc73c4595fa Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Fri, 9 Oct 2026 20:43:27 -0400 Subject: [PATCH 03/65] Numeric: division-free right-looking Cholesky in the regime-A and regime-B front kernels The fused subtree kernel and the regime-B panel helper performed the rank-1 trailing update with the UNSCALED column, dividing both factors by the pivot inside the inner loop: two f64 divisions per updated element, which on the 78k-bus ACOPF condensed KKT (pglib_opf_case78484_epigrids, n = 674562) is ~1.2e9 divisions per refactorization, each an order of magnitude costlier than the fused multiply-add. Restructured both column steps as scale-first: the first half checks the pivot and scales the column below the diagonal by one reciprocal (recomputed per work item from local memory, so no extra barrier and no shared pivot cell), the second half does the trailing rank-1 update with multiply-adds only and writes the diagonal (the diagonal is untouched in the first half so no work item reads a value another one is overwriting). Same barrier count, same pivot sequence, same failure semantics (info = first non-positive column, NaN included). Measured on a GV100 (fused refactorization of the 78k KKT, correctness-gated against the reference solve): regime-A subtree phase 10.3 -> 6.2 ms, fused panel/mega phase 16.4 -> 11.6 ms, end-to-end refactorization 29.3 -> 25.7 ms (cuDSS with its own reordering: 24.3 ms). Tests: full CPU suite 9813 pass (all four element types); CUDA suite pending CI. --- src/numeric/front.jl | 29 +++++++++++++---------------- src/numeric/subtree.jl | 42 +++++++++++++++++++++--------------------- 2 files changed, 34 insertions(+), 37 deletions(-) diff --git a/src/numeric/front.jl b/src/numeric/front.jl index 9652732a..eab5a6f1 100644 --- a/src/numeric/front.jl +++ b/src/numeric/front.jl @@ -42,17 +42,9 @@ end if j <= w && st[1] == 0 ajj = real(L11[_packed(j, j, W)]) if ajj > 0 - d = sqrt(ajj) - li == 1 && (piv[1] = d) - r = w - j - for q in (li - 1):WG:(r * r - 1) - k = j + 1 + q ÷ r - i = j + 1 + q % r - if i >= k - lij = L11[_packed(i, j, W)] / d - lkj = L11[_packed(k, j, W)] / d - L11[_packed(i, k, W)] -= lij * conj(lkj) - end + rd = inv(sqrt(ajj)) # recomputed per work item: no barrier needed; + for i in (j + li):WG:w # strictly below the diagonal, since the diagonal + L11[_packed(i, j, W)] *= rd # is replaced in the second half end elseif li == 1 st[1] = Int32(j) # not positive (or NaN): stop, as LAPACK potrf @@ -62,16 +54,21 @@ end return nothing end -# column j, second half: scale the column by the pivot stored by the first half +# column j, second half: rank-1 update of the trailing block with the scaled column +# (multiply-add only; the divisions live in the first half as one reciprocal) @inline function _front_chol_scale!(L11, st, piv, j, s, li, front_ncols, ::Val{W}, ::Val{WG}) where {W, WG} @inbounds begin w = front_ncols[s] if j <= w && st[1] == 0 - d = piv[1] - for i in (j + li):WG:w - L11[_packed(i, j, W)] /= d + li == 1 && (L11[_packed(j, j, W)] = sqrt(real(L11[_packed(j, j, W)]))) + r = w - j + for q in (li - 1):WG:(r * r - 1) + k = j + 1 + q ÷ r + i = j + 1 + q % r + if i >= k + L11[_packed(i, k, W)] -= L11[_packed(i, j, W)] * conj(L11[_packed(k, j, W)]) + end end - li == 1 && (L11[_packed(j, j, W)] = d) end end return nothing diff --git a/src/numeric/subtree.jl b/src/numeric/subtree.jl index 27031dc1..87c9fb3d 100644 --- a/src/numeric/subtree.jl +++ b/src/numeric/subtree.jl @@ -138,7 +138,9 @@ end return nothing end -# column j, first half: check the pivot, rank-1 update of the trailing front with the scaled column +# column j, first half: check the pivot and scale the column below the diagonal by it +# (every work item recomputes `d = sqrt(ajj)` from local memory: cheaper than sharing +# it through a barrier, and it keeps the second half free of divisions) @inline function _subtree_chol_update!(buf, ctl, piv, j, li, ::Val{WG}) where {WG} @inbounds begin if ctl[_ST_STATUS] == 0 @@ -146,18 +148,10 @@ end lf = ctl[_ST_LF] - 1 ajj = real(buf[lf + _packed(j, j, f)]) if ajj > 0 - d = sqrt(ajj) - li == 1 && (piv[1] = d) - r = f - j - for q in (li - 1):WG:(r * r - 1) - k = j + 1 + q ÷ r - i = j + 1 + q % r - if i >= k - lij = buf[lf + _packed(i, j, f)] / d - lkj = buf[lf + _packed(k, j, f)] / d - buf[lf + _packed(i, k, f)] -= lij * conj(lkj) - end - end + rd = inv(sqrt(ajj)) # strictly below the diagonal only: + for i in (j + li):WG:f # the diagonal is replaced in the second + buf[lf + _packed(i, j, f)] *= rd # half, so no work item reads a value + end # another one is overwriting elseif li == 1 ctl[_ST_STATUS] = j % eltype(ctl) # not positive (or NaN): stop, as LAPACK potrf end @@ -166,17 +160,23 @@ end return nothing end -# column j, second half: scale the column below the diagonal by the pivot +# column j, second half: rank-1 update of the trailing front with the scaled column +# (multiply-add only: the former divide-in-the-update doubled the flop cost in +# f64 divisions, the bulk of the fused regime-A kernel's time) @inline function _subtree_chol_scale!(buf, ctl, piv, j, li, ::Val{WG}) where {WG} @inbounds begin if ctl[_ST_STATUS] == 0 f = ctl[_ST_F] lf = ctl[_ST_LF] - 1 - d = piv[1] - for i in (j + li):WG:f - buf[lf + _packed(i, j, f)] /= d + li == 1 && (buf[lf + _packed(j, j, f)] = sqrt(real(buf[lf + _packed(j, j, f)]))) + r = f - j + for q in (li - 1):WG:(r * r - 1) + k = j + 1 + q ÷ r + i = j + 1 + q % r + if i >= k + buf[lf + _packed(i, k, f)] -= buf[lf + _packed(i, j, f)] * conj(buf[lf + _packed(k, j, f)]) + end end - li == 1 && (buf[lf + _packed(j, j, f)] = d) end end return nothing @@ -305,10 +305,10 @@ function _launch_subtrees!(N::Numeric{T}, S::Symbolic, nzval, first, count, ::Va end # the local-memory size class as a compile-time constant (explicit branches: no dynamic dispatch, no allocation; -# every branch is compiled with the first launch, hence the short list of sizes; branch 6 is the error) +# every branch is compiled with the first launch, hence the short list of sizes; branch 5 is the error) @inline function _with_local_bytes(fn, nbytes::Int) - length(SUBTREE_LOCAL_SIZES) == 5 || error("update _with_local_bytes") - Base.Cartesian.@nif 6 d -> (nbytes == SUBTREE_LOCAL_SIZES[d]) d -> fn(Val(SUBTREE_LOCAL_SIZES[d])) d -> throw( + length(SUBTREE_LOCAL_SIZES) == 4 || error("update _with_local_bytes") + Base.Cartesian.@nif 5 d -> (nbytes == SUBTREE_LOCAL_SIZES[d]) d -> fn(Val(SUBTREE_LOCAL_SIZES[d])) d -> throw( InvalidValueError("no regime-A kernel with $nbytes bytes of local memory; sizes: $SUBTREE_LOCAL_SIZES")) end From 81a725b4ff8f6ac4b0fd9be6188d1eb2162fabda Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Fri, 9 Oct 2026 21:29:46 -0400 Subject: [PATCH 04/65] Numeric: enumerate the packed triangle in the trailing updates The rank-1 trailing-update loops of the regime-A and regime-B column steps iterated the full r x r square and discarded the upper half with a guard, wasting half the work items (divergent lanes idle through the local-memory traffic of their warp). _tri_decode inverts the packed lower-triangle enumeration (one float sqrt plus two exact fixups, verified exhaustively for r = 1..700), so the loops now launch r(r+1)/2 work items with no guard. GV100, 78k ACOPF condensed KKT: fused refactorization 25.3 -> 24.4 ms (regime-A subtree phase 6.2 -> 5.1 ms); cuDSS with its own reordering on the same device: 24.3 ms. CPU suite green (cholesky + refactorization selections); CUDA suite pending. --- src/numeric/front.jl | 11 +++++------ src/numeric/subtree.jl | 11 +++++------ src/symbolic/schedule.jl | 15 +++++++++++++++ 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/numeric/front.jl b/src/numeric/front.jl index eab5a6f1..1e0008c5 100644 --- a/src/numeric/front.jl +++ b/src/numeric/front.jl @@ -62,12 +62,11 @@ end if j <= w && st[1] == 0 li == 1 && (L11[_packed(j, j, W)] = sqrt(real(L11[_packed(j, j, W)]))) r = w - j - for q in (li - 1):WG:(r * r - 1) - k = j + 1 + q ÷ r - i = j + 1 + q % r - if i >= k - L11[_packed(i, k, W)] -= L11[_packed(i, j, W)] * conj(L11[_packed(k, j, W)]) - end + for q in (li - 1):WG:(r * (r + 1) ÷ 2 - 1) + a, b = _tri_decode(q, r) + i = j + a + k = j + b + L11[_packed(i, k, W)] -= L11[_packed(i, j, W)] * conj(L11[_packed(k, j, W)]) end end end diff --git a/src/numeric/subtree.jl b/src/numeric/subtree.jl index 87c9fb3d..df410085 100644 --- a/src/numeric/subtree.jl +++ b/src/numeric/subtree.jl @@ -170,12 +170,11 @@ end lf = ctl[_ST_LF] - 1 li == 1 && (buf[lf + _packed(j, j, f)] = sqrt(real(buf[lf + _packed(j, j, f)]))) r = f - j - for q in (li - 1):WG:(r * r - 1) - k = j + 1 + q ÷ r - i = j + 1 + q % r - if i >= k - buf[lf + _packed(i, k, f)] -= buf[lf + _packed(i, j, f)] * conj(buf[lf + _packed(k, j, f)]) - end + for q in (li - 1):WG:(r * (r + 1) ÷ 2 - 1) + a, b = _tri_decode(q, r) + i = j + a + k = j + b + buf[lf + _packed(i, k, f)] -= buf[lf + _packed(i, j, f)] * conj(buf[lf + _packed(k, j, f)]) end end end diff --git a/src/symbolic/schedule.jl b/src/symbolic/schedule.jl index 1a30d712..2f3a9fed 100644 --- a/src/symbolic/schedule.jl +++ b/src/symbolic/schedule.jl @@ -113,6 +113,21 @@ packed_length(m::Integer) = Int(m) * (Int(m) + 1) ÷ 2 # column j starts after (j - 1)(2m - j + 2)/2 entries @inline _packed(i, j, m) = (j - 1) * (2 * m - j + 2) ÷ 2 + i - j + 1 +# inverse of the packed lower-triangle enumeration: 0-based q in an r x r lower +# triangle (col-major, n = r(r+1)/2 entries) -> 1-based (a, b), a >= b. Enumerating +# only the triangle halves the work-item count of the guarded full-square loops. +# The float sqrt can land one off at the boundary; the two fixups make it exact. +@inline function _tri_decode(q, r) + n = r * (r + 1) ÷ 2 + p = n - 1 - q + t = unsafe_trunc(Int, (sqrt(8.0 * p + 1.0) - 1.0) * 0.5) + t * (t + 1) ÷ 2 > p && (t -= 1) + (t + 1) * (t + 2) ÷ 2 <= p && (t += 1) + b = r - t + a = r - (p - t * (t + 1) ÷ 2) + return a, b +end + """ takes_c_path(schedule, s) -> Bool From f91facdd93cc85b66d3ff2c23495bc9c7ab57693 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Fri, 9 Oct 2026 21:54:28 -0400 Subject: [PATCH 05/65] bench/e2e: fresh-machine script for the MadNLP + SDS workflow One command on a clean NVIDIA box: installs Julia 1.13 via juliaup, clones the branch, runs the CPU and CUDA test suites, then the end-to-end MadNLP ACOPF solve (78k-bus pglib case) with cuDSS and with the SDS prototype wrapper on the same device. SDS_BRANCH/SDS_DIR/SKIP_TESTS/CPU_ONLY_TESTS knobs documented in the header. --- bench/e2e/fresh_machine.sh | 63 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100755 bench/e2e/fresh_machine.sh diff --git a/bench/e2e/fresh_machine.sh b/bench/e2e/fresh_machine.sh new file mode 100755 index 00000000..e4230240 --- /dev/null +++ b/bench/e2e/fresh_machine.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Fresh-machine test of the MadNLP + SparseDirectSolver workflow on an NVIDIA GPU box. +# Installs Julia 1.13 (juliaup), clones the repo, runs the test suites (CPU + CUDA), +# then the end-to-end MadNLP ACOPF solve (78k-bus pglib case, SparseCondensedKKTSystem) +# with cuDSS and with SDS as the linear solver, on the same device. +# +# bash fresh_machine.sh +# +# Knobs (env vars): +# SDS_BRANCH=divfree-chol git ref to test (default: the division-free kernels PR) +# SDS_DIR=$HOME/sds-test checkout location +# SKIP_TESTS=1 skip the test suites, run only the MadNLP workflow +# CPU_ONLY_TESTS=1 run only the CPU suite (no functional GPU needed for it) +# +# Needs: git, curl, an NVIDIA GPU + driver for the CUDA suite and the workflow +# (CUDA toolkit and cuDSS are downloaded by CUDA.jl/CUDSS.jl as artifacts), +# network access (pglib case data is fetched by ExaModelsPower on first use). +set -euo pipefail + +SDS_BRANCH="${SDS_BRANCH:-divfree-chol}" +SDS_DIR="${SDS_DIR:-$HOME/sds-test}" + +# --- 1. Julia 1.13 via juliaup ----------------------------------------------- +if ! command -v juliaup >/dev/null 2>&1 && [ ! -x "$HOME/.juliaup/bin/juliaup" ]; then + curl -fsSL https://install.julialang.org | sh -s -- --yes +fi +export PATH="$HOME/.juliaup/bin:$PATH" +juliaup add 1.13 2>/dev/null || true +JL="julia +1.13" +$JL --version + +# --- 2. Repository ------------------------------------------------------------ +if [ ! -d "$SDS_DIR/.git" ]; then + git clone https://github.com/exanauts/SparseDirectSolver.jl.git "$SDS_DIR" +fi +cd "$SDS_DIR" +git fetch origin "$SDS_BRANCH" +git checkout "$SDS_BRANCH" +git pull --ff-only origin "$SDS_BRANCH" || true +echo "== testing $(git log --oneline -1)" + +# --- 3. Test suites ----------------------------------------------------------- +if [ "${SKIP_TESTS:-0}" != "1" ]; then + $JL --project=. -e 'using Pkg; Pkg.instantiate()' + echo "== CPU suite" + SDS_TEST_GPU=0 $JL --project=. -e 'using Pkg; Pkg.test()' + if [ "${CPU_ONLY_TESTS:-0}" != "1" ]; then + echo "== CUDA suite" + $JL --project=test -e 'using Pkg; Pkg.add("CUDA")' # as .github/workflows/ci.yml does + SDS_TEST_CPU=0 SDS_TEST_SKIP=test_aqua $JL --project=. -e 'using Pkg; Pkg.test()' + fi +fi + +# --- 4. MadNLP workflow: 78k-bus ACOPF, cuDSS vs SDS on the same GPU ----------- +# bench/e2e: MadNLPSDS.jl defines the MadNLP.AbstractLinearSolver wrappers +# (SDSSolver = stock library; SDSProtoSolver = PR #107 prototype kernels); +# run_gv100.jl solves the case with MadNLPGPU.CUDSSSolver and with SDSProtoSolver +# and prints wall/solver/linear-solver times, iteration counts and objectives +# (convergence parity is the workflow test: same iterations, same objective). +echo "== MadNLP + SDS workflow (downloads the pglib case on first use)" +$JL --project=bench/e2e -e "using Pkg; Pkg.develop(path = \"$SDS_DIR\"); Pkg.instantiate()" +CUDA_VISIBLE_DEVICES="${CUDA_VISIBLE_DEVICES:-0}" $JL --project=bench/e2e bench/e2e/run_gv100.jl +echo "== fresh-machine run complete" From 8c1dd1f02aeda5f9cf6360479910324adbf83ccf Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Fri, 9 Oct 2026 21:58:35 -0400 Subject: [PATCH 06/65] bench/e2e: fresh-machine script covers AMD (vendor autodetection, AMDGPU suite, run_amd.jl) --- bench/e2e/fresh_machine.sh | 51 +++++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/bench/e2e/fresh_machine.sh b/bench/e2e/fresh_machine.sh index e4230240..b9ece22c 100755 --- a/bench/e2e/fresh_machine.sh +++ b/bench/e2e/fresh_machine.sh @@ -1,8 +1,10 @@ #!/usr/bin/env bash -# Fresh-machine test of the MadNLP + SparseDirectSolver workflow on an NVIDIA GPU box. -# Installs Julia 1.13 (juliaup), clones the repo, runs the test suites (CPU + CUDA), -# then the end-to-end MadNLP ACOPF solve (78k-bus pglib case, SparseCondensedKKTSystem) -# with cuDSS and with SDS as the linear solver, on the same device. +# Fresh-machine test of the MadNLP + SparseDirectSolver workflow on a GPU box. +# Installs Julia 1.13 (juliaup), clones the repo, runs the test suites (CPU + GPU), +# then the end-to-end MadNLP ACOPF solve (78k-bus pglib case, SparseCondensedKKTSystem). +# NVIDIA: cuDSS vs SDS on the same device. AMD: SDS stock vs SDS prototype kernels +# (no cuDSS exists there). The vendor is auto-detected (nvidia-smi / rocm-smi), or +# force it with SDS_BACKEND=cuda|amdgpu. # # bash fresh_machine.sh # @@ -11,15 +13,31 @@ # SDS_DIR=$HOME/sds-test checkout location # SKIP_TESTS=1 skip the test suites, run only the MadNLP workflow # CPU_ONLY_TESTS=1 run only the CPU suite (no functional GPU needed for it) +# SDS_BACKEND=cuda|amdgpu override the GPU vendor autodetection # -# Needs: git, curl, an NVIDIA GPU + driver for the CUDA suite and the workflow -# (CUDA toolkit and cuDSS are downloaded by CUDA.jl/CUDSS.jl as artifacts), -# network access (pglib case data is fetched by ExaModelsPower on first use). +# Needs: git, curl, network access (pglib case data is fetched by ExaModelsPower on +# first use), and a GPU + driver. NVIDIA: the CUDA toolkit and cuDSS are downloaded +# by CUDA.jl/CUDSS.jl as artifacts, nothing to install. AMD: a system ROCm +# installation is required (AMDGPU.jl uses the system ROCm, it is not an artifact). set -euo pipefail SDS_BRANCH="${SDS_BRANCH:-divfree-chol}" SDS_DIR="${SDS_DIR:-$HOME/sds-test}" +if [ -z "${SDS_BACKEND:-}" ]; then + if command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1; then + SDS_BACKEND=cuda + elif command -v rocm-smi >/dev/null 2>&1; then + SDS_BACKEND=amdgpu + else + echo "no GPU detected (nvidia-smi / rocm-smi); set SDS_BACKEND=cuda|amdgpu" >&2 + SDS_BACKEND=cuda + fi +fi +echo "== backend: $SDS_BACKEND" +GPU_PKG=CUDA; RUNNER=run_gv100.jl +[ "$SDS_BACKEND" = "amdgpu" ] && GPU_PKG=AMDGPU && RUNNER=run_amd.jl + # --- 1. Julia 1.13 via juliaup ----------------------------------------------- if ! command -v juliaup >/dev/null 2>&1 && [ ! -x "$HOME/.juliaup/bin/juliaup" ]; then curl -fsSL https://install.julialang.org | sh -s -- --yes @@ -45,19 +63,24 @@ if [ "${SKIP_TESTS:-0}" != "1" ]; then echo "== CPU suite" SDS_TEST_GPU=0 $JL --project=. -e 'using Pkg; Pkg.test()' if [ "${CPU_ONLY_TESTS:-0}" != "1" ]; then - echo "== CUDA suite" - $JL --project=test -e 'using Pkg; Pkg.add("CUDA")' # as .github/workflows/ci.yml does + echo "== $GPU_PKG suite" + $JL --project=test -e "using Pkg; Pkg.add(\"$GPU_PKG\")" # as .github/workflows/ci.yml does SDS_TEST_CPU=0 SDS_TEST_SKIP=test_aqua $JL --project=. -e 'using Pkg; Pkg.test()' fi fi # --- 4. MadNLP workflow: 78k-bus ACOPF, cuDSS vs SDS on the same GPU ----------- # bench/e2e: MadNLPSDS.jl defines the MadNLP.AbstractLinearSolver wrappers -# (SDSSolver = stock library; SDSProtoSolver = PR #107 prototype kernels); -# run_gv100.jl solves the case with MadNLPGPU.CUDSSSolver and with SDSProtoSolver -# and prints wall/solver/linear-solver times, iteration counts and objectives +# (SDSSolver = stock library; SDSProtoSolver = PR #107 prototype kernels). +# NVIDIA (run_gv100.jl): MadNLPGPU.CUDSSSolver vs SDSProtoSolver. +# AMD (run_amd.jl): SDSSolver vs SDSProtoSolver. +# Each prints wall/solver/linear-solver times, iteration counts and objectives # (convergence parity is the workflow test: same iterations, same objective). -echo "== MadNLP + SDS workflow (downloads the pglib case on first use)" +echo "== MadNLP + SDS workflow via $RUNNER (downloads the pglib case on first use)" $JL --project=bench/e2e -e "using Pkg; Pkg.develop(path = \"$SDS_DIR\"); Pkg.instantiate()" -CUDA_VISIBLE_DEVICES="${CUDA_VISIBLE_DEVICES:-0}" $JL --project=bench/e2e bench/e2e/run_gv100.jl +if [ "$SDS_BACKEND" = "cuda" ]; then + CUDA_VISIBLE_DEVICES="${CUDA_VISIBLE_DEVICES:-0}" $JL --project=bench/e2e "bench/e2e/$RUNNER" +else + $JL --project=bench/e2e "bench/e2e/$RUNNER" +fi echo "== fresh-machine run complete" From 1820b75dbbb419062d7fa730fe6f32d82c760904 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Fri, 9 Oct 2026 21:59:42 -0400 Subject: [PATCH 07/65] bench/e2e: fresh-machine script is explicitly detachable (stdin closed, nohup/sbatch usage in the header) --- bench/e2e/fresh_machine.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/bench/e2e/fresh_machine.sh b/bench/e2e/fresh_machine.sh index b9ece22c..d9d14904 100755 --- a/bench/e2e/fresh_machine.sh +++ b/bench/e2e/fresh_machine.sh @@ -6,7 +6,13 @@ # (no cuDSS exists there). The vendor is auto-detected (nvidia-smi / rocm-smi), or # force it with SDS_BACKEND=cuda|amdgpu. # -# bash fresh_machine.sh +# Everything is non-interactive (nothing reads the terminal), so the intended use +# is detached from the login session (the suites + workflow run for an hour or more): +# +# curl -fsSL https://raw.githubusercontent.com/exanauts/SparseDirectSolver.jl/divfree-chol/bench/e2e/fresh_machine.sh -o fresh_machine.sh +# nohup bash fresh_machine.sh > sds-fresh.log 2>&1 & # then log out; tail -f sds-fresh.log +# +# or under Slurm: sbatch -p --gres=gpu:1 -c 8 --mem=64G -t 120 --wrap "bash fresh_machine.sh" # # Knobs (env vars): # SDS_BRANCH=divfree-chol git ref to test (default: the division-free kernels PR) @@ -20,6 +26,7 @@ # by CUDA.jl/CUDSS.jl as artifacts, nothing to install. AMD: a system ROCm # installation is required (AMDGPU.jl uses the system ROCm, it is not an artifact). set -euo pipefail +exec < /dev/null # never read the terminal: safe to detach SDS_BRANCH="${SDS_BRANCH:-divfree-chol}" SDS_DIR="${SDS_DIR:-$HOME/sds-test}" From bf9ba897b2e6113895ccbc4121636203e67f98f4 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Fri, 9 Oct 2026 22:02:46 -0400 Subject: [PATCH 08/65] bench/e2e: one-file Julia fresh-machine script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A readable alternative to fresh_machine.sh for the workflow itself: a single julia file that bootstraps its own environment (registered packages plus this repository at the tested rev), then reads as the workflow does — using SparseDirectSolver, MadNLP, MadNLPGPU, ExaModels, ExaModelsPower; build the 78k-bus ACOPF; solve with cuDSS and with SDS on the same device. The shell script remains for the full test-suite pass. --- bench/e2e/fresh_machine.jl | 46 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 bench/e2e/fresh_machine.jl diff --git a/bench/e2e/fresh_machine.jl b/bench/e2e/fresh_machine.jl new file mode 100644 index 00000000..2ee031c9 --- /dev/null +++ b/bench/e2e/fresh_machine.jl @@ -0,0 +1,46 @@ +# MadNLP + SparseDirectSolver workflow on a fresh NVIDIA machine, one file: +# +# julia +1.13 fresh_machine.jl +# +# Installs everything into its own environment (~/.sds-fresh), downloads the +# pglib case on first use, then solves the 78k-bus ACOPF with cuDSS and with +# SparseDirectSolver as MadNLP's linear solver on the same GPU. The workflow +# passes when both solvers converge in the same iterations to the same objective. +# +# AMD machines: replace CUDA/CUDSS with AMDGPU below, CUDABackend with +# ROCBackend, and compare SDSSolver against SDSProtoSolver (there is no cuDSS); +# a system ROCm installation is required. + +import Pkg +Pkg.activate(joinpath(homedir(), ".sds-fresh")) +Pkg.add(["MadNLP", "MadNLPGPU", "ExaModels", "ExaModelsPower", "CUDA", "CUDSS", "Metis", "Printf"]) +Pkg.add(url = "https://github.com/exanauts/SparseDirectSolver.jl", rev = "divfree-chol") +Pkg.instantiate() + +using SparseDirectSolver, MadNLP, MadNLPGPU, ExaModels, ExaModelsPower +using CUDA, CUDSS, Metis, Printf + +# the MadNLP linear-solver wrappers (SDSSolver = stock, SDSProtoSolver = PR #107 +# prototype kernels), shipped with the package under bench/e2e +include(joinpath(pkgdir(SparseDirectSolver), "bench", "e2e", "MadNLPSDS.jl")) +using .MadNLPSDS + +function solve(label, model, linear_solver) + kwargs = (; kkt_system = MadNLP.SparseCondensedKKTSystem, + equality_treatment = MadNLP.RelaxEquality, + fixed_variable_treatment = MadNLP.RelaxBound, + linear_solver, tol = 1e-6, print_level = MadNLP.ERROR, max_iter = 200) + madnlp(model; kwargs..., max_iter = 2) # warm-up (JIT) + GC.gc(); CUDA.reclaim() + sol = madnlp(model; kwargs...) + c = sol.counters + @printf "%-10s %-20s iters %3d obj %.8e wall %6.2f s linsolve %6.2f s\n" label string(sol.status) c.k sol.objective c.total_time c.linear_solver_time + return sol +end + +println("device: ", CUDA.name(CUDA.device())) +model, _ = ac_opf_model("pglib_opf_case78484_epigrids.m"; backend = CUDABackend()) + +solve("cuDSS", model, MadNLPGPU.CUDSSSolver) +solve("SDS", model, SDSProtoSolver) +println("done") From 2586ff0add4945e2f4690738f1b4621d851dfb5a Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Fri, 9 Oct 2026 22:07:48 -0400 Subject: [PATCH 09/65] bench/e2e: fresh_machine.jl covers AMD (vendor autodetection, backend-split environment) --- bench/e2e/fresh_machine.jl | 43 ++++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/bench/e2e/fresh_machine.jl b/bench/e2e/fresh_machine.jl index 2ee031c9..5ddd50bc 100644 --- a/bench/e2e/fresh_machine.jl +++ b/bench/e2e/fresh_machine.jl @@ -1,24 +1,29 @@ -# MadNLP + SparseDirectSolver workflow on a fresh NVIDIA machine, one file: +# MadNLP + SparseDirectSolver workflow on a fresh GPU machine, one file: # -# julia +1.13 fresh_machine.jl +# julia +1.13 fresh_machine.jl (or: julia +1.13 -i fresh_machine.jl) # # Installs everything into its own environment (~/.sds-fresh), downloads the -# pglib case on first use, then solves the 78k-bus ACOPF with cuDSS and with -# SparseDirectSolver as MadNLP's linear solver on the same GPU. The workflow +# pglib case on first use, then solves the 78k-bus ACOPF with MadNLP on the GPU. +# NVIDIA: cuDSS vs SparseDirectSolver on the same device. AMD: SDS stock vs the +# prototype kernels (there is no cuDSS; a system ROCm installation is required). +# The vendor is autodetected; force it with SDS_BACKEND=cuda|amdgpu. The workflow # passes when both solvers converge in the same iterations to the same objective. -# -# AMD machines: replace CUDA/CUDSS with AMDGPU below, CUDABackend with -# ROCBackend, and compare SDSSolver against SDSProtoSolver (there is no cuDSS); -# a system ROCm installation is required. import Pkg -Pkg.activate(joinpath(homedir(), ".sds-fresh")) -Pkg.add(["MadNLP", "MadNLPGPU", "ExaModels", "ExaModelsPower", "CUDA", "CUDSS", "Metis", "Printf"]) +const BACKEND = get(ENV, "SDS_BACKEND", Sys.which("nvidia-smi") === nothing ? "amdgpu" : "cuda") +Pkg.activate(joinpath(homedir(), ".sds-fresh-" * BACKEND)) +Pkg.add(["MadNLP", "MadNLPGPU", "ExaModels", "ExaModelsPower", "Metis", "Printf"]) +Pkg.add(BACKEND == "cuda" ? ["CUDA", "CUDSS"] : ["AMDGPU"]) Pkg.add(url = "https://github.com/exanauts/SparseDirectSolver.jl", rev = "divfree-chol") Pkg.instantiate() using SparseDirectSolver, MadNLP, MadNLPGPU, ExaModels, ExaModelsPower -using CUDA, CUDSS, Metis, Printf +using Metis, Printf +if BACKEND == "cuda" + using CUDA, CUDSS +else + using AMDGPU +end # the MadNLP linear-solver wrappers (SDSSolver = stock, SDSProtoSolver = PR #107 # prototype kernels), shipped with the package under bench/e2e @@ -31,16 +36,22 @@ function solve(label, model, linear_solver) fixed_variable_treatment = MadNLP.RelaxBound, linear_solver, tol = 1e-6, print_level = MadNLP.ERROR, max_iter = 200) madnlp(model; kwargs..., max_iter = 2) # warm-up (JIT) - GC.gc(); CUDA.reclaim() + GC.gc() + BACKEND == "cuda" && CUDA.reclaim() sol = madnlp(model; kwargs...) c = sol.counters @printf "%-10s %-20s iters %3d obj %.8e wall %6.2f s linsolve %6.2f s\n" label string(sol.status) c.k sol.objective c.total_time c.linear_solver_time return sol end -println("device: ", CUDA.name(CUDA.device())) -model, _ = ac_opf_model("pglib_opf_case78484_epigrids.m"; backend = CUDABackend()) +println("device: ", BACKEND == "cuda" ? CUDA.name(CUDA.device()) : string(AMDGPU.device())) +model, _ = ac_opf_model("pglib_opf_case78484_epigrids.m"; + backend = BACKEND == "cuda" ? CUDABackend() : ROCBackend()) -solve("cuDSS", model, MadNLPGPU.CUDSSSolver) -solve("SDS", model, SDSProtoSolver) +solvers = BACKEND == "cuda" ? + ["cuDSS" => MadNLPGPU.CUDSSSolver, "SDS" => SDSProtoSolver] : + ["SDS stock" => SDSSolver, "SDS proto" => SDSProtoSolver] +for (label, ls) in solvers + solve(label, model, ls) +end println("done") From 23223d8cd09a833ab0be670355e3811a4e0ac438 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Fri, 9 Oct 2026 22:08:06 -0400 Subject: [PATCH 10/65] bench/e2e: fresh_machine.jl develops SDS from an editable checkout (~/.julia/dev) instead of a frozen add --- bench/e2e/fresh_machine.jl | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bench/e2e/fresh_machine.jl b/bench/e2e/fresh_machine.jl index 5ddd50bc..e3a603d3 100644 --- a/bench/e2e/fresh_machine.jl +++ b/bench/e2e/fresh_machine.jl @@ -14,7 +14,11 @@ const BACKEND = get(ENV, "SDS_BACKEND", Sys.which("nvidia-smi") === nothing ? "a Pkg.activate(joinpath(homedir(), ".sds-fresh-" * BACKEND)) Pkg.add(["MadNLP", "MadNLPGPU", "ExaModels", "ExaModelsPower", "Metis", "Printf"]) Pkg.add(BACKEND == "cuda" ? ["CUDA", "CUDSS"] : ["AMDGPU"]) -Pkg.add(url = "https://github.com/exanauts/SparseDirectSolver.jl", rev = "divfree-chol") +# SDS as a proper dev checkout (editable, canonical path), not a frozen Pkg.add +const SDS_DEV = joinpath(homedir(), ".julia", "dev", "SparseDirectSolver") +isdir(SDS_DEV) || + run(`git clone --branch divfree-chol https://github.com/exanauts/SparseDirectSolver.jl $SDS_DEV`) +Pkg.develop(path = SDS_DEV) Pkg.instantiate() using SparseDirectSolver, MadNLP, MadNLPGPU, ExaModels, ExaModelsPower From 3ef916fb53ba5dfb36765f0f4363ee650d9180f9 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Fri, 9 Oct 2026 22:15:09 -0400 Subject: [PATCH 11/65] bench/e2e: fresh_machine.jl runs only the SDSProtoSolver workflow --- bench/e2e/fresh_machine.jl | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/bench/e2e/fresh_machine.jl b/bench/e2e/fresh_machine.jl index e3a603d3..9728e5db 100644 --- a/bench/e2e/fresh_machine.jl +++ b/bench/e2e/fresh_machine.jl @@ -2,18 +2,17 @@ # # julia +1.13 fresh_machine.jl (or: julia +1.13 -i fresh_machine.jl) # -# Installs everything into its own environment (~/.sds-fresh), downloads the -# pglib case on first use, then solves the 78k-bus ACOPF with MadNLP on the GPU. -# NVIDIA: cuDSS vs SparseDirectSolver on the same device. AMD: SDS stock vs the -# prototype kernels (there is no cuDSS; a system ROCm installation is required). -# The vendor is autodetected; force it with SDS_BACKEND=cuda|amdgpu. The workflow -# passes when both solvers converge in the same iterations to the same objective. +# Installs everything into its own environment (~/.sds-fresh-), downloads +# the pglib case on first use, then solves the 78k-bus ACOPF with MadNLP on the +# GPU using SparseDirectSolver (the SDSProtoSolver wrapper) as the linear solver. +# The vendor is autodetected; force it with SDS_BACKEND=cuda|amdgpu. (AMD needs a +# system ROCm installation.) import Pkg const BACKEND = get(ENV, "SDS_BACKEND", Sys.which("nvidia-smi") === nothing ? "amdgpu" : "cuda") Pkg.activate(joinpath(homedir(), ".sds-fresh-" * BACKEND)) Pkg.add(["MadNLP", "MadNLPGPU", "ExaModels", "ExaModelsPower", "Metis", "Printf"]) -Pkg.add(BACKEND == "cuda" ? ["CUDA", "CUDSS"] : ["AMDGPU"]) +Pkg.add(BACKEND == "cuda" ? ["CUDA"] : ["AMDGPU"]) # SDS as a proper dev checkout (editable, canonical path), not a frozen Pkg.add const SDS_DEV = joinpath(homedir(), ".julia", "dev", "SparseDirectSolver") isdir(SDS_DEV) || @@ -24,7 +23,7 @@ Pkg.instantiate() using SparseDirectSolver, MadNLP, MadNLPGPU, ExaModels, ExaModelsPower using Metis, Printf if BACKEND == "cuda" - using CUDA, CUDSS + using CUDA else using AMDGPU end @@ -52,10 +51,5 @@ println("device: ", BACKEND == "cuda" ? CUDA.name(CUDA.device()) : string(AMDGPU model, _ = ac_opf_model("pglib_opf_case78484_epigrids.m"; backend = BACKEND == "cuda" ? CUDABackend() : ROCBackend()) -solvers = BACKEND == "cuda" ? - ["cuDSS" => MadNLPGPU.CUDSSSolver, "SDS" => SDSProtoSolver] : - ["SDS stock" => SDSSolver, "SDS proto" => SDSProtoSolver] -for (label, ls) in solvers - solve(label, model, ls) -end +solve("SDS", model, SDSProtoSolver) println("done") From 347c2da5a8f88ca2e407a521b30770bba1de13d5 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Fri, 9 Oct 2026 22:15:43 -0400 Subject: [PATCH 12/65] bench/e2e: fresh_machine.jl ends in a plain madnlp(m) call --- bench/e2e/fresh_machine.jl | 30 ++++++++++-------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/bench/e2e/fresh_machine.jl b/bench/e2e/fresh_machine.jl index 9728e5db..de5b15cc 100644 --- a/bench/e2e/fresh_machine.jl +++ b/bench/e2e/fresh_machine.jl @@ -11,7 +11,7 @@ import Pkg const BACKEND = get(ENV, "SDS_BACKEND", Sys.which("nvidia-smi") === nothing ? "amdgpu" : "cuda") Pkg.activate(joinpath(homedir(), ".sds-fresh-" * BACKEND)) -Pkg.add(["MadNLP", "MadNLPGPU", "ExaModels", "ExaModelsPower", "Metis", "Printf"]) +Pkg.add(["MadNLP", "MadNLPGPU", "ExaModels", "ExaModelsPower", "Metis"]) Pkg.add(BACKEND == "cuda" ? ["CUDA"] : ["AMDGPU"]) # SDS as a proper dev checkout (editable, canonical path), not a frozen Pkg.add const SDS_DEV = joinpath(homedir(), ".julia", "dev", "SparseDirectSolver") @@ -21,7 +21,7 @@ Pkg.develop(path = SDS_DEV) Pkg.instantiate() using SparseDirectSolver, MadNLP, MadNLPGPU, ExaModels, ExaModelsPower -using Metis, Printf +using Metis if BACKEND == "cuda" using CUDA else @@ -33,23 +33,13 @@ end include(joinpath(pkgdir(SparseDirectSolver), "bench", "e2e", "MadNLPSDS.jl")) using .MadNLPSDS -function solve(label, model, linear_solver) - kwargs = (; kkt_system = MadNLP.SparseCondensedKKTSystem, - equality_treatment = MadNLP.RelaxEquality, - fixed_variable_treatment = MadNLP.RelaxBound, - linear_solver, tol = 1e-6, print_level = MadNLP.ERROR, max_iter = 200) - madnlp(model; kwargs..., max_iter = 2) # warm-up (JIT) - GC.gc() - BACKEND == "cuda" && CUDA.reclaim() - sol = madnlp(model; kwargs...) - c = sol.counters - @printf "%-10s %-20s iters %3d obj %.8e wall %6.2f s linsolve %6.2f s\n" label string(sol.status) c.k sol.objective c.total_time c.linear_solver_time - return sol -end - println("device: ", BACKEND == "cuda" ? CUDA.name(CUDA.device()) : string(AMDGPU.device())) -model, _ = ac_opf_model("pglib_opf_case78484_epigrids.m"; - backend = BACKEND == "cuda" ? CUDABackend() : ROCBackend()) -solve("SDS", model, SDSProtoSolver) -println("done") +m, _ = ac_opf_model("pglib_opf_case78484_epigrids.m"; + backend = BACKEND == "cuda" ? CUDABackend() : ROCBackend()) + +sol = madnlp(m; linear_solver = SDSProtoSolver, + kkt_system = MadNLP.SparseCondensedKKTSystem, + equality_treatment = MadNLP.RelaxEquality, + fixed_variable_treatment = MadNLP.RelaxBound, + tol = 1e-6) From b0b19f2a1852755859106e56fc67c610e2d08647 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Fri, 9 Oct 2026 22:21:47 -0400 Subject: [PATCH 13/65] bench/e2e: drop the redundant madnlp options (MadNLP defaults them under SparseCondensedKKTSystem) --- bench/e2e/fresh_machine.jl | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/bench/e2e/fresh_machine.jl b/bench/e2e/fresh_machine.jl index de5b15cc..7863b6cf 100644 --- a/bench/e2e/fresh_machine.jl +++ b/bench/e2e/fresh_machine.jl @@ -38,8 +38,7 @@ println("device: ", BACKEND == "cuda" ? CUDA.name(CUDA.device()) : string(AMDGPU m, _ = ac_opf_model("pglib_opf_case78484_epigrids.m"; backend = BACKEND == "cuda" ? CUDABackend() : ROCBackend()) +# SparseCondensedKKTSystem is required (the SPD Cholesky path); MadNLP defaults +# equality/fixed-variable treatment correctly for it. tol: condensed default is 1e-4. sol = madnlp(m; linear_solver = SDSProtoSolver, - kkt_system = MadNLP.SparseCondensedKKTSystem, - equality_treatment = MadNLP.RelaxEquality, - fixed_variable_treatment = MadNLP.RelaxBound, - tol = 1e-6) + kkt_system = MadNLP.SparseCondensedKKTSystem, tol = 1e-6) From 1cdbe094a35c4e5c87f5fa2d264e1e9e12392e52 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Fri, 9 Oct 2026 22:34:58 -0400 Subject: [PATCH 14/65] fix: reapply the kernel optimizations on current main without reverting T23 The previous commits copied subtree.jl/front.jl wholesale from a pre-T23 checkout, silently reverting the per-backend local-memory machinery (the 5-entry SUBTREE_LOCAL_SIZES and the matching _with_local_bytes), which made every analysis fail with 'update _with_local_bytes' on a clean clone. Both files are rebuilt from origin/main with only the two optimization hunks applied (division-free scale-first column step; packed-triangle enumeration of the trailing update). --- src/numeric/front.jl | 2 +- src/numeric/subtree.jl | 24 +++++++++++++----------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/numeric/front.jl b/src/numeric/front.jl index 1e0008c5..672a7595 100644 --- a/src/numeric/front.jl +++ b/src/numeric/front.jl @@ -55,7 +55,7 @@ end end # column j, second half: rank-1 update of the trailing block with the scaled column -# (multiply-add only; the divisions live in the first half as one reciprocal) +# (multiply-add only; the packed-triangle enumeration replaces the guarded square) @inline function _front_chol_scale!(L11, st, piv, j, s, li, front_ncols, ::Val{W}, ::Val{WG}) where {W, WG} @inbounds begin w = front_ncols[s] diff --git a/src/numeric/subtree.jl b/src/numeric/subtree.jl index df410085..022425d0 100644 --- a/src/numeric/subtree.jl +++ b/src/numeric/subtree.jl @@ -139,8 +139,9 @@ end end # column j, first half: check the pivot and scale the column below the diagonal by it -# (every work item recomputes `d = sqrt(ajj)` from local memory: cheaper than sharing -# it through a barrier, and it keeps the second half free of divisions) +# (every work item recomputes `rd` from local memory: cheaper than sharing it through +# a barrier, and it keeps the second half free of divisions; the diagonal itself is +# written in the second half, so no work item reads a value another is overwriting) @inline function _subtree_chol_update!(buf, ctl, piv, j, li, ::Val{WG}) where {WG} @inbounds begin if ctl[_ST_STATUS] == 0 @@ -148,10 +149,10 @@ end lf = ctl[_ST_LF] - 1 ajj = real(buf[lf + _packed(j, j, f)]) if ajj > 0 - rd = inv(sqrt(ajj)) # strictly below the diagonal only: - for i in (j + li):WG:f # the diagonal is replaced in the second - buf[lf + _packed(i, j, f)] *= rd # half, so no work item reads a value - end # another one is overwriting + rd = inv(sqrt(ajj)) + for i in (j + li):WG:f + buf[lf + _packed(i, j, f)] *= rd + end elseif li == 1 ctl[_ST_STATUS] = j % eltype(ctl) # not positive (or NaN): stop, as LAPACK potrf end @@ -161,8 +162,9 @@ end end # column j, second half: rank-1 update of the trailing front with the scaled column -# (multiply-add only: the former divide-in-the-update doubled the flop cost in -# f64 divisions, the bulk of the fused regime-A kernel's time) +# (multiply-add only: the former divide-in-the-update doubled the flop cost in f64 +# divisions; the packed-triangle enumeration halves the work items of the old +# guarded full-square loop) @inline function _subtree_chol_scale!(buf, ctl, piv, j, li, ::Val{WG}) where {WG} @inbounds begin if ctl[_ST_STATUS] == 0 @@ -304,10 +306,10 @@ function _launch_subtrees!(N::Numeric{T}, S::Symbolic, nzval, first, count, ::Va end # the local-memory size class as a compile-time constant (explicit branches: no dynamic dispatch, no allocation; -# every branch is compiled with the first launch, hence the short list of sizes; branch 5 is the error) +# every branch is compiled with the first launch, hence the short list of sizes; branch 6 is the error) @inline function _with_local_bytes(fn, nbytes::Int) - length(SUBTREE_LOCAL_SIZES) == 4 || error("update _with_local_bytes") - Base.Cartesian.@nif 5 d -> (nbytes == SUBTREE_LOCAL_SIZES[d]) d -> fn(Val(SUBTREE_LOCAL_SIZES[d])) d -> throw( + length(SUBTREE_LOCAL_SIZES) == 5 || error("update _with_local_bytes") + Base.Cartesian.@nif 6 d -> (nbytes == SUBTREE_LOCAL_SIZES[d]) d -> fn(Val(SUBTREE_LOCAL_SIZES[d])) d -> throw( InvalidValueError("no regime-A kernel with $nbytes bytes of local memory; sizes: $SUBTREE_LOCAL_SIZES")) end From 4d7e19e648f8718d83eca4d917ef8c1f5a7536aa Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Fri, 9 Oct 2026 22:39:06 -0400 Subject: [PATCH 15/65] bench/e2e: fresh_machine.jl loads CUDSS again (MadNLPGPU's CUDA extension requires it to activate the GPU condensed-KKT path) --- bench/e2e/fresh_machine.jl | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bench/e2e/fresh_machine.jl b/bench/e2e/fresh_machine.jl index 7863b6cf..f3f40b66 100644 --- a/bench/e2e/fresh_machine.jl +++ b/bench/e2e/fresh_machine.jl @@ -12,7 +12,7 @@ import Pkg const BACKEND = get(ENV, "SDS_BACKEND", Sys.which("nvidia-smi") === nothing ? "amdgpu" : "cuda") Pkg.activate(joinpath(homedir(), ".sds-fresh-" * BACKEND)) Pkg.add(["MadNLP", "MadNLPGPU", "ExaModels", "ExaModelsPower", "Metis"]) -Pkg.add(BACKEND == "cuda" ? ["CUDA"] : ["AMDGPU"]) +Pkg.add(BACKEND == "cuda" ? ["CUDA", "CUDSS"] : ["AMDGPU"]) # SDS as a proper dev checkout (editable, canonical path), not a frozen Pkg.add const SDS_DEV = joinpath(homedir(), ".julia", "dev", "SparseDirectSolver") isdir(SDS_DEV) || @@ -23,8 +23,8 @@ Pkg.instantiate() using SparseDirectSolver, MadNLP, MadNLPGPU, ExaModels, ExaModelsPower using Metis if BACKEND == "cuda" - using CUDA -else + using CUDA, CUDSS # CUDSS must be LOADED: MadNLPGPU's CUDA extension (the GPU +else # condensed-KKT constructors) activates only with it present using AMDGPU end From a5be9315266be66edac04a8601a26970bfe9dc87 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Fri, 9 Oct 2026 22:46:31 -0400 Subject: [PATCH 16/65] bench/e2e: fresh_machine.jl updates an existing dev checkout to the branch head --- bench/e2e/fresh_machine.jl | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/bench/e2e/fresh_machine.jl b/bench/e2e/fresh_machine.jl index f3f40b66..0c6f16a0 100644 --- a/bench/e2e/fresh_machine.jl +++ b/bench/e2e/fresh_machine.jl @@ -13,10 +13,17 @@ const BACKEND = get(ENV, "SDS_BACKEND", Sys.which("nvidia-smi") === nothing ? "a Pkg.activate(joinpath(homedir(), ".sds-fresh-" * BACKEND)) Pkg.add(["MadNLP", "MadNLPGPU", "ExaModels", "ExaModelsPower", "Metis"]) Pkg.add(BACKEND == "cuda" ? ["CUDA", "CUDSS"] : ["AMDGPU"]) -# SDS as a proper dev checkout (editable, canonical path), not a frozen Pkg.add +# SDS as a proper dev checkout (editable, canonical path), not a frozen Pkg.add; +# an existing checkout is updated to the branch head (local edits block the pull loudly) const SDS_DEV = joinpath(homedir(), ".julia", "dev", "SparseDirectSolver") -isdir(SDS_DEV) || - run(`git clone --branch divfree-chol https://github.com/exanauts/SparseDirectSolver.jl $SDS_DEV`) +const SDS_REF = "divfree-chol" +if isdir(SDS_DEV) + run(`git -C $SDS_DEV fetch origin $SDS_REF`) + run(`git -C $SDS_DEV checkout $SDS_REF`) + run(`git -C $SDS_DEV merge --ff-only FETCH_HEAD`) +else + run(`git clone --branch $SDS_REF https://github.com/exanauts/SparseDirectSolver.jl $SDS_DEV`) +end Pkg.develop(path = SDS_DEV) Pkg.instantiate() From c972c8d08aa9b4e8c1a24a47c6a6679cda6ec482 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Fri, 9 Oct 2026 12:07:42 -0400 Subject: [PATCH 17/65] Ordering: expose METIS nseps as the nd_nseps config parameter METIS_OPTION_NSEPS (candidate separators per split) is the one NodeND knob that reproducibly improves the ACOPF KKT orderings: on kkt_pglib_opf_case78484_epigrids_condensed_10 (n = 157k), nd_nseps = 4 gives nnz(L) 14.42M vs 14.76M (-2.3%), factorization flops -6.7%, and the PR #107 fused refactorization 30.1 ms vs 32.1 ms (-6.2%, interleaved A/B on a GV100, correctness-gated) at the same schedule depth. The ordering itself runs ~2x slower, so the default stays at the METIS default (-1); the option pays off when one symbolic analysis is reused across many factorizations and is exposed like nd_ubfactor. ND_PROVIDER gains a third argument (values < 1 fall back to the library default); validated like the other config ints and covered in test_options and test_symbolic_etree. --- ext/SparseDirectSolverMetisExt.jl | 9 +++++---- src/options.jl | 8 ++++++-- src/symbolic/ordering.jl | 8 +++++--- test/test_options.jl | 5 +++-- test/test_symbolic_etree.jl | 6 ++++-- 5 files changed, 23 insertions(+), 13 deletions(-) diff --git a/ext/SparseDirectSolverMetisExt.jl b/ext/SparseDirectSolverMetisExt.jl index b83251bd..428c6ce4 100644 --- a/ext/SparseDirectSolverMetisExt.jl +++ b/ext/SparseDirectSolverMetisExt.jl @@ -11,13 +11,14 @@ using SparseDirectSolver using CliqueTrees using Metis -# METIS_NodeND with `ufactor = nd_ubfactor` (-1: METIS default). `nd_nlevels` is -# cuDSS' *minimum* number of dissection levels; METIS_NodeND dissects recursively +# METIS_NodeND with `ufactor = nd_ubfactor` and `nseps = nd_nseps` (-1: METIS +# defaults; values < 1 fall back to the default). `nd_nlevels` is cuDSS' +# *minimum* number of dissection levels; METIS_NodeND dissects recursively # until the parts are small, so the minimum holds whenever the graph is large # enough to be split that often. CliqueTrees' level-capped `ND{S}` (AMD below the # cap) was 4–13× slower and gave 15–80% more fill on Laplacians (T05 Report). -function nd_algorithm(nlevels::Integer, ubfactor::Integer) - return CliqueTrees.METIS(; ufactor = ubfactor) +function nd_algorithm(nlevels::Integer, ubfactor::Integer, nseps::Integer) + return CliqueTrees.METIS(; ufactor = ubfactor, nseps = nseps >= 1 ? nseps : -1) end function __init__() diff --git a/src/options.jl b/src/options.jl index 399e6ae8..7c2c664e 100644 --- a/src/options.jl +++ b/src/options.jl @@ -17,7 +17,7 @@ const CONFIG_PARAMETERS = ("reordering_alg", "factorization_alg", "solve_alg", "hybrid_device_memory_limit", "use_cuda_register_memory", "host_nthreads", "hybrid_execute_mode", "pivot_epsilon_alg", "nd_nlevels", "ubatch_size", "ubatch_index", "use_superpanels", "device_count", "device_indices", - "schur_mode", "deterministic_mode", "nd_ubfactor") + "schur_mode", "deterministic_mode", "nd_ubfactor", "nd_nseps") """ DATA_PARAMETERS @@ -127,6 +127,7 @@ defaults below. Keyword arguments are applied through [`setparam!`](@ref), so | `schur_mode` | `0` | off | | `deterministic_mode` | `0` | atomic forward solve allowed | | `nd_ubfactor` | `-1` | ordering library default | +| `nd_nseps` | `-1` | ordering library default (1 separator per split) | | `ir_mode` | `IR_PLAIN` | plain iterative refinement | | `factor_precision` | `nothing` | factors in the input precision | | `amalgamation` | `(max_width = 32, zero_fraction = 0.25, min_width = 8)` | | @@ -176,6 +177,7 @@ mutable struct Options schur_mode::Int deterministic_mode::Int nd_ubfactor::Int + nd_nseps::Int # configuration parameters beyond cuDSS (PLAN §1.7) ir_mode::IRMode factor_precision::Union{Nothing, DataType} @@ -202,7 +204,7 @@ mutable struct Options opts = new( REORDERING_DEFAULT, FACTORIZATION_DEFAULT, SOLVE_DEFAULT, MATCHING_NONE, 0, 0, 0.0, PIVOT_AUTO, 0.01, nothing, PIVOT_EPSILON_DEFAULT, -1, - 0, 0, 1, 0, 0, 10, 0, -1, 1, 0, 0, -1, + 0, 0, 1, 0, 0, 10, 0, -1, 1, 0, 0, -1, -1, IR_PLAIN, nothing, DEFAULT_AMALGAMATION, SCHEDULE_AUTO, PIVOT_PAIRS_DEFAULT, PIVOT_PAIR_TOLERANCE, 64, 512, copy(DEFAULT_SUBTREE_BUDGETS), SUBTREE_PARALLELISM, 0, -1, nothing, nothing, nothing, nothing, nothing, nothing, @@ -286,6 +288,7 @@ const PARAMETER_SPECS = Dict{String, ParameterSpec}( "schur_mode" => ParameterSpec(:config, :port, :schur_mode), "deterministic_mode" => ParameterSpec(:config, :port, :deterministic_mode), "nd_ubfactor" => ParameterSpec(:config, :port, :nd_ubfactor), + "nd_nseps" => ParameterSpec(:config, :port, :nd_nseps), # CUDSS.jl data parameters "info" => ParameterSpec(:data, :solver, :none), "lu_nnz" => ParameterSpec(:data, :output, :none), @@ -415,6 +418,7 @@ for (field, lo, hi, expected) in ( (:schur_mode, 0, 1, "0 or 1"), (:deterministic_mode, 0, 1, "0 or 1"), (:nd_ubfactor, -1, typemax(Int), "-1 (library default) or an integer ≥ 0"), + (:nd_nseps, -1, typemax(Int), "-1 (library default) or an integer ≥ 1"), ) @eval _parse_option(::Val{$(QuoteNode(field))}, value, _) = _parse_int($(String(field)), value, $lo, $hi, $expected) diff --git a/src/symbolic/ordering.jl b/src/symbolic/ordering.jl index 05bc8f79..d07555c0 100644 --- a/src/symbolic/ordering.jl +++ b/src/symbolic/ordering.jl @@ -8,7 +8,7 @@ """ ND_PROVIDER -`Ref` holding `nothing` or a function `(nd_nlevels, nd_ubfactor) -> alg` that +`Ref` holding `nothing` or a function `(nd_nlevels, nd_ubfactor, nd_nseps) -> alg` that returns the CliqueTrees nested-dissection algorithm. Set by the Metis extension. """ const ND_PROVIDER = Ref{Any}(nothing) @@ -25,7 +25,7 @@ function _nd_algorithm(opts::Options) nd_available() || throw(NotSupportedError("nested dissection (reordering_alg = \"algo4\") needs Metis.jl; " * "run `using Metis` to load SparseDirectSolverMetisExt")) - return ND_PROVIDER[](opts.nd_nlevels, opts.nd_ubfactor) + return ND_PROVIDER[](opts.nd_nlevels, opts.nd_ubfactor, opts.nd_nseps) end """ @@ -221,7 +221,9 @@ Fill-reducing ordering of the pattern `P` (PLAN §2.3 step 2): * `reordering_alg = "algo5"`: natural ordering; `"algo3"`: AMD (`CliqueTrees.AMD()`); `"algo4"`: nested dissection with Metis (needs `using Metis`, otherwise [`NotSupportedError`](@ref)): `METIS_NodeND` with - `ufactor = nd_ubfactor` (`-1`: METIS default); `nd_nlevels` is the cuDSS + `ufactor = nd_ubfactor` and `nseps = nd_nseps` (`-1`: METIS defaults; on + fill-bound problems `nd_nseps = 4` trades a slower ordering for less fill); + `nd_nlevels` is the cuDSS *minimum* number of dissection levels, which METIS' full recursion meets on every graph large enough to be split that often; `"algo1"`/`"algo2"`: AMD on the symmetric pattern; diff --git a/test/test_options.jl b/test/test_options.jl index 984c5521..5381c7eb 100644 --- a/test/test_options.jl +++ b/test/test_options.jl @@ -15,7 +15,7 @@ const CUDSS_JL_CONFIG_PARAMETERS = ("reordering_alg", "factorization_alg", "solv "hybrid_device_memory_limit", "use_cuda_register_memory", "host_nthreads", "hybrid_execute_mode", "pivot_epsilon_alg", "nd_nlevels", "ubatch_size", "ubatch_index", "use_superpanels", "device_count", "device_indices", - "schur_mode", "deterministic_mode", "nd_ubfactor") + "schur_mode", "deterministic_mode", "nd_ubfactor", "nd_nseps") # Configuration parameters PLAN §1.3/§1.7 mark port or reinterpret: # name => (valid value, value of the wrong type). @@ -41,6 +41,7 @@ const PORTED_CONFIG_VALUES = Dict{String, Tuple{Any, Any}}( "schur_mode" => (1, "on"), "deterministic_mode" => (1, 1.0), "nd_ubfactor" => (30, 0.3), + "nd_nseps" => (4, 0.25), # PLAN §1.7 "pivot_sign" => (Int8[1, -1, 0, 1], [1.0, -1.0]), "ir_mode" => ("fgmres", :fgmres), @@ -118,7 +119,7 @@ end ("reordering_alg", "algo6"), ("reordering_alg", 6), ("factorization_alg", "algo3"), ("solve_alg", "algo2"), ("matching_alg", "algo7"), ("pivot_epsilon_alg", "algo3"), ("use_superpanels", 2), ("ubatch_index", -2), ("ubatch_size", -1), - ("nd_ubfactor", -2), ("schedule", "fast"), ("ir_mode", "gmres"), + ("nd_ubfactor", -2), ("nd_nseps", -2), ("schedule", "fast"), ("ir_mode", "gmres"), ("amalgamation", (min_width = 64,)), ("amalgamation", (zero_fraction = -0.1,)), ("amalgamation", (foo = 1,)), ("pivot_sign", [2, 0]), ("user_schur_indices", [0, 2]), ("ubatch_mask", [1, -1])) diff --git a/test/test_symbolic_etree.jl b/test/test_symbolic_etree.jl index b4e68b94..1d1f8e44 100644 --- a/test/test_symbolic_etree.jl +++ b/test/test_symbolic_etree.jl @@ -111,8 +111,10 @@ end end end end - @test SDS.ND_PROVIDER[](10, 50).ufactor == 50 - @test SDS.ND_PROVIDER[](0, -1).ufactor == -1 + @test SDS.ND_PROVIDER[](10, 50, -1).ufactor == 50 + @test SDS.ND_PROVIDER[](0, -1, -1).ufactor == -1 + @test SDS.ND_PROVIDER[](10, -1, 4).nseps == 4 + @test SDS.ND_PROVIDER[](10, -1, 0).nseps == -1 @testset "user_perm" begin A = laplacian2d(7, 6) From c1015f9f7c774c75ec3bd67f9f4673409c29152c Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Fri, 9 Oct 2026 23:16:04 -0400 Subject: [PATCH 18/65] Ordering: nd_seed config parameter; e2e wrapper exposes custom reordering nd_seed mirrors nd_ubfactor/nd_nseps (ND_PROVIDER gains a fourth argument, the Metis extension passes it to CliqueTrees.METIS; values < 0 fall back to the library default). The MadNLP wrapper (bench/e2e/MadNLPSDS.jl) gains sds_nd_nseps/sds_nd_seed/sds_amalgamation with the 78k-ACOPF champion defaults (nseps = 4, seed = 3, max_width = 48: refactorization 24.4 ms vs 30.6 ms on the untuned ordering, GV100) and sds_user_perm, which bypasses the built-in reordering with a user permutation. fresh_machine.jl targets this branch and documents both hooks. --- bench/e2e/MadNLPSDS.jl | 26 ++++++++++++++++++++++++-- bench/e2e/fresh_machine.jl | 7 ++++++- ext/SparseDirectSolverMetisExt.jl | 5 +++-- src/options.jl | 8 ++++++-- src/symbolic/ordering.jl | 6 +++--- test/test_options.jl | 5 +++-- test/test_symbolic_etree.jl | 10 ++++++---- 7 files changed, 51 insertions(+), 16 deletions(-) diff --git a/bench/e2e/MadNLPSDS.jl b/bench/e2e/MadNLPSDS.jl index 71746f38..0e54b180 100644 --- a/bench/e2e/MadNLPSDS.jl +++ b/bench/e2e/MadNLPSDS.jl @@ -20,6 +20,28 @@ Base.@kwdef mutable struct SDSSolverOptions <: MadNLP.AbstractOptions sds_regime_c_rows::Int = 256 sds_subtree_parallelism::Int = 16384 sds_subtree_budgets::Vector{Int} = [16384] + # custom reordering: nd_* tune the built-in METIS nested dissection (the defaults + # are the 78k-ACOPF champions: nseps = 4, seed = 3, amalgamation max_width = 48); + # sds_user_perm bypasses the reordering entirely with a user permutation (1-based, + # length n, same convention as SDS's "user_perm" parameter). + sds_nd_nseps::Int = 4 + sds_nd_seed::Int = 3 + sds_amalgamation::NamedTuple = (max_width = 48, zero_fraction = 0.25, min_width = 8) + sds_user_perm::Union{Nothing, Vector{Int}} = nothing +end + +# shared solver construction knobs: the custom reordering (nd knobs or a user +# permutation) and the amalgamation, applied before the analysis +function _apply_reordering!(s, opt::SDSSolverOptions) + s.options.amalgamation = opt.sds_amalgamation + if opt.sds_user_perm !== nothing + SDS.setparam!(s, "user_perm", opt.sds_user_perm) + else + SDS.setparam!(s, "reordering_alg", opt.sds_reordering) + SDS.setparam!(s, "nd_nseps", opt.sds_nd_nseps) + SDS.setparam!(s, "nd_seed", opt.sds_nd_seed) + end + return s end mutable struct SDSSolver{T} <: MadNLP.AbstractLinearSolver{T} @@ -45,7 +67,7 @@ function SDSSolver( s.options.regime_c_rows = opt.sds_regime_c_rows s.options.subtree_parallelism = opt.sds_subtree_parallelism s.options.subtree_budgets = copy(opt.sds_subtree_budgets) - SDS.setparam!(s, "reordering_alg", opt.sds_reordering) + _apply_reordering!(s, opt) x = similar(csc.nzVal, n) b = similar(csc.nzVal, n) SDS.execute!("analysis", s, x, b; asynchronous = false) @@ -116,7 +138,7 @@ function SDSProtoSolver( s.options.regime_c_rows = 64 # prototype kernels own BOTH phases: no coupling conflict s.options.subtree_parallelism = 16384 s.options.subtree_budgets = [16384] - SDS.setparam!(s, "reordering_alg", opt.sds_reordering) + _apply_reordering!(s, opt) x = similar(csc.nzVal, n) b = similar(csc.nzVal, n) SDS.execute!("analysis", s, x, b; asynchronous = false) diff --git a/bench/e2e/fresh_machine.jl b/bench/e2e/fresh_machine.jl index 0c6f16a0..45acd8c9 100644 --- a/bench/e2e/fresh_machine.jl +++ b/bench/e2e/fresh_machine.jl @@ -16,7 +16,7 @@ Pkg.add(BACKEND == "cuda" ? ["CUDA", "CUDSS"] : ["AMDGPU"]) # SDS as a proper dev checkout (editable, canonical path), not a frozen Pkg.add; # an existing checkout is updated to the branch head (local edits block the pull loudly) const SDS_DEV = joinpath(homedir(), ".julia", "dev", "SparseDirectSolver") -const SDS_REF = "divfree-chol" +const SDS_REF = "custom-reordering" if isdir(SDS_DEV) run(`git -C $SDS_DEV fetch origin $SDS_REF`) run(`git -C $SDS_DEV checkout $SDS_REF`) @@ -47,5 +47,10 @@ m, _ = ac_opf_model("pglib_opf_case78484_epigrids.m"; # SparseCondensedKKTSystem is required (the SPD Cholesky path); MadNLP defaults # equality/fixed-variable treatment correctly for it. tol: condensed default is 1e-4. +# +# Custom reordering: the wrapper defaults to the tuned METIS ND (nseps = 4, seed = 3, +# amalgamation max_width = 48). To tune it, pass e.g. sds_nd_nseps = 8, sds_nd_seed = 1; +# to supply your own permutation (1-based, length n of the condensed KKT), pass +# sds_user_perm = perm — it bypasses the built-in reordering entirely. sol = madnlp(m; linear_solver = SDSProtoSolver, kkt_system = MadNLP.SparseCondensedKKTSystem, tol = 1e-6) diff --git a/ext/SparseDirectSolverMetisExt.jl b/ext/SparseDirectSolverMetisExt.jl index 428c6ce4..a23c94c5 100644 --- a/ext/SparseDirectSolverMetisExt.jl +++ b/ext/SparseDirectSolverMetisExt.jl @@ -17,8 +17,9 @@ using Metis # until the parts are small, so the minimum holds whenever the graph is large # enough to be split that often. CliqueTrees' level-capped `ND{S}` (AMD below the # cap) was 4–13× slower and gave 15–80% more fill on Laplacians (T05 Report). -function nd_algorithm(nlevels::Integer, ubfactor::Integer, nseps::Integer) - return CliqueTrees.METIS(; ufactor = ubfactor, nseps = nseps >= 1 ? nseps : -1) +function nd_algorithm(nlevels::Integer, ubfactor::Integer, nseps::Integer, seed::Integer) + return CliqueTrees.METIS(; ufactor = ubfactor, nseps = nseps >= 1 ? nseps : -1, + seed = seed >= 0 ? seed : -1) end function __init__() diff --git a/src/options.jl b/src/options.jl index 7c2c664e..3edb8259 100644 --- a/src/options.jl +++ b/src/options.jl @@ -17,7 +17,7 @@ const CONFIG_PARAMETERS = ("reordering_alg", "factorization_alg", "solve_alg", "hybrid_device_memory_limit", "use_cuda_register_memory", "host_nthreads", "hybrid_execute_mode", "pivot_epsilon_alg", "nd_nlevels", "ubatch_size", "ubatch_index", "use_superpanels", "device_count", "device_indices", - "schur_mode", "deterministic_mode", "nd_ubfactor", "nd_nseps") + "schur_mode", "deterministic_mode", "nd_ubfactor", "nd_nseps", "nd_seed") """ DATA_PARAMETERS @@ -128,6 +128,7 @@ defaults below. Keyword arguments are applied through [`setparam!`](@ref), so | `deterministic_mode` | `0` | atomic forward solve allowed | | `nd_ubfactor` | `-1` | ordering library default | | `nd_nseps` | `-1` | ordering library default (1 separator per split) | +| `nd_seed` | `-1` | ordering library default | | `ir_mode` | `IR_PLAIN` | plain iterative refinement | | `factor_precision` | `nothing` | factors in the input precision | | `amalgamation` | `(max_width = 32, zero_fraction = 0.25, min_width = 8)` | | @@ -178,6 +179,7 @@ mutable struct Options deterministic_mode::Int nd_ubfactor::Int nd_nseps::Int + nd_seed::Int # configuration parameters beyond cuDSS (PLAN §1.7) ir_mode::IRMode factor_precision::Union{Nothing, DataType} @@ -204,7 +206,7 @@ mutable struct Options opts = new( REORDERING_DEFAULT, FACTORIZATION_DEFAULT, SOLVE_DEFAULT, MATCHING_NONE, 0, 0, 0.0, PIVOT_AUTO, 0.01, nothing, PIVOT_EPSILON_DEFAULT, -1, - 0, 0, 1, 0, 0, 10, 0, -1, 1, 0, 0, -1, -1, + 0, 0, 1, 0, 0, 10, 0, -1, 1, 0, 0, -1, -1, -1, IR_PLAIN, nothing, DEFAULT_AMALGAMATION, SCHEDULE_AUTO, PIVOT_PAIRS_DEFAULT, PIVOT_PAIR_TOLERANCE, 64, 512, copy(DEFAULT_SUBTREE_BUDGETS), SUBTREE_PARALLELISM, 0, -1, nothing, nothing, nothing, nothing, nothing, nothing, @@ -289,6 +291,7 @@ const PARAMETER_SPECS = Dict{String, ParameterSpec}( "deterministic_mode" => ParameterSpec(:config, :port, :deterministic_mode), "nd_ubfactor" => ParameterSpec(:config, :port, :nd_ubfactor), "nd_nseps" => ParameterSpec(:config, :port, :nd_nseps), + "nd_seed" => ParameterSpec(:config, :port, :nd_seed), # CUDSS.jl data parameters "info" => ParameterSpec(:data, :solver, :none), "lu_nnz" => ParameterSpec(:data, :output, :none), @@ -419,6 +422,7 @@ for (field, lo, hi, expected) in ( (:deterministic_mode, 0, 1, "0 or 1"), (:nd_ubfactor, -1, typemax(Int), "-1 (library default) or an integer ≥ 0"), (:nd_nseps, -1, typemax(Int), "-1 (library default) or an integer ≥ 1"), + (:nd_seed, -1, typemax(Int), "-1 (library default) or an integer ≥ 0"), ) @eval _parse_option(::Val{$(QuoteNode(field))}, value, _) = _parse_int($(String(field)), value, $lo, $hi, $expected) diff --git a/src/symbolic/ordering.jl b/src/symbolic/ordering.jl index d07555c0..4824f45d 100644 --- a/src/symbolic/ordering.jl +++ b/src/symbolic/ordering.jl @@ -8,7 +8,7 @@ """ ND_PROVIDER -`Ref` holding `nothing` or a function `(nd_nlevels, nd_ubfactor, nd_nseps) -> alg` that +`Ref` holding `nothing` or a function `(nd_nlevels, nd_ubfactor, nd_nseps, nd_seed) -> alg` that returns the CliqueTrees nested-dissection algorithm. Set by the Metis extension. """ const ND_PROVIDER = Ref{Any}(nothing) @@ -25,7 +25,7 @@ function _nd_algorithm(opts::Options) nd_available() || throw(NotSupportedError("nested dissection (reordering_alg = \"algo4\") needs Metis.jl; " * "run `using Metis` to load SparseDirectSolverMetisExt")) - return ND_PROVIDER[](opts.nd_nlevels, opts.nd_ubfactor, opts.nd_nseps) + return ND_PROVIDER[](opts.nd_nlevels, opts.nd_ubfactor, opts.nd_nseps, opts.nd_seed) end """ @@ -221,7 +221,7 @@ Fill-reducing ordering of the pattern `P` (PLAN §2.3 step 2): * `reordering_alg = "algo5"`: natural ordering; `"algo3"`: AMD (`CliqueTrees.AMD()`); `"algo4"`: nested dissection with Metis (needs `using Metis`, otherwise [`NotSupportedError`](@ref)): `METIS_NodeND` with - `ufactor = nd_ubfactor` and `nseps = nd_nseps` (`-1`: METIS defaults; on + `ufactor = nd_ubfactor`, `nseps = nd_nseps` and `seed = nd_seed` (`-1`: METIS defaults; on fill-bound problems `nd_nseps = 4` trades a slower ordering for less fill); `nd_nlevels` is the cuDSS *minimum* number of dissection levels, which METIS' full recursion meets on diff --git a/test/test_options.jl b/test/test_options.jl index 5381c7eb..aa49dfff 100644 --- a/test/test_options.jl +++ b/test/test_options.jl @@ -15,7 +15,7 @@ const CUDSS_JL_CONFIG_PARAMETERS = ("reordering_alg", "factorization_alg", "solv "hybrid_device_memory_limit", "use_cuda_register_memory", "host_nthreads", "hybrid_execute_mode", "pivot_epsilon_alg", "nd_nlevels", "ubatch_size", "ubatch_index", "use_superpanels", "device_count", "device_indices", - "schur_mode", "deterministic_mode", "nd_ubfactor", "nd_nseps") + "schur_mode", "deterministic_mode", "nd_ubfactor", "nd_nseps", "nd_seed") # Configuration parameters PLAN §1.3/§1.7 mark port or reinterpret: # name => (valid value, value of the wrong type). @@ -42,6 +42,7 @@ const PORTED_CONFIG_VALUES = Dict{String, Tuple{Any, Any}}( "deterministic_mode" => (1, 1.0), "nd_ubfactor" => (30, 0.3), "nd_nseps" => (4, 0.25), + "nd_seed" => (3, 0.3), # PLAN §1.7 "pivot_sign" => (Int8[1, -1, 0, 1], [1.0, -1.0]), "ir_mode" => ("fgmres", :fgmres), @@ -119,7 +120,7 @@ end ("reordering_alg", "algo6"), ("reordering_alg", 6), ("factorization_alg", "algo3"), ("solve_alg", "algo2"), ("matching_alg", "algo7"), ("pivot_epsilon_alg", "algo3"), ("use_superpanels", 2), ("ubatch_index", -2), ("ubatch_size", -1), - ("nd_ubfactor", -2), ("nd_nseps", -2), ("schedule", "fast"), ("ir_mode", "gmres"), + ("nd_ubfactor", -2), ("nd_nseps", -2), ("nd_seed", -2), ("schedule", "fast"), ("ir_mode", "gmres"), ("amalgamation", (min_width = 64,)), ("amalgamation", (zero_fraction = -0.1,)), ("amalgamation", (foo = 1,)), ("pivot_sign", [2, 0]), ("user_schur_indices", [0, 2]), ("ubatch_mask", [1, -1])) diff --git a/test/test_symbolic_etree.jl b/test/test_symbolic_etree.jl index 1d1f8e44..bc1ba462 100644 --- a/test/test_symbolic_etree.jl +++ b/test/test_symbolic_etree.jl @@ -111,10 +111,12 @@ end end end end - @test SDS.ND_PROVIDER[](10, 50, -1).ufactor == 50 - @test SDS.ND_PROVIDER[](0, -1, -1).ufactor == -1 - @test SDS.ND_PROVIDER[](10, -1, 4).nseps == 4 - @test SDS.ND_PROVIDER[](10, -1, 0).nseps == -1 + @test SDS.ND_PROVIDER[](10, 50, -1, -1).ufactor == 50 + @test SDS.ND_PROVIDER[](0, -1, -1, -1).ufactor == -1 + @test SDS.ND_PROVIDER[](10, -1, 4, -1).nseps == 4 + @test SDS.ND_PROVIDER[](10, -1, 0, -1).nseps == -1 + @test SDS.ND_PROVIDER[](10, -1, 4, 3).seed == 3 + @test SDS.ND_PROVIDER[](10, -1, 4, -1).seed == -1 @testset "user_perm" begin A = laplacian2d(7, 6) From 0ee4687849a00bb35d832547af2d1e0910e6f183 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Fri, 9 Oct 2026 23:18:35 -0400 Subject: [PATCH 19/65] test: the T22 chooser testset passes all four ND_PROVIDER arguments --- test/test_symbolic_etree.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_symbolic_etree.jl b/test/test_symbolic_etree.jl index bc1ba462..d76c46f2 100644 --- a/test/test_symbolic_etree.jl +++ b/test/test_symbolic_etree.jl @@ -210,7 +210,7 @@ end P = SDS.SymmetricPattern(SDS.CSR(A), "S"; view = 'F') G = SparseMatrixCSC(P) amd = Vector{Int}(first(SDS.CliqueTrees.permutation(G; alg = SDS.CliqueTrees.AMD()))) - nd = Vector{Int}(first(SDS.CliqueTrees.permutation(G; alg = SDS.ND_PROVIDER[](10, -1)))) + nd = Vector{Int}(first(SDS.CliqueTrees.permutation(G; alg = SDS.ND_PROVIDER[](10, -1, -1, -1)))) for (s, ref) in (("algo1", amd), ("algo2", amd), ("algo3", amd), ("algo4", nd)) ord = SDS.compute_ordering(P, Options(reordering_alg = s)) @test ord.perm == ref From 13ea37be523df093c5124af8e74f7ccea9065e8d Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Sat, 10 Oct 2026 00:00:26 -0400 Subject: [PATCH 20/65] bench/e2e: on-disk permutation cache in the MadNLP wrapper The tuned METIS ordering (nd_nseps = 4) costs ~2x the default ordering at this size, every solver construction. The wrapper now caches the computed fill-reducing permutation (perm_reorder_row after the analysis) keyed by the matrix pattern hash + ordering knobs under ~/.julia/sds_perm_cache and reloads it as a user permutation on any later construction with the same structure: the first solve pays METIS once, every later init loads the permutation in milliseconds. sds_perm_cache = "" disables. --- bench/e2e/MadNLPSDS.jl | 50 +++++++++++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/bench/e2e/MadNLPSDS.jl b/bench/e2e/MadNLPSDS.jl index 0e54b180..89be171f 100644 --- a/bench/e2e/MadNLPSDS.jl +++ b/bench/e2e/MadNLPSDS.jl @@ -28,20 +28,52 @@ Base.@kwdef mutable struct SDSSolverOptions <: MadNLP.AbstractOptions sds_nd_seed::Int = 3 sds_amalgamation::NamedTuple = (max_width = 48, zero_fraction = 0.25, min_width = 8) sds_user_perm::Union{Nothing, Vector{Int}} = nothing + # on-disk permutation cache: the tuned METIS ordering costs ~2x the default + # (tens of seconds at this size), so the computed permutation is cached keyed + # by the matrix pattern + ordering knobs and reloaded on any later solver + # construction with the same structure ("" disables) + sds_perm_cache::String = joinpath(homedir(), ".julia", "sds_perm_cache") +end + +function _perm_cache_file(opt::SDSSolverOptions, csc) + (opt.sds_perm_cache == "" || opt.sds_user_perm !== nothing) && return nothing + n = size(csc, 1) + h = hash((n, length(csc.rowVal), hash(Array(csc.colPtr)), hash(Array(csc.rowVal)), + opt.sds_reordering, opt.sds_nd_nseps, opt.sds_nd_seed)) + return joinpath(opt.sds_perm_cache, "perm-" * string(h, base = 16) * ".bin") end # shared solver construction knobs: the custom reordering (nd knobs or a user # permutation) and the amalgamation, applied before the analysis -function _apply_reordering!(s, opt::SDSSolverOptions) +# returns the cache file to WRITE after the analysis (nothing: no write needed) +function _apply_reordering!(s, opt::SDSSolverOptions, csc) s.options.amalgamation = opt.sds_amalgamation if opt.sds_user_perm !== nothing SDS.setparam!(s, "user_perm", opt.sds_user_perm) - else - SDS.setparam!(s, "reordering_alg", opt.sds_reordering) - SDS.setparam!(s, "nd_nseps", opt.sds_nd_nseps) - SDS.setparam!(s, "nd_seed", opt.sds_nd_seed) + return nothing + end + cf = _perm_cache_file(opt, csc) + if cf !== nothing && isfile(cf) + n = size(csc, 1) + perm = Vector{Int32}(undef, n) + read!(cf, perm) + SDS.setparam!(s, "user_perm", Int.(perm)) + return nothing + end + SDS.setparam!(s, "reordering_alg", opt.sds_reordering) + SDS.setparam!(s, "nd_nseps", opt.sds_nd_nseps) + SDS.setparam!(s, "nd_seed", opt.sds_nd_seed) + return cf +end + +function _save_perm(cf, s) + cf === nothing && return nothing + perm = SDS.getparam(s, "perm_reorder_row") + mkpath(dirname(cf)) + open(cf, "w") do io + write(io, Int32.(perm)) end - return s + return nothing end mutable struct SDSSolver{T} <: MadNLP.AbstractLinearSolver{T} @@ -67,10 +99,11 @@ function SDSSolver( s.options.regime_c_rows = opt.sds_regime_c_rows s.options.subtree_parallelism = opt.sds_subtree_parallelism s.options.subtree_budgets = copy(opt.sds_subtree_budgets) - _apply_reordering!(s, opt) + cf = _apply_reordering!(s, opt, csc) x = similar(csc.nzVal, n) b = similar(csc.nzVal, n) SDS.execute!("analysis", s, x, b; asynchronous = false) + _save_perm(cf, s) return SDSSolver{T}(s, csc, x, b, false, 0, opt, logger) end @@ -138,10 +171,11 @@ function SDSProtoSolver( s.options.regime_c_rows = 64 # prototype kernels own BOTH phases: no coupling conflict s.options.subtree_parallelism = 16384 s.options.subtree_budgets = [16384] - _apply_reordering!(s, opt) + cf = _apply_reordering!(s, opt, csc) x = similar(csc.nzVal, n) b = similar(csc.nzVal, n) SDS.execute!("analysis", s, x, b; asynchronous = false) + _save_perm(cf, s) SDS.execute!("factorization", s, x, b; asynchronous = false) SDS.execute!("solve", s, x, b; asynchronous = false) # allocates the solve workspace nzval = SDS._factor_values(s) From 10f209e9f0cf6fef7c37b8b9aad8211bcb170ded Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Sat, 10 Oct 2026 00:04:28 -0400 Subject: [PATCH 21/65] bench/e2e: vendor detection runs the GPU tool (nvidia-smi on PATH without a driver chose cuda on AMD nodes); document --startup-file=no everywhere --- bench/e2e/fresh_machine.jl | 16 ++++++++++++++-- bench/e2e/fresh_machine.sh | 2 +- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/bench/e2e/fresh_machine.jl b/bench/e2e/fresh_machine.jl index 45acd8c9..b5f5407f 100644 --- a/bench/e2e/fresh_machine.jl +++ b/bench/e2e/fresh_machine.jl @@ -1,6 +1,7 @@ # MadNLP + SparseDirectSolver workflow on a fresh GPU machine, one file: # -# julia +1.13 fresh_machine.jl (or: julia +1.13 -i fresh_machine.jl) +# julia +1.13 --startup-file=no fresh_machine.jl +# julia +1.13 --startup-file=no -i fresh_machine.jl (drop into a REPL after) # # Installs everything into its own environment (~/.sds-fresh-), downloads # the pglib case on first use, then solves the 78k-bus ACOPF with MadNLP on the @@ -9,7 +10,18 @@ # system ROCm installation.) import Pkg -const BACKEND = get(ENV, "SDS_BACKEND", Sys.which("nvidia-smi") === nothing ? "amdgpu" : "cuda") +# vendor detection must RUN the tool, not just find it: cluster nodes often have +# nvidia-smi on PATH without an NVIDIA driver (and vice versa) +_works(cmd) = try + success(pipeline(cmd; stdout = devnull, stderr = devnull)) +catch + false +end +const BACKEND = get(ENV, "SDS_BACKEND") do + _works(`nvidia-smi -L`) ? "cuda" : + (_works(`rocm-smi`) || _works(`rocminfo`)) ? "amdgpu" : + error("no working GPU tool found (nvidia-smi / rocm-smi); set SDS_BACKEND=cuda|amdgpu") +end Pkg.activate(joinpath(homedir(), ".sds-fresh-" * BACKEND)) Pkg.add(["MadNLP", "MadNLPGPU", "ExaModels", "ExaModelsPower", "Metis"]) Pkg.add(BACKEND == "cuda" ? ["CUDA", "CUDSS"] : ["AMDGPU"]) diff --git a/bench/e2e/fresh_machine.sh b/bench/e2e/fresh_machine.sh index d9d14904..94e329ac 100755 --- a/bench/e2e/fresh_machine.sh +++ b/bench/e2e/fresh_machine.sh @@ -51,7 +51,7 @@ if ! command -v juliaup >/dev/null 2>&1 && [ ! -x "$HOME/.juliaup/bin/juliaup" ] fi export PATH="$HOME/.juliaup/bin:$PATH" juliaup add 1.13 2>/dev/null || true -JL="julia +1.13" +JL="julia +1.13 --startup-file=no" $JL --version # --- 2. Repository ------------------------------------------------------------ From 9223dfd85366173b730f213c30866264e633b4e1 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Sat, 10 Oct 2026 00:06:14 -0400 Subject: [PATCH 22/65] bench/e2e: NVIDIA detection also requires a listed device, not just a clean nvidia-smi exit --- bench/e2e/fresh_machine.jl | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bench/e2e/fresh_machine.jl b/bench/e2e/fresh_machine.jl index b5f5407f..46dedd05 100644 --- a/bench/e2e/fresh_machine.jl +++ b/bench/e2e/fresh_machine.jl @@ -17,8 +17,13 @@ _works(cmd) = try catch false end +_has_nvidia_gpu() = try + occursin("GPU", read(`nvidia-smi -L`, String)) # exit 0 AND at least one device listed +catch + false +end const BACKEND = get(ENV, "SDS_BACKEND") do - _works(`nvidia-smi -L`) ? "cuda" : + _has_nvidia_gpu() ? "cuda" : (_works(`rocm-smi`) || _works(`rocminfo`)) ? "amdgpu" : error("no working GPU tool found (nvidia-smi / rocm-smi); set SDS_BACKEND=cuda|amdgpu") end From ecabd8a8fc08e537ff4d85a7d64cbc7c489011ef Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Fri, 9 Oct 2026 10:07:22 -0400 Subject: [PATCH 23/65] bench: full comparison record on a Quadro GV100 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The complete bench/compare.jl run of PR #107's harness state on a second device — every done-task feature, all harness matrices including the case14/118/1354 dumps (iterations 1, 10, 20) and the 78k-bus epigrids dumps, both solvers, plus the rendered report. Kept beside bench/comparison so the RTX 4080 record stands; the CSVs carry the device per row. Co-Authored-By: Claude Fable 5 --- bench/comparison-gv100/comparison.md | 238 ++++++++++++++++++++++++++ bench/comparison-gv100/comparison.png | Bin 0 -> 203636 bytes bench/comparison-gv100/cudss.csv | 127 ++++++++++++++ bench/comparison-gv100/sds.csv | 106 ++++++++++++ 4 files changed, 471 insertions(+) create mode 100644 bench/comparison-gv100/comparison.md create mode 100644 bench/comparison-gv100/comparison.png create mode 100644 bench/comparison-gv100/cudss.csv create mode 100644 bench/comparison-gv100/sds.csv diff --git a/bench/comparison-gv100/comparison.md b/bench/comparison-gv100/comparison.md new file mode 100644 index 00000000..fdcd6264 --- /dev/null +++ b/bench/comparison-gv100/comparison.md @@ -0,0 +1,238 @@ +# cuDSS vs SparseDirectSolver.jl + +Generated by `bench/compare_report.jl` from `bench/comparison/{cudss,sds}.csv` (`bench/compare.jl`). Times are BenchmarkTools medians per phase on a fresh solver; the ratio is SDS time / cuDSS time, so below 1 means SparseDirectSolver.jl is faster. Features whose task is not done in TASKS.md have blank SDS cells. + +![SDS/cuDSS time ratio per feature](comparison.png) + +## Overview + +Geometric mean of the SDS/cuDSS ratio over the matrices both solvers ran. + +| feature | task | status | matrices | analysis | factorization | refactorization | solve | +| --- | --- | --- | --- | --- | --- | --- | --- | +| Cholesky, Float64 | T13 | done | 16 | 0.80× | 2.81× | 4.63× | 5.43× | +| Cholesky, 16 right-hand sides | T12 | done | 16 | 0.80× | 3.63× | 4.66× | 3.68× | +| LDLᵀ, static pivoting | T15 | done | 27 | 1.59× | 6.01× | 8.01× | 5.09× | +| LDLᵀ + 2 refinement steps, K2 dumps | T16 | done | 11 | 4.60× | 7.39× | 9.57× | 24.47× | +| Uniform batch of 8, Cholesky | T17 | done | 11 | 0.56× | 1.21× | 1.84× | 3.69× | +| LU, unsymmetric | T19 | done | 2 | 1.20× | 11.99× | 11.41× | 10.83× | +| Schur complement, Cholesky | T20 | done | 11 | 0.62× | 2.88× | 4.31× | 4.56× | +| LDLᵀ + matching (algo5), K2 dumps | T21 | done | 11 | 0.84× | 3.11× | 4.39× | 4.91× | +| Non-uniform batch, condensed dumps | T22 | pending | | | | | | +| Partitioned-inverse solve (SDS algo1, cuDSS default) | T25 | pending | | | | | | +| Hybrid memory mode | T26 | pending | | | | | | +| Float32 factors, Float64 refinement | T27 | pending | | | | | | + +## Cholesky, Float64 + +`cholesky_f64`, T13 (done), structure `SPD`, Float64, nrhs = 1. + +| matrix | n | analysis cuDSS ms | SDS ms | ratio | factorization cuDSS ms | SDS ms | ratio | refactorization cuDSS ms | SDS ms | ratio | solve cuDSS ms | SDS ms | ratio | nnz(L) cuDSS | SDS | relres cuDSS | SDS | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Boeing/bcsstk38 | 8032 | 65 | 121 | 1.85× | 3.1 | 27.4 | 8.84× | 2.81 | 27.4 | 9.75× | 0.642 | 2.67 | 4.16× | 7.99e+05 | 8.05e+05 | 2.14e-10 | 9.26e-11 | +| GHS_psdef/apache2 | 715176 | 7.71e+03 | 1.32e+04 | 1.71× | 265 | 763 | 2.88× | 264 | 752 | 2.85× | 9.94 | 86.8 | 8.73× | 1.45e+08 | 1.29e+08 | 1.79e-10 | 1.57e-10 | +| HB/bcsstk17 | 10974 | 73.4 | 151 | 2.06× | 4.27 | 30.7 | 7.19× | 2.91 | 30.7 | 10.55× | 0.67 | 4.95 | 7.38× | 1.08e+06 | 1.04e+06 | 3.91e-11 | 3.88e-11 | +| kkt_pglib_opf_case118_ieee_condensed_1 | 1088 | 19.9 | 10.6 | 0.53× | 0.848 | 1.16 | 1.36× | 0.315 | 1.14 | 3.62× | 0.227 | 1.28 | 5.64× | 1.52e+04 | 1.16e+04 | 3.64e-05 | 5.22e-05 | +| kkt_pglib_opf_case118_ieee_condensed_10 | 1088 | 19.9 | 10.6 | 0.53× | 3.17 | 1.15 | 0.36× | 0.314 | 1.13 | 3.60× | 0.225 | 1.35 | 5.99× | 1.52e+04 | 1.16e+04 | 1.38e-05 | 1.12e-05 | +| kkt_pglib_opf_case118_ieee_condensed_20 | 1088 | 19.9 | 10.8 | 0.54× | 0.623 | 1.14 | 1.82× | 0.316 | 1.13 | 3.57× | 0.226 | 1.27 | 5.62× | 1.52e+04 | 1.16e+04 | 0.0466 | 0.0455 | +| kkt_pglib_opf_case1354_pegase_condensed_1 | 11192 | 98.1 | 121 | 1.24× | 3.31 | 3.89 | 1.18× | 0.78 | 3.86 | 4.95× | 0.332 | 1.71 | 5.15× | 1.55e+05 | 1.21e+05 | 0.000312 | 0.00028 | +| kkt_pglib_opf_case1354_pegase_condensed_10 | 11192 | 98.1 | 121 | 1.23× | 3.26 | 3.87 | 1.19× | 0.778 | 3.83 | 4.93× | 0.328 | 1.63 | 4.95× | 1.55e+05 | 1.21e+05 | 0.000144 | 9.67e-05 | +| kkt_pglib_opf_case1354_pegase_condensed_20 | 11192 | 98.4 | 122 | 1.24× | 1.11 | 3.85 | 3.47× | 0.785 | 3.84 | 4.89× | 0.323 | 1.76 | 5.45× | 1.55e+05 | 1.21e+05 | 7.72e-05 | 5.03e-05 | +| kkt_pglib_opf_case14_ieee_condensed_1 | 118 | 10.8 | 1.37 | 0.13× | 0.184 | 0.409 | 2.23× | 0.166 | 0.414 | 2.50× | 0.139 | 0.65 | 4.68× | 1.27e+03 | 1.03e+03 | 1.13e-06 | 1.3e-06 | +| kkt_pglib_opf_case14_ieee_condensed_10 | 118 | 10.8 | 1.4 | 0.13× | 0.186 | 0.415 | 2.24× | 0.165 | 0.4 | 2.42× | 0.138 | 0.664 | 4.82× | 1.27e+03 | 1.03e+03 | 4.27e-05 | 7.85e-05 | +| kkt_pglib_opf_case14_ieee_condensed_11 | 118 | 10.8 | 1.39 | 0.13× | 0.186 | 0.401 | 2.16× | 0.167 | 0.384 | 2.30× | 0.139 | 0.661 | 4.76× | 1.27e+03 | 1.03e+03 | 9.6e-06 | 9.57e-06 | +| kkt_pglib_opf_case78484_epigrids_condensed_1 | 674562 | 6.57e+03 | 1.14e+04 | 1.73× | 24.9 | 167 | 6.71× | 24.5 | 167 | 6.82× | 3.79 | 18.3 | 4.82× | 1.56e+07 | 1.32e+07 | 0.153 | 0.141 | +| kkt_pglib_opf_case78484_epigrids_condensed_10 | 674562 | 6.56e+03 | 1.16e+04 | 1.76× | 24.8 | 167 | 6.73× | 24.4 | 167 | 6.82× | 3.79 | 18.2 | 4.82× | 1.56e+07 | 1.32e+07 | 0.0176 | 0.0147 | +| lap2d_300 | 90000 | 512 | 787 | 1.54× | 6.06 | 33.7 | 5.56× | 5.53 | 33.6 | 6.09× | 1.14 | 5.12 | 4.51× | 2.43e+06 | 2.47e+06 | 1.84e-12 | 1.76e-12 | +| lap3d_40 | 64000 | 637 | 904 | 1.42× | 44.3 | 303 | 6.83× | 43.2 | 303 | 7.01× | 2.44 | 17.5 | 7.17× | 1.75e+07 | 1.44e+07 | 8.21e-14 | 7.33e-14 | + +## Cholesky, 16 right-hand sides + +`cholesky_nrhs16`, T12 (done), structure `SPD`, Float64, nrhs = 16. + +| matrix | n | analysis cuDSS ms | SDS ms | ratio | factorization cuDSS ms | SDS ms | ratio | refactorization cuDSS ms | SDS ms | ratio | solve cuDSS ms | SDS ms | ratio | nnz(L) cuDSS | SDS | relres cuDSS | SDS | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Boeing/bcsstk38 | 8032 | 61.5 | 120 | 1.95× | 3.12 | 27.4 | 8.77× | 2.81 | 27.4 | 9.72× | 0.888 | 3.23 | 3.63× | 7.99e+05 | 8.05e+05 | 7.31e-10 | 1.72e-10 | +| GHS_psdef/apache2 | 715176 | 7.67e+03 | 1.31e+04 | 1.71× | 265 | 750 | 2.83× | 264 | 751 | 2.84× | 43.9 | 176 | 4.00× | 1.45e+08 | 1.29e+08 | 1.82e-10 | 1.53e-10 | +| HB/bcsstk17 | 10974 | 74.1 | 151 | 2.03× | 3.21 | 30.7 | 9.57× | 2.91 | 30.7 | 10.55× | 0.967 | 5.86 | 6.05× | 1.08e+06 | 1.04e+06 | 4.57e-11 | 5.57e-11 | +| kkt_pglib_opf_case118_ieee_condensed_1 | 1088 | 19.9 | 10.6 | 0.53× | 0.858 | 1.16 | 1.35× | 0.319 | 1.14 | 3.58× | 0.308 | 1.36 | 4.42× | 1.52e+04 | 1.16e+04 | 5.34e-05 | 0.000108 | +| kkt_pglib_opf_case118_ieee_condensed_10 | 1088 | 19.9 | 10.6 | 0.53× | 0.616 | 1.14 | 1.85× | 0.322 | 1.13 | 3.51× | 0.307 | 1.38 | 4.51× | 1.52e+04 | 1.16e+04 | 1.48e-05 | 1.69e-05 | +| kkt_pglib_opf_case118_ieee_condensed_20 | 1088 | 19.9 | 10.7 | 0.54× | 0.624 | 1.15 | 1.84× | 0.316 | 1.13 | 3.58× | 0.306 | 1.37 | 4.47× | 1.52e+04 | 1.16e+04 | 0.108 | 0.0835 | +| kkt_pglib_opf_case1354_pegase_condensed_1 | 11192 | 101 | 121 | 1.20× | 1.09 | 3.9 | 3.57× | 0.771 | 3.86 | 5.01× | 0.612 | 1.89 | 3.08× | 1.55e+05 | 1.21e+05 | 0.000392 | 0.000291 | +| kkt_pglib_opf_case1354_pegase_condensed_10 | 11192 | 97.9 | 121 | 1.23× | 1.09 | 3.88 | 3.56× | 0.774 | 3.84 | 4.96× | 0.609 | 1.89 | 3.10× | 1.55e+05 | 1.21e+05 | 0.000152 | 0.000114 | +| kkt_pglib_opf_case1354_pegase_condensed_20 | 11192 | 103 | 124 | 1.21× | 1.09 | 3.87 | 3.54× | 0.767 | 3.84 | 5.01× | 0.609 | 1.9 | 3.11× | 1.55e+05 | 1.21e+05 | 0.000101 | 6.47e-05 | +| kkt_pglib_opf_case14_ieee_condensed_1 | 118 | 10.8 | 1.38 | 0.13× | 0.187 | 0.418 | 2.24× | 0.165 | 0.417 | 2.52× | 0.16 | 0.708 | 4.42× | 1.27e+03 | 1.03e+03 | 1.18e-06 | 1.47e-06 | +| kkt_pglib_opf_case14_ieee_condensed_10 | 118 | 10.8 | 1.39 | 0.13× | 0.187 | 0.42 | 2.24× | 0.167 | 0.419 | 2.51× | 0.164 | 0.706 | 4.30× | 1.27e+03 | 1.03e+03 | 6.75e-05 | 0.000143 | +| kkt_pglib_opf_case14_ieee_condensed_11 | 118 | 10.8 | 1.46 | 0.14× | 0.187 | 0.407 | 2.17× | 0.169 | 0.403 | 2.39× | 0.157 | 0.711 | 4.52× | 1.27e+03 | 1.03e+03 | 4.36e-05 | 2.29e-05 | +| kkt_pglib_opf_case78484_epigrids_condensed_1 | 674562 | 6.56e+03 | 1.19e+04 | 1.81× | 24.9 | 167 | 6.71× | 24.5 | 167 | 6.84× | 21.7 | 51.7 | 2.38× | 1.56e+07 | 1.32e+07 | 0.185 | 0.175 | +| kkt_pglib_opf_case78484_epigrids_condensed_10 | 674562 | 6.55e+03 | 1.17e+04 | 1.79× | 25.2 | 167 | 6.62× | 24.4 | 167 | 6.84× | 21.7 | 51.7 | 2.38× | 1.56e+07 | 1.32e+07 | 0.0178 | 0.0148 | +| lap2d_300 | 90000 | 510 | 787 | 1.55× | 6.08 | 33.6 | 5.53× | 5.51 | 33.6 | 6.11× | 4.23 | 9.99 | 2.36× | 2.43e+06 | 2.47e+06 | 1.85e-12 | 1.76e-12 | +| lap3d_40 | 64000 | 633 | 904 | 1.43× | 43.7 | 303 | 6.93× | 42.9 | 303 | 7.06× | 5.54 | 22.9 | 4.14× | 1.75e+07 | 1.44e+07 | 8.92e-14 | 6.39e-14 | + +## LDLᵀ, static pivoting + +`ldlt`, T15 (done), structure `S`, Float64, nrhs = 1. + +| matrix | n | analysis cuDSS ms | SDS ms | ratio | factorization cuDSS ms | SDS ms | ratio | refactorization cuDSS ms | SDS ms | ratio | solve cuDSS ms | SDS ms | ratio | nnz(L) cuDSS | SDS | relres cuDSS | SDS | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Boeing/bcsstk38 | 8032 | 68 | 139 | 2.05× | 5.39 | 47.1 | 8.75× | 3.72 | 47.1 | 12.66× | 0.675 | 2.79 | 4.14× | 7.99e+05 | 8.05e+05 | 7.1e-11 | 1.77e-10 | +| GHS_psdef/apache2 | 715176 | 7.69e+03 | 1.33e+04 | 1.73× | 314 | 1.88e+03 | 5.99× | 313 | 1.87e+03 | 5.98× | 13.2 | 108 | 8.18× | 1.45e+08 | 1.29e+08 | 1.57e-10 | 1.42e-10 | +| HB/bcsstk17 | 10974 | 76 | 169 | 2.22× | 4.1 | 56.6 | 13.81× | 3.8 | 56.5 | 14.88× | 0.702 | 5.18 | 7.38× | 1.08e+06 | 1.04e+06 | 3.62e-11 | 4.16e-11 | +| kkt_pglib_opf_case118_ieee_condensed_1 | 1088 | 24.8 | 11.4 | 0.46× | 0.758 | 2.67 | 3.53× | 0.476 | 2.65 | 5.57× | 0.342 | 1.28 | 3.75× | 1.52e+04 | 1.16e+04 | 5.35e-05 | 0.000111 | +| kkt_pglib_opf_case118_ieee_condensed_10 | 1088 | 24.7 | 11.4 | 0.46× | 1.25 | 2.6 | 2.08× | 0.484 | 2.56 | 5.30× | 0.334 | 1.37 | 4.10× | 1.52e+04 | 1.16e+04 | 1.18e-05 | 1.01e-05 | +| kkt_pglib_opf_case118_ieee_condensed_20 | 1088 | 24.8 | 11.4 | 0.46× | 0.724 | 2.67 | 3.69× | 0.475 | 2.64 | 5.56× | 0.336 | 1.3 | 3.87× | 1.52e+04 | 1.16e+04 | 0.0353 | 0.0365 | +| kkt_pglib_opf_case118_ieee_k2_1 | 3185 | 27.9 | 134 | 4.80× | 0.901 | 2.66 | 2.95× | 0.367 | 2.63 | 7.18× | 0.221 | 1.58 | 7.13× | 2e+04 | 2.11e+04 | 3.8 | 1.05 | +| kkt_pglib_opf_case118_ieee_k2_10 | 3185 | 27.9 | 137 | 4.93× | 0.675 | 2.59 | 3.84× | 0.353 | 2.56 | 7.26× | 0.222 | 1.61 | 7.27× | 2e+04 | 2.06e+04 | 7.08 | 0.00737 | +| kkt_pglib_opf_case118_ieee_k2_20 | 3185 | 27.9 | 134 | 4.81× | 2.12 | 2.56 | 1.21× | 0.356 | 2.53 | 7.10× | 0.223 | 1.56 | 7.01× | 2e+04 | 2.06e+04 | 12.6 | 0.0238 | +| kkt_pglib_opf_case1354_pegase_condensed_1 | 11192 | 101 | 127 | 1.26× | 1.26 | 10.3 | 8.17× | 0.956 | 10.2 | 10.70× | 0.335 | 1.71 | 5.11× | 1.55e+05 | 1.21e+05 | 0.000291 | 0.000313 | +| kkt_pglib_opf_case1354_pegase_condensed_10 | 11192 | 102 | 128 | 1.26× | 1.26 | 10.4 | 8.23× | 0.951 | 10.4 | 10.90× | 0.334 | 1.77 | 5.31× | 1.55e+05 | 1.21e+05 | 0.000146 | 0.00011 | +| kkt_pglib_opf_case1354_pegase_condensed_20 | 11192 | 99.7 | 128 | 1.29× | 1.26 | 10.4 | 8.27× | 0.935 | 10.4 | 11.11× | 0.337 | 1.73 | 5.13× | 1.55e+05 | 1.21e+05 | 6.73e-05 | 5.62e-05 | +| kkt_pglib_opf_case1354_pegase_k2_1 | 33811 | 193 | 2.3e+03 | 11.90× | 1.99 | 10.4 | 5.22× | 1.37 | 10.3 | 7.50× | 0.444 | 2.35 | 5.30× | 2.02e+05 | 2.16e+05 | 91.7 | 16.9 | +| kkt_pglib_opf_case1354_pegase_k2_10 | 33811 | 195 | 2.37e+03 | 12.18× | 1.66 | 9.47 | 5.72× | 1.37 | 9.44 | 6.90× | 0.442 | 2.19 | 4.95× | 2.02e+05 | 2.2e+05 | 56.3 | 29.6 | +| kkt_pglib_opf_case1354_pegase_k2_20 | 33811 | 192 | 2.39e+03 | 12.41× | 1.7 | 10.1 | 5.97× | 1.37 | 10.1 | 7.36× | 0.545 | 2.22 | 4.07× | 2.02e+05 | 2.21e+05 | 1.16e+03 | 27.5 | +| kkt_pglib_opf_case14_ieee_condensed_1 | 118 | 13.8 | 1.53 | 0.11× | 0.284 | 0.632 | 2.22× | 0.289 | 0.628 | 2.17× | 0.248 | 0.696 | 2.81× | 1.27e+03 | 1.03e+03 | 1.07e-06 | 8.58e-07 | +| kkt_pglib_opf_case14_ieee_condensed_10 | 118 | 13.8 | 1.51 | 0.11× | 0.29 | 0.637 | 2.19× | 0.287 | 0.632 | 2.20× | 0.246 | 0.692 | 2.81× | 1.27e+03 | 1.03e+03 | 3.42e-05 | 1.82e-05 | +| kkt_pglib_opf_case14_ieee_condensed_11 | 118 | 13.8 | 1.51 | 0.11× | 0.296 | 0.641 | 2.17× | 0.283 | 0.638 | 2.25× | 0.25 | 0.669 | 2.68× | 1.27e+03 | 1.03e+03 | 1.04e-05 | 7.29e-06 | +| kkt_pglib_opf_case14_ieee_k2_1 | 347 | 15.5 | 7.26 | 0.47× | 0.313 | 0.957 | 3.06× | 0.302 | 0.96 | 3.18× | 0.269 | 1.03 | 3.83× | 2.05e+03 | 2.15e+03 | 0.518 | 7.57e-08 | +| kkt_pglib_opf_case14_ieee_k2_10 | 347 | 15.5 | 7.23 | 0.47× | 0.197 | 0.965 | 4.91× | 0.177 | 0.952 | 5.38× | 0.154 | 0.977 | 6.34× | 2.05e+03 | 2.16e+03 | 0.409 | 4.36e-05 | +| kkt_pglib_opf_case14_ieee_k2_15 | 347 | 12 | 5.62 | 0.47× | 0.197 | 1.58 | 8.02× | 0.185 | 1.57 | 8.47× | 0.152 | 1.13 | 7.41× | 2.05e+03 | 3.23e+03 | 0.698 | 0.123 | +| kkt_pglib_opf_case78484_epigrids_condensed_1 | 674562 | 6.55e+03 | 1.21e+04 | 1.85× | 30.8 | 535 | 17.36× | 30.3 | 535 | 17.62× | 4.1 | 20.1 | 4.90× | 1.56e+07 | 1.32e+07 | 0.165 | 0.114 | +| kkt_pglib_opf_case78484_epigrids_condensed_10 | 674562 | 6.59e+03 | 1.14e+04 | 1.72× | 39.2 | 510 | 13.01× | 29.8 | 509 | 17.10× | 4.1 | 20.2 | 4.92× | 1.56e+07 | 1.32e+07 | 0.0168 | 0.0121 | +| kkt_pglib_opf_case78484_epigrids_k2_1 | 2091669 | 2.01e+04 | 5.44e+05 | 27.03× | 48.1 | 2.29e+03 | 47.68× | 47.4 | 2.26e+03 | 47.70× | 13.3 | 81.3 | 6.12× | 1.85e+07 | 2.77e+07 | 6.46e+09 | 1.9e+04 | +| kkt_pglib_opf_case78484_epigrids_k2_10 | 2091669 | 2.02e+04 | 5.69e+05 | 28.16× | 49.9 | 2.25e+03 | 45.05× | 46.7 | 2.23e+03 | 47.73× | 13.3 | 79 | 5.94× | 1.85e+07 | 2.7e+07 | 2.55e+04 | 8.08e+03 | +| lap2d_300 | 90000 | 478 | 807 | 1.69× | 7.38 | 50 | 6.77× | 7.02 | 49.9 | 7.11× | 1.21 | 5.94 | 4.92× | 2.43e+06 | 2.47e+06 | 1.61e-12 | 1.58e-12 | +| lap3d_40 | 64000 | 610 | 925 | 1.52× | 53 | 515 | 9.72× | 52.9 | 515 | 9.73× | 2.53 | 23.2 | 9.19× | 1.75e+07 | 1.44e+07 | 7.58e-14 | 6.27e-14 | + +## LDLᵀ + 2 refinement steps, K2 dumps + +`ldlt_ir2`, T16 (done), structure `S`, Float64, nrhs = 1. + +| matrix | n | analysis cuDSS ms | SDS ms | ratio | factorization cuDSS ms | SDS ms | ratio | refactorization cuDSS ms | SDS ms | ratio | solve cuDSS ms | SDS ms | ratio | nnz(L) cuDSS | SDS | relres cuDSS | SDS | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| kkt_pglib_opf_case118_ieee_k2_1 | 3185 | 28.9 | 133 | 4.59× | 0.664 | 2.63 | 3.96× | 0.357 | 2.59 | 7.26× | 0.489 | 7.54 | 15.44× | 2e+04 | 2.11e+04 | 0.000464 | 1.72e-08 | +| kkt_pglib_opf_case118_ieee_k2_10 | 3185 | 27.8 | 138 | 4.94× | 0.67 | 2.55 | 3.81× | 0.359 | 2.52 | 7.03× | 0.487 | 7.4 | 15.19× | 2e+04 | 2.06e+04 | 0.228 | 6.76e-10 | +| kkt_pglib_opf_case118_ieee_k2_20 | 3185 | 27.9 | 132 | 4.75× | 0.676 | 2.52 | 3.73× | 0.362 | 2.48 | 6.86× | 0.486 | 7.61 | 15.64× | 2e+04 | 2.06e+04 | 0.0968 | 1.29e-09 | +| kkt_pglib_opf_case1354_pegase_k2_1 | 33811 | 195 | 2.22e+03 | 11.39× | 1.67 | 10.3 | 6.17× | 1.37 | 10.3 | 7.49× | 1.27 | 43.2 | 34.00× | 2.02e+05 | 2.16e+05 | 35.3 | 0.0178 | +| kkt_pglib_opf_case1354_pegase_k2_10 | 33811 | 191 | 2.35e+03 | 12.26× | 1.67 | 9.51 | 5.68× | 1.36 | 9.41 | 6.90× | 1.26 | 42.5 | 33.87× | 2.02e+05 | 2.2e+05 | 60.4 | 0.657 | +| kkt_pglib_opf_case1354_pegase_k2_20 | 33811 | 193 | 2.36e+03 | 12.21× | 2.2 | 10.1 | 4.57× | 1.37 | 10 | 7.34× | 1.27 | 44.8 | 35.25× | 2.02e+05 | 2.21e+05 | 102 | 0.0683 | +| kkt_pglib_opf_case14_ieee_k2_1 | 347 | 12 | 6.89 | 0.57× | 0.2 | 0.934 | 4.67× | 0.188 | 0.92 | 4.90× | 0.295 | 3.21 | 10.87× | 2.05e+03 | 2.15e+03 | 3.35e-05 | 5.98e-13 | +| kkt_pglib_opf_case14_ieee_k2_10 | 347 | 12 | 7.22 | 0.60× | 0.199 | 0.943 | 4.74× | 0.192 | 0.925 | 4.83× | 0.296 | 3.23 | 10.93× | 2.05e+03 | 2.16e+03 | 5.93e-07 | 3.12e-14 | +| kkt_pglib_opf_case14_ieee_k2_15 | 347 | 12 | 5.47 | 0.46× | 0.197 | 1.56 | 7.95× | 0.18 | 1.53 | 8.52× | 0.592 | 3.58 | 6.06× | 2.05e+03 | 3.23e+03 | 2.35e-07 | 1.77e-09 | +| kkt_pglib_opf_case78484_epigrids_k2_1 | 2091669 | 2.01e+04 | 4.93e+05 | 24.49× | 48 | 2.29e+03 | 47.69× | 47.2 | 2.27e+03 | 47.98× | 46.1 | 6.18e+03 | 133.91× | 1.85e+07 | 2.77e+07 | 6.01e+06 | 4.13e+05 | +| kkt_pglib_opf_case78484_epigrids_k2_10 | 2091669 | 2.01e+04 | 5.61e+05 | 27.85× | 47.4 | 2.26e+03 | 47.65× | 46.5 | 2.23e+03 | 48.04× | 46.2 | 6.06e+03 | 131.15× | 1.85e+07 | 2.7e+07 | 6.25e+05 | 1.99e+05 | + +## Uniform batch of 8, Cholesky + +`ubatch8`, T17 (done), structure `SPD`, Float64, nrhs = 1, uniform batch of 8. + +| matrix | n | analysis cuDSS ms | SDS ms | ratio | factorization cuDSS ms | SDS ms | ratio | refactorization cuDSS ms | SDS ms | ratio | solve cuDSS ms | SDS ms | ratio | nnz(L) cuDSS | SDS | relres cuDSS | SDS | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| kkt_pglib_opf_case118_ieee_condensed_1 | 1088 | 19.9 | 10.7 | 0.54× | 0.966 | 1.17 | 1.21× | 0.627 | 1.14 | 1.82× | 0.265 | 1.22 | 4.61× | 1.52e+04 | 1.16e+04 | 1.84e-05 | 0.000101 | +| kkt_pglib_opf_case118_ieee_condensed_10 | 1088 | 19.9 | 10.7 | 0.54× | 0.952 | 1.15 | 1.20× | 0.626 | 1.12 | 1.79× | 0.265 | 1.27 | 4.78× | 1.52e+04 | 1.16e+04 | 1.35e-05 | 1.03e-05 | +| kkt_pglib_opf_case118_ieee_condensed_20 | 1088 | 19.9 | 10.7 | 0.54× | 0.962 | 1.15 | 1.19× | 0.627 | 1.13 | 1.80× | 0.264 | 1.3 | 4.93× | 1.52e+04 | 1.16e+04 | 0.146 | 0.0495 | +| kkt_pglib_opf_case1354_pegase_condensed_1 | 11192 | 97.9 | 122 | 1.25× | 3.13 | 4.81 | 1.54× | 2.82 | 4.76 | 1.69× | 0.644 | 1.76 | 2.73× | 1.55e+05 | 1.21e+05 | 0.000317 | 0.000273 | +| kkt_pglib_opf_case1354_pegase_condensed_10 | 11192 | 98.8 | 120 | 1.22× | 3.27 | 4.74 | 1.45× | 2.82 | 4.7 | 1.67× | 0.639 | 1.74 | 2.72× | 1.55e+05 | 1.21e+05 | 0.000154 | 0.000104 | +| kkt_pglib_opf_case1354_pegase_condensed_20 | 11192 | 100 | 121 | 1.21× | 3.12 | 4.74 | 1.52× | 2.82 | 4.71 | 1.67× | 0.642 | 1.72 | 2.68× | 1.55e+05 | 1.21e+05 | 7.5e-05 | 5.43e-05 | +| kkt_pglib_opf_case14_ieee_condensed_1 | 118 | 10.8 | 1.37 | 0.13× | 0.484 | 0.4 | 0.83× | 0.172 | 0.364 | 2.11× | 0.131 | 0.627 | 4.78× | 1.27e+03 | 1.03e+03 | 3.24e-07 | 9.03e-07 | +| kkt_pglib_opf_case14_ieee_condensed_10 | 118 | 10.8 | 1.37 | 0.13× | 0.484 | 0.392 | 0.81× | 0.171 | 0.398 | 2.33× | 0.131 | 0.633 | 4.82× | 1.27e+03 | 1.03e+03 | 2.89e-05 | 3.97e-05 | +| kkt_pglib_opf_case14_ieee_condensed_11 | 118 | 10.8 | 1.44 | 0.13× | 0.487 | 0.375 | 0.77× | 0.173 | 0.371 | 2.15× | 0.131 | 0.643 | 4.89× | 1.27e+03 | 1.03e+03 | 8.59e-06 | 7.96e-06 | +| kkt_pglib_opf_case78484_epigrids_condensed_1 | 674562 | 6.6e+03 | 1.06e+04 | 1.60× | 170 | 282 | 1.66× | 169 | 282 | 1.67× | 25.2 | 66.6 | 2.65× | 1.56e+07 | 1.32e+07 | 0.173 | 0.135 | +| kkt_pglib_opf_case78484_epigrids_condensed_10 | 674562 | 6.58e+03 | 1.06e+04 | 1.62× | 170 | 279 | 1.65× | 169 | 279 | 1.65× | 25.2 | 66.7 | 2.65× | 1.56e+07 | 1.32e+07 | 0.0176 | 0.0149 | + +## LU, unsymmetric + +`lu`, T19 (done), structure `G`, Float64, nrhs = 1. + +| matrix | n | analysis cuDSS ms | SDS ms | ratio | factorization cuDSS ms | SDS ms | ratio | refactorization cuDSS ms | SDS ms | ratio | solve cuDSS ms | SDS ms | ratio | nnz(L) cuDSS | SDS | relres cuDSS | SDS | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Rajat/rajat21 | 411676 | 3.76e+03 | 4.86e+03 | 1.29× | 42.2 | 646 | 15.29× | 51.5 | 646 | 12.54× | 4.32 | 32.7 | 7.55× | 6.89e+06 | 2.37e+06 | 3.34e+07 | 2.9e+03 | +| TSOPF/TSOPF_RS_b39_c7 | 14098 | 199 | 223 | 1.12× | 2.99 | 28.1 | 9.41× | 2.71 | 28.1 | 10.38× | 0.516 | 8.02 | 15.52× | 1.47e+06 | 5.22e+05 | 9.07e+29 | 20.3 | + +## Schur complement, Cholesky + +`schur`, T20 (done), structure `SPD`, Float64, nrhs = 1. + +| matrix | n | analysis cuDSS ms | SDS ms | ratio | factorization cuDSS ms | SDS ms | ratio | refactorization cuDSS ms | SDS ms | ratio | solve cuDSS ms | SDS ms | ratio | nnz(L) cuDSS | SDS | relres cuDSS | SDS | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| kkt_pglib_opf_case118_ieee_condensed_1 | 1088 | 24.9 | 11.6 | 0.47× | 0.709 | 1.57 | 2.21× | 0.335 | 1.5 | 4.47× | 0.142 | 0.754 | 5.33× | 1.96e+04 | 1.85e+04 | | | +| kkt_pglib_opf_case118_ieee_condensed_10 | 1088 | 19.7 | 11.8 | 0.60× | 0.647 | 1.58 | 2.44× | 0.34 | 1.52 | 4.46× | 0.143 | 0.735 | 5.15× | 1.96e+04 | 1.85e+04 | | | +| kkt_pglib_opf_case118_ieee_condensed_20 | 1088 | 19.7 | 11.7 | 0.60× | 3.38 | 1.5 | 0.44× | 0.353 | 1.48 | 4.18× | 0.159 | 0.675 | 4.25× | 1.96e+04 | 1.85e+04 | | | +| kkt_pglib_opf_case1354_pegase_condensed_1 | 11192 | 103 | 129 | 1.25× | 1.18 | 4.86 | 4.10× | 0.856 | 4.83 | 5.64× | 0.2 | 1.01 | 5.04× | 1.65e+05 | 1.31e+05 | | | +| kkt_pglib_opf_case1354_pegase_condensed_10 | 11192 | 99.9 | 128 | 1.28× | 1.18 | 4.83 | 4.11× | 0.851 | 4.81 | 5.64× | 0.213 | 0.973 | 4.57× | 1.65e+05 | 1.31e+05 | | | +| kkt_pglib_opf_case1354_pegase_condensed_20 | 11192 | 103 | 128 | 1.24× | 1.17 | 4.84 | 4.15× | 0.873 | 4.8 | 5.50× | 0.199 | 1.01 | 5.07× | 1.65e+05 | 1.31e+05 | | | +| kkt_pglib_opf_case14_ieee_condensed_1 | 118 | 10.8 | 1.61 | 0.15× | 0.218 | 0.504 | 2.31× | 0.197 | 0.464 | 2.36× | 0.115 | 0.405 | 3.52× | 1.51e+03 | 1.3e+03 | | | +| kkt_pglib_opf_case14_ieee_condensed_10 | 118 | 10.8 | 1.96 | 0.18× | 0.222 | 0.691 | 3.11× | 0.201 | 0.533 | 2.66× | 0.117 | 0.441 | 3.77× | 1.51e+03 | 1.3e+03 | | | +| kkt_pglib_opf_case14_ieee_condensed_11 | 118 | 10.8 | 2.01 | 0.19× | 0.222 | 0.545 | 2.46× | 0.195 | 0.508 | 2.60× | 0.116 | 0.435 | 3.75× | 1.51e+03 | 1.3e+03 | | | +| kkt_pglib_opf_case78484_epigrids_condensed_1 | 674562 | 6.65e+03 | 1.24e+04 | 1.86× | 26.7 | 166 | 6.22× | 26 | 166 | 6.37× | 1.89 | 9.71 | 5.14× | 1.57e+07 | 1.34e+07 | | | +| kkt_pglib_opf_case78484_epigrids_condensed_10 | 674562 | 6.66e+03 | 1.19e+04 | 1.79× | 26.8 | 165 | 6.18× | 26.2 | 165 | 6.30× | 1.89 | 9.59 | 5.06× | 1.57e+07 | 1.34e+07 | | | + +## LDLᵀ + matching (algo5), K2 dumps + +`ldlt_matching`, T21 (done), structure `S`, Float64, nrhs = 1. + +| matrix | n | analysis cuDSS ms | SDS ms | ratio | factorization cuDSS ms | SDS ms | ratio | refactorization cuDSS ms | SDS ms | ratio | solve cuDSS ms | SDS ms | ratio | nnz(L) cuDSS | SDS | relres cuDSS | SDS | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| kkt_pglib_opf_case118_ieee_k2_1 | 3185 | 39.3 | 30 | 0.76× | 6.89 | 3.38 | 0.49× | 0.841 | 3.36 | 4.00× | 0.279 | 1.81 | 6.47× | 7.41e+04 | 3.61e+04 | 3.1e-12 | 4.39e-12 | +| kkt_pglib_opf_case118_ieee_k2_10 | 3185 | 39.3 | 37.9 | 0.96× | 1.12 | 3.16 | 2.82× | 0.776 | 3.08 | 3.96× | 0.253 | 1.66 | 6.54× | 6.87e+04 | 3.47e+04 | 7.32e-13 | 7.39e-13 | +| kkt_pglib_opf_case118_ieee_k2_20 | 3185 | 39.5 | 31.3 | 0.79× | 2.16 | 3.52 | 1.63× | 0.809 | 3.48 | 4.30× | 0.261 | 1.76 | 6.74× | 6.93e+04 | 3.44e+04 | 5.12e-13 | 4.08e-13 | +| kkt_pglib_opf_case1354_pegase_k2_1 | 33811 | 255 | 349 | 1.37× | 3.75 | 16 | 4.28× | 3.1 | 16 | 5.16× | 0.524 | 2.53 | 4.83× | 7.23e+05 | 3.68e+05 | 0.00447 | 0.000311 | +| kkt_pglib_opf_case1354_pegase_k2_10 | 33811 | 259 | 374 | 1.44× | 3.34 | 14.8 | 4.43× | 3.03 | 14.7 | 4.86× | 0.517 | 2.48 | 4.80× | 7.21e+05 | 3.65e+05 | 0.00902 | 0.000577 | +| kkt_pglib_opf_case1354_pegase_k2_20 | 33811 | 260 | 419 | 1.61× | 3.49 | 14.8 | 4.23× | 3.17 | 14.7 | 4.64× | 0.526 | 2.26 | 4.29× | 7.23e+05 | 3.63e+05 | 0.0147 | 0.000394 | +| kkt_pglib_opf_case14_ieee_k2_1 | 347 | 13.6 | 4.13 | 0.30× | 0.33 | 1.23 | 3.73× | 0.313 | 1.19 | 3.80× | 0.17 | 0.832 | 4.89× | 7.11e+03 | 3.64e+03 | 7.95e-13 | 1.58e-12 | +| kkt_pglib_opf_case14_ieee_k2_10 | 347 | 13.1 | 3.95 | 0.30× | 0.295 | 1.04 | 3.54× | 0.283 | 1.02 | 3.61× | 0.159 | 0.811 | 5.10× | 6.76e+03 | 3.54e+03 | 5.73e-14 | 7.09e-14 | +| kkt_pglib_opf_case14_ieee_k2_15 | 347 | 13.1 | 4.07 | 0.31× | 0.301 | 1.06 | 3.53× | 0.286 | 1.06 | 3.70× | 0.157 | 0.857 | 5.46× | 6.76e+03 | 3.53e+03 | 9.6e-14 | 4.87e-14 | +| kkt_pglib_opf_case78484_epigrids_k2_1 | 2091669 | 2.43e+04 | 3.71e+04 | 1.53× | 130 | 714 | 5.49× | 129 | 685 | 5.31× | 14.4 | 43.1 | 2.99× | 7.45e+07 | 4.08e+07 | 1.06e+08 | 0.000612 | +| kkt_pglib_opf_case78484_epigrids_k2_10 | 2091669 | 2.48e+04 | 4.27e+04 | 1.73× | 132 | 760 | 5.74× | 132 | 726 | 5.51× | 14.4 | 49.9 | 3.46× | 7.41e+07 | 4.13e+07 | 6.03e+09 | 0.000205 | + +## Non-uniform batch, condensed dumps + +`nubatch`, T22 (pending), structure `SPD`, Float64, nrhs = 1. + +| matrix | n | analysis cuDSS ms | SDS ms | ratio | factorization cuDSS ms | SDS ms | ratio | refactorization cuDSS ms | SDS ms | ratio | solve cuDSS ms | SDS ms | ratio | nnz(L) cuDSS | SDS | relres cuDSS | SDS | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| batch_of_11 | 1386318 | 1.43e+04 | | | 53.1 | | | 50.9 | | | 11.9 | | | 3.16e+07 | | 0.14 | | + +## Partitioned-inverse solve (SDS algo1, cuDSS default) + +`solve_algo1`, T25 (pending), structure `SPD`, Float64, nrhs = 1. + +| matrix | n | analysis cuDSS ms | SDS ms | ratio | factorization cuDSS ms | SDS ms | ratio | refactorization cuDSS ms | SDS ms | ratio | solve cuDSS ms | SDS ms | ratio | nnz(L) cuDSS | SDS | relres cuDSS | SDS | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Boeing/bcsstk38 | 8032 | 61.4 | | | 5.15 | | | 2.81 | | | 0.647 | | | 7.99e+05 | | 2.53e-10 | | +| GHS_psdef/apache2 | 715176 | 7.66e+03 | | | 264 | | | 264 | | | 9.97 | | | 1.45e+08 | | 1.79e-10 | | +| HB/bcsstk17 | 10974 | 77.1 | | | 3.22 | | | 2.9 | | | 0.671 | | | 1.08e+06 | | 4.07e-11 | | +| kkt_pglib_opf_case118_ieee_condensed_1 | 1088 | 19.9 | | | 0.635 | | | 0.328 | | | 0.245 | | | 1.52e+04 | | 6.49e-05 | | +| kkt_pglib_opf_case118_ieee_condensed_10 | 1088 | 19.9 | | | 0.66 | | | 0.318 | | | 0.233 | | | 1.52e+04 | | 1.06e-05 | | +| kkt_pglib_opf_case118_ieee_condensed_20 | 1088 | 19.9 | | | 0.646 | | | 0.316 | | | 0.225 | | | 1.52e+04 | | 0.0836 | | +| kkt_pglib_opf_case1354_pegase_condensed_1 | 11192 | 100 | | | 1.1 | | | 0.784 | | | 0.345 | | | 1.55e+05 | | 0.000326 | | +| kkt_pglib_opf_case1354_pegase_condensed_10 | 11192 | 98 | | | 1.1 | | | 0.777 | | | 0.325 | | | 1.55e+05 | | 0.000152 | | +| kkt_pglib_opf_case1354_pegase_condensed_20 | 11192 | 98.2 | | | 1.87 | | | 0.764 | | | 0.326 | | | 1.55e+05 | | 8.09e-05 | | +| kkt_pglib_opf_case14_ieee_condensed_1 | 118 | 10.8 | | | 0.181 | | | 0.161 | | | 0.137 | | | 1.27e+03 | | 2.55e-07 | | +| kkt_pglib_opf_case14_ieee_condensed_10 | 118 | 10.8 | | | 0.181 | | | 0.16 | | | 0.136 | | | 1.27e+03 | | 3.97e-05 | | +| kkt_pglib_opf_case14_ieee_condensed_11 | 118 | 10.8 | | | 0.183 | | | 0.16 | | | 0.135 | | | 1.27e+03 | | 2.24e-05 | | +| kkt_pglib_opf_case78484_epigrids_condensed_1 | 674562 | 6.55e+03 | | | 25.1 | | | 24.4 | | | 3.8 | | | 1.56e+07 | | 0.155 | | +| kkt_pglib_opf_case78484_epigrids_condensed_10 | 674562 | 6.56e+03 | | | 25 | | | 24.4 | | | 3.8 | | | 1.56e+07 | | 0.0178 | | +| lap2d_300 | 90000 | 479 | | | 5.83 | | | 5.5 | | | 1.13 | | | 2.43e+06 | | 1.84e-12 | | +| lap3d_40 | 64000 | 612 | | | 46.3 | | | 42.9 | | | 2.41 | | | 1.75e+07 | | 8.21e-14 | | + +## Hybrid memory mode + +`hybrid_memory`, T26 (pending), structure `SPD`, Float64, nrhs = 1. + +| matrix | n | analysis cuDSS ms | SDS ms | ratio | factorization cuDSS ms | SDS ms | ratio | refactorization cuDSS ms | SDS ms | ratio | solve cuDSS ms | SDS ms | ratio | nnz(L) cuDSS | SDS | relres cuDSS | SDS | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| GHS_psdef/apache2 | 715176 | 7.71e+03 | | | 265 | | | 264 | | | 9.97 | | | 1.45e+08 | | 1.79e-10 | | +| kkt_pglib_opf_case1354_pegase_condensed_1 | 11192 | 99.2 | | | 1.16 | | | 0.781 | | | 0.327 | | | 1.55e+05 | | 0.000321 | | +| kkt_pglib_opf_case1354_pegase_condensed_10 | 11192 | 98.4 | | | 1.09 | | | 0.764 | | | 0.325 | | | 1.55e+05 | | 0.000146 | | +| kkt_pglib_opf_case1354_pegase_condensed_20 | 11192 | 98.2 | | | 1.09 | | | 0.764 | | | 0.327 | | | 1.55e+05 | | 7.71e-05 | | + +## Float32 factors, Float64 refinement + +`mixed_precision`, T27 (pending), structure `SPD`, Float64, nrhs = 1, no cuDSS counterpart. + +Not run yet. + +## Runs + +* cuDSS 0.8.0 on Quadro GV100, repository 8a452f0+dirty, 2026-10-08. +* SparseDirectSolver.jl 0.1.0 on Quadro GV100, repository 8a452f0+dirty, 2026-10-09. diff --git a/bench/comparison-gv100/comparison.png b/bench/comparison-gv100/comparison.png new file mode 100644 index 0000000000000000000000000000000000000000..7f35d6c1d97dd56f9ea0fbbb7b8701e8cb827bbc GIT binary patch literal 203636 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

Nz=KD4tw@30-sX|4-X%Rnv`}gmIQCKwq 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ŞgDN~x{)w^Nrp7MNP=bw-f(bTU0c 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 z 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

V3ABnk^edF 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 Zo7QLTi*di?^wy7eckSMBhs)6K{s*2? Date: Sat, 10 Oct 2026 08:18:23 -0400 Subject: [PATCH 24/65] fix: the fused kernel's completion test used the atomic's NEW value Atomix.@atomic x[i] += v returns the new value, so 'left == 1' in the TRSM and TILE completion branches fired on the second-to-last finisher: cb_done, arrived[parent] and the write-through ea_left were signaled while one block of the front was still writing its contribution. A fence cannot repair a logically early signal; architectures with 2048 threads/SM (sm_70/80/90) hid it behind co-scheduling latency, 1536-thread parts (sm_86/89, Blackwell) and gfx906 exposed it. Found by SASS-and-protocol audit (the wide-front roles already used the safe spin-to-zero design, narrow roles kept the last-decrementer shortcut with the wrong-value test). left == 0 is the true last finisher. Measured: L40S race_repro 20/20 bad -> 0/20; AMD Radeon VII merged-launch stress 3/12 bad -> 0/24; GV100/H200 unchanged-clean. --- bench/e2e/SDSProto.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bench/e2e/SDSProto.jl b/bench/e2e/SDSProto.jl index d51fcbc2..95248cac 100644 --- a/bench/e2e/SDSProto.jl +++ b/bench/e2e/SDSProto.jl @@ -953,7 +953,7 @@ end if li == 1 Threads.atomic_fence() left = (Atomix.@atomic trsm_left[v] += Int32(-1)) - if left == Int32(1) && tiles_left[v] == Int32(0) + if left == Int32(0) && tiles_left[v] == Int32(0) # new-value semantics: 0 = last finisher Atomix.@atomic cb_done[v] += Int32(1) pv = Int(snparent[v]) pv > 0 && (Atomix.@atomic arrived[pv] += Int32(1)) @@ -1053,7 +1053,7 @@ end if li == 1 Threads.atomic_fence() left = (Atomix.@atomic tiles_left[v] += Int32(-1)) - if left == Int32(1) + if left == Int32(0) # Atomix += returns the NEW value: 0 = last finisher Atomix.@atomic cb_done[v] += Int32(1) pv = Int(snparent[v]) pv > 0 && (Atomix.@atomic arrived[pv] += Int32(1)) From 719dcd99f4501792a3f7c3da77e275b703e14ff6 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Sat, 10 Oct 2026 09:11:37 -0400 Subject: [PATCH 25/65] bench/e2e: wide-front roles in SDSProto + per-device wide strategy in the wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SDSProto gains the in-kernel blocked wide-front factorization (WPANEL/WTRSM/ WTILE/WSIG roles with per-panel dependency counters, all-thread release fences, the corrected last-finisher test) and build_fused_plan grows inkernel_wides/merge_segments. The MadNLP wrapper picks the strategy per device: parts with < 2048 threads/SM (sm_86/89, Blackwell workstation, AMD) take merged in-kernel wides — measured 53.0 -> 32.4 ms on an RTX 3080 and 34.1 -> 16.2 ms on an L40S — while sm_70/80/90 keep the host vendor wides (24.2 vs 25.9 ms on a GV100). sds_wides = "host"/"merged" overrides. Correctness: 20/20 gated refactorizations clean on 3080/L40S/RTX Pro 6000, 24/24 on Radeon VII merged, GV100/H200 unchanged. --- bench/e2e/MadNLPSDS.jl | 19 ++- bench/e2e/SDSProto.jl | 280 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 289 insertions(+), 10 deletions(-) diff --git a/bench/e2e/MadNLPSDS.jl b/bench/e2e/MadNLPSDS.jl index 89be171f..5b9d7d9a 100644 --- a/bench/e2e/MadNLPSDS.jl +++ b/bench/e2e/MadNLPSDS.jl @@ -28,6 +28,10 @@ Base.@kwdef mutable struct SDSSolverOptions <: MadNLP.AbstractOptions sds_nd_seed::Int = 3 sds_amalgamation::NamedTuple = (max_width = 48, zero_fraction = 0.25, min_width = 8) sds_user_perm::Union{Nothing, Vector{Int}} = nothing + # wide-front strategy: "auto" picks by device (merged in-kernel wides on parts with + # < 2048 threads/SM — sm_86/89, Blackwell workstation, AMD — measured 1.6-2.1x there; + # host vendor wides on sm_70/80/90 where they are ~1.7 ms faster); "host"/"merged" force + sds_wides::String = "auto" # on-disk permutation cache: the tuned METIS ordering costs ~2x the default # (tens of seconds at this size), so the computed permutation is cached keyed # by the matrix pattern + ordering knobs and reloaded on any later solver @@ -35,6 +39,18 @@ Base.@kwdef mutable struct SDSSolverOptions <: MadNLP.AbstractOptions sds_perm_cache::String = joinpath(homedir(), ".julia", "sds_perm_cache") end +# merged in-kernel wides win on parts with < 2048 threads/SM (and on AMD); the CUDA +# module is reached through the backend's parent module, so this file stays backend-free +function _prefer_merged(backend, opt::SDSSolverOptions) + opt.sds_wides == "merged" && return true + opt.sds_wides == "host" && return false + if occursin("CUDABackend", string(typeof(backend))) + M = parentmodule(typeof(backend)) + return M.attribute(M.device(), M.DEVICE_ATTRIBUTE_MAX_THREADS_PER_MULTIPROCESSOR) < 2048 + end + return true +end + function _perm_cache_file(opt::SDSSolverOptions, csc) (opt.sds_perm_cache == "" || opt.sds_user_perm !== nothing) && return nothing n = size(csc, 1) @@ -179,7 +195,8 @@ function SDSProtoSolver( SDS.execute!("factorization", s, x, b; asynchronous = false) SDS.execute!("solve", s, x, b; asynchronous = false) # allocates the solve workspace nzval = SDS._factor_values(s) - fp = SDSProto.build_fused_plan(s) + merged = _prefer_merged(backend, opt) + fp = SDSProto.build_fused_plan(s; inkernel_wides = merged, merge_segments = merged) inv11, inv_ptr, invertf, _ = SDSProto.build_inverse(s) # solve-side plan state (module globals, single solver instance) plan0 = s.workspace.plan diff --git a/bench/e2e/SDSProto.jl b/bench/e2e/SDSProto.jl index 95248cac..fae8b4f0 100644 --- a/bench/e2e/SDSProto.jl +++ b/bench/e2e/SDSProto.jl @@ -746,6 +746,10 @@ const ROLE_PANEL_C = Int8(2) const ROLE_TRSM = Int8(3) const ROLE_TILE = Int8(4) const ROLE_EA = Int8(5) +const ROLE_WPANEL = Int8(6) # wide fronts in-kernel: blocked 64-panel Cholesky +const ROLE_WTRSM = Int8(7) # (rb = panel k | rb = chunk, rc = k | rc = k*1024 + tjj) +const ROLE_WTILE = Int8(8) +const ROLE_WSIG = Int8(9) # spin with backoff on an Int32 cell until pred(value) holds; thread 1 only @inline function _spin_until_eq!(arr, idx, target, scratch) @@ -767,6 +771,7 @@ end cb_done, @Const(snparent), @Const(front_ptr), @Const(front_nrows), @Const(front_ncols), @Const(cb_ptr), @Const(child_ptr), @Const(child_list), @Const(relind_ptr), @Const(relind), base, + @Const(wbase), wupd, wpdone2, wtrsml, wcbl, ::Val{WG}) where {WG} @uniform TT = eltype(factor) @uniform RT = real(eltype(factor)) @@ -813,8 +818,9 @@ end end end @synchronize + Threads.atomic_fence() # ALL threads: each wave drains its own stores (gfx9) + @synchronize if li == 1 - Threads.atomic_fence() Atomix.@atomic ea_left[v] += Int32(-1) end elseif r == ROLE_PANEL_B || r == ROLE_PANEL_C @@ -902,15 +908,17 @@ end SDS._front_syrk!(factor, pcb, st, v, li, front_ptr, front_nrows, front_ncols, cb2, Val(WG)) @synchronize + Threads.atomic_fence() # ALL threads: each wave drains its own stores (gfx9) + @synchronize if li == 1 - Threads.atomic_fence() Atomix.@atomic cb_done[v] += Int32(1) pv = Int(snparent[v]) pv > 0 && (Atomix.@atomic arrived[pv] += Int32(1)) end else + Threads.atomic_fence() # ALL threads (gfx9) + @synchronize if li == 1 - Threads.atomic_fence() Atomix.@atomic panel_done[v] += Int32(1) end end @@ -950,8 +958,9 @@ end end end @synchronize + Threads.atomic_fence() # ALL threads: each wave drains its own stores (gfx9) + @synchronize if li == 1 - Threads.atomic_fence() left = (Atomix.@atomic trsm_left[v] += Int32(-1)) if left == Int32(0) && tiles_left[v] == Int32(0) # new-value semantics: 0 = last finisher Atomix.@atomic cb_done[v] += Int32(1) @@ -1050,8 +1059,9 @@ end end end @synchronize + Threads.atomic_fence() # ALL threads: each wave drains its own stores (gfx9) + @synchronize if li == 1 - Threads.atomic_fence() left = (Atomix.@atomic tiles_left[v] += Int32(-1)) if left == Int32(0) # Atomix += returns the NEW value: 0 = last finisher Atomix.@atomic cb_done[v] += Int32(1) @@ -1060,6 +1070,208 @@ end Int(wt[v]) > 0 && (Atomix.@atomic ea_left[Int(wt[v])] += Int32(-1)) end end + elseif r == ROLE_WPANEL + v = Int(ra[G]); k = Int(rb[G]) + sl = Int(wbase[v]) + k + if li == 1 + if k == 1 + _spin_until_eq!(ea_left, v, Int32(0), fscr) + else + _spin_until_eq!(wupd, sl, Int32(0), fscr) + end + end + @synchronize + Threads.atomic_fence() + f = Int(front_nrows[v]); w = Int(front_ncols[v]) + c0 = (k - 1) * 64 + kb = min(64, w - c0) + p0 = Int(front_ptr[v]) + li == 1 && (st[1] = Int32(0)) + @synchronize + for q in (li - 1):WG:(kb * kb - 1) + jj = q ÷ kb + 1 + ii = q - (jj - 1) * kb + 1 + ii >= jj && (sh[SDS._packed(ii, jj, 64)] = factor[p0 + (c0 + jj - 1) * f + c0 + ii - 1]) + end + @synchronize + for j in 1:kb + if li == 1 + d = real(sh[SDS._packed(j, j, 64)]) + if st[1] == Int32(0) && d > zero(RT) + piv[1] = sqrt(d) + sh[SDS._packed(j, j, 64)] = piv[1] + elseif st[1] == Int32(0) + st[1] = Int32(c0 + j) + piv[1] = one(RT) + end + end + @synchronize + pvt = piv[1] + if st[1] == Int32(0) + for i in (j + li):WG:kb + sh[SDS._packed(i, j, 64)] /= pvt + end + end + @synchronize + if st[1] == Int32(0) + for q in (li - 1):WG:(kb * kb - 1) + jj = q ÷ kb + 1 + ii = q - (jj - 1) * kb + 1 + if ii >= jj && jj > j + sh[SDS._packed(ii, jj, 64)] -= sh[SDS._packed(ii, j, 64)] * sh[SDS._packed(jj, j, 64)] + end + end + end + @synchronize + end + for q in (li - 1):WG:(kb * kb - 1) + jj = q ÷ kb + 1 + ii = q - (jj - 1) * kb + 1 + ii >= jj && (factor[p0 + (c0 + jj - 1) * f + c0 + ii - 1] = sh[SDS._packed(ii, jj, 64)]) + end + li == 1 && (k == 1 || st[1] != Int32(0)) && (info[v] = st[1]) + @synchronize + Threads.atomic_fence() # ALL threads: each wave drains its own stores (gfx9) + @synchronize + if li == 1 + Atomix.@atomic wpdone2[sl] += Int32(1) + end + elseif r == ROLE_WTRSM + v = Int(ra[G]); ch = Int(rb[G]); k = Int(rc[G]) + sl = Int(wbase[v]) + k + if li == 1 + _spin_until_eq!(wpdone2, sl, Int32(1), fscr) + end + @synchronize + Threads.atomic_fence() + f = Int(front_nrows[v]); w = Int(front_ncols[v]) + c0 = (k - 1) * 64 + kb = min(64, w - c0) + p0 = Int(front_ptr[v]) + for q in li:WG:(kb * (kb + 1) ÷ 2) + j = 1 + acc = kb + qq = q + while qq > acc + qq -= acc + acc -= 1 + j += 1 + end + i = j + qq - 1 + sh[SDS._packed(i, j, 64)] = factor[p0 + (c0 + j - 1) * f + c0 + i - 1] + end + @synchronize + if info[v] == Int32(0) + i = c0 + kb + (ch - 1) * WG + li + if i <= f + for kk in 1:kb + x = factor[p0 + (c0 + kk - 1) * f + i - 1] + for j in 1:(kk - 1) + x -= factor[p0 + (c0 + j - 1) * f + i - 1] * sh[SDS._packed(kk, j, 64)] + end + factor[p0 + (c0 + kk - 1) * f + i - 1] = x / sh[SDS._packed(kk, kk, 64)] + end + end + end + @synchronize + Threads.atomic_fence() # ALL threads: each wave drains its own stores (gfx9) + @synchronize + if li == 1 + Atomix.@atomic wtrsml[sl] += Int32(-1) + end + elseif r == ROLE_WTILE + v = Int(ra[G]); tii = Int(rb[G]) + k = Int(rc[G]) ÷ 1024; tjj = Int(rc[G]) % 1024 + sl = Int(wbase[v]) + k + if li == 1 + _spin_until_eq!(wtrsml, sl, Int32(0), fscr) + end + @synchronize + Threads.atomic_fence() + f = Int(front_nrows[v]); w = Int(front_ncols[v]) + m = f - w + c0 = (k - 1) * 64 + kb = min(64, w - c0) + p0 = Int(front_ptr[v]) + rb0 = c0 + kb + i0 = rb0 + (tii - 1) * TILE + j0 = rb0 + (tjj - 1) * TILE + ok = info[v] == Int32(0) + acc1 = zero(TT); acc2 = zero(TT); acc3 = zero(TT); acc4 = zero(TT) + acc5 = zero(TT); acc6 = zero(TT); acc7 = zero(TT); acc8 = zero(TT) + kk0 = 0 + while kk0 < kb + kbk = min(32, kb - kk0) + if ok + for q in (li - 1):WG:(TILE * kbk - 1) + rr = q % TILE + 1 + cc = q ÷ TILE + 1 + row = i0 + rr + sh[rr + (cc - 1) * TILE] = row <= f ? factor[p0 + (c0 + kk0 + cc - 1) * f + row - 1] : zero(TT) + row2 = j0 + rr + sh[1024 + rr + (cc - 1) * TILE] = + row2 <= f ? factor[p0 + (c0 + kk0 + cc - 1) * f + row2 - 1] : zero(TT) + end + end + @synchronize + if ok + for (slot, q) in enumerate((li - 1):WG:(TILE * TILE - 1)) + rr = q % TILE + 1 + cc = q ÷ TILE + 1 + a = zero(TT) + for kk in 1:kbk + a += sh[rr + (kk - 1) * TILE] * sh[1024 + cc + (kk - 1) * TILE] + end + slot == 1 && (acc1 += a); slot == 2 && (acc2 += a) + slot == 3 && (acc3 += a); slot == 4 && (acc4 += a) + slot == 5 && (acc5 += a); slot == 6 && (acc6 += a) + slot == 7 && (acc7 += a); slot == 8 && (acc8 += a) + end + end + @synchronize + kk0 += 32 + end + if ok + c0cb = Int(cb2[v]) + for (slot, q) in enumerate((li - 1):WG:(TILE * TILE - 1)) + rr = q % TILE + 1 + cc = q ÷ TILE + 1 + ii = i0 + rr + jj = j0 + cc + if ii <= f && ii >= jj + a = slot == 1 ? acc1 : slot == 2 ? acc2 : slot == 3 ? acc3 : slot == 4 ? acc4 : + slot == 5 ? acc5 : slot == 6 ? acc6 : slot == 7 ? acc7 : acc8 + if jj <= w + Atomix.@atomic factor[p0 + (jj - 1) * f + ii - 1] -= a + elseif c0cb > 0 + Atomix.@atomic pcb[c0cb + SDS._packed(ii - w, jj - w, m) - 1] -= a + end + end + end + end + @synchronize + Threads.atomic_fence() # ALL threads: each wave drains its own stores (gfx9) + @synchronize + if li == 1 + if j0 < w + Atomix.@atomic wupd[Int(wbase[v]) + j0 ÷ 64 + 1] += Int32(-1) + end + if j0 + TILE > w + Atomix.@atomic wcbl[v] += Int32(-1) + end + end + elseif r == ROLE_WSIG + v = Int(ra[G]); K = Int(rb[G]) + sl = Int(wbase[v]) + K + if li == 1 + _spin_until_eq!(wpdone2, sl, Int32(1), fscr) + _spin_until_eq!(wtrsml, sl, Int32(0), fscr) + _spin_until_eq!(wcbl, v, Int32(0), fscr) + Threads.atomic_fence() + Atomix.@atomic cb_done[v] += Int32(1) + pv = Int(snparent[v]) + pv > 0 && (Atomix.@atomic arrived[pv] += Int32(1)) + end end end end @@ -1122,7 +1334,7 @@ end # --------------------------------------------------------------------------- # host: the topologically ordered role list + counter bases + stream-2 program -function build_fused_plan(s) +function build_fused_plan(s; inkernel_wides::Bool = false, merge_segments::Bool = false) S = s.symbolic plan = s.numeric.plan nodes = S.schedule.group_nodes @@ -1205,6 +1417,41 @@ function build_fused_plan(s) return nothing end + # in-kernel wides: per-(front, panel) counter slots; host arithmetic MIRRORS the + # kernel's decrement conditions exactly (j0 < w -> wupd[kj]; j0 + TILE > w -> wcb[v]) + wbase_h = zeros(Int32, ns) + wupd_b = Int32[]; wtl_b = Int32[] + wcb_b = zeros(Int32, ns) + nwslots = 0 + emit_wide(v) = begin + f = rows_h[v]; w = width_h[v] + K = cld(w, 64) + wbase_h[v] = Int32(nwslots) + append!(wupd_b, zeros(Int32, K)); append!(wtl_b, zeros(Int32, K)) + for k in 1:K + push!(role, ROLE_WPANEL); push!(ra, Int32(v)); push!(rb, Int32(k)); push!(rc, Int32(0)) + c0 = (k - 1) * 64 + kb = min(64, w - c0) + nbelow = f - (c0 + kb) + nch = cld(nbelow, WGF) + for ch in 1:nch + push!(role, ROLE_WTRSM); push!(ra, Int32(v)); push!(rb, Int32(ch)); push!(rc, Int32(k)) + end + wtl_b[nwslots + k] = Int32(nch) + nt = cld(nbelow, TILE) + for tjj in 1:nt, tii in tjj:nt + push!(role, ROLE_WTILE); push!(ra, Int32(v)); push!(rb, Int32(tii)) + push!(rc, Int32(k * 1024 + tjj)) + j0 = c0 + kb + (tjj - 1) * TILE + j0 < w && (wupd_b[nwslots + j0 ÷ 64 + 1] += Int32(1)) + j0 + TILE > w && (wcb_b[v] += Int32(1)) + end + end + push!(role, ROLE_WSIG); push!(ra, Int32(v)); push!(rb, Int32(K)); push!(rc, Int32(0)) + nwslots += K + return nothing + end + segments = NTuple{3, Any}[] # (base, count, wides-at-boundary) seg_base = 0 for k in eachindex(plan.group_first) @@ -1234,8 +1481,14 @@ function build_fused_plan(s) width_h[nodes[q]] <= 64 && emit_split(nodes[q]) end if !isempty(gwides) - push!(segments, (seg_base, length(role) - seg_base, gwides)) - seg_base = length(role) + if inkernel_wides + foreach(emit_wide, gwides) + gwides = Int[] + end + if !(inkernel_wides && merge_segments) # merged: one launch, no split + push!(segments, (seg_base, length(role) - seg_base, gwides)) + seg_base = length(role) + end append!(wides, gwides) end end @@ -1284,6 +1537,10 @@ function build_fused_plan(s) arrived = KA.zeros(BACKEND[], Int32, ns), ea = KA.zeros(BACKEND[], Int32, ns), trsm = KA.zeros(BACKEND[], Int32, ns), tiles = KA.zeros(BACKEND[], Int32, ns), pdone = KA.zeros(BACKEND[], Int32, ns), cbd = KA.zeros(BACKEND[], Int32, ns), + wbase = DVEC(wbase_h), wupd_b = DVEC(wupd_b), wtl_b = DVEC(wtl_b), + wcb_b = DVEC(wcb_b), wupd = KA.zeros(BACKEND[], Int32, max(nwslots, 1)), + wtl = KA.zeros(BACKEND[], Int32, max(nwslots, 1)), wpd = KA.zeros(BACKEND[], Int32, max(nwslots, 1)), + wcb = KA.zeros(BACKEND[], Int32, ns), s2 = nothing) end @@ -1298,6 +1555,10 @@ function refact_fused!(s, nzval, fp; nstreams::Int = 1) copyto!(fp.tiles, fp.tiles_base) copyto!(fp.cbd, fp.cb_base) fill!(fp.pdone, Int32(0)) + length(fp.wupd_b) > 0 && copyto!(fp.wupd, fp.wupd_b) + length(fp.wtl_b) > 0 && copyto!(fp.wtl, fp.wtl_b) + fill!(fp.wpd, Int32(0)) + copyto!(fp.wcb, fp.wcb_b) for k in eachindex(plan.sub_first) launch_subtrees_sorted!(N, S, nzval, fp.ssub[k], plan.sub_local[k], backend) end @@ -1318,7 +1579,8 @@ function refact_fused!(s, nzval, fp; nstreams::Int = 1) fp.arrived, fp.ea, fp.pdone, fp.trsm, fp.tiles, fp.cbd, S.snparent, S.front_ptr, S.front_nrows, S.front_ncols, S.cb_ptr, S.child_ptr, S.child_list, S.relind_ptr, S.relind, - Int32(base), Val(WGF); ndrange = WGF * count) + Int32(base), fp.wbase, fp.wupd, fp.wpd, fp.wtl, fp.wcb, + Val(WGF); ndrange = WGF * count) if true for v in gwides SDS._factor_panel_c!(N.factor, fp.pcb, N.work, N.info, v, S.layout.panel_ptr[v], From 5332ed23fda62ec9c2dfb7ac7b1f40b624db1058 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Sat, 10 Oct 2026 09:18:36 -0400 Subject: [PATCH 26/65] bench/e2e: the backend's root module (CUDACore under the CUDA 6 split) carries the device attributes, not the KA sub-module --- bench/e2e/MadNLPSDS.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bench/e2e/MadNLPSDS.jl b/bench/e2e/MadNLPSDS.jl index 5b9d7d9a..e1a0ae19 100644 --- a/bench/e2e/MadNLPSDS.jl +++ b/bench/e2e/MadNLPSDS.jl @@ -45,7 +45,7 @@ function _prefer_merged(backend, opt::SDSSolverOptions) opt.sds_wides == "merged" && return true opt.sds_wides == "host" && return false if occursin("CUDABackend", string(typeof(backend))) - M = parentmodule(typeof(backend)) + M = Base.moduleroot(parentmodule(typeof(backend))) # CUDACore (CUDA 6 split) or CUDA return M.attribute(M.device(), M.DEVICE_ATTRIBUTE_MAX_THREADS_PER_MULTIPROCESSOR) < 2048 end return true From 91c9bf4637788fcabb14e65bfc989fe297f66c9f Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Sat, 10 Oct 2026 09:23:10 -0400 Subject: [PATCH 27/65] bench/e2e: GPU detection reads /dev/nvidia*//dev/kfd first; NVML can be wedged (driver updated under a loaded module) while the compute path works --- bench/e2e/fresh_machine.jl | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bench/e2e/fresh_machine.jl b/bench/e2e/fresh_machine.jl index 46dedd05..14b9fd2f 100644 --- a/bench/e2e/fresh_machine.jl +++ b/bench/e2e/fresh_machine.jl @@ -17,15 +17,15 @@ _works(cmd) = try catch false end -_has_nvidia_gpu() = try - occursin("GPU", read(`nvidia-smi -L`, String)) # exit 0 AND at least one device listed -catch - false -end +# device files first: they answer correctly even when NVML/nvidia-smi is wedged +# (driver updated under a loaded module — a real failure mode); tools as fallback +_has_nvidia_gpu() = !isempty(filter(f -> occursin(r"^nvidia\d+$", f), readdir("/dev"))) || + try occursin("GPU", read(`nvidia-smi -L`, String)) catch; false end +_has_amd_gpu() = ispath("/dev/kfd") || _works(`rocm-smi`) || _works(`rocminfo`) const BACKEND = get(ENV, "SDS_BACKEND") do _has_nvidia_gpu() ? "cuda" : - (_works(`rocm-smi`) || _works(`rocminfo`)) ? "amdgpu" : - error("no working GPU tool found (nvidia-smi / rocm-smi); set SDS_BACKEND=cuda|amdgpu") + _has_amd_gpu() ? "amdgpu" : + error("no GPU found (/dev/nvidia*, /dev/kfd, nvidia-smi, rocm-smi all absent); set SDS_BACKEND=cuda|amdgpu") end Pkg.activate(joinpath(homedir(), ".sds-fresh-" * BACKEND)) Pkg.add(["MadNLP", "MadNLPGPU", "ExaModels", "ExaModelsPower", "Metis"]) From 35afa6b346802410e3ba67450ce2e61a3b8a06bc Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Sat, 10 Oct 2026 13:20:22 -0400 Subject: [PATCH 28/65] bench/e2e: all tuning knobs in one runtime SDSTuning bundle; per-arch tuning documented SDSTuning carries every performance knob (wide strategy, subtree budgets and parallelism, regime widths, amalgamation, fused workgroup size, spin backoff cap, METIS nseps/seed) as plain runtime fields; default_tuning(backend) is the single per-architecture defaults table (2048-threads/SM NVIDIA: host wides, 16K; 1536-thread parts: merged, 8K; AMD gfx906-measured: merged, 8K, workgroup 256, cap 16). SDSProto's fused kernel now takes the workgroup size per plan and the spin cap as a kernel argument, so nothing in the wrapper path is compile-time or environment-dependent. bench/ARCH-TUNING.md documents the tuning procedure (gates, interleaving, occupancy arithmetic, knob mechanisms) and the measured champions. --- bench/ARCH-TUNING.md | 103 +++++++++++++++++++++++++++++++ bench/e2e/MadNLPSDS.jl | 122 ++++++++++++++++++++++--------------- bench/e2e/SDSProto.jl | 63 +++++++++---------- bench/e2e/fresh_machine.jl | 7 +-- 4 files changed, 211 insertions(+), 84 deletions(-) create mode 100644 bench/ARCH-TUNING.md diff --git a/bench/ARCH-TUNING.md b/bench/ARCH-TUNING.md new file mode 100644 index 00000000..325f197d --- /dev/null +++ b/bench/ARCH-TUNING.md @@ -0,0 +1,103 @@ +# Per-architecture tuning of the fused factorization + +How the October 2026 campaign tuned the fused refactorization prototype per GPU +architecture: the knob inventory, the measurement discipline, the step-by-step +procedure for a new device, the mechanisms behind each knob, and the measured +champions. Workload throughout: the 78k-bus ACOPF condensed KKT +(`pglib_opf_case78484_epigrids`, n = 674562, nnz(L) ≈ 14.3M), tuned ordering +(METIS nseps = 4, seed = 3). + +## Ground rules (these produced every number below) + +1. **Gate every configuration.** A trial is valid only with `info == 0`, + elementwise factor delta ~1e-9 against the stock factorization, and the + residual matching stock. An `info == 0` check alone is insufficient: one + race produced a silently wrong factor (Δ ≈ 1e-5) with clean info. +2. **Interleave with the baseline.** Shared boxes drift by 2× within an hour; + A/B/A/B in one process is the only defensible comparison. Three pairs + minimum; treat sub-2% differences as noise. +3. **Correctness before speed on a new chip.** Run 20 gated refactorizations + (`bench/race_repro.jl 20`) before believing any timing. This is what caught + the sm_86/89/Blackwell/gfx906 completion-signal race that sm_70/80/90 hide. +4. **Occupancy arithmetic before submitting sweeps.** Most structural knobs are + decided by `blocks/SM = min(smem_per_SM ÷ smem_per_block, + threads_per_SM ÷ workgroup)`; compute it first and you can predict the sign + of the result (every prediction below held). + +## Knob inventory + +| knob | where | kind | mechanism | +|---|---|---|---| +| wide-front strategy (`wk`/`mseg`) | plan builder | runtime | host vendor calls vs in-kernel blocked wides + single merged launch; trades ~100 host launches against in-kernel spin residency | +| subtree budget (`subtree_budgets`) | solver option | runtime | regime-A local-memory class; smaller class = more subtree workgroups resident per SM/CU | +| subtree parallelism / amalgamation | solver options | runtime | subtree formation and supernode merging; saturated on every tested arch except amal 32→48 (small win on big-SM parts) | +| ordering (`nd_nseps`, `nd_seed`, `user_perm` + on-disk perm cache) | solver/wrapper | runtime | fill/flops; arch-independent; pay METIS once, cache the permutation | +| panel width (`pw`) | plan + kernel `Val` | runtime per-plan | fronts ≤ pw factor in-kernel; packed panel = pw(pw+1)/2 · 8B of shared memory **for every block of the kernel** (one kernel = one shared size) | +| tile size (`tl`) | plan + kernel `Val` | runtime per-plan | syrk tile; shared halves = 2·32·tl · 8B; accumulators = tl²/WGF registers per thread | +| workgroup size (`SDS_WGF`) | env (const at include) | per-process | waves per workgroup; latency hiding on wave64 AMD; occupancy on 1536-thread parts | +| spin backoff cap (`SDS_SPIN_CAP`) | env (const at include) | per-process | dependency-counter wake-up latency vs polling traffic | +| CUDA graph replay (`graph`) | run_fused kwarg | runtime | whole refactorization captured once, replayed as one launch | + +## Procedure for a new architecture + +1. **Correctness:** `race_repro.jl 20` (kernel-level, <2 GB device memory). + Any failure: stop, bisect kernel-vs-src-vs-config before timing anything. +2. **Phase profile:** `profile_refact()` — sync-inflated but reveals the split + (resets / subtrees / mega kernel / host wides / stats). The dominant phase + picks the first lever: host-wides share ≥50% ⇒ try merged wides first. +3. **Wide strategy:** `mseg_ab.jl`. Decision so far follows one attribute: + `MAX_THREADS_PER_MULTIPROCESSOR < 2048` (sm_86/89, Blackwell workstation) + or AMD ⇒ merged wins big (1.6–2.1×); 2048-thread parts (sm_70/80/90) keep + host wides (merged costs ~1.5 ms there). +4. **Knob sweep:** `tune_platform.jl` (budgets, sp, amal, cw, rows, flip-check + of the wide strategy), baseline-interleaved. Expect budgets to be the only + mover: pick the class whose size ≈ `LDS_per_SM ÷ desired_workgroups`. +5. **Structural (only where shared memory allows):** `pw_ab.jl`, `tl_ab.jl`. + Run the occupancy arithmetic first; on ≤100 KB/SM parts pw=96/tl=64 lose by + construction, on 228 KB (Hopper) they are free. +6. **Process knobs:** `SDS_WGF` ∈ {128, 256} and `SDS_SPIN_CAP` ∈ {128, 16} + (one process per value). WGF=256 was −20% on wave64 AMD, flat-to-pending on + NVIDIA; the spin cap matters only on residency-thin parts. +7. **Graphs:** `graph_ab.jl` — small (~1%) and free; keep it on everywhere. +8. **Stack and re-validate:** combine the winners, three gated interleaved + pairs (`combo_ab.jl`), then 20 gated refactorizations of the final config. + +## Mechanisms, compressed + +- **2048- vs 1536-thread SMs is the single most predictive attribute.** It + decides the wide strategy, the subtree budget, and whether the completion + race (pre-fix) manifested at all. +- **One fused kernel means one shared-memory size.** Any panel/tile growth + taxes *all* roles' occupancy; it only pays where SM shared memory grew + faster than the panel (Hopper). +- **Launch overhead doesn't scale with silicon.** The faster the chip, the + larger the share of host launches and dependency-hop latency; hence merged + wides and graphs matter more on newer parts, flops-side knobs less. +- **Wave64 wants bigger workgroups.** 128 threads = 2 waves on AMD leaves too + little in-flight work per workgroup; 256 = 4 waves was −20%. + +## Measured champions (fused refactorization, gated, interleaved) + +| device | arch | threads/SM | config | SDS | cuDSS | +|---|---|---|---|---|---| +| Quadro GV100 | sm_70 | 2048 | host wides, bud 16K, graph | **24.1 ms** | 24.3 ms | +| A100-80GB | sm_80 | 2048 | host wides, bud 16K | 19–21 ms | 17.3–18.7 ms | +| H200 | sm_90 | 2048 | pending the structural matrix | 13.6 ms so far | 11.1 ms | +| L40S | sm_89 | 1536 | merged, bud 8K, graph | **15.2 ms** | 12.2 ms | +| RTX Pro 6000 | Blackwell | 1536 | merged, bud 8K | **15.8 ms** | 13.5 ms | +| RTX 3080 | sm_86 | 1536 | merged | 32.4 ms (10 GB: kernel-level only) | — | +| Radeon VII | gfx906 | wave64 | merged, bud 8K, WGF 256, cap 16 | **40.0 ms** | — | + +All of this is bundled: `MadNLPSDS.SDSTuning` holds every knob as a runtime +field, `default_tuning(backend)` is the single per-architecture defaults table, +and `madnlp(m; ..., sds_tuning = SDSTuning(; workgroup = 256))` overrides it. +The portable kernel takes workgroup size and spin cap per plan — nothing is +compile-time or environment-dependent in the wrapper path. + +## Scripts + +`race_repro.jl` (gated correctness), `profile_refact` in `fact_lib.jl` (phase +split), `mseg_ab.jl` / `pw_ab.jl` / `tl_ab.jl` / `graph_ab.jl` / `combo_ab.jl` +(paired A/Bs), `tune_platform.jl` (knob sweep), `h200_matrix.jl` (structural +matrix), `e2e/amd_tune.jl` (AMD sweep). All print per-trial gates; all take the +matrix from `bench/data/` and the tuned permutation from `bench/perm_cand3.bin`. diff --git a/bench/e2e/MadNLPSDS.jl b/bench/e2e/MadNLPSDS.jl index e1a0ae19..ecb367c2 100644 --- a/bench/e2e/MadNLPSDS.jl +++ b/bench/e2e/MadNLPSDS.jl @@ -14,61 +14,81 @@ const KA = SDS.KernelAbstractions include("SDSProto.jl") using .SDSProto +""" + SDSTuning + +Every performance knob of the fused pipeline in one immutable bundle, so a +platform's tuning is a single value (see bench/ARCH-TUNING.md for how each +field was measured and the per-architecture champions). All fields are +runtime parameters — no environment variables, no recompilation. +""" +Base.@kwdef struct SDSTuning + wides::Symbol = :auto # :auto | :host | :merged + subtree_budgets::Vector{Int} = [16384] # regime-A local-memory class(es) + subtree_parallelism::Int = 16384 + regime_c_width::Int = 32 + regime_c_rows::Int = 64 + amalgamation::NamedTuple = (max_width = 48, zero_fraction = 0.25, min_width = 8) + workgroup::Int = 128 # fused-kernel workgroup size + spin_cap::Int = 128 # dependency-counter backoff cap + nd_nseps::Int = 4 # METIS knobs (78k-ACOPF champions) + nd_seed::Int = 3 +end + +""" + default_tuning(backend) -> SDSTuning + +The measured per-architecture defaults — the single place they live. +2048-threads/SM NVIDIA parts (sm_70/80/90): host wides, 16K budgets. +1536-thread parts (sm_86/89, Blackwell workstation): merged wides, 8K budgets. +AMD (gfx906-measured): merged, 8K, workgroup 256, spin cap 16. +""" +function default_tuning(backend) + if occursin("CUDABackend", string(typeof(backend))) + M = Base.moduleroot(parentmodule(typeof(backend))) # CUDACore under the CUDA 6 split + small = M.attribute(M.device(), M.DEVICE_ATTRIBUTE_MAX_THREADS_PER_MULTIPROCESSOR) < 2048 + return small ? SDSTuning(; wides = :merged, subtree_budgets = [8192]) : + SDSTuning(; wides = :host) + end + return SDSTuning(; wides = :merged, subtree_budgets = [8192], workgroup = 256, spin_cap = 16) +end + +_resolve(t::SDSTuning, backend) = t.wides === :auto ? + SDSTuning(t; wides = default_tuning(backend).wides) : t +SDSTuning(t::SDSTuning; wides) = SDSTuning(wides, t.subtree_budgets, t.subtree_parallelism, + t.regime_c_width, t.regime_c_rows, t.amalgamation, t.workgroup, t.spin_cap, t.nd_nseps, t.nd_seed) + Base.@kwdef mutable struct SDSSolverOptions <: MadNLP.AbstractOptions - sds_reordering::String = "algo4" # native ND (see PR #107: the auto chooser picks AMD here) - sds_regime_c_width::Int = 32 - sds_regime_c_rows::Int = 256 - sds_subtree_parallelism::Int = 16384 - sds_subtree_budgets::Vector{Int} = [16384] - # custom reordering: nd_* tune the built-in METIS nested dissection (the defaults - # are the 78k-ACOPF champions: nseps = 4, seed = 3, amalgamation max_width = 48); - # sds_user_perm bypasses the reordering entirely with a user permutation (1-based, - # length n, same convention as SDS's "user_perm" parameter). - sds_nd_nseps::Int = 4 - sds_nd_seed::Int = 3 - sds_amalgamation::NamedTuple = (max_width = 48, zero_fraction = 0.25, min_width = 8) + sds_reordering::String = "algo4" # native ND + # ALL performance knobs live in the tuning bundle; nothing (the default) + # selects the measured per-architecture profile from default_tuning(backend) + sds_tuning::Union{Nothing, SDSTuning} = nothing + # a user permutation bypasses the reordering entirely (1-based, length n) sds_user_perm::Union{Nothing, Vector{Int}} = nothing - # wide-front strategy: "auto" picks by device (merged in-kernel wides on parts with - # < 2048 threads/SM — sm_86/89, Blackwell workstation, AMD — measured 1.6-2.1x there; - # host vendor wides on sm_70/80/90 where they are ~1.7 ms faster); "host"/"merged" force - sds_wides::String = "auto" - # on-disk permutation cache: the tuned METIS ordering costs ~2x the default - # (tens of seconds at this size), so the computed permutation is cached keyed - # by the matrix pattern + ordering knobs and reloaded on any later solver - # construction with the same structure ("" disables) + # on-disk permutation cache keyed by pattern + ordering knobs ("" disables) sds_perm_cache::String = joinpath(homedir(), ".julia", "sds_perm_cache") end # merged in-kernel wides win on parts with < 2048 threads/SM (and on AMD); the CUDA # module is reached through the backend's parent module, so this file stays backend-free -function _prefer_merged(backend, opt::SDSSolverOptions) - opt.sds_wides == "merged" && return true - opt.sds_wides == "host" && return false - if occursin("CUDABackend", string(typeof(backend))) - M = Base.moduleroot(parentmodule(typeof(backend))) # CUDACore (CUDA 6 split) or CUDA - return M.attribute(M.device(), M.DEVICE_ATTRIBUTE_MAX_THREADS_PER_MULTIPROCESSOR) < 2048 - end - return true -end - -function _perm_cache_file(opt::SDSSolverOptions, csc) +function _perm_cache_file(opt::SDSSolverOptions, t::SDSTuning, csc) (opt.sds_perm_cache == "" || opt.sds_user_perm !== nothing) && return nothing n = size(csc, 1) h = hash((n, length(csc.rowVal), hash(Array(csc.colPtr)), hash(Array(csc.rowVal)), - opt.sds_reordering, opt.sds_nd_nseps, opt.sds_nd_seed)) + opt.sds_reordering, t.nd_nseps, t.nd_seed)) return joinpath(opt.sds_perm_cache, "perm-" * string(h, base = 16) * ".bin") end # shared solver construction knobs: the custom reordering (nd knobs or a user # permutation) and the amalgamation, applied before the analysis # returns the cache file to WRITE after the analysis (nothing: no write needed) -function _apply_reordering!(s, opt::SDSSolverOptions, csc) - s.options.amalgamation = opt.sds_amalgamation +function _apply_reordering!(s, opt::SDSSolverOptions, t::SDSTuning, csc) + s.options.amalgamation = t.amalgamation if opt.sds_user_perm !== nothing SDS.setparam!(s, "user_perm", opt.sds_user_perm) return nothing end - cf = _perm_cache_file(opt, csc) + cf = _perm_cache_file(opt, t, csc) if cf !== nothing && isfile(cf) n = size(csc, 1) perm = Vector{Int32}(undef, n) @@ -77,8 +97,8 @@ function _apply_reordering!(s, opt::SDSSolverOptions, csc) return nothing end SDS.setparam!(s, "reordering_alg", opt.sds_reordering) - SDS.setparam!(s, "nd_nseps", opt.sds_nd_nseps) - SDS.setparam!(s, "nd_seed", opt.sds_nd_seed) + SDS.setparam!(s, "nd_nseps", t.nd_nseps) + SDS.setparam!(s, "nd_seed", t.nd_seed) return cf end @@ -111,11 +131,13 @@ function SDSSolver( T = eltype(csc.nzVal) n = size(csc, 1) s = SDS.DirectSolver(csc.colPtr, csc.rowVal, csc.nzVal, "SPD", 'U') - s.options.regime_c_width = opt.sds_regime_c_width - s.options.regime_c_rows = opt.sds_regime_c_rows - s.options.subtree_parallelism = opt.sds_subtree_parallelism - s.options.subtree_budgets = copy(opt.sds_subtree_budgets) - cf = _apply_reordering!(s, opt, csc) + t = _resolve(something(opt.sds_tuning, default_tuning(KA.get_backend(csc.nzVal))), + KA.get_backend(csc.nzVal)) + s.options.regime_c_width = t.regime_c_width + s.options.regime_c_rows = max(t.regime_c_rows, 256) # stock solve path wants rows >= 256 + s.options.subtree_parallelism = t.subtree_parallelism + s.options.subtree_budgets = copy(t.subtree_budgets) + cf = _apply_reordering!(s, opt, t, csc) x = similar(csc.nzVal, n) b = similar(csc.nzVal, n) SDS.execute!("analysis", s, x, b; asynchronous = false) @@ -182,12 +204,13 @@ function SDSProtoSolver( n = size(csc, 1) backend = KA.get_backend(csc.nzVal) SDSProto.BACKEND[] = backend + t = _resolve(something(opt.sds_tuning, default_tuning(backend)), backend) s = SDS.DirectSolver(csc.colPtr, csc.rowVal, csc.nzVal, "SPD", 'U') - s.options.regime_c_width = 32 - s.options.regime_c_rows = 64 # prototype kernels own BOTH phases: no coupling conflict - s.options.subtree_parallelism = 16384 - s.options.subtree_budgets = [16384] - cf = _apply_reordering!(s, opt, csc) + s.options.regime_c_width = t.regime_c_width + s.options.regime_c_rows = t.regime_c_rows # prototype kernels own BOTH phases: no coupling conflict + s.options.subtree_parallelism = t.subtree_parallelism + s.options.subtree_budgets = copy(t.subtree_budgets) + cf = _apply_reordering!(s, opt, t, csc) x = similar(csc.nzVal, n) b = similar(csc.nzVal, n) SDS.execute!("analysis", s, x, b; asynchronous = false) @@ -195,8 +218,9 @@ function SDSProtoSolver( SDS.execute!("factorization", s, x, b; asynchronous = false) SDS.execute!("solve", s, x, b; asynchronous = false) # allocates the solve workspace nzval = SDS._factor_values(s) - merged = _prefer_merged(backend, opt) - fp = SDSProto.build_fused_plan(s; inkernel_wides = merged, merge_segments = merged) + merged = t.wides === :merged + fp = SDSProto.build_fused_plan(s; inkernel_wides = merged, merge_segments = merged, + wgf = t.workgroup, scap = t.spin_cap) inv11, inv_ptr, invertf, _ = SDSProto.build_inverse(s) # solve-side plan state (module globals, single solver instance) plan0 = s.workspace.plan diff --git a/bench/e2e/SDSProto.jl b/bench/e2e/SDSProto.jl index fae8b4f0..51f47cce 100644 --- a/bench/e2e/SDSProto.jl +++ b/bench/e2e/SDSProto.jl @@ -18,7 +18,7 @@ DVEC(::Type{T}, v) where {T} = (out = KA.allocate(BACKEND[], T, length(v)); copy const WG = 32 const MW = 256 # max front width on the inverse path -const WGF = 128 # workgroup size of the fused kernels +const WGF = 128 # default workgroup size; the fused kernel takes it per plan (fp.wgf) const CHWG = 1024 # workgroup size of the chain kernels const NSTRIP = 4 # strip parallelism of the chain kernels @@ -290,14 +290,14 @@ end ip = Int(inv_ptr[v]) - 1 if li == 1 tgt = nchild[v] - pause = Int32(4) + pause = min(Int32(4), scap) while (Atomix.@atomic arrived[v] += Int32(0)) < tgt z = 0.0 for _ in 1:pause z += 1.0 end z < 0 && (yb[1] = z) # keep the backoff loop alive - pause = min(pause << 1, Int32(128)) + pause = min(pause << 1, scap) end end @synchronize @@ -351,14 +351,14 @@ end if li == 1 pv = Int(snparent[root]) if pv > 0 - pause = Int32(4) + pause = min(Int32(4), scap) while (Atomix.@atomic done[pv] += Int32(0)) == Int32(0) z = 0.0 for _ in 1:pause z += 1.0 end z < 0 && (t[1] = z) - pause = min(pause << 1, Int32(128)) + pause = min(pause << 1, scap) end end end @@ -402,14 +402,14 @@ end if li == 1 pv = Int(snparent[v]) if pv > 0 - pause = Int32(4) + pause = min(Int32(4), scap) while (Atomix.@atomic done[pv] += Int32(0)) == Int32(0) z = 0.0 for _ in 1:pause z += 1.0 end z < 0 && (t[1] = z) - pause = min(pause << 1, Int32(128)) + pause = min(pause << 1, scap) end end end @@ -752,15 +752,15 @@ const ROLE_WTILE = Int8(8) const ROLE_WSIG = Int8(9) # spin with backoff on an Int32 cell until pred(value) holds; thread 1 only -@inline function _spin_until_eq!(arr, idx, target, scratch) - pause = Int32(4) +@inline function _spin_until_eq!(arr, idx, target, scratch, scap) + pause = min(Int32(4), scap) while (Atomix.@atomic arr[idx] += Int32(0)) != target z = 0.0 for _ in 1:pause z += 1.0 end z < 0 && (scratch[1] = z) - pause = min(pause << 1, Int32(128)) + pause = min(pause << 1, scap) end return nothing end @@ -771,7 +771,7 @@ end cb_done, @Const(snparent), @Const(front_ptr), @Const(front_nrows), @Const(front_ncols), @Const(cb_ptr), @Const(child_ptr), @Const(child_list), @Const(relind_ptr), @Const(relind), base, - @Const(wbase), wupd, wpdone2, wtrsml, wcbl, + @Const(wbase), wupd, wpdone2, wtrsml, wcbl, scap, ::Val{WG}) where {WG} @uniform TT = eltype(factor) @uniform RT = real(eltype(factor)) @@ -788,7 +788,7 @@ end c = Int(rb[G]) # child q0 = (Int(rc[G]) - 1) * 16384 if li == 1 - _spin_until_eq!(cb_done, c, Int32(1), fscr) + _spin_until_eq!(cb_done, c, Int32(1), fscr, scap) end @synchronize Threads.atomic_fence() @@ -827,9 +827,9 @@ end v = Int(ra[G]) if li == 1 if r == ROLE_PANEL_B - _spin_until_eq!(arrived, v, nchild[v], fscr) + _spin_until_eq!(arrived, v, nchild[v], fscr, scap) else - _spin_until_eq!(ea_left, v, Int32(0), fscr) + _spin_until_eq!(ea_left, v, Int32(0), fscr, scap) end end @synchronize @@ -925,7 +925,7 @@ end elseif r == ROLE_TRSM v = Int(ra[G]) if li == 1 - _spin_until_eq!(panel_done, v, Int32(1), fscr) + _spin_until_eq!(panel_done, v, Int32(1), fscr, scap) end @synchronize Threads.atomic_fence() @@ -971,7 +971,7 @@ end elseif r == ROLE_TILE v = Int(ra[G]) if li == 1 - _spin_until_eq!(trsm_left, v, Int32(0), fscr) + _spin_until_eq!(trsm_left, v, Int32(0), fscr, scap) end @synchronize Threads.atomic_fence() @@ -1075,9 +1075,9 @@ end sl = Int(wbase[v]) + k if li == 1 if k == 1 - _spin_until_eq!(ea_left, v, Int32(0), fscr) + _spin_until_eq!(ea_left, v, Int32(0), fscr, scap) else - _spin_until_eq!(wupd, sl, Int32(0), fscr) + _spin_until_eq!(wupd, sl, Int32(0), fscr, scap) end end @synchronize @@ -1140,7 +1140,7 @@ end v = Int(ra[G]); ch = Int(rb[G]); k = Int(rc[G]) sl = Int(wbase[v]) + k if li == 1 - _spin_until_eq!(wpdone2, sl, Int32(1), fscr) + _spin_until_eq!(wpdone2, sl, Int32(1), fscr, scap) end @synchronize Threads.atomic_fence() @@ -1184,7 +1184,7 @@ end k = Int(rc[G]) ÷ 1024; tjj = Int(rc[G]) % 1024 sl = Int(wbase[v]) + k if li == 1 - _spin_until_eq!(wtrsml, sl, Int32(0), fscr) + _spin_until_eq!(wtrsml, sl, Int32(0), fscr, scap) end @synchronize Threads.atomic_fence() @@ -1264,9 +1264,9 @@ end v = Int(ra[G]); K = Int(rb[G]) sl = Int(wbase[v]) + K if li == 1 - _spin_until_eq!(wpdone2, sl, Int32(1), fscr) - _spin_until_eq!(wtrsml, sl, Int32(0), fscr) - _spin_until_eq!(wcbl, v, Int32(0), fscr) + _spin_until_eq!(wpdone2, sl, Int32(1), fscr, scap) + _spin_until_eq!(wtrsml, sl, Int32(0), fscr, scap) + _spin_until_eq!(wcbl, v, Int32(0), fscr, scap) Threads.atomic_fence() Atomix.@atomic cb_done[v] += Int32(1) pv = Int(snparent[v]) @@ -1294,14 +1294,14 @@ end @kernel function waiter_kernel!(ea_left, v, fscr_unused) li = @index(Local, Linear) if li == 1 - pause = Int32(4) + pause = min(Int32(4), scap) @inbounds while (Atomix.@atomic ea_left[v] += Int32(0)) != Int32(0) z = 0.0 for _ in 1:pause z += 1.0 end z < 0 && (ea_left[v] = Int32(0)) - pause = min(pause << 1, Int32(128)) + pause = min(pause << 1, scap) end end end @@ -1334,7 +1334,8 @@ end # --------------------------------------------------------------------------- # host: the topologically ordered role list + counter bases + stream-2 program -function build_fused_plan(s; inkernel_wides::Bool = false, merge_segments::Bool = false) +function build_fused_plan(s; inkernel_wides::Bool = false, merge_segments::Bool = false, + wgf::Int = WGF, scap::Integer = 128) S = s.symbolic plan = s.numeric.plan nodes = S.schedule.group_nodes @@ -1403,7 +1404,7 @@ function build_fused_plan(s; inkernel_wides::Bool = false, merge_segments::Bool emit_split(v) = begin m = rows_h[v] - width_h[v] m > SYRK_IN || return nothing - for ch in 1:cld(m, WGF) + for ch in 1:cld(m, wgf) push!(role, ROLE_TRSM); push!(ra, Int32(v)); push!(rb, Int32(ch)); push!(rc, Int32(0)) trsm_left[v] += 1 end @@ -1433,7 +1434,7 @@ function build_fused_plan(s; inkernel_wides::Bool = false, merge_segments::Bool c0 = (k - 1) * 64 kb = min(64, w - c0) nbelow = f - (c0 + kb) - nch = cld(nbelow, WGF) + nch = cld(nbelow, wgf) for ch in 1:nch push!(role, ROLE_WTRSM); push!(ra, Int32(v)); push!(rb, Int32(ch)); push!(rc, Int32(k)) end @@ -1540,7 +1541,7 @@ function build_fused_plan(s; inkernel_wides::Bool = false, merge_segments::Bool wbase = DVEC(wbase_h), wupd_b = DVEC(wupd_b), wtl_b = DVEC(wtl_b), wcb_b = DVEC(wcb_b), wupd = KA.zeros(BACKEND[], Int32, max(nwslots, 1)), wtl = KA.zeros(BACKEND[], Int32, max(nwslots, 1)), wpd = KA.zeros(BACKEND[], Int32, max(nwslots, 1)), - wcb = KA.zeros(BACKEND[], Int32, ns), + wcb = KA.zeros(BACKEND[], Int32, ns), wgf, scap = Int32(scap), s2 = nothing) end @@ -1574,13 +1575,13 @@ function refact_fused!(s, nzval, fp; nstreams::Int = 1) end for (base, count, gwides) in fp.segments count > 0 && - fused_fact_kernel!(backend, WGF)(N.factor, N.stack, fp.pcb, N.info, nzval, S.amap, S.amap_ptr, + fused_fact_kernel!(backend, fp.wgf)(N.factor, N.stack, fp.pcb, N.info, nzval, S.amap, S.amap_ptr, S.amap_src, fp.cb2, fp.wt, fp.role, fp.ra, fp.rb, fp.rc, fp.nchild, fp.arrived, fp.ea, fp.pdone, fp.trsm, fp.tiles, fp.cbd, S.snparent, S.front_ptr, S.front_nrows, S.front_ncols, S.cb_ptr, S.child_ptr, S.child_list, S.relind_ptr, S.relind, Int32(base), fp.wbase, fp.wupd, fp.wpd, fp.wtl, fp.wcb, - Val(WGF); ndrange = WGF * count) + fp.scap, Val(fp.wgf); ndrange = fp.wgf * count) if true for v in gwides SDS._factor_panel_c!(N.factor, fp.pcb, N.work, N.info, v, S.layout.panel_ptr[v], diff --git a/bench/e2e/fresh_machine.jl b/bench/e2e/fresh_machine.jl index 14b9fd2f..3198214c 100644 --- a/bench/e2e/fresh_machine.jl +++ b/bench/e2e/fresh_machine.jl @@ -65,9 +65,8 @@ m, _ = ac_opf_model("pglib_opf_case78484_epigrids.m"; # SparseCondensedKKTSystem is required (the SPD Cholesky path); MadNLP defaults # equality/fixed-variable treatment correctly for it. tol: condensed default is 1e-4. # -# Custom reordering: the wrapper defaults to the tuned METIS ND (nseps = 4, seed = 3, -# amalgamation max_width = 48). To tune it, pass e.g. sds_nd_nseps = 8, sds_nd_seed = 1; -# to supply your own permutation (1-based, length n of the condensed KKT), pass -# sds_user_perm = perm — it bypasses the built-in reordering entirely. +# All performance knobs live in one bundle: pass sds_tuning = MadNLPSDS.SDSTuning(; ...) +# to override the measured per-architecture defaults (see bench/ARCH-TUNING.md); +# sds_user_perm = perm (1-based, length n) bypasses the built-in reordering entirely. sol = madnlp(m; linear_solver = SDSProtoSolver, kkt_system = MadNLP.SparseCondensedKKTSystem, tol = 1e-6) From b82e2a4dc6ff4d0974ea45c9f91e44156b55dcc5 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Sat, 10 Oct 2026 13:26:50 -0400 Subject: [PATCH 29/65] fix: the solve kernels' inline spin loops keep their literal backoff cap (scap is a fused-kernel argument only; the blanket replace had leaked it into kernels without the binding) --- bench/e2e/SDSProto.jl | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bench/e2e/SDSProto.jl b/bench/e2e/SDSProto.jl index 51f47cce..071c4685 100644 --- a/bench/e2e/SDSProto.jl +++ b/bench/e2e/SDSProto.jl @@ -290,14 +290,14 @@ end ip = Int(inv_ptr[v]) - 1 if li == 1 tgt = nchild[v] - pause = min(Int32(4), scap) + pause = Int32(4) while (Atomix.@atomic arrived[v] += Int32(0)) < tgt z = 0.0 for _ in 1:pause z += 1.0 end z < 0 && (yb[1] = z) # keep the backoff loop alive - pause = min(pause << 1, scap) + pause = min(pause << 1, Int32(128)) end end @synchronize @@ -351,14 +351,14 @@ end if li == 1 pv = Int(snparent[root]) if pv > 0 - pause = min(Int32(4), scap) + pause = Int32(4) while (Atomix.@atomic done[pv] += Int32(0)) == Int32(0) z = 0.0 for _ in 1:pause z += 1.0 end z < 0 && (t[1] = z) - pause = min(pause << 1, scap) + pause = min(pause << 1, Int32(128)) end end end @@ -402,14 +402,14 @@ end if li == 1 pv = Int(snparent[v]) if pv > 0 - pause = min(Int32(4), scap) + pause = Int32(4) while (Atomix.@atomic done[pv] += Int32(0)) == Int32(0) z = 0.0 for _ in 1:pause z += 1.0 end z < 0 && (t[1] = z) - pause = min(pause << 1, scap) + pause = min(pause << 1, Int32(128)) end end end From 5304f70a6368f3bc23375e0c84b7c0cd1d745a40 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Sat, 10 Oct 2026 16:47:00 -0400 Subject: [PATCH 30/65] bench: performance document for the October 2026 cross-platform campaign --- bench/PERFORMANCE-2026-10.md | 110 +++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 bench/PERFORMANCE-2026-10.md diff --git a/bench/PERFORMANCE-2026-10.md b/bench/PERFORMANCE-2026-10.md new file mode 100644 index 00000000..4a8795f2 --- /dev/null +++ b/bench/PERFORMANCE-2026-10.md @@ -0,0 +1,110 @@ +# SparseDirectSolver vs cuDSS across seven GPU platforms — October 2026 campaign + +Benchmark: refactorization of the condensed ACOPF KKT system of +`pglib_opf_case78484_epigrids` (n = 674562, nnz(L) ≈ 14.3M after the tuned +ordering), the operation an interior-point method repeats ~200 times per solve. +Every number below is a median of gated runs (info = 0, elementwise factor +delta ~1e-9 vs the stock factorization, residual matching stock), interleaved +A/B against a baseline in the same process. Methodology and knob mechanisms: +`bench/ARCH-TUNING.md`. + +## Headline results + +| device | arch | f64:f32 | cuDSS refact | SDS fused refact | ratio | SDS config (per-arch defaults) | +|---|---|---|---|---|---|---| +| Quadro GV100 | sm_70 | 1:2 | 24.3 ms | **24.1 ms** | **0.99×** | host wides, 16K budgets, graph | +| NVIDIA H200 | sm_90 | 1:2 | 11.1 ms | **12.0 ms** | 1.08× | host wides, 16K, 96-wide panels, graph | +| NVIDIA A100-80GB | sm_80 | 1:2 | 17.3–18.7 ms | 19–21 ms | ~1.1× | host wides, 16K | +| RTX Pro 6000 (Blackwell) | sm_120 | 1:64 | 13.5 ms | 15.8 ms | 1.17× | merged wides, 8K | +| NVIDIA L40S | sm_89 | 1:64 | 12.2 ms | 15.2 ms | 1.25× | merged wides, 8K, graph | +| RTX 3080 (10 GB) | sm_86 | 1:64 | — | 32.4 ms | — | merged wides (kernel-level; e2e needs ~17 GB) | +| AMD Radeon VII | gfx906 | 1:4 | n/a | 40.0 ms | — | merged wides, 8K, workgroup 256, spin cap 16 | + +Solve (triangular sweeps): SDS 3.61 ms vs cuDSS 3.77 ms on the GV100 — ahead. +End-to-end MadNLP (`SparseCondensedKKTSystem`, tol 1e-6): identical iteration +counts and objectives as cuDSS on every platform tested; AMD end-to-end linear +algebra improved 36.5 → 34.0 s from the per-arch defaults alone. + +Within one day of per-architecture tuning the campaign moved: L40S 34.1 → 15.2, +H200 13.6 → 12.0, Radeon VII 66 → 40.0, GV100 29.3 → 24.1 (the division-free +and triangle-enumeration kernel work, PR #125, is included in all rows). + +## Why the times look the way they do + +**The workload is scheduling-bound, not flop-bound.** One refactorization is +~1.2 GFLOP spread over ~92k mostly tiny fronts (median supernode width ~7–16). +The instruction stream is dominated by index arithmetic, gathers/scatters, +barriers, dependency counters and kernel-launch latency — all full-rate on +every chip — with f64 FMAs a small minority. Two measurements pin this: + +- H200 has ~50× the f64 throughput of an L40S, yet is only 1.27× faster for SDS + and 1.1× for cuDSS. Both solvers' times track *core clock*, not FLOP rate. +- cuDSS across chips orders as A100 (1.4 GHz) 17.3 > H200 (2.0 GHz) 11.1 ≈ + L40S (2.5 GHz) 12.2: the signature of a latency-bound computation. + +**Consequently, f64 on "fp32 chips" is nearly free for this problem class.** +On 1:64 parts (L40S, 3080, workstation Blackwell) the pure arithmetic is +~1–2 ms of a ~15 ms budget. The caveat marks the boundary: wherever the work +becomes dense-block-shaped the penalty returns (the stock path, which leans on +dense vendor kernels, spreads 3× between H200 and L40S where the fused pipeline +spreads 1.27×; large-supernode 3D problems would be far worse). Sparse +small-front power-grid KKTs are close to the best case for cheap consumer GPUs. + +**Tensor-core f64 cannot close the remaining gaps.** A role-ablation ceiling +measurement on the A100 shows that making the GEMM-shaped tile-syrk infinitely +fast saves ~0.75 ms, and freeing *all* narrow-front in-kernel compute saves +~5 ms of ~21. DMMA would at best double the speed of that subset, bounding the +realistic gain at ≤10% on Ampere/Hopper — and the chips furthest from cuDSS +(sm_86/89/120) have no f64 tensor cores at all. The DMMA kernel was therefore +not built. + +**Where cuDSS's remaining edge lives.** Feeding cuDSS any external permutation +(even its own, extracted and fed back, bit-identical fill) collapses its +refactorization ~12× — its speed is scheduler metadata generated inside its +reorderer, not ordering quality (our fill is slightly better than its own: +14.3M vs 15.55M by its own count). The residual 8–25% on Hopper/Ada/Blackwell +is that scheduling craftsmanship against clock-insensitive dependency-hop +latency in our counter-based design; the L40S ratio (1.25×) is inflated by +cuDSS being anomalously strong on high-clock mature-toolchain Ada — our own +times on L40S and Blackwell are nearly identical (15.2 vs 15.8). + +## What moved the needle, per class (measured, gated) + +- 2048-threads/SM NVIDIA (sm_70/80/90): host vendor wides; CUDA-graph replay + of the whole refactorization (−0.5 ms on H200); on Hopper only, 96-wide + in-kernel panels (−1.8 ms; occupancy-free in 228 KB/SM shared memory). +- 1536-threads/SM NVIDIA (sm_86/89/120): in-kernel blocked wide fronts with a + single merged launch (1.6–2.1×), 8 KB subtree budget (~−1 ms), graphs. +- AMD gfx906: merged wides, 8 KB budgets, workgroup 256 (wave64 wants 4 waves + per workgroup: −20%), spin cap 16 (−1 ms). +- Everywhere: division-free scale-first Cholesky column step and packed-triangle + trailing-update enumeration (PR #125: 29.3 → 24.4 ms on GV100 alone); the + nseps = 4 / seed = 3 METIS ordering (−6%, PR #124) with an on-disk + permutation cache so METIS runs once per matrix pattern. + +Tried and rejected with measurements: inverse-based TRSM (2×), multi-stream +wides, subtree/mega-kernel overlap, spin-cap tuning on NVIDIA, tile-64 on every +chip, 96-panels on ≤100 KB/SM parts, nseps beyond 4, Scotch ordering (+20–60% +fill), cuDSS-extracted orderings. + +## Correctness note + +All of the above sits on the completion-signal fix (`left == 0`): the fused +kernel's last-finisher test had used the atomic's *new* value since PR #107, +signaling one block early — invisible on 2048-thread parts, 20/20 failures on +sm_86/89/120 and ~25% on gfx906, including one *silent* wrong factor with +info = 0. Validated 0-failure on all seven platforms after the fix. Gate on +factor deltas, not info alone. + +## Reproduction + +One command on a fresh GPU machine (NVIDIA or AMD, autodetected): + +```bash +julia +1.13 --startup-file=no -i -e 'include(download("https://raw.githubusercontent.com/exanauts/SparseDirectSolver.jl/custom-reordering/bench/e2e/fresh_machine.jl"))' +``` + +Kernel-level benchmarks: the scripts listed in `bench/ARCH-TUNING.md` against +`bench/data/kkt_pglib_opf_case78484_epigrids_condensed_10.mtx`. Per-arch +defaults: `MadNLPSDS.default_tuning`; override any knob with +`madnlp(m; sds_tuning = MadNLPSDS.SDSTuning(; ...))`. From 52d3d3a182151bb008e5742728f709a68a690a85 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Sat, 10 Oct 2026 16:47:30 -0400 Subject: [PATCH 31/65] bench/e2e: the fused solver is THE solver The stock-library MadNLP wrapper is retired: the fused pipeline won on every platform tested (bench/PERFORMANCE-2026-10.md), so SDSSolver now names the fused solver (SDSProtoSolver remains as an alias for existing scripts). run_gv100/run_amd/fresh_machine updated accordingly. --- bench/e2e/MadNLPSDS.jl | 99 ++++++++------------------------------ bench/e2e/fresh_machine.jl | 8 +-- bench/e2e/run_amd.jl | 3 +- bench/e2e/run_gv100.jl | 2 +- 4 files changed, 25 insertions(+), 87 deletions(-) diff --git a/bench/e2e/MadNLPSDS.jl b/bench/e2e/MadNLPSDS.jl index ecb367c2..434dcc22 100644 --- a/bench/e2e/MadNLPSDS.jl +++ b/bench/e2e/MadNLPSDS.jl @@ -112,74 +112,11 @@ function _save_perm(cf, s) return nothing end -mutable struct SDSSolver{T} <: MadNLP.AbstractLinearSolver{T} - inner::Any # SDS.DirectSolver (type elided: backend-parametric) - tril::Any # the device CSC whose nzVal MadNLP updates in place - x::Any - b::Any - factorized_once::Bool - last_info::Int - opt::SDSSolverOptions - logger::MadNLP.MadNLPLogger -end - -function SDSSolver( - csc; - opt = SDSSolverOptions(), - logger = MadNLP.MadNLPLogger(), - ) - T = eltype(csc.nzVal) - n = size(csc, 1) - s = SDS.DirectSolver(csc.colPtr, csc.rowVal, csc.nzVal, "SPD", 'U') - t = _resolve(something(opt.sds_tuning, default_tuning(KA.get_backend(csc.nzVal))), - KA.get_backend(csc.nzVal)) - s.options.regime_c_width = t.regime_c_width - s.options.regime_c_rows = max(t.regime_c_rows, 256) # stock solve path wants rows >= 256 - s.options.subtree_parallelism = t.subtree_parallelism - s.options.subtree_budgets = copy(t.subtree_budgets) - cf = _apply_reordering!(s, opt, t, csc) - x = similar(csc.nzVal, n) - b = similar(csc.nzVal, n) - SDS.execute!("analysis", s, x, b; asynchronous = false) - _save_perm(cf, s) - return SDSSolver{T}(s, csc, x, b, false, 0, opt, logger) -end - -function MadNLP.factorize!(M::SDSSolver) - M.inner.A.nzval === M.tril.nzVal || - SDS.update!(M.inner, M.tril.colPtr, M.tril.rowVal, M.tril.nzVal) - phase = M.factorized_once ? "refactorization" : "factorization" - SDS.execute!(phase, M.inner, M.x, M.b; asynchronous = false) - M.factorized_once = true - M.last_info = Int(SDS.getparam(M.inner, "info")) - return M -end - -function MadNLP.solve_linear_system!(M::SDSSolver{T}, xb) where {T} - copyto!(M.b, xb) - SDS.execute!("solve", M.inner, M.x, M.b; asynchronous = false) - copyto!(xb, M.x) - return xb -end - -# older MadNLP spellings route to the same implementation -MadNLP.solve!(M::SDSSolver, xb::AbstractVector) = MadNLP.solve_linear_system!(M, xb) - -MadNLP.input_type(::Type{SDSSolver}) = :csc -MadNLP.default_options(::Type{SDSSolver}) = SDSSolverOptions() -MadNLP.is_inertia(::SDSSolver) = true -function MadNLP.inertia(M::SDSSolver) - n = size(M.tril, 1) - return M.last_info == 0 ? (n, 0, 0) : (n - 2, 1, 1) -end -MadNLP.improve!(::SDSSolver) = false -MadNLP.introduce(::SDSSolver) = "SparseDirectSolver.jl" -MadNLP.is_supported(::Type{SDSSolver}, ::Type{Float32}) = true -MadNLP.is_supported(::Type{SDSSolver}, ::Type{Float64}) = true - # --------------------------------------------------------------------------- -# prototype-kernel wrapper: fused factorization + algo1/fused solve (PR #107) -mutable struct SDSProtoSolver{T} <: MadNLP.AbstractLinearSolver{T} +# THE solver: fused factorization + partitioned-inverse/fused solve (PR #107 kernels, +# October 2026 per-architecture tuning; the stock-library wrapper was retired after +# the fused path won on every platform — see bench/PERFORMANCE-2026-10.md) +mutable struct SDSSolver{T} <: MadNLP.AbstractLinearSolver{T} inner::Any tril::Any x::Any @@ -195,7 +132,7 @@ mutable struct SDSProtoSolver{T} <: MadNLP.AbstractLinearSolver{T} logger::MadNLP.MadNLPLogger end -function SDSProtoSolver( +function SDSSolver( csc; opt = SDSSolverOptions(), logger = MadNLP.MadNLPLogger(), @@ -239,18 +176,18 @@ function SDSProtoSolver( fz1 = SDSProto.build_fused(s; kstart = 1, wcap = 256) segs = [(fz1, 64)] fz = (segs, true) - M = SDSProtoSolver{T}(s, csc, x, b, nzval, fp, fz, inv11, inv_ptr, invertf, 0, opt, logger) + M = SDSSolver{T}(s, csc, x, b, nzval, fp, fz, inv11, inv_ptr, invertf, 0, opt, logger) MadNLP.factorize!(M) # prime the fused path once return M end -function MadNLP.factorize!(M::SDSProtoSolver) +function MadNLP.factorize!(M::SDSSolver) M.last_info = Int(SDSProto.refact_fused!(M.inner, M.nzval, M.fp)) M.invert!() return M end -function MadNLP.solve_linear_system!(M::SDSProtoSolver{T}, xb) where {T} +function MadNLP.solve_linear_system!(M::SDSSolver{T}, xb) where {T} copyto!(M.b, xb) SDSProto.algo2_solve!(M.x, M.inner, M.b, M.inv11, M.inv_ptr, M.fz) copyto!(xb, M.x) @@ -258,18 +195,20 @@ function MadNLP.solve_linear_system!(M::SDSProtoSolver{T}, xb) where {T} return xb end -MadNLP.solve!(M::SDSProtoSolver, xb::AbstractVector) = MadNLP.solve_linear_system!(M, xb) -MadNLP.input_type(::Type{SDSProtoSolver}) = :csc -MadNLP.default_options(::Type{SDSProtoSolver}) = SDSSolverOptions() -MadNLP.is_inertia(::SDSProtoSolver) = true -function MadNLP.inertia(M::SDSProtoSolver) +MadNLP.solve!(M::SDSSolver, xb::AbstractVector) = MadNLP.solve_linear_system!(M, xb) +MadNLP.input_type(::Type{SDSSolver}) = :csc +MadNLP.default_options(::Type{SDSSolver}) = SDSSolverOptions() +MadNLP.is_inertia(::SDSSolver) = true +function MadNLP.inertia(M::SDSSolver) n = size(M.tril, 1) return M.last_info == 0 ? (n, 0, 0) : (n - 2, 1, 1) end -MadNLP.improve!(::SDSProtoSolver) = false -MadNLP.introduce(::SDSProtoSolver) = "SparseDirectSolver.jl (PR #107 prototype kernels)" -MadNLP.is_supported(::Type{SDSProtoSolver}, ::Type{Float32}) = true -MadNLP.is_supported(::Type{SDSProtoSolver}, ::Type{Float64}) = true +MadNLP.improve!(::SDSSolver) = false +MadNLP.introduce(::SDSSolver) = "SparseDirectSolver.jl (PR #107 prototype kernels)" +MadNLP.is_supported(::Type{SDSSolver}, ::Type{Float32}) = true +MadNLP.is_supported(::Type{SDSSolver}, ::Type{Float64}) = true + +const SDSProtoSolver = SDSSolver # the former name; the prototype is the solver now export SDSSolver, SDSSolverOptions, SDSProtoSolver diff --git a/bench/e2e/fresh_machine.jl b/bench/e2e/fresh_machine.jl index 3198214c..b5efba5f 100644 --- a/bench/e2e/fresh_machine.jl +++ b/bench/e2e/fresh_machine.jl @@ -5,7 +5,7 @@ # # Installs everything into its own environment (~/.sds-fresh-), downloads # the pglib case on first use, then solves the 78k-bus ACOPF with MadNLP on the -# GPU using SparseDirectSolver (the SDSProtoSolver wrapper) as the linear solver. +# GPU using SparseDirectSolver (the SDSSolver wrapper) as the linear solver. # The vendor is autodetected; force it with SDS_BACKEND=cuda|amdgpu. (AMD needs a # system ROCm installation.) @@ -52,8 +52,8 @@ else # condensed-KKT constructors) activates only with it using AMDGPU end -# the MadNLP linear-solver wrappers (SDSSolver = stock, SDSProtoSolver = PR #107 -# prototype kernels), shipped with the package under bench/e2e +# the MadNLP linear-solver wrapper (SDSSolver: the fused kernels with per-arch +# tuning), shipped with the package under bench/e2e include(joinpath(pkgdir(SparseDirectSolver), "bench", "e2e", "MadNLPSDS.jl")) using .MadNLPSDS @@ -68,5 +68,5 @@ m, _ = ac_opf_model("pglib_opf_case78484_epigrids.m"; # All performance knobs live in one bundle: pass sds_tuning = MadNLPSDS.SDSTuning(; ...) # to override the measured per-architecture defaults (see bench/ARCH-TUNING.md); # sds_user_perm = perm (1-based, length n) bypasses the built-in reordering entirely. -sol = madnlp(m; linear_solver = SDSProtoSolver, +sol = madnlp(m; linear_solver = SDSSolver, kkt_system = MadNLP.SparseCondensedKKTSystem, tol = 1e-6) diff --git a/bench/e2e/run_amd.jl b/bench/e2e/run_amd.jl index 3d3fe4fb..eb158f72 100644 --- a/bench/e2e/run_amd.jl +++ b/bench/e2e/run_amd.jl @@ -31,6 +31,5 @@ end println("building model on ROCm…"); flush(stdout) model, _ = ac_opf_model(CASE; backend = ROCBackend()) -run_one("SDS/AMD stock", model, SDSSolver) -run_one("SDS/AMD proto", model, SDSProtoSolver) +run_one("SDS/AMD", model, SDSSolver) println("done") diff --git a/bench/e2e/run_gv100.jl b/bench/e2e/run_gv100.jl index 8db2016a..2e7ff9fa 100644 --- a/bench/e2e/run_gv100.jl +++ b/bench/e2e/run_gv100.jl @@ -32,5 +32,5 @@ end println("building model on CUDA…"); flush(stdout) model, _ = ac_opf_model(CASE; backend = CUDABackend()) run_one("cuDSS", model, MadNLPGPU.CUDSSSolver) -run_one("SDS-proto", model, SDSProtoSolver) +run_one("SDS", model, SDSSolver) println("done") From d6e6c76fcaf77ee1e84a8c0b9ed70f9eb0f3e6c1 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Sat, 10 Oct 2026 16:53:19 -0400 Subject: [PATCH 32/65] bench/e2e: tolerate concurrent dev-checkout updates (shared NFS home): retry the fetch, then proceed on the existing tree with a warning --- bench/e2e/fresh_machine.jl | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/bench/e2e/fresh_machine.jl b/bench/e2e/fresh_machine.jl index b5efba5f..fb6a6aff 100644 --- a/bench/e2e/fresh_machine.jl +++ b/bench/e2e/fresh_machine.jl @@ -35,9 +35,16 @@ Pkg.add(BACKEND == "cuda" ? ["CUDA", "CUDSS"] : ["AMDGPU"]) const SDS_DEV = joinpath(homedir(), ".julia", "dev", "SparseDirectSolver") const SDS_REF = "custom-reordering" if isdir(SDS_DEV) - run(`git -C $SDS_DEV fetch origin $SDS_REF`) - run(`git -C $SDS_DEV checkout $SDS_REF`) - run(`git -C $SDS_DEV merge --ff-only FETCH_HEAD`) + # the checkout may be shared (NFS home) with a concurrent run: retry once on + # git lock contention, then proceed on the existing tree with a warning + ok = success(`git -C $SDS_DEV fetch origin $SDS_REF`) || + (sleep(5); success(`git -C $SDS_DEV fetch origin $SDS_REF`)) + if ok + run(`git -C $SDS_DEV checkout $SDS_REF`) + run(`git -C $SDS_DEV merge --ff-only FETCH_HEAD`) + else + @warn "could not update $SDS_DEV (concurrent run?); continuing on the existing checkout" + end else run(`git clone --branch $SDS_REF https://github.com/exanauts/SparseDirectSolver.jl $SDS_DEV`) end From 85f25c05945f108c0c17d23e412b3c38b5a9c55b Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Sat, 10 Oct 2026 20:39:55 -0400 Subject: [PATCH 33/65] fused kernel: no-pivot LDL^T mode (Val-dispatched; stock src untouched) The fused mega-kernel and the sorted-subtree launch gain a compile-time LDL mode for structure "S" with pivot_type 'N' (real element types): the stock LDL factor layout (unit lower panels, D in numeric.d, identity piv), produced entirely by the fused pipeline: - dedicated subtree_ldl_kernel! (same skeleton and assembly helpers as the stock regime-A kernel; d-folded rank-1 over the whole trailing triangle, so no separate finalize/CB pass); - PANEL/TRSM/TILE and WPANEL/WTRSM/WTILE roles take Val(LDL): the pivot d stays on the sh diagonal in flight (restored from d on reload, in the load loop itself -- a second loop races the diagonal slots), the in-panel and split TRSM reconstruct the unscaled column with one extra shared multiply, the tiles stage each k-block's 32 pivots in sh[2049:2080] and fold them into the Ai side only; - build_fused_plan(ldl = true) forces in-kernel wides (no vendor potrf for LDL); refact_fused! skips cholesky_stats! (inertia = signs of d); - algo2_solve! applies D between the unit-triangular sweeps. 78k ACOPF condensed KKT, GV100, gated vs stock pivot-N (info 0, dfactor 1.7e-7, dd 6.8e-10): fused LDL refact 49.3 ms vs stock LDL 132.6 ms and fused Cholesky 37.1 ms at identical settings (ratio 1.33, partly the forced in-kernel wide strategy). The Cholesky instantiation is unchanged machine code; src/ is byte-identical to the branch head. Co-Authored-By: Claude Fable 5 --- bench/e2e/SDSProto.jl | 355 +++++++++++++++++++++++++++++++---- bench/e2e/ldl_fused_debug.jl | 71 +++++++ bench/e2e/ldl_fused_test.jl | 95 ++++++++++ 3 files changed, 484 insertions(+), 37 deletions(-) create mode 100644 bench/e2e/ldl_fused_debug.jl create mode 100644 bench/e2e/ldl_fused_test.jl diff --git a/bench/e2e/SDSProto.jl b/bench/e2e/SDSProto.jl index 071c4685..495fc944 100644 --- a/bench/e2e/SDSProto.jl +++ b/bench/e2e/SDSProto.jl @@ -716,6 +716,9 @@ function algo2_solve!(x, s, b, inv11, inv_ptr, fz; debug::Bool = false) fwd_entry(k); k += 1 end debug && (KA.synchronize(BACKEND[]); println(" algo2: forward done"); flush(stdout)) + if SDS._is_ldlt_structure(S.structure) # LDL: apply D between the unit-triangular sweeps + Yv ./= view(N.d, 1:length(Yv)) + end k = length(plan.kind) for (fzk, wg) in Iterators.reverse(segs) while k > fzk.i2 @@ -741,6 +744,201 @@ const SYRK_IN = 64 const TILE = 32 const NL64 = 2080 # packed 64x64 lower triangle +# --------------------------------------------------------------------------- +# no-pivot LDLᵀ mode of the fused kernel (real element types): the factor stores +# the UNIT lower panels and D goes to `d` (global factor-column indexing), the +# stock LDL layout, so the stock solve and the fused solve (one extra D pass +# between the sweeps) both work on it. A zero pivot sets info[v] to the local +# column and the front's consumers skip, exactly the Cholesky failure path; +# NEGATIVE pivots factor through (inertia for MadNLP). The mode is a compile-time +# `Val{LDL}` kernel parameter: the Cholesky instantiation is unchanged machine code. +# +# Panel convention while a front is in flight: the packed `sh` triangle keeps the +# pivot d_j ON THE DIAGONAL (the unit is implied), so the in-panel TRSM both +# reconstructs the unscaled column (`* sh[j,j]`) and divides by the pivot +# (`/ sh[kk,kk]`) with the same reads as Cholesky; write-out emits the unit +# diagonal. The TRSM role reloads the panel from the factor (diag = 1) and +# restores d into the diagonal slots from `d`; the TILE roles stage the 32 +# pivots of each k-block in sh[2049:2080] (free: NL64 - 2*1024) and fold them +# into the Ai tile only. + +# regime A: a dedicated no-pivot LDL variant of SDS.subtree_cholesky_kernel! (the stock +# kernel stays untouched; its CPU-launch allocation budget is load-bearing in the tests). +# Identical skeleton and assembly helpers; only the two column phases and the extension +# slots differ: the whole trailing triangle (CB included) takes the d-folded rank-1, so +# no separate finalize/CB pass exists, and d/piv/pivot_kind are filled as the stock LDL +# path (identity pivot order; a zero pivot sets the front status, as Cholesky). +const _SP_C0 = SDS._ST_CTL + 1 +const _SP_CTL = _SP_C0 + +@inline function _subtree_ldl_update!(buf, ctl, pv, j, li, d, piv, pivot_kind, ::Val{WG}) where {WG} + @inbounds begin + T = eltype(buf) + IT = eltype(ctl) + f = ctl[SDS._ST_F] + lf = ctl[SDS._ST_LF] - 1 + if ctl[SDS._ST_STATUS] == 0 + dk = real(buf[lf + SDS._packed(j, j, f)]) + if !iszero(dk) + if li == 1 + g = Int(ctl[_SP_C0]) + j - 1 + d[g] = dk + piv[g] = Int32(g) + pivot_kind[g] = SDS.PIVOT_KIND_1X1 + pv[1] = dk + end + rd = inv(dk) + for i in (j + li):WG:f + buf[lf + SDS._packed(i, j, f)] *= rd + end + elseif li == 1 + ctl[SDS._ST_STATUS] = j % IT + end + end + end + return nothing +end + +@inline function _subtree_ldl_scale!(buf, ctl, pv, j, li, ::Val{WG}) where {WG} + @inbounds begin + if ctl[SDS._ST_STATUS] == 0 + f = ctl[SDS._ST_F] + lf = ctl[SDS._ST_LF] - 1 + dk = pv[1] + li == 1 && (buf[lf + SDS._packed(j, j, f)] = one(eltype(buf))) + r = f - j + for q in (li - 1):WG:(r * (r + 1) ÷ 2 - 1) + a, b = SDS._tri_decode(q, r) + i = j + a + k = j + b + buf[lf + SDS._packed(i, k, f)] -= buf[lf + SDS._packed(i, j, f)] * dk * + buf[lf + SDS._packed(k, j, f)] + end + end + end + return nothing +end + +@kernel function subtree_ldl_kernel!(factor, stack, info, d, piv, pivot_kind, @Const(nzval), @Const(amap), + @Const(amap_ptr), @Const(amap_src), @Const(trees), bm, @Const(subtree_ptr), + @Const(subtree_nodes), @Const(super_ptr), @Const(front_ptr), + @Const(front_nrows), @Const(front_ncols), @Const(cb_ptr), + @Const(local_front), @Const(local_cb), @Const(child_ptr), + @Const(child_list), @Const(relind_ptr), @Const(relind), + ::Val{NE}, ::Val{WG}) where {NE, WG} + @uniform TT = eltype(factor) + @uniform IT = eltype(subtree_nodes) + li = @index(Local, Linear) + G = @index(Group, Linear) + buf = @localmem TT (NE,) + ctl = @localmem IT (_SP_CTL,) + pv = @localmem TT (1,) + if li == 1 + @inbounds t = trees[bm.first + SDS._bm_node(bm, G) - 1] + @inbounds ctl[SDS._ST_FIRST] = subtree_ptr[t] + @inbounds ctl[SDS._ST_COUNT] = subtree_ptr[t + 1] - subtree_ptr[t] + end + @synchronize + for k in 1:ctl[SDS._ST_COUNT] + if li == 1 + SDS._subtree_setup!(ctl, k, subtree_nodes, front_nrows, front_ncols, local_front, local_cb, + child_ptr) + @inbounds ctl[_SP_C0] = super_ptr[ctl[SDS._ST_NODE]] % IT + end + @synchronize + SDS._subtree_zero!(buf, ctl, li, Val(WG)) + @synchronize + SDS._subtree_scatter!(buf, ctl, SDS._mview(nzval, SDS._bm_gmember(bm, G), bm.nbatch), amap, amap_ptr, + amap_src, front_ptr, li, Val(WG)) + @synchronize + for kc in 1:ctl[SDS._ST_NCHILD] + SDS._subtree_extend_add!(buf, ctl, kc, child_ptr, child_list, front_nrows, front_ncols, local_cb, + relind_ptr, relind, li, Val(WG)) + @synchronize + end + for j in 1:ctl[SDS._ST_W] + _subtree_ldl_update!(buf, ctl, pv, j, li, SDS._mview(d, SDS._bm_gmember(bm, G), bm.nbatch), + SDS._mview(piv, SDS._bm_gmember(bm, G), bm.nbatch), + SDS._mview(pivot_kind, SDS._bm_gmember(bm, G), bm.nbatch), Val(WG)) + @synchronize + _subtree_ldl_scale!(buf, ctl, pv, j, li, Val(WG)) + @synchronize + end + mb = SDS._bm_gmember(bm, G) + SDS._subtree_write!(factor, SDS._mview(stack, mb, bm.nbatch), SDS._iview(info, mb, bm.nbatch), buf, ctl, + SDS.member_panels(front_ptr, mb, bm.nbatch), cb_ptr, li, Val(WG)) + @synchronize + for r in 1:ctl[SDS._ST_ROUNDS] + SDS._subtree_move!(buf, ctl, r, li, Val(WG)) + @synchronize + end + end +end + +@inline function _front_ldl_update!(sh, st, pv, j, v, li, front_ncols, d, g0, ::Val{WG}) where {WG} + @inbounds begin + w = Int(front_ncols[v]) + if j <= w && st[1] == Int32(0) + dk = real(sh[SDS._packed(j, j, 64)]) + if !iszero(dk) + if li == 1 + d[g0 + j] = dk + pv[1] = dk + end + rd = inv(dk) + for i in (j + li):WG:w + sh[SDS._packed(i, j, 64)] *= rd + end + elseif li == 1 + st[1] = Int32(j) + end + end + end + return nothing +end + +@inline function _front_ldl_scale!(sh, st, pv, j, v, li, front_ncols, ::Val{WG}) where {WG} + @inbounds begin + w = Int(front_ncols[v]) + if j <= w && st[1] == Int32(0) + dk = pv[1] + r = w - j + for q in (li - 1):WG:(r * (r + 1) ÷ 2 - 1) + a, b = SDS._tri_decode(q, r) + i = j + a + k = j + b + sh[SDS._packed(i, k, 64)] -= sh[SDS._packed(i, j, 64)] * dk * sh[SDS._packed(k, j, 64)] + end + end + end + return nothing +end + +# F22 ← F22 − (L21 D) L21ᵀ on the private CB; d_k read from the sh diagonal (still live) +@inline function _front_ldl_syrk!(factor, pcb, sh, st, v, li, front_ptr, front_nrows, front_ncols, cb2, + ::Val{WG}) where {WG} + @inbounds begin + TT = eltype(factor) + c0 = Int(cb2[v]) + if st[1] == Int32(0) && c0 > 0 + f = Int(front_nrows[v]) + w = Int(front_ncols[v]) + m = f - w + p0 = Int(front_ptr[v]) + w - 1 + for q in (li - 1):WG:(m * (m + 1) ÷ 2 - 1) + ii, jj = SDS._tri_decode(q, m) + acc = zero(TT) + for k in 1:w + acc += factor[p0 + (k - 1) * f + ii] * real(sh[SDS._packed(k, k, 64)]) * + factor[p0 + (k - 1) * f + jj] + end + pcb[c0 + SDS._packed(ii, jj, m) - 1] -= acc + end + end + end + return nothing +end + const ROLE_PANEL_B = Int8(1) const ROLE_PANEL_C = Int8(2) const ROLE_TRSM = Int8(3) @@ -771,8 +969,8 @@ end cb_done, @Const(snparent), @Const(front_ptr), @Const(front_nrows), @Const(front_ncols), @Const(cb_ptr), @Const(child_ptr), @Const(child_list), @Const(relind_ptr), @Const(relind), base, - @Const(wbase), wupd, wpdone2, wtrsml, wcbl, scap, - ::Val{WG}) where {WG} + @Const(wbase), wupd, wpdone2, wtrsml, wcbl, scap, d, @Const(super_ptr), + ::Val{LDL}, ::Val{WG}) where {LDL, WG} @uniform TT = eltype(factor) @uniform RT = real(eltype(factor)) li = @index(Local, Linear) @@ -877,10 +1075,19 @@ end SDS._front_load!(sh, st, factor, v, li, front_ptr, front_nrows, front_ncols, Val(64), Val(WG)) @synchronize w = Int(front_ncols[v]) + g0 = Int(super_ptr[v]) - 1 for j in 1:w - SDS._front_chol_update!(sh, st, piv, j, v, li, front_ncols, Val(64), Val(WG)) + if LDL + _front_ldl_update!(sh, st, piv, j, v, li, front_ncols, d, g0, Val(WG)) + else + SDS._front_chol_update!(sh, st, piv, j, v, li, front_ncols, Val(64), Val(WG)) + end @synchronize - SDS._front_chol_scale!(sh, st, piv, j, v, li, front_ncols, Val(64), Val(WG)) + if LDL + _front_ldl_scale!(sh, st, piv, j, v, li, front_ncols, Val(WG)) + else + SDS._front_chol_scale!(sh, st, piv, j, v, li, front_ncols, Val(64), Val(WG)) + end @synchronize end f = Int(front_nrows[v]) @@ -888,7 +1095,8 @@ end for q in (li - 1):WG:(w * w - 1) j = q ÷ w + 1 i = q - (j - 1) * w + 1 - i >= j && (factor[p0 + (j - 1) * f + i - 1] = sh[SDS._packed(i, j, 64)]) + i >= j && (factor[p0 + (j - 1) * f + i - 1] = (LDL && i == j) ? one(TT) : + sh[SDS._packed(i, j, 64)]) end m = f - w if st[1] == Int32(0) && m <= SYRK_IN @@ -896,7 +1104,9 @@ end for kk in 1:w x = factor[p0 + (kk - 1) * f + i - 1] for j in 1:(kk - 1) - x -= factor[p0 + (j - 1) * f + i - 1] * sh[SDS._packed(kk, j, 64)] + lij = factor[p0 + (j - 1) * f + i - 1] + x -= LDL ? lij * (real(sh[SDS._packed(j, j, 64)]) * sh[SDS._packed(kk, j, 64)]) : + lij * sh[SDS._packed(kk, j, 64)] end factor[p0 + (kk - 1) * f + i - 1] = x / sh[SDS._packed(kk, kk, 64)] end @@ -905,8 +1115,13 @@ end li == 1 && (info[v] = st[1]) @synchronize if m <= SYRK_IN - SDS._front_syrk!(factor, pcb, st, v, li, front_ptr, front_nrows, front_ncols, cb2, - Val(WG)) + if LDL + _front_ldl_syrk!(factor, pcb, sh, st, v, li, front_ptr, front_nrows, front_ncols, cb2, + Val(WG)) + else + SDS._front_syrk!(factor, pcb, st, v, li, front_ptr, front_nrows, front_ncols, cb2, + Val(WG)) + end @synchronize Threads.atomic_fence() # ALL threads: each wave drains its own stores (gfx9) @synchronize @@ -942,7 +1157,10 @@ end j += 1 end i = j + qq - 1 - sh[SDS._packed(i, j, 64)] = factor[p0 + (j - 1) * f + i - 1] + # LDL: the factor diagonal is unit; restore d in the same loop (a second + # loop would race this one on the diagonal slots) + sh[SDS._packed(i, j, 64)] = (LDL && i == j) ? TT(d[Int(super_ptr[v]) - 1 + j]) : + factor[p0 + (j - 1) * f + i - 1] end @synchronize if info[v] == Int32(0) @@ -951,7 +1169,9 @@ end for kk in 1:w x = factor[p0 + (kk - 1) * f + i - 1] for j in 1:(kk - 1) - x -= factor[p0 + (j - 1) * f + i - 1] * sh[SDS._packed(kk, j, 64)] + lij = factor[p0 + (j - 1) * f + i - 1] + x -= LDL ? lij * (real(sh[SDS._packed(j, j, 64)]) * sh[SDS._packed(kk, j, 64)]) : + lij * sh[SDS._packed(kk, j, 64)] end factor[p0 + (kk - 1) * f + i - 1] = x / sh[SDS._packed(kk, kk, 64)] end @@ -988,12 +1208,23 @@ end kk0 = 0 while kk0 < w # k in two halves: Ai/Aj share sh (2 x 1024) kb = min(32, w - kk0) + if LDL # stage the block's pivots; fold into Ai only + if ok + g0 = Int(super_ptr[v]) - 1 + for c in li:WG:kb + sh[2048 + c] = TT(d[g0 + kk0 + c]) + end + end + @synchronize + end if ok for q in (li - 1):WG:(TILE * kb - 1) rr = q % TILE + 1 cc = q ÷ TILE + 1 row = i0 + rr - sh[rr + (cc - 1) * TILE] = row <= m ? factor[p0 + (kk0 + cc - 1) * f + row] : zero(TT) + sh[rr + (cc - 1) * TILE] = row <= m ? + (LDL ? factor[p0 + (kk0 + cc - 1) * f + row] * sh[2048 + cc] : + factor[p0 + (kk0 + cc - 1) * f + row]) : zero(TT) row2 = j0 + rr sh[1024 + rr + (cc - 1) * TILE] = row2 <= m ? factor[p0 + (kk0 + cc - 1) * f + row2] : zero(TT) @@ -1096,20 +1327,35 @@ end @synchronize for j in 1:kb if li == 1 - d = real(sh[SDS._packed(j, j, 64)]) - if st[1] == Int32(0) && d > zero(RT) - piv[1] = sqrt(d) - sh[SDS._packed(j, j, 64)] = piv[1] - elseif st[1] == Int32(0) - st[1] = Int32(c0 + j) - piv[1] = one(RT) + dj = real(sh[SDS._packed(j, j, 64)]) + if LDL + if st[1] == Int32(0) && !iszero(dj) + piv[1] = dj # d stays on the diagonal + d[Int(super_ptr[v]) - 1 + c0 + j] = dj + elseif st[1] == Int32(0) + st[1] = Int32(c0 + j) + piv[1] = one(RT) + end + else + if st[1] == Int32(0) && dj > zero(RT) + piv[1] = sqrt(dj) + sh[SDS._packed(j, j, 64)] = piv[1] + elseif st[1] == Int32(0) + st[1] = Int32(c0 + j) + piv[1] = one(RT) + end end end @synchronize pvt = piv[1] if st[1] == Int32(0) + rpv = inv(pvt) for i in (j + li):WG:kb - sh[SDS._packed(i, j, 64)] /= pvt + if LDL + sh[SDS._packed(i, j, 64)] *= rpv + else + sh[SDS._packed(i, j, 64)] /= pvt + end end end @synchronize @@ -1118,7 +1364,9 @@ end jj = q ÷ kb + 1 ii = q - (jj - 1) * kb + 1 if ii >= jj && jj > j - sh[SDS._packed(ii, jj, 64)] -= sh[SDS._packed(ii, j, 64)] * sh[SDS._packed(jj, j, 64)] + sh[SDS._packed(ii, jj, 64)] -= LDL ? + sh[SDS._packed(ii, j, 64)] * pvt * sh[SDS._packed(jj, j, 64)] : + sh[SDS._packed(ii, j, 64)] * sh[SDS._packed(jj, j, 64)] end end end @@ -1127,7 +1375,8 @@ end for q in (li - 1):WG:(kb * kb - 1) jj = q ÷ kb + 1 ii = q - (jj - 1) * kb + 1 - ii >= jj && (factor[p0 + (c0 + jj - 1) * f + c0 + ii - 1] = sh[SDS._packed(ii, jj, 64)]) + ii >= jj && (factor[p0 + (c0 + jj - 1) * f + c0 + ii - 1] = + (LDL && ii == jj) ? one(TT) : sh[SDS._packed(ii, jj, 64)]) end li == 1 && (k == 1 || st[1] != Int32(0)) && (info[v] = st[1]) @synchronize @@ -1158,7 +1407,10 @@ end j += 1 end i = j + qq - 1 - sh[SDS._packed(i, j, 64)] = factor[p0 + (c0 + j - 1) * f + c0 + i - 1] + # LDL: the factor diagonal is unit; restore d in the same loop (a second + # loop would race this one on the diagonal slots) + sh[SDS._packed(i, j, 64)] = (LDL && i == j) ? TT(d[Int(super_ptr[v]) - 1 + c0 + j]) : + factor[p0 + (c0 + j - 1) * f + c0 + i - 1] end @synchronize if info[v] == Int32(0) @@ -1167,7 +1419,9 @@ end for kk in 1:kb x = factor[p0 + (c0 + kk - 1) * f + i - 1] for j in 1:(kk - 1) - x -= factor[p0 + (c0 + j - 1) * f + i - 1] * sh[SDS._packed(kk, j, 64)] + lij = factor[p0 + (c0 + j - 1) * f + i - 1] + x -= LDL ? lij * (real(sh[SDS._packed(j, j, 64)]) * sh[SDS._packed(kk, j, 64)]) : + lij * sh[SDS._packed(kk, j, 64)] end factor[p0 + (c0 + kk - 1) * f + i - 1] = x / sh[SDS._packed(kk, kk, 64)] end @@ -1202,12 +1456,23 @@ end kk0 = 0 while kk0 < kb kbk = min(32, kb - kk0) + if LDL # stage the block's pivots; fold into Ai only + if ok + g0 = Int(super_ptr[v]) - 1 + c0 + for c in li:WG:kbk + sh[2048 + c] = TT(d[g0 + kk0 + c]) + end + end + @synchronize + end if ok for q in (li - 1):WG:(TILE * kbk - 1) rr = q % TILE + 1 cc = q ÷ TILE + 1 row = i0 + rr - sh[rr + (cc - 1) * TILE] = row <= f ? factor[p0 + (c0 + kk0 + cc - 1) * f + row - 1] : zero(TT) + sh[rr + (cc - 1) * TILE] = row <= f ? + (LDL ? factor[p0 + (c0 + kk0 + cc - 1) * f + row - 1] * sh[2048 + cc] : + factor[p0 + (c0 + kk0 + cc - 1) * f + row - 1]) : zero(TT) row2 = j0 + rr sh[1024 + rr + (cc - 1) * TILE] = row2 <= f ? factor[p0 + (c0 + kk0 + cc - 1) * f + row2 - 1] : zero(TT) @@ -1317,27 +1582,42 @@ end end # subtree launcher with a custom (sorted) list, local memory sized by eltype -function launch_subtrees_sorted!(N, S, nzval, list, lb, backend) +function launch_subtrees_sorted!(N, S, nzval, list, lb, backend, ldl::Bool = false) SDS._with_local_bytes(Int(lb)) do LBv - _sorted_subtree_launch(N, S, nzval, list, LBv, Val(SDS.SUBTREE_WORKGROUP), backend) + _sorted_subtree_launch(N, S, nzval, list, Val(ldl), LBv, Val(SDS.SUBTREE_WORKGROUP), backend) end end -function _sorted_subtree_launch(N::SDS.Numeric{T}, S, nzval, list, ::Val{LB}, ::Val{WG}, backend) where {T, LB, WG} +function _sorted_subtree_launch(N::SDS.Numeric{T}, S, nzval, list, ::Val{LDL}, ::Val{LB}, ::Val{WG}, + backend) where {T, LDL, LB, WG} bm = SDS.batch_map(N; first = 1) - k! = SDS.subtree_cholesky_kernel!(backend, WG) - k!(N.factor, N.stack, N.info, nzval, S.amap, S.amap_ptr, S.amap_src, list, bm, S.subtree_ptr, - S.subtree_nodes, S.front_ptr, S.front_nrows, S.front_ncols, S.cb_ptr, S.local_front, S.local_cb, - S.child_ptr, S.child_list, S.relind_ptr, S.relind, - Val((LB - SDS.SUBTREE_LOCAL_RESERVE) ÷ sizeof(T)), Val(WG); ndrange = WG * length(list) * bm.nact) + if LDL + # the schedule sized the "S" budget classes with the LDLT reserve: match it + k! = subtree_ldl_kernel!(backend, WG) + k!(N.factor, N.stack, N.info, N.d, N.piv, N.pivot_kind, nzval, S.amap, S.amap_ptr, S.amap_src, + list, bm, S.subtree_ptr, S.subtree_nodes, S.super_ptr, S.front_ptr, S.front_nrows, S.front_ncols, + S.cb_ptr, S.local_front, S.local_cb, S.child_ptr, S.child_list, S.relind_ptr, S.relind, + Val((LB - SDS.SUBTREE_LOCAL_RESERVE_LDLT) ÷ sizeof(T)), Val(WG); + ndrange = WG * length(list) * bm.nact) + else + k! = SDS.subtree_cholesky_kernel!(backend, WG) + k!(N.factor, N.stack, N.info, nzval, S.amap, S.amap_ptr, S.amap_src, list, bm, S.subtree_ptr, + S.subtree_nodes, S.front_ptr, S.front_nrows, S.front_ncols, S.cb_ptr, S.local_front, S.local_cb, + S.child_ptr, S.child_list, S.relind_ptr, S.relind, + Val((LB - SDS.SUBTREE_LOCAL_RESERVE) ÷ sizeof(T)), Val(WG); ndrange = WG * length(list) * bm.nact) + end return nothing end # --------------------------------------------------------------------------- # host: the topologically ordered role list + counter bases + stream-2 program function build_fused_plan(s; inkernel_wides::Bool = false, merge_segments::Bool = false, - wgf::Int = WGF, scap::Integer = 128) + wgf::Int = WGF, scap::Integer = 128, ldl::Bool = false) S = s.symbolic plan = s.numeric.plan + if ldl + eltype(s.numeric.factor) <: Real || error("fused LDL mode: real element types only") + inkernel_wides = true # no vendor potrf for LDL: wide fronts stay in-kernel + end nodes = S.schedule.group_nodes rows_h = S.schedule.rows width_h = S.schedule.width @@ -1541,7 +1821,7 @@ function build_fused_plan(s; inkernel_wides::Bool = false, merge_segments::Bool wbase = DVEC(wbase_h), wupd_b = DVEC(wupd_b), wtl_b = DVEC(wtl_b), wcb_b = DVEC(wcb_b), wupd = KA.zeros(BACKEND[], Int32, max(nwslots, 1)), wtl = KA.zeros(BACKEND[], Int32, max(nwslots, 1)), wpd = KA.zeros(BACKEND[], Int32, max(nwslots, 1)), - wcb = KA.zeros(BACKEND[], Int32, ns), wgf, scap = Int32(scap), + wcb = KA.zeros(BACKEND[], Int32, ns), wgf, scap = Int32(scap), ldl, s2 = nothing) end @@ -1561,7 +1841,7 @@ function refact_fused!(s, nzval, fp; nstreams::Int = 1) fill!(fp.wpd, Int32(0)) copyto!(fp.wcb, fp.wcb_b) for k in eachindex(plan.sub_first) - launch_subtrees_sorted!(N, S, nzval, fp.ssub[k], plan.sub_local[k], backend) + launch_subtrees_sorted!(N, S, nzval, fp.ssub[k], plan.sub_local[k], backend, fp.ldl) end nodes = S.schedule.group_nodes fill!(fp.pcb, 0.0) @@ -1581,7 +1861,8 @@ function refact_fused!(s, nzval, fp; nstreams::Int = 1) S.snparent, S.front_ptr, S.front_nrows, S.front_ncols, S.cb_ptr, S.child_ptr, S.child_list, S.relind_ptr, S.relind, Int32(base), fp.wbase, fp.wupd, fp.wpd, fp.wtl, fp.wcb, - fp.scap, Val(fp.wgf); ndrange = fp.wgf * count) + fp.scap, N.d, S.super_ptr, Val(fp.ldl), Val(fp.wgf); + ndrange = fp.wgf * count) if true for v in gwides SDS._factor_panel_c!(N.factor, fp.pcb, N.work, N.info, v, S.layout.panel_ptr[v], @@ -1593,7 +1874,7 @@ function refact_fused!(s, nzval, fp; nstreams::Int = 1) end end SDS.assemble_schur!(N, S, nzval) - SDS.cholesky_stats!(N, S) + fp.ldl || SDS.cholesky_stats!(N, S) # LDL: inertia comes from the signs of d return SDS._numeric_info!(N, S, true) end diff --git a/bench/e2e/ldl_fused_debug.jl b/bench/e2e/ldl_fused_debug.jl new file mode 100644 index 00000000..78423340 --- /dev/null +++ b/bench/e2e/ldl_fused_debug.jl @@ -0,0 +1,71 @@ +# Locate NaNs in the fused LDL factor by front class. +# CUDA_VISIBLE_DEVICES=1 julia +1.13 --project=bench bench/e2e/ldl_fused_debug.jl +using SparseDirectSolver, SparseArrays, LinearAlgebra, Random, Statistics, Printf +using CUDA, CUDA.CUSPARSE +const SDS = SparseDirectSolver +include(joinpath(@__DIR__, "SDSProto.jl")) +using .SDSProto +const BD = joinpath(@__DIR__, "..") +include(joinpath(BD, "matrices.jl")) +using .BenchMatrices: read_mtx + +A = SparseMatrixCSC{Float64, Int}(sparse(read_mtx(joinpath(BD, "data", + "kkt_pglib_opf_case78484_epigrids_condensed_10.mtx")))) +n = size(A, 1) +Lh = tril(A) +Random.seed!(666) +bh = rand(n) +S3 = (p = Vector{Int32}(undef, n); read!(joinpath(BD, "perm_cand3.bin"), p); Int.(p)) +SDSProto.BACKEND[] = CUDA.CUDABackend() + +bd = CuArray(bh); xd = similar(bd) +s = DirectSolver(CuSparseMatrixCSR(Lh), "S", 'L') +SDS.setparam!(s, "user_perm", S3) +SDS.setparam!(s, "pivot_type", 'N') +ex(ph) = SDS.execute!(ph, s, xd, bd; asynchronous = false) +ex("analysis"); ex("factorization"); ex("refactorization"); ex("solve"); CUDA.synchronize() +fac0 = Array(s.numeric.factor) +d0 = Array(s.numeric.d) + +nzval = SDS._factor_values(s) +fp = SDSProto.build_fused_plan(s; ldl = true, wgf = 128, scap = 128) +info = SDSProto.refact_fused!(s, nzval, fp) +CUDA.synchronize() +fac1 = Array(s.numeric.factor) +d1 = Array(s.numeric.d) + +S = s.symbolic +fptr = Array(S.front_ptr); fr = Array(S.front_nrows); fc = Array(S.front_ncols) +sptr = Array(S.super_ptr) +stn = Array(S.subtree_nodes) +ns = length(fr) +covered = falses(ns); for v in stn; covered[v] = true; end +dbad = findall(isnan, @view d1[1:n]) +println("NaN in d[1:n]: ", length(dbad), " first cols: ", first(dbad, 5)) +# classify fronts +nbad = Dict{String,Int}(); tot = Dict{String,Int}() +firstbad = Int[] +for v in 1:ns + f, w = fr[v], fc[v] + m = f - w + cls = covered[v] ? "subtree" : (w > 64 ? "wide" : (m <= 64 ? "panel-inline" : "panel-split")) + tot[cls] = get(tot, cls, 0) + 1 + rng = fptr[v]:(fptr[v] + f * w - 1) + bad = any(isnan, @view fac1[rng]) + dbadv = any(isnan, @view d1[sptr[v]:(sptr[v] + w - 1)]) + if bad || dbadv + nbad[cls] = get(nbad, cls, 0) + 1 + length(firstbad) < 6 && push!(firstbad, v) + end +end +println("front classes: ", tot) +println("NaN fronts: ", nbad) +for v in firstbad + f, w = fr[v], fc[v] + dfrag = d1[sptr[v]:min(sptr[v] + w - 1, sptr[v] + 4)] + @printf " front %d f %d w %d covered %s d[1:5] %s d0 %s\n" v f w string(covered[v]) string(round.(dfrag; sigdigits=3)) string(round.(d0[sptr[v]:min(sptr[v]+4, sptr[v]+w-1)]; sigdigits=3)) +end +# also: max |Δ| over non-NaN fronts +ok = .!isnan.(fac1) +println("Δfactor over finite entries: ", maximum(abs.(fac1[ok] .- fac0[ok])) / maximum(abs, fac0)) +println("done") diff --git a/bench/e2e/ldl_fused_test.jl b/bench/e2e/ldl_fused_test.jl new file mode 100644 index 00000000..226027f1 --- /dev/null +++ b/bench/e2e/ldl_fused_test.jl @@ -0,0 +1,95 @@ +# Fused no-pivot LDL vs stock LDL (pivot 'N') on the 78k ACOPF condensed KKT, plus +# the fused-Cholesky control in the same process. +# CUDA_VISIBLE_DEVICES=1 julia +1.13 --project=bench bench/e2e/ldl_fused_test.jl +using SparseDirectSolver, SparseArrays, LinearAlgebra, Random, Statistics, Printf +using CUDA, CUDA.CUSPARSE +const SDS = SparseDirectSolver +include(joinpath(@__DIR__, "SDSProto.jl")) +using .SDSProto + +const BD = joinpath(@__DIR__, "..") +include(joinpath(BD, "matrices.jl")) +using .BenchMatrices: read_mtx + +A = SparseMatrixCSC{Float64, Int}(sparse(read_mtx(joinpath(BD, "data", + "kkt_pglib_opf_case78484_epigrids_condensed_10.mtx")))) +n = size(A, 1) +Lh = tril(A) +Random.seed!(666) +bh = rand(n) +S3 = (p = Vector{Int32}(undef, n); read!(joinpath(BD, "perm_cand3.bin"), p); Int.(p)) +SDSProto.BACKEND[] = CUDA.CUDABackend() + +function setup(structure) + bd = CuArray(bh); xd = similar(bd) + s = DirectSolver(CuSparseMatrixCSR(Lh), structure, 'L') + SDS.setparam!(s, "user_perm", S3) + structure == "S" && SDS.setparam!(s, "pivot_type", 'N') + ex(ph) = SDS.execute!(ph, s, xd, bd; asynchronous = false) + ex("analysis"); ex("factorization"); ex("refactorization"); ex("solve") + CUDA.synchronize() + return s, ex, bd, xd +end + +say(x...) = (println(x...); flush(stdout)) + +# --- stock LDL pivot-N: reference factor/d + timing --- +s, ex, bd, xd = setup("S") +tr_stock = median([CUDA.@elapsed ex("refactorization") for _ in 1:7]) +fac0 = Array(s.numeric.factor) +d0 = Array(s.numeric.d) +@printf "stock S/N refact %8.2f ms info %d\n" tr_stock*1e3 SDS.getparam(s, "info") +flush(stdout) + +# --- fused LDL on the same solver --- +nzval = SDS._factor_values(s) +fp = SDSProto.build_fused_plan(s; ldl = true, wgf = 128, scap = 128) +info = SDSProto.refact_fused!(s, nzval, fp) +CUDA.synchronize() +fac1 = Array(s.numeric.factor) +d1 = Array(s.numeric.d) +df = maximum(abs, fac1 - fac0) / maximum(abs, fac0) +dd = maximum(abs, d1 - d0) / maximum(abs, d0) +@printf "fused S/N info %d Δfactor %.2e Δd %.2e\n" info df dd +(info == 0 && df < 1e-6 && dd < 1e-6) || error("fused LDL gate FAILED") +tr_fused = median([CUDA.@elapsed SDSProto.refact_fused!(s, nzval, fp) for _ in 1:7]) +@printf "fused S/N refact %8.2f ms\n" tr_fused*1e3 +flush(stdout) + +# --- fused solve (inverse path + D pass) --- +inv11, inv_ptr, invertf, _ = SDSProto.build_inverse(s) +plan0 = s.workspace.plan +nodes0 = s.symbolic.schedule.group_nodes +stp = Array(s.symbolic.subtree_ptr) +sub_ids = Int32[] +for k in eachindex(plan0.kind) + plan0.kind[k] == SDS.SOLVE_SUBTREES || continue + append!(sub_ids, Int32.(nodes0[plan0.first[k]:plan0.last[k]])) +end +sort!(sub_ids; by = e -> stp[e + 1] - stp[e], rev = true) +SDSProto.SSUB[] = SDSProto.DVEC(sub_ids) +SDSProto.NSUB[] = length(sub_ids) +SDSProto.INV11[] = inv11 +SDSProto.INVPTR[] = inv_ptr +fz1 = SDSProto.build_fused(s; kstart = 1, wcap = 256) +fz = ([(fz1, 64)], true) +invertf() +x2 = similar(bd) +SDSProto.algo2_solve!(x2, s, bd, inv11, inv_ptr, fz) +CUDA.synchronize() +rel = norm(bh - A * Array(x2)) / norm(bh) +@printf "fused S/N solve relres %.2e\n" rel +rel < 1.0 || error("fused LDL solve gate FAILED") +ts_fused = median([CUDA.@elapsed SDSProto.algo2_solve!(x2, s, bd, inv11, inv_ptr, fz) for _ in 1:7]) +@printf "fused S/N solve %8.2f ms\n" ts_fused*1e3 +flush(stdout) + +# --- fused Cholesky control (fresh solver, same matrix as SPD) --- +s2, ex2, bd2, xd2 = setup("SPD") +nz2 = SDS._factor_values(s2) +fp2 = SDSProto.build_fused_plan(s2; wgf = 128, scap = 128) +info2 = SDSProto.refact_fused!(s2, nz2, fp2) +tr_chol = median([CUDA.@elapsed SDSProto.refact_fused!(s2, nz2, fp2) for _ in 1:7]) +@printf "fused SPD refact %8.2f ms info %d (control)\n" tr_chol*1e3 info2 +@printf "RATIO fused LDL / fused Cholesky = %.3f\n" tr_fused / tr_chol +say("done") From a62ac0eb53c28314f70bc03a203326934faf5217 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Sat, 10 Oct 2026 20:51:06 -0400 Subject: [PATCH 34/65] rename the fused-kernel module: SDSProto -> SDSFused The prototype IS the solver since the legacy-wrapper cleanup (SDSSolver = the fused solver; SDSProtoSolver remains as a compatibility alias), so the module housing the fused kernels drops the 'proto' name. Mechanical rename, validated by a full gated run (fused LDL: info 0, dfactor 1.8e-7, dd 6.8e-10, refact 49.2 ms; fused Cholesky control 37.0 ms). Race validation on the LDL mode: 20/20 gated refactorizations clean; same-wide-strategy ratio LDL/Cholesky 1.134. Co-Authored-By: Claude Fable 5 --- bench/README.md | 2 +- bench/e2e/MadNLPSDS.jl | 24 +++++------ bench/e2e/{SDSProto.jl => SDSFused.jl} | 2 +- bench/e2e/ldl_fused_debug.jl | 10 ++--- bench/e2e/ldl_fused_race.jl | 60 ++++++++++++++++++++++++++ bench/e2e/ldl_fused_test.jl | 34 +++++++-------- 6 files changed, 96 insertions(+), 36 deletions(-) rename bench/e2e/{SDSProto.jl => SDSFused.jl} (99%) create mode 100644 bench/e2e/ldl_fused_race.jl diff --git a/bench/README.md b/bench/README.md index 38c46109..3de3e0f7 100644 --- a/bench/README.md +++ b/bench/README.md @@ -22,7 +22,7 @@ Baselines every later milestone is measured against (PLAN.md §5 M0, §7). The | `order_search.jl`, `get_cudss_perm.jl` | PR #107 ordering study: METIS knob sweep and schedule depth; extraction of cuDSS's reordering through the raw C API for replay under `user_perm` | | `ordering_chooser.jl` | T22 (issue #108): AMD vs ND per harness matrix and dump (schedule depth, fundamental and column-etree depth, nnz(L), flops) and the choice of the T22 and T05 cost models; host only, no GPU: `julia --project=bench bench/ordering_chooser.jl` (`SDS_BENCH_SUITESPARSE=0` skips MatrixDepot) | | `amd_proto.jl`, `amd/Project.toml` | the solve prototype on ROCm (ROCArray, raw-array constructor, `Threads.atomic_fence`); environment `bench/amd/` | -| `e2e/MadNLPSDS.jl`, `e2e/SDSProto.jl`, `e2e/run_gv100.jl`, `e2e/run_amd.jl` | MadNLP `AbstractLinearSolver` over the public API (prototype of the External task) and the prototype kernels packaged backend-portably; full ACOPF interior-point runs on CUDA and AMD; environment `bench/e2e/` (MadNLP, MadNLPGPU, ExaModels, ExaModelsPower) | +| `e2e/MadNLPSDS.jl`, `e2e/SDSFused.jl`, `e2e/run_gv100.jl`, `e2e/run_amd.jl` | MadNLP `AbstractLinearSolver` over the public API (prototype of the External task) and the prototype kernels packaged backend-portably; full ACOPF interior-point runs on CUDA and AMD; environment `bench/e2e/` (MadNLP, MadNLPGPU, ExaModels, ExaModelsPower) | | `pivot_pairs.jl` | 2×2 pivot pairs of the `S` analysis (issue #66): `pivot_pairs` = `none`/`default`/`all` on the K2 dumps and the KKT generators, with nnz(L), zero/perturbed/2×2 pivots, max abs L and factor error of the CPU reference LDLᵀ; package environment: `julia --project=. bench/pivot_pairs.jl [--only=case118,...] [--generators=false]` | ## Commands diff --git a/bench/e2e/MadNLPSDS.jl b/bench/e2e/MadNLPSDS.jl index 434dcc22..77f4f4c3 100644 --- a/bench/e2e/MadNLPSDS.jl +++ b/bench/e2e/MadNLPSDS.jl @@ -11,8 +11,8 @@ using SparseDirectSolver const SDS = SparseDirectSolver const KA = SDS.KernelAbstractions -include("SDSProto.jl") -using .SDSProto +include("SDSFused.jl") +using .SDSFused """ SDSTuning @@ -140,7 +140,7 @@ function SDSSolver( T = eltype(csc.nzVal) n = size(csc, 1) backend = KA.get_backend(csc.nzVal) - SDSProto.BACKEND[] = backend + SDSFused.BACKEND[] = backend t = _resolve(something(opt.sds_tuning, default_tuning(backend)), backend) s = SDS.DirectSolver(csc.colPtr, csc.rowVal, csc.nzVal, "SPD", 'U') s.options.regime_c_width = t.regime_c_width @@ -156,9 +156,9 @@ function SDSSolver( SDS.execute!("solve", s, x, b; asynchronous = false) # allocates the solve workspace nzval = SDS._factor_values(s) merged = t.wides === :merged - fp = SDSProto.build_fused_plan(s; inkernel_wides = merged, merge_segments = merged, + fp = SDSFused.build_fused_plan(s; inkernel_wides = merged, merge_segments = merged, wgf = t.workgroup, scap = t.spin_cap) - inv11, inv_ptr, invertf, _ = SDSProto.build_inverse(s) + inv11, inv_ptr, invertf, _ = SDSFused.build_inverse(s) # solve-side plan state (module globals, single solver instance) plan0 = s.workspace.plan nodes0 = s.symbolic.schedule.group_nodes @@ -169,11 +169,11 @@ function SDSSolver( append!(sub_ids, Int32.(nodes0[plan0.first[k]:plan0.last[k]])) end sort!(sub_ids; by = e -> stp[e + 1] - stp[e], rev = true) - SDSProto.SSUB[] = SDSProto.DVEC(sub_ids) - SDSProto.NSUB[] = length(sub_ids) - SDSProto.INV11[] = inv11 - SDSProto.INVPTR[] = inv_ptr - fz1 = SDSProto.build_fused(s; kstart = 1, wcap = 256) + SDSFused.SSUB[] = SDSFused.DVEC(sub_ids) + SDSFused.NSUB[] = length(sub_ids) + SDSFused.INV11[] = inv11 + SDSFused.INVPTR[] = inv_ptr + fz1 = SDSFused.build_fused(s; kstart = 1, wcap = 256) segs = [(fz1, 64)] fz = (segs, true) M = SDSSolver{T}(s, csc, x, b, nzval, fp, fz, inv11, inv_ptr, invertf, 0, opt, logger) @@ -182,14 +182,14 @@ function SDSSolver( end function MadNLP.factorize!(M::SDSSolver) - M.last_info = Int(SDSProto.refact_fused!(M.inner, M.nzval, M.fp)) + M.last_info = Int(SDSFused.refact_fused!(M.inner, M.nzval, M.fp)) M.invert!() return M end function MadNLP.solve_linear_system!(M::SDSSolver{T}, xb) where {T} copyto!(M.b, xb) - SDSProto.algo2_solve!(M.x, M.inner, M.b, M.inv11, M.inv_ptr, M.fz) + SDSFused.algo2_solve!(M.x, M.inner, M.b, M.inv11, M.inv_ptr, M.fz) copyto!(xb, M.x) KA.synchronize(KA.get_backend(xb)) return xb diff --git a/bench/e2e/SDSProto.jl b/bench/e2e/SDSFused.jl similarity index 99% rename from bench/e2e/SDSProto.jl rename to bench/e2e/SDSFused.jl index 495fc944..d3c4ec56 100644 --- a/bench/e2e/SDSProto.jl +++ b/bench/e2e/SDSFused.jl @@ -1,7 +1,7 @@ # Prototype kernels of PR #107 assembled as a backend-portable module, so the # MadNLP wrapper can run the fused factorization and the algo1/fused solve on # CUDA and AMDGPU alike. Single-solver-instance (module-global plan state). -module SDSProto +module SDSFused using LinearAlgebra, SparseArrays, Statistics, Printf using SparseDirectSolver diff --git a/bench/e2e/ldl_fused_debug.jl b/bench/e2e/ldl_fused_debug.jl index 78423340..a574325f 100644 --- a/bench/e2e/ldl_fused_debug.jl +++ b/bench/e2e/ldl_fused_debug.jl @@ -3,8 +3,8 @@ using SparseDirectSolver, SparseArrays, LinearAlgebra, Random, Statistics, Printf using CUDA, CUDA.CUSPARSE const SDS = SparseDirectSolver -include(joinpath(@__DIR__, "SDSProto.jl")) -using .SDSProto +include(joinpath(@__DIR__, "SDSFused.jl")) +using .SDSFused const BD = joinpath(@__DIR__, "..") include(joinpath(BD, "matrices.jl")) using .BenchMatrices: read_mtx @@ -16,7 +16,7 @@ Lh = tril(A) Random.seed!(666) bh = rand(n) S3 = (p = Vector{Int32}(undef, n); read!(joinpath(BD, "perm_cand3.bin"), p); Int.(p)) -SDSProto.BACKEND[] = CUDA.CUDABackend() +SDSFused.BACKEND[] = CUDA.CUDABackend() bd = CuArray(bh); xd = similar(bd) s = DirectSolver(CuSparseMatrixCSR(Lh), "S", 'L') @@ -28,8 +28,8 @@ fac0 = Array(s.numeric.factor) d0 = Array(s.numeric.d) nzval = SDS._factor_values(s) -fp = SDSProto.build_fused_plan(s; ldl = true, wgf = 128, scap = 128) -info = SDSProto.refact_fused!(s, nzval, fp) +fp = SDSFused.build_fused_plan(s; ldl = true, wgf = 128, scap = 128) +info = SDSFused.refact_fused!(s, nzval, fp) CUDA.synchronize() fac1 = Array(s.numeric.factor) d1 = Array(s.numeric.d) diff --git a/bench/e2e/ldl_fused_race.jl b/bench/e2e/ldl_fused_race.jl new file mode 100644 index 00000000..03c54054 --- /dev/null +++ b/bench/e2e/ldl_fused_race.jl @@ -0,0 +1,60 @@ +# 20 gated fused-LDL refactorizations (race validation) + the in-kernel-wides +# Cholesky control that isolates the wide-strategy cost from the LDL cost. +# CUDA_VISIBLE_DEVICES=1 julia +1.13 --project=bench bench/e2e/ldl_fused_race.jl +using SparseDirectSolver, SparseArrays, LinearAlgebra, Random, Statistics, Printf +using CUDA, CUDA.CUSPARSE +const SDS = SparseDirectSolver +include(joinpath(@__DIR__, "SDSFused.jl")) +using .SDSFused +const BD = joinpath(@__DIR__, "..") +include(joinpath(BD, "matrices.jl")) +using .BenchMatrices: read_mtx + +A = SparseMatrixCSC{Float64, Int}(sparse(read_mtx(joinpath(BD, "data", + "kkt_pglib_opf_case78484_epigrids_condensed_10.mtx")))) +n = size(A, 1) +Lh = tril(A) +Random.seed!(666) +bh = rand(n) +S3 = (p = Vector{Int32}(undef, n); read!(joinpath(BD, "perm_cand3.bin"), p); Int.(p)) +SDSFused.BACKEND[] = CUDA.CUDABackend() + +function setup(structure) + bd = CuArray(bh); xd = similar(bd) + s = DirectSolver(CuSparseMatrixCSR(Lh), structure, 'L') + SDS.setparam!(s, "user_perm", S3) + structure == "S" && SDS.setparam!(s, "pivot_type", 'N') + ex(ph) = SDS.execute!(ph, s, xd, bd; asynchronous = false) + ex("analysis"); ex("factorization"); ex("refactorization"); ex("solve") + CUDA.synchronize() + return s +end + +s = setup("S") +fac0 = Array(s.numeric.factor) +d0 = Array(s.numeric.d) +mref = maximum(abs, fac0) +nz = SDS._factor_values(s) +fp = SDSFused.build_fused_plan(s; ldl = true, wgf = 128, scap = 128) +nbad = 0 +for t in 1:20 + info = SDSFused.refact_fused!(s, nz, fp); CUDA.synchronize() + df = maximum(abs, Array(s.numeric.factor) - fac0) / mref + dd = maximum(abs, Array(s.numeric.d) - d0) / maximum(abs, d0) + ok = info == 0 && df < 1e-6 && dd < 1e-6 + ok || (global nbad += 1) + @printf "run %2d %s info %d Δfactor %.2e Δd %.2e\n" t (ok ? "OK " : "BAD") info df dd + flush(stdout) +end +println("bad: ", nbad, " of 20") + +# Cholesky with the SAME wide strategy (in-kernel), isolating the LDL-math cost +s2 = setup("SPD") +nz2 = SDS._factor_values(s2) +fp2i = SDSFused.build_fused_plan(s2; inkernel_wides = true, wgf = 128, scap = 128) +SDSFused.refact_fused!(s2, nz2, fp2i) +tr_chol_ik = median([CUDA.@elapsed SDSFused.refact_fused!(s2, nz2, fp2i) for _ in 1:7]) +tr_ldl = median([CUDA.@elapsed SDSFused.refact_fused!(s, nz, fp) for _ in 1:7]) +@printf "fused SPD (in-kernel wides) refact %8.2f ms\n" tr_chol_ik*1e3 +@printf "fused S/N (in-kernel wides) refact %8.2f ms ratio same-strategy %.3f\n" tr_ldl*1e3 tr_ldl/tr_chol_ik +println("done") diff --git a/bench/e2e/ldl_fused_test.jl b/bench/e2e/ldl_fused_test.jl index 226027f1..1549aca6 100644 --- a/bench/e2e/ldl_fused_test.jl +++ b/bench/e2e/ldl_fused_test.jl @@ -4,8 +4,8 @@ using SparseDirectSolver, SparseArrays, LinearAlgebra, Random, Statistics, Printf using CUDA, CUDA.CUSPARSE const SDS = SparseDirectSolver -include(joinpath(@__DIR__, "SDSProto.jl")) -using .SDSProto +include(joinpath(@__DIR__, "SDSFused.jl")) +using .SDSFused const BD = joinpath(@__DIR__, "..") include(joinpath(BD, "matrices.jl")) @@ -18,7 +18,7 @@ Lh = tril(A) Random.seed!(666) bh = rand(n) S3 = (p = Vector{Int32}(undef, n); read!(joinpath(BD, "perm_cand3.bin"), p); Int.(p)) -SDSProto.BACKEND[] = CUDA.CUDABackend() +SDSFused.BACKEND[] = CUDA.CUDABackend() function setup(structure) bd = CuArray(bh); xd = similar(bd) @@ -43,8 +43,8 @@ flush(stdout) # --- fused LDL on the same solver --- nzval = SDS._factor_values(s) -fp = SDSProto.build_fused_plan(s; ldl = true, wgf = 128, scap = 128) -info = SDSProto.refact_fused!(s, nzval, fp) +fp = SDSFused.build_fused_plan(s; ldl = true, wgf = 128, scap = 128) +info = SDSFused.refact_fused!(s, nzval, fp) CUDA.synchronize() fac1 = Array(s.numeric.factor) d1 = Array(s.numeric.d) @@ -52,12 +52,12 @@ df = maximum(abs, fac1 - fac0) / maximum(abs, fac0) dd = maximum(abs, d1 - d0) / maximum(abs, d0) @printf "fused S/N info %d Δfactor %.2e Δd %.2e\n" info df dd (info == 0 && df < 1e-6 && dd < 1e-6) || error("fused LDL gate FAILED") -tr_fused = median([CUDA.@elapsed SDSProto.refact_fused!(s, nzval, fp) for _ in 1:7]) +tr_fused = median([CUDA.@elapsed SDSFused.refact_fused!(s, nzval, fp) for _ in 1:7]) @printf "fused S/N refact %8.2f ms\n" tr_fused*1e3 flush(stdout) # --- fused solve (inverse path + D pass) --- -inv11, inv_ptr, invertf, _ = SDSProto.build_inverse(s) +inv11, inv_ptr, invertf, _ = SDSFused.build_inverse(s) plan0 = s.workspace.plan nodes0 = s.symbolic.schedule.group_nodes stp = Array(s.symbolic.subtree_ptr) @@ -67,29 +67,29 @@ for k in eachindex(plan0.kind) append!(sub_ids, Int32.(nodes0[plan0.first[k]:plan0.last[k]])) end sort!(sub_ids; by = e -> stp[e + 1] - stp[e], rev = true) -SDSProto.SSUB[] = SDSProto.DVEC(sub_ids) -SDSProto.NSUB[] = length(sub_ids) -SDSProto.INV11[] = inv11 -SDSProto.INVPTR[] = inv_ptr -fz1 = SDSProto.build_fused(s; kstart = 1, wcap = 256) +SDSFused.SSUB[] = SDSFused.DVEC(sub_ids) +SDSFused.NSUB[] = length(sub_ids) +SDSFused.INV11[] = inv11 +SDSFused.INVPTR[] = inv_ptr +fz1 = SDSFused.build_fused(s; kstart = 1, wcap = 256) fz = ([(fz1, 64)], true) invertf() x2 = similar(bd) -SDSProto.algo2_solve!(x2, s, bd, inv11, inv_ptr, fz) +SDSFused.algo2_solve!(x2, s, bd, inv11, inv_ptr, fz) CUDA.synchronize() rel = norm(bh - A * Array(x2)) / norm(bh) @printf "fused S/N solve relres %.2e\n" rel rel < 1.0 || error("fused LDL solve gate FAILED") -ts_fused = median([CUDA.@elapsed SDSProto.algo2_solve!(x2, s, bd, inv11, inv_ptr, fz) for _ in 1:7]) +ts_fused = median([CUDA.@elapsed SDSFused.algo2_solve!(x2, s, bd, inv11, inv_ptr, fz) for _ in 1:7]) @printf "fused S/N solve %8.2f ms\n" ts_fused*1e3 flush(stdout) # --- fused Cholesky control (fresh solver, same matrix as SPD) --- s2, ex2, bd2, xd2 = setup("SPD") nz2 = SDS._factor_values(s2) -fp2 = SDSProto.build_fused_plan(s2; wgf = 128, scap = 128) -info2 = SDSProto.refact_fused!(s2, nz2, fp2) -tr_chol = median([CUDA.@elapsed SDSProto.refact_fused!(s2, nz2, fp2) for _ in 1:7]) +fp2 = SDSFused.build_fused_plan(s2; wgf = 128, scap = 128) +info2 = SDSFused.refact_fused!(s2, nz2, fp2) +tr_chol = median([CUDA.@elapsed SDSFused.refact_fused!(s2, nz2, fp2) for _ in 1:7]) @printf "fused SPD refact %8.2f ms info %d (control)\n" tr_chol*1e3 info2 @printf "RATIO fused LDL / fused Cholesky = %.3f\n" tr_fused / tr_chol say("done") From 192690d72c1ef2afcbcf5e7cf748016487b66327 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Sat, 10 Oct 2026 21:46:47 -0400 Subject: [PATCH 35/65] fused LDL: host-wide hybrid path (vendor trsm/gemm + 64-block panel kernel), in-kernel wides stay the LDL default The host-wide front for LDL reuses the Cholesky vendor sequence: our wide_ldl_panel_kernel! for each 64-column diagonal block (no vendor no-pivot LDL exists), the SAME vendor TRSM with the unit-triangular flag plus a broadcast column scale for D, the trailing panel columns and the contribution block through the SAME vendor GEMM against a D-scaled scratch (fp.ldlw, allocated at plan build), and pack_add!. Gated correct through the full pipeline (dfactor 1.8e-7, dd 6.8e-10 vs stock pivot-N). Measured SLOWER than in-kernel wides on the GV100 (55.5 vs 38.6 ms tuned, interleaved): the path needs ~5 operations per 64-column block where Cholesky's potrf is ONE vendor call, and the workload is launch-latency bound. build_fused_plan therefore defaults LDL to in-kernel wides (inkernel_wides = nothing resolves to ldl); pass inkernel_wides = false to select the hybrid. Final GV100 standings (tuned, interleaved): fused LDL 38.6 ms = 1.12x of same-strategy Cholesky (34.6), 1.50x of Cholesky's host-wide best (25.8) -- the residual gap is exactly 'vendor potrf exists, vendor LDL does not', and only affects hosts where host wides win (sm_70/80/90). Co-Authored-By: Claude Fable 5 --- bench/e2e/SDSFused.jl | 160 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 152 insertions(+), 8 deletions(-) diff --git a/bench/e2e/SDSFused.jl b/bench/e2e/SDSFused.jl index d3c4ec56..50647d11 100644 --- a/bench/e2e/SDSFused.jl +++ b/bench/e2e/SDSFused.jl @@ -1541,6 +1541,139 @@ end end end +# --------------------------------------------------------------------------- +# host-wide LDL path: the Cholesky vendor sequence (potrf/trsm/herk) with the +# diagonal blocks factored by a small kernel (no vendor no-pivot LDL exists), +# the SAME vendor TRSM with the unit-triangular flag, broadcast column scalings +# for D, and the CB through the SAME vendor GEMM against a D-scaled scratch. + +# one workgroup: no-pivot LDL of the kb×kb diagonal block at (c0b+1, c0b+1) of the +# f×w panel at p0; unit-L written back (diag = 1), D to d[g0 + c0b + j] +@kernel function wide_ldl_panel_kernel!(factor, d, info, v, p0, f, c0b, kb, g0, ::Val{WG}) where {WG} + li = @index(Local, Linear) + @uniform TT = eltype(factor) + sh = @localmem TT (NL64,) + st = @localmem Int32 (1,) + pv = @localmem TT (1,) + @inbounds begin + for q in li:WG:(kb * (kb + 1) ÷ 2) + j = 1 + acc = kb + qq = q + while qq > acc + qq -= acc + acc -= 1 + j += 1 + end + i = j + qq - 1 + sh[SDS._packed(i, j, 64)] = factor[p0 + (c0b + j - 1) * f + c0b + i - 1] + end + li == 1 && (st[1] = Int32(0)) + end + @synchronize + for j in 1:kb + @inbounds begin + if li == 1 + dk = real(sh[SDS._packed(j, j, 64)]) + if st[1] == Int32(0) && !iszero(dk) + pv[1] = dk + d[g0 + c0b + j] = dk + elseif st[1] == Int32(0) + st[1] = Int32(c0b + j) + pv[1] = one(TT) + end + end + end + @synchronize + @inbounds begin + pvt = pv[1] + if st[1] == Int32(0) + rpv = inv(pvt) + for i in (j + li):WG:kb + sh[SDS._packed(i, j, 64)] *= rpv + end + end + end + @synchronize + @inbounds begin + pvt = pv[1] + if st[1] == Int32(0) + for q in (li - 1):WG:(kb * kb - 1) + jj = q ÷ kb + 1 + ii = q - (jj - 1) * kb + 1 + if ii >= jj && jj > j + sh[SDS._packed(ii, jj, 64)] -= sh[SDS._packed(ii, j, 64)] * pvt * sh[SDS._packed(jj, j, 64)] + end + end + end + end + @synchronize + end + @inbounds begin + for q in (li - 1):WG:(kb * kb - 1) + jj = q ÷ kb + 1 + ii = q - (jj - 1) * kb + 1 + ii >= jj && (factor[p0 + (c0b + jj - 1) * f + c0b + ii - 1] = + ii == jj ? one(TT) : sh[SDS._packed(ii, jj, 64)]) + end + li == 1 && st[1] != Int32(0) && info[v] == Int32(0) && (info[v] = st[1]) + end +end + +@kernel function zero_upper_kernel!(factor, p0, f, w) + q = @index(Global, Linear) + @inbounds begin + j = (q - 1) ÷ w + 1 + i = q - (j - 1) * w + i < j && (factor[p0 + (j - 1) * f + i - 1] = zero(eltype(factor))) + end +end + +# the host-wide front for LDL: blocked right-looking over 64-column panels, then one +# D-scaled GEMM for the contribution block (the Cholesky path's trsm/herk shape) +function _factor_panel_c_ldl!(N, S, fp, v, p, backend) + T = eltype(N.factor) + f = Int(S.schedule.rows[v]) + w = Int(S.schedule.width[v]) + p0 = Int(S.layout.panel_ptr[v]) + c0 = fp.cb2_h[v] + g0 = fp.sptr_h[v] - 1 + m = f - w + gi = SDS.select_impl(:gemm, N.factor, :auto) + P = reshape(view(N.factor, p0:(p0 + f * w - 1)), f, w) + for k in 1:cld(w, 64) + c0b = (k - 1) * 64 + kb = min(64, w - c0b) + wide_ldl_panel_kernel!(backend, 128)(N.factor, N.d, N.info, v, p0, f, c0b, kb, g0, Val(128); + ndrange = 128) + r1 = c0b + kb + 1 + r1 <= f || continue + Lblk = view(P, (c0b + 1):(c0b + kb), (c0b + 1):(c0b + kb)) + R = view(P, r1:f, (c0b + 1):(c0b + kb)) # A L1⁻ᵀ = W = L D + SDS._trsm_impl!(p.trsm, 'R', 'L', 'C', 'U', one(T), Lblk, R) + dvb = transpose(view(N.d, (g0 + c0b + 1):(g0 + c0b + kb))) + if r1 <= w # trailing panel columns + Wscr = reshape(view(fp.ldlw, 1:((f - r1 + 1) * kb)), f - r1 + 1, kb) + Wscr .= R # keep W; R becomes final L + R ./= dvb + Lc = view(P, r1:w, (c0b + 1):(c0b + kb)) + Ctr = view(P, r1:f, r1:w) + SDS._gemm_impl!(gi, 'N', 'T', -one(T), Wscr, Lc, one(T), Ctr) + else + R ./= dvb + end + end + zero_upper_kernel!(backend, 128)(N.factor, p0, f, w; ndrange = w * w) + (m > 0 && c0 > 0) || return nothing + L21 = view(P, (w + 1):f, 1:w) + W21 = reshape(view(fp.ldlw, 1:(m * w)), m, w) + W21 .= L21 .* transpose(view(N.d, (g0 + 1):(g0 + w))) + C = reshape(view(N.work, 1:(m * m)), m, m) + SDS._gemm_impl!(gi, 'N', 'T', -one(T), W21, L21, zero(T), C) + SDS.pack_add!(fp.pcb, c0, N.work, m) + return nothing +end + @kernel function panel_zero_kernel!(factor, @Const(list), @Const(front_ptr), @Const(front_nrows), @Const(front_ncols), ::Val{WG}) where {WG} li = @index(Local, Linear) @@ -1610,14 +1743,16 @@ end # --------------------------------------------------------------------------- # host: the topologically ordered role list + counter bases + stream-2 program -function build_fused_plan(s; inkernel_wides::Bool = false, merge_segments::Bool = false, - wgf::Int = WGF, scap::Integer = 128, ldl::Bool = false) +function build_fused_plan(s; ldl::Bool = false, inkernel_wides::Union{Nothing, Bool} = nothing, + merge_segments::Bool = false, wgf::Int = WGF, scap::Integer = 128) S = s.symbolic plan = s.numeric.plan - if ldl - eltype(s.numeric.factor) <: Real || error("fused LDL mode: real element types only") - inkernel_wides = true # no vendor potrf for LDL: wide fronts stay in-kernel - end + ldl && (eltype(s.numeric.factor) <: Real || error("fused LDL mode: real element types only")) + # LDL defaults to in-kernel wides: the host-wide LDL path (_factor_panel_c_ldl!) is + # correct but needs ~5 launches per 64-column block where Cholesky's potrf is one + # vendor call -- measured SLOWER on the GV100 (55.5 vs 38.6 ms tuned). Pass + # inkernel_wides = false explicitly to use it. + inkernel_wides = inkernel_wides === nothing ? ldl : inkernel_wides nodes = S.schedule.group_nodes rows_h = S.schedule.rows width_h = S.schedule.width @@ -1822,6 +1957,11 @@ function build_fused_plan(s; inkernel_wides::Bool = false, merge_segments::Bool wcb_b = DVEC(wcb_b), wupd = KA.zeros(BACKEND[], Int32, max(nwslots, 1)), wtl = KA.zeros(BACKEND[], Int32, max(nwslots, 1)), wpd = KA.zeros(BACKEND[], Int32, max(nwslots, 1)), wcb = KA.zeros(BACKEND[], Int32, ns), wgf, scap = Int32(scap), ldl, + sptr_h = Array(S.super_ptr), + ldlw = KA.zeros(BACKEND[], Float64, + ldl ? maximum(vcat([1], [max(rows_h[v] * 64, + (rows_h[v] - width_h[v]) * width_h[v]) + for v in wides])) : 1), s2 = nothing) end @@ -1865,8 +2005,12 @@ function refact_fused!(s, nzval, fp; nstreams::Int = 1) ndrange = fp.wgf * count) if true for v in gwides - SDS._factor_panel_c!(N.factor, fp.pcb, N.work, N.info, v, S.layout.panel_ptr[v], - S.schedule.rows[v], S.schedule.width[v], fp.cb2_h[v], p) + if fp.ldl + _factor_panel_c_ldl!(N, S, fp, v, p, backend) + else + SDS._factor_panel_c!(N.factor, fp.pcb, N.work, N.info, v, S.layout.panel_ptr[v], + S.schedule.rows[v], S.schedule.width[v], fp.cb2_h[v], p) + end signal_kernel!(backend, 32)(fp.cbd, fp.arrived, S.snparent, v; ndrange = 32) end elseif false From b8995475df9d9535c15d77b117931b8044de0509 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Sat, 10 Oct 2026 23:28:45 -0400 Subject: [PATCH 36/65] fused LDL: potrf-derived host-wide fronts (the minimal-difference path) A positive-definite wide front factors through the VERBATIM Cholesky vendor sequence (potrf/trsm/herk/pack_add -- and the herk CB is bit-identical for both factorizations, since F21_chol = L21 D^(1/2)), followed by three micro-kernels deriving the LDL form: d = diag(C)^2, L = C diag(C)^-1, unit diagonal. Enabled with build_fused_plan(ldl = true, wides_potrf = true); valid where a front's pivots share a sign (always for an SPD matrix factored as "S"; per-front sign gating from the a-priori pivot signs is the planned extension for quasidefinite KKTs, with the in-kernel wide path as the mixed-sign fallback). GV100, 78k ACOPF KKT, tuned, interleaved, gated (info 0, dfactor 1.7e-7, dd 4.9e-10; 20/20 repeated refactorizations clean): LDL refact 28.6 ms vs Cholesky-best 25.8 ms = multiplier 1.106 -- under the 1.25 target, and 18% faster than cuDSS's own LDL (33.8 ms; cuDSS pays 1.25x vs its Cholesky 27.0). Co-Authored-By: Claude Fable 5 --- bench/e2e/SDSFused.jl | 47 ++++++++++++++++++++++++++++++++++--- bench/e2e/ldl_fused_race.jl | 2 +- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/bench/e2e/SDSFused.jl b/bench/e2e/SDSFused.jl index 50647d11..86dbbbee 100644 --- a/bench/e2e/SDSFused.jl +++ b/bench/e2e/SDSFused.jl @@ -1629,6 +1629,43 @@ end end end +# potrf-derived LDL of a positive-definite wide front: A = C Cᵀ = L D Lᵀ with +# L = C diag(C)⁻¹ and d = diag(C)², and F21_chol = L21 D^½ makes the Cholesky +# herk CB bit-identical to the LDL one — so the host path is the VERBATIM +# Cholesky vendor sequence plus three micro-kernels. Valid only where the +# front's pivots share a sign (always, for an SPD matrix factored as "S"). +@kernel function chol2ldl_d_kernel!(factor, d, p0, f, g0) + j = @index(Global, Linear) + @inbounds begin + c = real(factor[p0 + (j - 1) * f + j - 1]) + d[g0 + j] = c * c + end +end +@kernel function chol2ldl_scale_kernel!(factor, p0, f, w) # strict lower: /= diag of the column + q = @index(Global, Linear) + @inbounds begin + j = (Int(q) - 1) ÷ f + 1 + i = Int(q) - (j - 1) * f + i > j && (factor[p0 + (j - 1) * f + i - 1] /= real(factor[p0 + (j - 1) * f + j - 1])) + end +end +@kernel function chol2ldl_diag_kernel!(factor, p0, f) + j = @index(Global, Linear) + @inbounds factor[p0 + (j - 1) * f + j - 1] = one(eltype(factor)) +end + +function _factor_panel_c_ldl_potrf!(N, S, fp, v, p, backend) + f = Int(S.schedule.rows[v]) + w = Int(S.schedule.width[v]) + p0 = Int(S.layout.panel_ptr[v]) + g0 = fp.sptr_h[v] - 1 + SDS._factor_panel_c!(N.factor, fp.pcb, N.work, N.info, v, p0, f, w, fp.cb2_h[v], p) + chol2ldl_d_kernel!(backend, 128)(N.factor, N.d, p0, f, g0; ndrange = w) + chol2ldl_scale_kernel!(backend, 128)(N.factor, p0, f, w; ndrange = f * w) + chol2ldl_diag_kernel!(backend, 128)(N.factor, p0, f; ndrange = w) + return nothing +end + # the host-wide front for LDL: blocked right-looking over 64-column panels, then one # D-scaled GEMM for the contribution block (the Cholesky path's trsm/herk shape) function _factor_panel_c_ldl!(N, S, fp, v, p, backend) @@ -1744,7 +1781,8 @@ end # --------------------------------------------------------------------------- # host: the topologically ordered role list + counter bases + stream-2 program function build_fused_plan(s; ldl::Bool = false, inkernel_wides::Union{Nothing, Bool} = nothing, - merge_segments::Bool = false, wgf::Int = WGF, scap::Integer = 128) + wides_potrf::Bool = false, merge_segments::Bool = false, + wgf::Int = WGF, scap::Integer = 128) S = s.symbolic plan = s.numeric.plan ldl && (eltype(s.numeric.factor) <: Real || error("fused LDL mode: real element types only")) @@ -1752,7 +1790,7 @@ function build_fused_plan(s; ldl::Bool = false, inkernel_wides::Union{Nothing, B # correct but needs ~5 launches per 64-column block where Cholesky's potrf is one # vendor call -- measured SLOWER on the GV100 (55.5 vs 38.6 ms tuned). Pass # inkernel_wides = false explicitly to use it. - inkernel_wides = inkernel_wides === nothing ? ldl : inkernel_wides + inkernel_wides = inkernel_wides === nothing ? (ldl && !wides_potrf) : inkernel_wides nodes = S.schedule.group_nodes rows_h = S.schedule.rows width_h = S.schedule.width @@ -1957,6 +1995,7 @@ function build_fused_plan(s; ldl::Bool = false, inkernel_wides::Union{Nothing, B wcb_b = DVEC(wcb_b), wupd = KA.zeros(BACKEND[], Int32, max(nwslots, 1)), wtl = KA.zeros(BACKEND[], Int32, max(nwslots, 1)), wpd = KA.zeros(BACKEND[], Int32, max(nwslots, 1)), wcb = KA.zeros(BACKEND[], Int32, ns), wgf, scap = Int32(scap), ldl, + ldl_potrf = ldl && wides_potrf, sptr_h = Array(S.super_ptr), ldlw = KA.zeros(BACKEND[], Float64, ldl ? maximum(vcat([1], [max(rows_h[v] * 64, @@ -2005,7 +2044,9 @@ function refact_fused!(s, nzval, fp; nstreams::Int = 1) ndrange = fp.wgf * count) if true for v in gwides - if fp.ldl + if fp.ldl && fp.ldl_potrf + _factor_panel_c_ldl_potrf!(N, S, fp, v, p, backend) + elseif fp.ldl _factor_panel_c_ldl!(N, S, fp, v, p, backend) else SDS._factor_panel_c!(N.factor, fp.pcb, N.work, N.info, v, S.layout.panel_ptr[v], diff --git a/bench/e2e/ldl_fused_race.jl b/bench/e2e/ldl_fused_race.jl index 03c54054..049f2a3e 100644 --- a/bench/e2e/ldl_fused_race.jl +++ b/bench/e2e/ldl_fused_race.jl @@ -35,7 +35,7 @@ fac0 = Array(s.numeric.factor) d0 = Array(s.numeric.d) mref = maximum(abs, fac0) nz = SDS._factor_values(s) -fp = SDSFused.build_fused_plan(s; ldl = true, wgf = 128, scap = 128) +fp = SDSFused.build_fused_plan(s; ldl = true, wides_potrf = true, wgf = 128, scap = 128) nbad = 0 for t in 1:20 info = SDSFused.refact_fused!(s, nz, fp); CUDA.synchronize() From 58b18f824ecbbcb9b29f46e8c8683ba0a2399fe7 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Sun, 11 Oct 2026 14:26:13 -0400 Subject: [PATCH 37/65] CPU fast path prototype: task-parallel refactorization with per-front BLAS bench/e2e/SDSCPU.jl (stock src untouched): the stock schedule driven from the host -- subtree groups as chunked Julia tasks running the serial regime-A algorithm (scalar, per-task scratch from a channel pool; threadid-indexed buffers break under task migration), regime-B/C fronts as chunked tasks doing their own assembly (zero + scatter + extend-add, serial and cache-local per front) then LAPACK potrf + BLAS trsm/syrk with BLAS pinned to one thread; thin levels (the 121-level plan's root chain, mostly 1-6 fronts) run serially under fully threaded BLAS. Persistent CPUCtx holds the buffer pools. 78k ACOPF KKT, dual Xeon 8160, gated vs stock (dfactor 3.4e-9): refact 110.6 ms at 24 threads (stock 737, 6.7x; HSL MA86 76.5 = within 1.45x), 129.2 ms at 8 threads. What moved it: per-front assembly instead of the group-level KA-CPU kernels (345 -> 119; those kernels plateau at ~180 ms regardless of thread count), then the persistent pools (-13 ms). Tried and reverted: sliced parallel assembly for thin-level fronts (the per-slice extend-add rescans every child column: 85 -> 185 ms; needs a plan-time column index to pay). hsl_cpu_bench.jl: MA27/MA57 fail their symbolic phase at this scale through the registered wrappers, MA97 hits a symbol mismatch vs the local libHSL override; MA86 stands as the CPU state-of-the-art anchor. Co-Authored-By: Claude Fable 5 --- bench/e2e/SDSCPU.jl | 302 +++++++++++++++++++++++++++++++++++++ bench/e2e/cpu_fast_test.jl | 39 +++++ bench/e2e/hsl_cpu_bench.jl | 56 +++++++ 3 files changed, 397 insertions(+) create mode 100644 bench/e2e/SDSCPU.jl create mode 100644 bench/e2e/cpu_fast_test.jl create mode 100644 bench/e2e/hsl_cpu_bench.jl diff --git a/bench/e2e/SDSCPU.jl b/bench/e2e/SDSCPU.jl new file mode 100644 index 00000000..6b6cf0db --- /dev/null +++ b/bench/e2e/SDSCPU.jl @@ -0,0 +1,302 @@ +# CPU fast path for the refactorization (prototype, stock src untouched): the +# stock schedule's groups processed in order, with +# - subtree groups: one Julia task per subtree, the serial multifrontal stack +# in a per-task scratch (the regime-A algorithm in plain scalar Julia); +# - regime-B/C groups: the stock assembly kernels (KA CPU, internally +# threaded), then one task per front running LAPACK potrf + BLAS trsm/syrk +# on the dense panel views (BLAS pinned to one thread; task-level +# parallelism is ours). +# Cholesky ("SPD") first; the potrf-derived LDL conversion drops in later. +module SDSCPU + +using SparseDirectSolver +using LinearAlgebra +using Base.Threads +using Printf +const SDS = SparseDirectSolver + +_packed(i, j, n) = SDS._packed(i, j, n) + +# split a:b into at most n contiguous chunks +function _chunks(a::Int, b::Int, n::Int) + len = b - a + 1 + len <= 0 && return UnitRange{Int}[] + c = min(n, len) + sz = cld(len, c) + return [ (a + (i - 1) * sz):min(a + i * sz - 1, b) for i in 1:cld(len, sz) ] +end + +# serial regime-A subtree: the GPU kernel's algorithm on a plain scratch vector +function _subtree!(factor::Vector{T}, stack::Vector{T}, info, nzval, S, t, buf::Vector{T}) where {T} + stn = S.subtree_nodes + @inbounds for q in S.subtree_ptr[t]:(S.subtree_ptr[t + 1] - 1) + v = Int(stn[q]) + f = Int(S.front_nrows[v]) + w = Int(S.front_ncols[v]) + m = f - w + lf = Int(S.local_front[v]) - 1 + # zero + scatter A + fill!(view(buf, (lf + 1):(lf + f * (f + 1) ÷ 2)), zero(T)) + p0 = Int(S.front_ptr[v]) + for k in Int(S.amap_ptr[v]):(Int(S.amap_ptr[v + 1]) - 1) + p = Int(S.amap_src[k]) + off = S.amap[p] + loc = Int(abs(off)) - p0 + j = loc ÷ f + 1 + i = loc - (j - 1) * f + 1 + buf[lf + _packed(i, j, f)] += SDS._amap_value(T, nzval[p], off) + end + # extend-add the children (their packed CBs live lower in the scratch) + for kc in Int(S.child_ptr[v]):(Int(S.child_ptr[v + 1]) - 1) + c = Int(S.child_list[kc]) + mc = Int(S.front_nrows[c]) - Int(S.front_ncols[c]) + lc = Int(S.local_cb[c]) - 1 + r0 = Int(S.relind_ptr[c]) - 1 + for jj in 1:mc + rj = Int(S.relind[r0 + jj]) + for ii in jj:mc + ri = Int(S.relind[r0 + ii]) + buf[lf + _packed(ri, rj, f)] += buf[lc + _packed(ii, jj, mc)] + end + end + end + # right-looking Cholesky of the first w columns (whole packed front) + st = Int32(0) + for j in 1:w + dj = real(buf[lf + _packed(j, j, f)]) + if !(dj > 0) + st = Int32(j) + break + end + c = sqrt(dj) + buf[lf + _packed(j, j, f)] = c + rd = inv(c) + base = lf + _packed(j, j, f) # column j is contiguous below the diagonal + @simd for i in 1:(f - j) + buf[base + i] *= rd + end + for k in (j + 1):f + lkj = conj(buf[base + k - j]) + bk = lf + _packed(k, k, f) - k + @simd for i in k:f + buf[bk + i] -= buf[base + i - j] * lkj + end + end + end + # write the panel (zero strict upper), status, and the root CB to the stack + for j in 1:w, i in 1:f + factor[p0 + (j - 1) * f + i - 1] = i >= j ? buf[lf + _packed(i, j, f)] : zero(T) + end + st != 0 && (info[v] = st) + lc = Int(S.local_cb[v]) - 1 + src = lf + w * (2 * f - w + 1) ÷ 2 + if lc < 0 # subtree root: CB to the update stack + c0 = Int(S.cb_ptr[v]) + c0 > 0 && copyto!(stack, c0, buf, src + 1, m * (m + 1) ÷ 2) + else # move the CB down (src > dst: forward copy) + copyto!(buf, lc + 1, buf, src + 1, m * (m + 1) ÷ 2) + end + end + return nothing +end + +# extend-add of all children into parent columns rj ∈ cols (panel) and CB columns +# rj - w ∈ cols .- w; disjoint across column slices, so safe to run sliced in parallel +function _extend_cols!(factor::Vector{T}, stack::Vector{T}, S, v, f, w, m, p0, c0, cols) where {T} + @inbounds for kc in Int(S.child_ptr[v]):(Int(S.child_ptr[v + 1]) - 1) + c = Int(S.child_list[kc]) + cc = Int(S.cb_ptr[c]) + cc > 0 || continue + mc = Int(S.front_nrows[c]) - Int(S.front_ncols[c]) + r0 = Int(S.relind_ptr[c]) - 1 + for jj in 1:mc + rj = Int(S.relind[r0 + jj]) + rj in cols || continue + if rj <= w + b = p0 + (rj - 1) * f - 1 + for ii in jj:mc + factor[b + Int(S.relind[r0 + ii])] += stack[cc + _packed(ii, jj, mc) - 1] + end + else + bj = c0 + _packed(rj - w, rj - w, m) - 1 - (rj - w) + for ii in jj:mc + ri = Int(S.relind[r0 + ii]) + stack[bj + ri - w] += stack[cc + _packed(ii, jj, mc) - 1] + end + end + end + end + return nothing +end + +# one regime-B/C front: assembly (zero + scatter A + extend-add, serial and cache-local) +# then LAPACK potrf + BLAS trsm + syrk, CB packed into the stack +function _front_blas!(factor::Vector{T}, stack::Vector{T}, work::Vector{T}, info, S, nzval, v, + par::Bool = false) where {T} + f = Int(S.front_nrows[v]) + w = Int(S.front_ncols[v]) + m = f - w + p0 = Int(S.front_ptr[v]) + c0 = Int(S.cb_ptr[v]) + @inbounds begin + if par + @sync begin + for r in _chunks(1, f * w, 4 * nthreads()) + Threads.@spawn fill!(view(factor, (p0 + first(r) - 1):(p0 + last(r) - 1)), zero(T)) + end + c0 > 0 && for r in _chunks(1, m * (m + 1) ÷ 2, 2 * nthreads()) + Threads.@spawn fill!(view(stack, (c0 + first(r) - 1):(c0 + last(r) - 1)), zero(T)) + end + end + else + fill!(view(factor, p0:(p0 + f * w - 1)), zero(T)) + c0 > 0 && fill!(view(stack, c0:(c0 + m * (m + 1) ÷ 2 - 1)), zero(T)) + end + for k in Int(S.amap_ptr[v]):(Int(S.amap_ptr[v + 1]) - 1) + p = Int(S.amap_src[k]) + off = S.amap[p] + factor[Int(abs(off))] += SDS._amap_value(T, nzval[p], off) + end + if par + @sync for r in _chunks(1, f, 4 * nthreads()) + Threads.@spawn _extend_cols!(factor, stack, S, v, f, w, m, p0, c0, r) + end + else + _extend_cols!(factor, stack, S, v, f, w, m, p0, c0, 1:f) + end + end + P = reshape(view(factor, p0:(p0 + f * w - 1)), f, w) + F11 = view(P, 1:w, 1:w) + _, st = LAPACK.potrf!('L', F11) + if st != 0 + @inbounds info[v] = Int32(st) + return nothing + end + m > 0 || return nothing + F21 = view(P, (w + 1):f, 1:w) + BLAS.trsm!('R', 'L', 'C', 'N', one(T), F11, F21) + c0 > 0 || return nothing + C = reshape(view(work, 1:(m * m)), m, m) + T <: Real ? BLAS.syrk!('L', 'N', -one(T), F21, zero(T), C) : + BLAS.herk!('L', 'N', -one(real(T)), F21, zero(real(T)), C) + if par + @sync for r in _chunks(1, m, 4 * nthreads()) + Threads.@spawn @inbounds for jj in r + b = c0 + _packed(jj, jj, m) - 1 - jj + @simd for ii in jj:m + stack[b + ii] += C[ii, jj] + end + end + end + else + @inbounds for jj in 1:m + b = c0 + _packed(jj, jj, m) - 1 - jj + @simd for ii in jj:m + stack[b + ii] += C[ii, jj] + end + end + end + return nothing +end + +""" + CPUCtx(s) + +Persistent buffers for [`refact_cpu!`](@ref): per-task scratch and workspace +pools (task-safe channels; threadid-indexed buffers break under task +migration). +""" +struct CPUCtx{T} + spool::Channel{Vector{T}} + wpool::Channel{Vector{T}} +end + +function CPUCtx(s) + N = s.numeric + T = eltype(N.factor) + plan = N.plan + maxloc = isempty(plan.sub_local) ? 0 : maximum(plan.sub_local) + spool = Channel{Vector{T}}(nthreads()) + wpool = Channel{Vector{T}}(nthreads()) + for _ in 1:nthreads() + put!(spool, Vector{T}(undef, cld(maxloc, sizeof(T)))) + put!(wpool, Vector{T}(undef, length(N.work))) + end + return CPUCtx{T}(spool, wpool) +end + +""" + refact_cpu!(s, nzval, ctx = CPUCtx(s)) -> info + +Task-parallel CPU refactorization of a factorized "SPD" solver on the CPU +backend, writing the stock factor layout (panels + packed CB stack). Wide +levels run one task per chunk of fronts with BLAS pinned to one thread; thin +levels (the root chain) run serially under fully threaded BLAS. +""" +function refact_cpu!(s, nzval, ctx::CPUCtx = CPUCtx(s)) + N, S = s.numeric, s.symbolic + plan = N.plan + T = eltype(N.factor) + nblas = BLAS.get_num_threads() + nt = nthreads() + fill!(N.info, Int32(0)) + spool, wpool = ctx.spool, ctx.wpool + nch = 8 * nt # chunked tasks: spawn cost amortized + tsub = 0.0; tblas = 0.0 + wk0 = take!(wpool) # serial-level workspace + try + BLAS.set_num_threads(1) + tsub = @elapsed for k in eachindex(plan.sub_first) + a, b = plan.sub_first[k], plan.sub_last[k] + @sync for r in _chunks(a, b, nch) + Threads.@spawn begin + buf = take!(spool) + try + for q in r + _subtree!(N.factor, N.stack, N.info, nzval, S, Int(S.group_nodes[q]), buf) + end + finally + put!(spool, buf) + end + end + end + end + for k in eachindex(plan.group_first) + a, b = plan.group_first[k], plan.group_last[k] + if b - a + 1 <= max(2, nt ÷ 8) # thin level: serial fronts, threaded BLAS + tblas += @elapsed begin + BLAS.set_num_threads(nt) + # par = true (sliced assembly) measured SLOWER here: _extend_cols! rescans + # every child column per slice, O(slices x columns); a per-slice column + # index built at plan time would be needed to make it pay + for q in a:b + _front_blas!(N.factor, N.stack, wk0, N.info, S, nzval, Int(S.group_nodes[q]), false) + end + BLAS.set_num_threads(1) + end + else + tblas += @elapsed @sync for r in _chunks(a, b, nch) + Threads.@spawn begin + wk = take!(wpool) + try + for q in r + _front_blas!(N.factor, N.stack, wk, N.info, S, nzval, Int(S.group_nodes[q])) + end + finally + put!(wpool, wk) + end + end + end + end + end + finally + put!(wpool, wk0) + BLAS.set_num_threads(nblas) + end + haskey(ENV, "SDSCPU_STAGES") && + @printf(" stages: subtrees %7.1f ms fronts %7.1f ms\n", tsub * 1e3, tblas * 1e3) + SDS.assemble_schur!(N, S, nzval) + return maximum(Array(N.info)) == 0 ? 0 : 1 +end + +end # module diff --git a/bench/e2e/cpu_fast_test.jl b/bench/e2e/cpu_fast_test.jl new file mode 100644 index 00000000..341a4e72 --- /dev/null +++ b/bench/e2e/cpu_fast_test.jl @@ -0,0 +1,39 @@ +# CPU fast-path prototype: gate vs stock refactorization + timing, 78k KKT. +# julia +1.13 -t --project=bench bench/e2e/cpu_fast_test.jl +using SparseDirectSolver, SparseArrays, LinearAlgebra, Random, Statistics, Printf +const SDS = SparseDirectSolver +include(joinpath(@__DIR__, "SDSCPU.jl")) +using .SDSCPU +const BD = joinpath(@__DIR__, "..") +include(joinpath(BD, "matrices.jl")) +using .BenchMatrices: read_mtx + +A = SparseMatrixCSC{Float64, Int}(sparse(read_mtx(joinpath(BD, "data", + "kkt_pglib_opf_case78484_epigrids_condensed_10.mtx")))) +n = size(A, 1) +Lt = sparse(transpose(tril(A))) +Random.seed!(666) +bh = rand(n) +S3 = (p = Vector{Int32}(undef, n); read!(joinpath(BD, "perm_cand3.bin"), p); Int.(p)) + +s = DirectSolver(Vector{Int}(Lt.colptr), Vector{Int}(Lt.rowval), Vector{Float64}(Lt.nzval), "SPD", 'L') +s.options.subtree_parallelism = 16384 +s.options.subtree_budgets = [16384] +SDS.setparam!(s, "user_perm", S3) +x = zeros(n); b = copy(bh) +ex(ph) = SDS.execute!(ph, s, x, b; asynchronous = false) +ex("analysis"); ex("factorization"); ex("refactorization") +tr0 = median([(@elapsed ex("refactorization")) for _ in 1:3]) +fac0 = copy(s.numeric.factor) +nz = SDS._factor_values(s) + +ctx = SDSCPU.CPUCtx(s) +info = SDSCPU.refact_cpu!(s, nz, ctx) +df = maximum(abs, s.numeric.factor - fac0) / maximum(abs, fac0) +@printf "threads %d gate: info %d Δfactor %.2e\n" Threads.nthreads() info df +(info == 0 && df < 1e-6) || error("CPU fast path gate FAILED") +tr1 = median([(@elapsed SDSCPU.refact_cpu!(s, nz, ctx)) for _ in 1:5]) +ex("solve") +rel = norm(bh - A * x) / norm(bh) +@printf "threads %d stock refact %8.1f ms fast refact %8.1f ms speedup %.2fx relres(stock solve) %.2e\n" Threads.nthreads() tr0*1e3 tr1*1e3 tr0/tr1 rel +println("done") diff --git a/bench/e2e/hsl_cpu_bench.jl b/bench/e2e/hsl_cpu_bench.jl new file mode 100644 index 00000000..bd98cc74 --- /dev/null +++ b/bench/e2e/hsl_cpu_bench.jl @@ -0,0 +1,56 @@ +# CPU state-of-the-art baseline on the 78k KKT: MA27, MA57 (serial), MA97 (OpenMP), +# via the registered MadNLPHSL wrappers (default options = each solver's own ordering). +# julia +1 --startup-file=no --project=~/.julia/environments/hslbench bench/e2e/hsl_cpu_bench.jl +using SparseArrays, LinearAlgebra, Random, Statistics, Printf +using MadNLPHSL, HSL +import MadNLP: factorize!, solve! +const BD = joinpath(@__DIR__, "..") +include(joinpath(BD, "matrices.jl")) +using .BenchMatrices: read_mtx + +A = SparseMatrixCSC{Float64, Int}(sparse(read_mtx(joinpath(BD, "data", + "kkt_pglib_opf_case78484_epigrids_condensed_10.mtx")))) +n = size(A, 1) +L = tril(A) +csc32 = SparseMatrixCSC{Float64, Int32}(Int32(n), Int32(n), Int32.(L.colptr), Int32.(L.rowval), L.nzval) +Random.seed!(666) +bh = rand(n) +say(x...) = (println(x...); flush(stdout)) + +function bench(label, M) + tf = @elapsed factorize!(M) + tr = median([(@elapsed factorize!(M)) for _ in 1:5]) + x = copy(bh); solve!(M, x) + ts = median([begin x .= bh; @elapsed solve!(M, x) end for _ in 1:5]) + x .= bh; solve!(M, x) + rel = norm(bh - A * x) / norm(bh) + @printf "%-12s first %8.1f ms refact %8.1f ms solve %6.1f ms relres %.2e\n" label tf*1e3 tr*1e3 ts*1e3 rel + flush(stdout) +end + +try +say("MA27 (serial)…") +ta = @elapsed M27 = MadNLPHSL.Ma27Solver(csc32; opt = MadNLPHSL.Ma27Options(ma27_liw_init_factor = 15.0, ma27_la_init_factor = 30.0, ma27_meminc_factor = 3.0)) +say(" init ", round(ta; digits = 2), " s") +bench("MA27", M27) +catch e + say("MA27 FAILED: ", typeof(e)) +end + +try +say("MA57 (serial + BLAS)…") +ta = @elapsed M57 = MadNLPHSL.Ma57Solver(csc32) +say(" init ", round(ta; digits = 2), " s") +bench("MA57", M57) +catch e + say("MA57 FAILED: ", typeof(e)) +end + +say("MA97 (OpenMP)…") +ta = @elapsed M97 = MadNLPHSL.Ma97Solver(csc32) +say(" init ", round(ta; digits = 2), " s") +for nt in (1, 8, 24) + HSL.omp_set_num_threads(Cint(nt)) + bench("MA97 nt=$nt", M97) +end +say("done") From 5d4ad4a5db65b641d39bb068250a640b33f4c17e Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Sun, 11 Oct 2026 15:21:26 -0400 Subject: [PATCH 38/65] SDSCPU: fix single-thread deadlock (workspace pool needs nthreads()+1 entries) refact_cpu! holds one workspace for the serial thin levels while wide-level tasks draw from the same pool; with one thread the pool emptied and the first task blocked forever. Single-thread now: fast 1878 ms vs stock 4576 (2.4x), gate dfactor 3.2e-9. Co-Authored-By: Claude Fable 5 --- bench/e2e/SDSCPU.jl | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bench/e2e/SDSCPU.jl b/bench/e2e/SDSCPU.jl index 6b6cf0db..f50d97ce 100644 --- a/bench/e2e/SDSCPU.jl +++ b/bench/e2e/SDSCPU.jl @@ -217,11 +217,12 @@ function CPUCtx(s) plan = N.plan maxloc = isempty(plan.sub_local) ? 0 : maximum(plan.sub_local) spool = Channel{Vector{T}}(nthreads()) - wpool = Channel{Vector{T}}(nthreads()) - for _ in 1:nthreads() + wpool = Channel{Vector{T}}(nthreads() + 1) # +1: refact_cpu! holds one for the + for _ in 1:nthreads() # serial levels while tasks draw the rest put!(spool, Vector{T}(undef, cld(maxloc, sizeof(T)))) put!(wpool, Vector{T}(undef, length(N.work))) end + put!(wpool, Vector{T}(undef, length(N.work))) return CPUCtx{T}(spool, wpool) end From fd378effbc11bd8a3726108c83c867bd4abb7c25 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Sun, 11 Oct 2026 15:57:53 -0400 Subject: [PATCH 39/65] SDSCPU: single-thread specialization (the ExaModels nothing-backend shape) With one Julia thread the refactorization drops all machinery -- no tasks, no channels, no chunking -- and runs the plain serial loops end to end. Worth 4.3x serially: 1878 -> 438.5 ms on the 78k KKT (the scheduler/channel tax at -t 1), gate dfactor 3.2e-9; 24 threads unchanged-to-better (101.5 ms). Serial standings on this matrix: SDS fast 438.5 ms vs CHOLMOD 966 and MA86 963 -- 2.2x ahead of the serial state of the art; at 8 threads 129 vs MA86 151; at 24 threads 101.5 vs MA86 76.5 (1.33x behind, their DAG tail). Co-Authored-By: Claude Fable 5 --- bench/e2e/SDSCPU.jl | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/bench/e2e/SDSCPU.jl b/bench/e2e/SDSCPU.jl index f50d97ce..b02a508b 100644 --- a/bench/e2e/SDSCPU.jl +++ b/bench/e2e/SDSCPU.jl @@ -245,6 +245,30 @@ function refact_cpu!(s, nzval, ctx::CPUCtx = CPUCtx(s)) nch = 8 * nt # chunked tasks: spawn cost amortized tsub = 0.0; tblas = 0.0 wk0 = take!(wpool) # serial-level workspace + if nt == 1 # single-thread specialization: no tasks + buf0 = take!(spool) + try + BLAS.set_num_threads(1) + tsub = @elapsed for k in eachindex(plan.sub_first) + for q in plan.sub_first[k]:plan.sub_last[k] + _subtree!(N.factor, N.stack, N.info, nzval, S, Int(S.group_nodes[q]), buf0) + end + end + tblas = @elapsed for k in eachindex(plan.group_first) + for q in plan.group_first[k]:plan.group_last[k] + _front_blas!(N.factor, N.stack, wk0, N.info, S, nzval, Int(S.group_nodes[q])) + end + end + finally + put!(spool, buf0) + put!(wpool, wk0) + BLAS.set_num_threads(nblas) + end + haskey(ENV, "SDSCPU_STAGES") && + @printf(" stages: subtrees %7.1f ms fronts %7.1f ms\n", tsub * 1e3, tblas * 1e3) + SDS.assemble_schur!(N, S, nzval) + return maximum(Array(N.info)) == 0 ? 0 : 1 + end try BLAS.set_num_threads(1) tsub = @elapsed for k in eachindex(plan.sub_first) From c7eee03476339be9bd14531723db9ca532e94634 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Sun, 11 Oct 2026 17:27:53 -0400 Subject: [PATCH 40/65] bench: MA27 reference via libcoinhsl (the libHSL 2025 build stubs the Archive codes) Finding worth recording: libhsl.so's ma27ad_/ma27id_ symbols exist (3.5 KB of code) but return instantly with zeroed output -- info[1] = 0 and untouched arguments on any input, both i4 and i8 -- and libhsl_subset returns -26. MA27 is an HSL Archive code and is not actually in the libHSL bundle; the full library fails by SUCCEEDING, which also explains MadNLPHSL's Ma27Solver SymbolicException (its fixed analysis workspace was never the issue). The driver here calls /usr/local/lib/libcoinhsl.so (coinhsl 2.2.5), whose MA27 is real (proper MA27ID defaults, nonzero forecasts). 78k ACOPF KKT: analysis 1.7 s, refact 2909.2 ms, solve 110.5 ms, relres 2.7e-2 -- vs the SDS CPU fast path at 438.5 ms serial. Co-Authored-By: Claude Fable 5 --- bench/e2e/ma27_bench.jl | 86 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 bench/e2e/ma27_bench.jl diff --git a/bench/e2e/ma27_bench.jl b/bench/e2e/ma27_bench.jl new file mode 100644 index 00000000..27f02d66 --- /dev/null +++ b/bench/e2e/ma27_bench.jl @@ -0,0 +1,86 @@ +# MA27 (serial multifrontal, 1983) on the 78k KKT, via libcoinhsl (the libHSL +# 2025 build STUBS the Archive codes: its ma27*_ symbols return instantly with +# zeroed output, and the subset build returns -26 "not available"). +# julia +1 --startup-file=no --project=bench bench/e2e/ma27_bench.jl +using SparseArrays, LinearAlgebra, Random, Statistics, Printf +const LIB = "/usr/local/lib/libcoinhsl.so" +const BD = joinpath(@__DIR__, "..") +include(joinpath(BD, "matrices.jl")) +using .BenchMatrices: read_mtx + +A = SparseMatrixCSC{Float64, Int}(sparse(read_mtx(joinpath(BD, "data", + "kkt_pglib_opf_case78484_epigrids_condensed_10.mtx")))) +n = Int32(size(A, 1)) +L = tril(A) +nz = Int32(nnz(L)) +I32 = Vector{Int32}(undef, nz); J32 = Vector{Int32}(undef, nz) +let k = 0 + for j in 1:size(L, 2), p in L.colptr[j]:(L.colptr[j + 1] - 1) + k += 1 + I32[k] = Int32(L.rowval[p]) + J32[k] = Int32(j) + end +end +vals = L.nzval +Random.seed!(666) +bh = rand(Float64, Int(n)) +say(x...) = (println(x...); flush(stdout)) + +icntl = zeros(Int32, 30); cntl = zeros(Float64, 5) +@ccall LIB.ma27id_(icntl::Ptr{Int32}, cntl::Ptr{Float64})::Cvoid +icntl[1:2] .= 0 +cntl[1] = 1e-8 # pivtol as MadNLP's IPM default +info = zeros(Int32, 20) + +liw = Int32(8 * (2 * Int(nz) + 3 * Int(n) + 1)) +iw = Vector{Int32}(undef, liw) +ikeep = zeros(Int32, 3n) +iw1 = zeros(Int32, 2n) +nsteps = Int32[0]; iflag = Int32[0]; ops = Float64[0.0] +ta = @elapsed @ccall LIB.ma27ad_(n::Ref{Int32}, nz::Ref{Int32}, I32::Ptr{Int32}, J32::Ptr{Int32}, + iw::Ptr{Int32}, liw::Ref{Int32}, ikeep::Ptr{Int32}, iw1::Ptr{Int32}, + nsteps::Ptr{Int32}, iflag::Ptr{Int32}, icntl::Ptr{Int32}, + cntl::Ptr{Float64}, info::Ptr{Int32}, ops::Ptr{Float64})::Cvoid +say("analysis ", round(ta; digits = 2), " s info[1] ", info[1], " forecast la ", info[5], + " forecast liw ", info[6], " nsteps ", nsteps[1]) +info[1] >= 0 || error("ma27ad failed: info = $(info[1:4])") + +a = Vector{Float64}(undef, max(5 * Int(info[5]), 2 * Int(nz))) +resize!(iw, max(5 * Int(info[6]), 2 * Int(nz))) +maxfrt = Int32[0] +function factor!() + copyto!(a, 1, vals, 1, Int(nz)) + @ccall LIB.ma27bd_(n::Ref{Int32}, nz::Ref{Int32}, I32::Ptr{Int32}, J32::Ptr{Int32}, + a::Ptr{Float64}, Int32(length(a))::Ref{Int32}, iw::Ptr{Int32}, + Int32(length(iw))::Ref{Int32}, ikeep::Ptr{Int32}, nsteps::Ptr{Int32}, + maxfrt::Ptr{Int32}, iw1::Ptr{Int32}, icntl::Ptr{Int32}, cntl::Ptr{Float64}, + info::Ptr{Int32})::Cvoid + return Int(info[1]) +end +tf = 0.0; st = 0 +for attempt in 1:4 + global tf = @elapsed global st = factor!() + say("factor attempt ", attempt, ": info[1] ", st, " info[2] ", info[2], " maxfrt ", maxfrt[1]) + if st == -3 + resize!(iw, 2 * max(length(iw), Int(info[2]))) + elseif st == -4 + resize!(a, 2 * max(length(a), Int(info[2]))) + else + break + end +end +st >= 0 || error("ma27bd failed: info = $(info[1:4])") +say("first factor ", round(tf * 1e3; digits = 1), " ms") +tr = median([(@elapsed factor!()) for _ in 1:5]) + +w = Vector{Float64}(undef, Int(maxfrt[1])) +x = copy(bh) +sv!() = @ccall LIB.ma27cd_(n::Ref{Int32}, a::Ptr{Float64}, Int32(length(a))::Ref{Int32}, + iw::Ptr{Int32}, Int32(length(iw))::Ref{Int32}, w::Ptr{Float64}, + maxfrt::Ptr{Int32}, x::Ptr{Float64}, iw1::Ptr{Int32}, nsteps::Ptr{Int32}, + icntl::Ptr{Int32}, info::Ptr{Int32})::Cvoid +sv!() +rel = norm(bh - A * x) / norm(bh) +ts = median([begin x .= bh; @elapsed sv!() end for _ in 1:5]) +@printf "MA27 refact %8.1f ms solve %6.1f ms relres %.2e\n" tr*1e3 ts*1e3 rel +say("done") From c5ed5b8d3f2733a1867c71605e74c7488ce4bf28 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Sun, 11 Oct 2026 18:06:25 -0400 Subject: [PATCH 41/65] bench: MA57 reference via libcoinhsl; MA27 on the augmented k2 system MA57 (serial, BLAS3) on the condensed 78k KKT: refact 1908.6 ms, fill 18.5M, solve 149.8 ms -- the expected step over MA27 (2797.5 ms, fill 14.3M), which also validates the coinhsl build. MA27 on the augmented k2 system (n = 2.09M): 3709.1 ms, i.e. the condensed system is not what makes MA27 slow here; at this scale ~2-4 s is what the scalar 1983 multifrontal does. Serial standings: SDS fast path 438.5 < CHOLMOD 966 ~ MA86 963 < MA57 1909 < MA27 2798 ms. Co-Authored-By: Claude Fable 5 --- bench/e2e/ma27_bench.jl | 8 ++-- bench/e2e/ma57_bench.jl | 84 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 bench/e2e/ma57_bench.jl diff --git a/bench/e2e/ma27_bench.jl b/bench/e2e/ma27_bench.jl index 27f02d66..2aab4e24 100644 --- a/bench/e2e/ma27_bench.jl +++ b/bench/e2e/ma27_bench.jl @@ -8,8 +8,9 @@ const BD = joinpath(@__DIR__, "..") include(joinpath(BD, "matrices.jl")) using .BenchMatrices: read_mtx -A = SparseMatrixCSC{Float64, Int}(sparse(read_mtx(joinpath(BD, "data", - "kkt_pglib_opf_case78484_epigrids_condensed_10.mtx")))) +MAT = get(ENV, "MA27_MAT", "kkt_pglib_opf_case78484_epigrids_condensed_10.mtx") +A = SparseMatrixCSC{Float64, Int}(sparse(read_mtx(joinpath(BD, "data", MAT)))) +println("matrix: ", MAT, " n ", size(A, 1), " nnz(tril) ", nnz(tril(A))) n = Int32(size(A, 1)) L = tril(A) nz = Int32(nnz(L)) @@ -70,7 +71,8 @@ for attempt in 1:4 end end st >= 0 || error("ma27bd failed: info = $(info[1:4])") -say("first factor ", round(tf * 1e3; digits = 1), " ms") +say("first factor ", round(tf * 1e3; digits = 1), " ms factor-real info[9] ", info[9], + " factor-int info[10] ", info[10], " ncmpa info[11] ", info[11], " twos info[14] ", info[14]) tr = median([(@elapsed factor!()) for _ in 1:5]) w = Vector{Float64}(undef, Int(maxfrt[1])) diff --git a/bench/e2e/ma57_bench.jl b/bench/e2e/ma57_bench.jl new file mode 100644 index 00000000..8cb16928 --- /dev/null +++ b/bench/e2e/ma57_bench.jl @@ -0,0 +1,84 @@ +# MA57 (serial multifrontal with BLAS3, MA27's successor) via libcoinhsl. +# julia +1 --startup-file=no bench/e2e/ma57_bench.jl +using SparseArrays, LinearAlgebra, Random, Statistics, Printf +const LIB = "/usr/local/lib/libcoinhsl.so" +const BD = joinpath(@__DIR__, "..") +include(joinpath(BD, "matrices.jl")) +using .BenchMatrices: read_mtx + +MAT = get(ENV, "MA57_MAT", "kkt_pglib_opf_case78484_epigrids_condensed_10.mtx") +A = SparseMatrixCSC{Float64, Int}(sparse(read_mtx(joinpath(BD, "data", MAT)))) +println("matrix: ", MAT, " n ", size(A, 1), " nnz(tril) ", nnz(tril(A))) +n = Int32(size(A, 1)) +L = tril(A) +ne = Int32(nnz(L)) +I32 = Vector{Int32}(undef, ne); J32 = Vector{Int32}(undef, ne) +let k = 0 + for j in 1:size(L, 2), p in L.colptr[j]:(L.colptr[j + 1] - 1) + k += 1 + I32[k] = Int32(L.rowval[p]) + J32[k] = Int32(j) + end +end +vals = L.nzval +Random.seed!(666) +bh = rand(Float64, Int(n)) +say(x...) = (println(x...); flush(stdout)) + +cntl = zeros(Float64, 5); icntl = zeros(Int32, 20) +@ccall LIB.ma57id_(cntl::Ptr{Float64}, icntl::Ptr{Int32})::Cvoid +icntl[5] = 0 # print level +cntl[1] = 1e-8 # pivtol, as MadNLP +info = zeros(Int32, 40); rinfo = zeros(Float64, 20) +lkeep = Int32(7 * Int(n) + Int(ne) + max(Int(n), Int(ne)) + 42) +keep = zeros(Int32, lkeep) +iwork = zeros(Int32, 5 * Int(n)) +ta = @elapsed @ccall LIB.ma57ad_(n::Ref{Int32}, ne::Ref{Int32}, I32::Ptr{Int32}, J32::Ptr{Int32}, + lkeep::Ref{Int32}, keep::Ptr{Int32}, iwork::Ptr{Int32}, + icntl::Ptr{Int32}, info::Ptr{Int32}, rinfo::Ptr{Float64})::Cvoid +say("analysis ", round(ta; digits = 2), " s info[1] ", info[1], " forecast fact ", info[9], + " forecast ifact ", info[10]) +info[1] >= 0 || error("ma57ad failed: info = $(info[1:4])") + +lfact = Int32(max(3 * Int(info[9]), 2 * Int(ne))) +fact = Vector{Float64}(undef, lfact) +lifact = Int32(max(3 * Int(info[10]), 2 * Int(ne))) +ifact = Vector{Int32}(undef, lifact) +iwn = zeros(Int32, n) +function factor!() + @ccall LIB.ma57bd_(n::Ref{Int32}, ne::Ref{Int32}, vals::Ptr{Float64}, fact::Ptr{Float64}, + Int32(length(fact))::Ref{Int32}, ifact::Ptr{Int32}, + Int32(length(ifact))::Ref{Int32}, lkeep::Ref{Int32}, keep::Ptr{Int32}, + iwn::Ptr{Int32}, icntl::Ptr{Int32}, cntl::Ptr{Float64}, info::Ptr{Int32}, + rinfo::Ptr{Float64})::Cvoid + return Int(info[1]) +end +tf = 0.0; st = 0 +for attempt in 1:4 + global tf = @elapsed global st = factor!() + say("factor attempt ", attempt, ": info[1] ", st, " info[2] ", info[2]) + if st == -3 + resize!(fact, 2 * max(length(fact), Int(info[17]))) + elseif st == -4 + resize!(ifact, 2 * max(length(ifact), Int(info[18]))) + else + break + end +end +st >= 0 || error("ma57bd failed: info = $(info[1:4])") +say("first factor ", round(tf * 1e3; digits = 1), " ms factor-real info[14] ", info[14], + " negpiv info[24] ", info[24]) +tr = median([(@elapsed factor!()) for _ in 1:5]) + +x = copy(bh) +wrk = zeros(Float64, n) +sv!() = @ccall LIB.ma57cd_(Int32(1)::Ref{Int32}, n::Ref{Int32}, fact::Ptr{Float64}, + Int32(length(fact))::Ref{Int32}, ifact::Ptr{Int32}, + Int32(length(ifact))::Ref{Int32}, Int32(1)::Ref{Int32}, x::Ptr{Float64}, + n::Ref{Int32}, wrk::Ptr{Float64}, n::Ref{Int32}, iwn::Ptr{Int32}, + icntl::Ptr{Int32}, info::Ptr{Int32})::Cvoid +sv!() +rel = norm(bh - A * x) / norm(bh) +ts = median([begin x .= bh; @elapsed sv!() end for _ in 1:5]) +@printf "MA57 refact %8.1f ms solve %6.1f ms relres %.2e\n" tr*1e3 ts*1e3 rel +say("done") From a78b0f579979dba3509f2d14272192e266e4314c Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Sun, 11 Oct 2026 18:21:11 -0400 Subject: [PATCH 42/65] gfx906: fused LDL validated (1.05x of Cholesky); champion config confirmed optimal across the knob space amd_baseline.jl: the campaign champion reproduced exactly once ALL its knobs are set (39.6 ms; regime_c 32/64 and amalgamation 48/0.25/8 were the two that cost 17% when omitted). First fused-LDL run on AMD: 41.6 ms = 1.05x of Cholesky, gates clean (dfactor 1.6e-7, dd 2.2e-10) -- on merged-wides platforms LDL is at parity, as the GV100 same-strategy analysis predicted. amd_sweep.jl + an ea_chunk plan parameter (the EA role's 16384-entry chunk, now build_fused_plan(ea_chunk = ...)): 12 configurations interleaved against the champion, every deviation flat or worse -- spin cap {8,32,64} within 1%, wgf 512 +16%, wgf 128 +29%, budgets 4K/16K +24%/+10%, amalgamation 64/96 +6%/+39%, ea_chunk {8K,32K,64K} within 3%. The gfx906 champion (merged wides, 8K budgets, workgroup 256, spin cap 16, amal 48, ea_chunk 16K) is a genuine local optimum; further AMD gains need GCN-level kernel work (the per-role all-threads fence idiom, spin-free signaling), not parameters. Co-Authored-By: Claude Fable 5 --- bench/e2e/SDSFused.jl | 14 +++---- bench/e2e/amd_baseline.jl | 65 ++++++++++++++++++++++++++++++ bench/e2e/amd_sweep.jl | 85 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 bench/e2e/amd_baseline.jl create mode 100644 bench/e2e/amd_sweep.jl diff --git a/bench/e2e/SDSFused.jl b/bench/e2e/SDSFused.jl index 86dbbbee..45b96835 100644 --- a/bench/e2e/SDSFused.jl +++ b/bench/e2e/SDSFused.jl @@ -969,7 +969,7 @@ end cb_done, @Const(snparent), @Const(front_ptr), @Const(front_nrows), @Const(front_ncols), @Const(cb_ptr), @Const(child_ptr), @Const(child_list), @Const(relind_ptr), @Const(relind), base, - @Const(wbase), wupd, wpdone2, wtrsml, wcbl, scap, d, @Const(super_ptr), + @Const(wbase), wupd, wpdone2, wtrsml, wcbl, scap, eac, d, @Const(super_ptr), ::Val{LDL}, ::Val{WG}) where {LDL, WG} @uniform TT = eltype(factor) @uniform RT = real(eltype(factor)) @@ -984,7 +984,7 @@ end if r == ROLE_EA v = Int(ra[G]) # parent c = Int(rb[G]) # child - q0 = (Int(rc[G]) - 1) * 16384 + q0 = (Int(rc[G]) - 1) * Int(eac) if li == 1 _spin_until_eq!(cb_done, c, Int32(1), fscr, scap) end @@ -999,7 +999,7 @@ end pp = Int(front_ptr[v]) cp = Int(cb2[v]) # parent CB is always private r0 = Int(relind_ptr[c]) - 1 - qe = min(q0 + 16384, mc * mc) - 1 + qe = min(q0 + Int(eac), mc * mc) - 1 for q in (q0 + li - 1):WG:qe jj = q ÷ mc + 1 ii = q - (jj - 1) * mc + 1 @@ -1782,7 +1782,7 @@ end # host: the topologically ordered role list + counter bases + stream-2 program function build_fused_plan(s; ldl::Bool = false, inkernel_wides::Union{Nothing, Bool} = nothing, wides_potrf::Bool = false, merge_segments::Bool = false, - wgf::Int = WGF, scap::Integer = 128) + wgf::Int = WGF, scap::Integer = 128, ea_chunk::Integer = 16384) S = s.symbolic plan = s.numeric.plan ldl && (eltype(s.numeric.factor) <: Real || error("fused LDL mode: real element types only")) @@ -1849,7 +1849,7 @@ function build_fused_plan(s; ldl::Bool = false, inkernel_wides::Union{Nothing, B continue end mc = rows_h[c] - width_h[c] - for ch in 1:cld(mc * mc, 16384) + for ch in 1:cld(mc * mc, ea_chunk) push!(role, ROLE_EA); push!(ra, Int32(v)); push!(rb, Int32(c)); push!(rc, Int32(ch)) ea_left[v] += 1 end @@ -1994,7 +1994,7 @@ function build_fused_plan(s; ldl::Bool = false, inkernel_wides::Union{Nothing, B wbase = DVEC(wbase_h), wupd_b = DVEC(wupd_b), wtl_b = DVEC(wtl_b), wcb_b = DVEC(wcb_b), wupd = KA.zeros(BACKEND[], Int32, max(nwslots, 1)), wtl = KA.zeros(BACKEND[], Int32, max(nwslots, 1)), wpd = KA.zeros(BACKEND[], Int32, max(nwslots, 1)), - wcb = KA.zeros(BACKEND[], Int32, ns), wgf, scap = Int32(scap), ldl, + wcb = KA.zeros(BACKEND[], Int32, ns), wgf, scap = Int32(scap), eac = Int32(ea_chunk), ldl, ldl_potrf = ldl && wides_potrf, sptr_h = Array(S.super_ptr), ldlw = KA.zeros(BACKEND[], Float64, @@ -2040,7 +2040,7 @@ function refact_fused!(s, nzval, fp; nstreams::Int = 1) S.snparent, S.front_ptr, S.front_nrows, S.front_ncols, S.cb_ptr, S.child_ptr, S.child_list, S.relind_ptr, S.relind, Int32(base), fp.wbase, fp.wupd, fp.wpd, fp.wtl, fp.wcb, - fp.scap, N.d, S.super_ptr, Val(fp.ldl), Val(fp.wgf); + fp.scap, fp.eac, N.d, S.super_ptr, Val(fp.ldl), Val(fp.wgf); ndrange = fp.wgf * count) if true for v in gwides diff --git a/bench/e2e/amd_baseline.jl b/bench/e2e/amd_baseline.jl new file mode 100644 index 00000000..643ab093 --- /dev/null +++ b/bench/e2e/amd_baseline.jl @@ -0,0 +1,65 @@ +# Radeon VII (gfx906) baseline: fused Cholesky (campaign champion knobs) and the +# first fused-LDL run on AMD, gated, interleaved. +# ~/.juliaup/bin/julia +1.13 --startup-file=no --project=~/.julia/environments/sdsamd \ +# bench/e2e/amd_baseline.jl +using SparseDirectSolver, SparseArrays, LinearAlgebra, Random, Statistics, Printf +using AMDGPU +const SDS = SparseDirectSolver +include(joinpath(@__DIR__, "SDSFused.jl")) +using .SDSFused +const BD = joinpath(@__DIR__, "..") +include(joinpath(BD, "matrices.jl")) +using .BenchMatrices: read_mtx + +A = SparseMatrixCSC{Float64, Int}(sparse(read_mtx(joinpath(BD, "data", + "kkt_pglib_opf_case78484_epigrids_condensed_10.mtx")))) +n = size(A, 1) +Lh = tril(A) +Random.seed!(666) +bh = rand(n) +S3 = (p = Vector{Int32}(undef, n); read!(joinpath(BD, "perm_cand3.bin"), p); Int.(p)) +SDSFused.BACKEND[] = AMDGPU.ROCBackend() +sync() = AMDGPU.synchronize() + +# build via plain device arrays (AMDGPU has no CuSparseMatrixCSR; DirectSolver takes raw triplets) +function mks(structure) + Lt = sparse(transpose(Lh)) # CSC of upper = CSR of lower + colptr = ROCArray(Vector{Int}(Lt.colptr)) + rowval = ROCArray(Vector{Int}(Lt.rowval)) + nzval = ROCArray(Vector{Float64}(Lt.nzval)) + s = DirectSolver(colptr, rowval, nzval, structure, 'L') + s.options.subtree_parallelism = 16384 + s.options.subtree_budgets = [8192] # campaign AMD champion + s.options.regime_c_width = 32 + s.options.regime_c_rows = 64 + s.options.amalgamation = (max_width = 48, zero_fraction = 0.25, min_width = 8) + SDS.setparam!(s, "user_perm", S3) + structure == "S" && SDS.setparam!(s, "pivot_type", 'N') + bd = ROCArray(bh); xd = similar(bd) + ex(ph) = SDS.execute!(ph, s, xd, bd; asynchronous = false) + ex("analysis"); ex("factorization"); ex("refactorization"); ex("solve"); sync() + return s, bd, xd +end + +say(x...) = (println(x...); flush(stdout)) +say("building solvers…") +sc, bdc, xdc = mks("SPD") +sl, bdl, xdl = mks("S") +fac0 = Array(sl.numeric.factor); d0 = Array(sl.numeric.d) +nzc = SDS._factor_values(sc); nzl = SDS._factor_values(sl) +# campaign AMD champion: merged in-kernel wides, wgf 256, spin cap 16 +fpc = SDSFused.build_fused_plan(sc; inkernel_wides = true, merge_segments = true, wgf = 256, scap = 16) +fpl = SDSFused.build_fused_plan(sl; ldl = true, merge_segments = true, wgf = 256, scap = 16) +infoc = SDSFused.refact_fused!(sc, nzc, fpc); sync() +infol = SDSFused.refact_fused!(sl, nzl, fpl); sync() +df = maximum(abs, Array(sl.numeric.factor) - fac0) / maximum(abs, fac0) +dd = maximum(abs, Array(sl.numeric.d) - d0) / maximum(abs, d0) +@printf "gates: chol info %d ldl info %d Δfactor %.2e Δd %.2e\n" infoc infol df dd +(infoc == 0 && infol == 0 && df < 1e-6 && dd < 1e-6) || error("AMD gate FAILED") +tc = Float64[]; tl = Float64[] +for r in 1:7 + push!(tc, @elapsed (SDSFused.refact_fused!(sc, nzc, fpc); sync())) + push!(tl, @elapsed (SDSFused.refact_fused!(sl, nzl, fpl); sync())) +end +@printf "gfx906 interleaved chol %7.2f ms ldl %7.2f ms ratio %.3f\n" median(tc)*1e3 median(tl)*1e3 median(tl)/median(tc) +say("done") diff --git a/bench/e2e/amd_sweep.jl b/bench/e2e/amd_sweep.jl new file mode 100644 index 00000000..513ede63 --- /dev/null +++ b/bench/e2e/amd_sweep.jl @@ -0,0 +1,85 @@ +# gfx906 knob sweep around the champion config (chol fused refact, gated, each +# config interleaved against the champion in the same process). +# julia +1.13 --project=~/.julia/environments/sdsamd bench/e2e/amd_sweep.jl +using SparseDirectSolver, SparseArrays, LinearAlgebra, Random, Statistics, Printf +using AMDGPU +const SDS = SparseDirectSolver +include(joinpath(@__DIR__, "SDSFused.jl")) +using .SDSFused +const BD = joinpath(@__DIR__, "..") +include(joinpath(BD, "matrices.jl")) +using .BenchMatrices: read_mtx + +A = SparseMatrixCSC{Float64, Int}(sparse(read_mtx(joinpath(BD, "data", + "kkt_pglib_opf_case78484_epigrids_condensed_10.mtx")))) +n = size(A, 1) +Lh = tril(A) +Random.seed!(666) +bh = rand(n) +S3 = (p = Vector{Int32}(undef, n); read!(joinpath(BD, "perm_cand3.bin"), p); Int.(p)) +SDSFused.BACKEND[] = AMDGPU.ROCBackend() +sync() = AMDGPU.synchronize() +say(x...) = (println(x...); flush(stdout)) + +function mks(; budgets = [8192], amal = (max_width = 48, zero_fraction = 0.25, min_width = 8)) + Lt = sparse(transpose(Lh)) + s = DirectSolver(ROCArray(Vector{Int}(Lt.colptr)), ROCArray(Vector{Int}(Lt.rowval)), + ROCArray(Vector{Float64}(Lt.nzval)), "SPD", 'L') + s.options.subtree_parallelism = 16384 + s.options.subtree_budgets = budgets + s.options.regime_c_width = 32 + s.options.regime_c_rows = 64 + s.options.amalgamation = amal + SDS.setparam!(s, "user_perm", S3) + bd = ROCArray(bh); xd = similar(bd) + ex(ph) = SDS.execute!(ph, s, xd, bd; asynchronous = false) + ex("analysis"); ex("factorization"); ex("refactorization"); ex("solve"); sync() + return s, Array(s.numeric.factor) # stock factor = per-solver gate reference +end + +# champion baseline solver (fixed) for interleaving +sb, facb = mks() +nzb = SDS._factor_values(sb) +fpb = SDSFused.build_fused_plan(sb; inkernel_wides = true, merge_segments = true, wgf = 256, scap = 16) +SDSFused.refact_fused!(sb, nzb, fpb); sync() + +function trial(label, s, fac0, fp) + nz = SDS._factor_values(s) + info = SDSFused.refact_fused!(s, nz, fp); sync() + df = maximum(abs, Array(s.numeric.factor) - fac0) / maximum(abs, fac0) + ok = info == 0 && df < 1e-6 + tb = Float64[]; tt = Float64[] + for r in 1:5 + push!(tb, @elapsed (SDSFused.refact_fused!(sb, nzb, fpb); sync())) + push!(tt, @elapsed (SDSFused.refact_fused!(s, nz, fp); sync())) + end + @printf "%-34s %s trial %7.2f ms champion %7.2f ms delta %+6.1f%%\n" label (ok ? "OK " : "BAD") median(tt)*1e3 median(tb)*1e3 (median(tt)/median(tb) - 1)*100 + flush(stdout) +end + +# plan-level knobs on the SAME solver/symbolic (cheap) +for (wgf, scap) in ((256, 8), (256, 32), (256, 64), (512, 16), (128, 16)) + fp = SDSFused.build_fused_plan(sb; inkernel_wides = true, merge_segments = true, wgf = wgf, scap = scap) + trial("wgf=$wgf scap=$scap", sb, facb, fp) +end + +# symbolic-level knobs (re-analysis each) +for budgets in ([4096], [16384]) + s, f0 = mks(; budgets) + fp = SDSFused.build_fused_plan(s; inkernel_wides = true, merge_segments = true, wgf = 256, scap = 16) + trial("budgets=$budgets", s, f0, fp) +end +for mw in (64, 96) + s, f0 = mks(; amal = (max_width = mw, zero_fraction = 0.25, min_width = 8)) + fp = SDSFused.build_fused_plan(s; inkernel_wides = true, merge_segments = true, wgf = 256, scap = 16) + trial("amal max_width=$mw", s, f0, fp) +end +say("done") + +# EA chunk size (plan-level) +for eac in (8192, 32768, 65536) + fp = SDSFused.build_fused_plan(sb; inkernel_wides = true, merge_segments = true, wgf = 256, + scap = 16, ea_chunk = eac) + trial("ea_chunk=$eac", sb, facb, fp) +end +say("done2") From ff5db17584794bafdcec7962252d92f5fe194ff8 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Sun, 11 Oct 2026 23:06:43 -0400 Subject: [PATCH 43/65] CPU fast path: amalgamation 96 default for the CPU test (-17% at 24 threads, interleaved) The 24-thread scaling investigation, with its negative results recorded: - DAG scheduling (per-front tasks, atomic child counters, private CBs since DAG order breaks the update stack's slot-reuse lifetimes): correct but ~6x slower in the fronts phase -- the stack's slot reuse IS the cache locality, and private CB storage trades ~20 ms of barrier stalls for ~400 ms of cold memory traffic. Reverted; level-sync stays. - Single-socket NUMA pinning (numactl -N0 -m0): ~2.5x slower -- the fronts phase wants both sockets' aggregate bandwidth, not locality. - Amalgamation 96 (vs the GPU champion 48): -16.8% interleaved at 24 threads, fill 20.6M -> 21.1M. The CPU prefers fewer, bigger fronts (fewer assembly passes, BLAS3-friendlier); the GPU prefers 48 (amal 96 was +39% on gfx906). Fronts phase is bandwidth-bound past ~8 threads; with amal 96 the projected quiet-box 24-thread refact is ~85 ms vs MA86's 76.5. Co-Authored-By: Claude Fable 5 --- bench/e2e/cpu_fast_test.jl | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bench/e2e/cpu_fast_test.jl b/bench/e2e/cpu_fast_test.jl index 341a4e72..96ffcd4b 100644 --- a/bench/e2e/cpu_fast_test.jl +++ b/bench/e2e/cpu_fast_test.jl @@ -19,6 +19,10 @@ S3 = (p = Vector{Int32}(undef, n); read!(joinpath(BD, "perm_cand3.bin"), p); Int s = DirectSolver(Vector{Int}(Lt.colptr), Vector{Int}(Lt.rowval), Vector{Float64}(Lt.nzval), "SPD", 'L') s.options.subtree_parallelism = 16384 s.options.subtree_budgets = [16384] +# CPU wants wider supernodes than the GPU champion (48): amal 96 = -17% at 24t, +# interleaved (fewer, bigger fronts = fewer memory passes + BLAS3-friendlier) +s.options.amalgamation = (max_width = parse(Int, get(ENV, "SDSCPU_AMAL", "96")), + zero_fraction = 0.25, min_width = 8) SDS.setparam!(s, "user_perm", S3) x = zeros(n); b = copy(bh) ex(ph) = SDS.execute!(ph, s, x, b; asynchronous = false) From 918326d4bed2c6f36db4fe78a80419d893e6d85d Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Mon, 12 Oct 2026 09:24:06 -0400 Subject: [PATCH 44/65] bench: performance write-up updated through the LDL, CPU and AMD work; drop the one-off NaN locator PERFORMANCE-2026-10.md gains three sections: the fused LDL^T mode (GV100 28.6 ms = 1.106x of Cholesky vs cuDSS's own 1.25x; Radeon VII 41.6 = 1.051x), the CPU fast path (449/106/~85 ms at 1/8/24 threads vs MA86 963/151/76.5, CHOLMOD 966, MA57 1909, MA27 2798; DAG and NUMA pinning ruled out with measurements), and the one-row-per-hardware summary table. ldl_fused_debug.jl served its purpose (the diag-restore race is fixed and documented in the git history). Co-Authored-By: Claude Fable 5 --- bench/PERFORMANCE-2026-10.md | 58 +++++++++++++++++++++++++++++ bench/e2e/ldl_fused_debug.jl | 71 ------------------------------------ 2 files changed, 58 insertions(+), 71 deletions(-) delete mode 100644 bench/e2e/ldl_fused_debug.jl diff --git a/bench/PERFORMANCE-2026-10.md b/bench/PERFORMANCE-2026-10.md index 4a8795f2..40b814af 100644 --- a/bench/PERFORMANCE-2026-10.md +++ b/bench/PERFORMANCE-2026-10.md @@ -87,6 +87,64 @@ wides, subtree/mega-kernel overlap, spin-cap tuning on NVIDIA, tile-64 on every chip, 96-panels on ≤100 KB/SM parts, nseps beyond 4, Scotch ordering (+20–60% fill), cuDSS-extracted orderings. +## LDL^T in the fused pipeline (added 2026-10-11) + +The mega-kernel gained a no-pivot LDL^T mode (real element types; the MadNLP +case): a compile-time `Val` switches the column phases, D stays on the shared +diagonal in flight, the factor keeps the stock LDL layout (unit-lower panels + +separate D), and the solve applies D between the unit-triangular sweeps. Wide +fronts factor through the VERBATIM Cholesky vendor path where the front's +pivots share a sign (A = CC^T = LDL^T with L = C diag(C)^-1, d = diag(C)^2; +the herk contribution block is bit-identical), with the in-kernel wide roles +as the mixed-sign fallback. Gated against stock pivot-N (info 0, factor +~1e-7, D ~5e-10, 20/20 repeats): + +| device | fused LDL | fused Cholesky | LDL multiplier | cuDSS LDL | +|---|---|---|---|---| +| Quadro GV100 | 28.6 ms | 25.8 ms | **1.106** | 33.8 ms (1.25x of its own Cholesky) | +| AMD Radeon VII | 41.6 ms | 39.6 ms | **1.051** | n/a | + +## CPU: the fast path (added 2026-10-11) + +`bench/e2e/SDSCPU.jl`: a task-parallel CPU refactorization over the stock +schedule - subtree groups as chunked tasks running the regime-A algorithm in +plain scalar Julia, regime-B/C fronts assembling themselves per task (cache +local) then LAPACK potrf + BLAS trsm/syrk with BLAS pinned to one thread, and +a single-thread specialization with no task machinery at all. CPU-specific +amalgamation: max_width 96 (-17% at 24 threads vs the GPU champion 48; the +GPU prefers 48 by +39%). Measured and ruled out with the reasons recorded: +DAG scheduling (correct, 6x slower: the CB stack's slot reuse IS the cache +locality) and single-socket NUMA pinning (2.5x slower: the fronts phase wants +both sockets' bandwidth). The phase is memory-bound past ~8 threads. +MA27/MA57 reference numbers come via libcoinhsl: the libHSL 2025 build STUBS +the HSL Archive codes (its ma27 symbols return success with zeroed output; +the subset build returns -26). + +## The full picture + +Refactorization of the 78k condensed KKT; one row per hardware; best +validated configuration each; all gated. + +| hardware | SDS LL^T | SDS LDL^T | best alternative (LL^T / LDL^T) | +|---|---|---|---| +| NVIDIA H200 | 12.0 | - | cuDSS 11.1 / - | +| NVIDIA L40S | 15.2 | - | cuDSS 12.2 / - | +| RTX Pro 6000 | 15.8 | - | cuDSS 13.5 / - | +| NVIDIA A100 | 19-21 | - | cuDSS 17.3-18.7 / - | +| Quadro GV100 | 24.1 | 28.6 | cuDSS 24.3 / 33.8 | +| RTX 3080 (kernel-level, 10 GB) | 32.4 | - | - | +| AMD Radeon VII | 39.6 | 41.6 | no vendor solver exists | +| 2x Xeon 8160, 24 threads | ~85* | - | - / MA86 76.5 | +| 2x Xeon 8160, 8 threads | 106 | - | - / MA86 151 | +| 2x Xeon 8160, 1 thread | 449 | - | CHOLMOD 966 / MA86 963 | + +More 1-thread references: MA57 1909 ms, MA27 2798 ms (both LDL^T, libcoinhsl; +MA27 on the augmented k2 system of the same problem: 3709 ms at n = 2.09M). +*Projected from the measured interleaved -16.8% (amalgamation 96) on the +quiet-box 101.5 ms; every other entry is directly measured. Solve times where +they matter: SDS GPU 3.4-3.6 ms, cuDSS 3.7-4.1 ms, SDS CPU 81 ms at 24 +threads, MA86 168 ms at 24 threads. + ## Correctness note All of the above sits on the completion-signal fix (`left == 0`): the fused diff --git a/bench/e2e/ldl_fused_debug.jl b/bench/e2e/ldl_fused_debug.jl deleted file mode 100644 index a574325f..00000000 --- a/bench/e2e/ldl_fused_debug.jl +++ /dev/null @@ -1,71 +0,0 @@ -# Locate NaNs in the fused LDL factor by front class. -# CUDA_VISIBLE_DEVICES=1 julia +1.13 --project=bench bench/e2e/ldl_fused_debug.jl -using SparseDirectSolver, SparseArrays, LinearAlgebra, Random, Statistics, Printf -using CUDA, CUDA.CUSPARSE -const SDS = SparseDirectSolver -include(joinpath(@__DIR__, "SDSFused.jl")) -using .SDSFused -const BD = joinpath(@__DIR__, "..") -include(joinpath(BD, "matrices.jl")) -using .BenchMatrices: read_mtx - -A = SparseMatrixCSC{Float64, Int}(sparse(read_mtx(joinpath(BD, "data", - "kkt_pglib_opf_case78484_epigrids_condensed_10.mtx")))) -n = size(A, 1) -Lh = tril(A) -Random.seed!(666) -bh = rand(n) -S3 = (p = Vector{Int32}(undef, n); read!(joinpath(BD, "perm_cand3.bin"), p); Int.(p)) -SDSFused.BACKEND[] = CUDA.CUDABackend() - -bd = CuArray(bh); xd = similar(bd) -s = DirectSolver(CuSparseMatrixCSR(Lh), "S", 'L') -SDS.setparam!(s, "user_perm", S3) -SDS.setparam!(s, "pivot_type", 'N') -ex(ph) = SDS.execute!(ph, s, xd, bd; asynchronous = false) -ex("analysis"); ex("factorization"); ex("refactorization"); ex("solve"); CUDA.synchronize() -fac0 = Array(s.numeric.factor) -d0 = Array(s.numeric.d) - -nzval = SDS._factor_values(s) -fp = SDSFused.build_fused_plan(s; ldl = true, wgf = 128, scap = 128) -info = SDSFused.refact_fused!(s, nzval, fp) -CUDA.synchronize() -fac1 = Array(s.numeric.factor) -d1 = Array(s.numeric.d) - -S = s.symbolic -fptr = Array(S.front_ptr); fr = Array(S.front_nrows); fc = Array(S.front_ncols) -sptr = Array(S.super_ptr) -stn = Array(S.subtree_nodes) -ns = length(fr) -covered = falses(ns); for v in stn; covered[v] = true; end -dbad = findall(isnan, @view d1[1:n]) -println("NaN in d[1:n]: ", length(dbad), " first cols: ", first(dbad, 5)) -# classify fronts -nbad = Dict{String,Int}(); tot = Dict{String,Int}() -firstbad = Int[] -for v in 1:ns - f, w = fr[v], fc[v] - m = f - w - cls = covered[v] ? "subtree" : (w > 64 ? "wide" : (m <= 64 ? "panel-inline" : "panel-split")) - tot[cls] = get(tot, cls, 0) + 1 - rng = fptr[v]:(fptr[v] + f * w - 1) - bad = any(isnan, @view fac1[rng]) - dbadv = any(isnan, @view d1[sptr[v]:(sptr[v] + w - 1)]) - if bad || dbadv - nbad[cls] = get(nbad, cls, 0) + 1 - length(firstbad) < 6 && push!(firstbad, v) - end -end -println("front classes: ", tot) -println("NaN fronts: ", nbad) -for v in firstbad - f, w = fr[v], fc[v] - dfrag = d1[sptr[v]:min(sptr[v] + w - 1, sptr[v] + 4)] - @printf " front %d f %d w %d covered %s d[1:5] %s d0 %s\n" v f w string(covered[v]) string(round.(dfrag; sigdigits=3)) string(round.(d0[sptr[v]:min(sptr[v]+4, sptr[v]+w-1)]; sigdigits=3)) -end -# also: max |Δ| over non-NaN fronts -ok = .!isnan.(fac1) -println("Δfactor over finite entries: ", maximum(abs.(fac1[ok] .- fac0[ok])) / maximum(abs, fac0)) -println("done") From c70a705f7cee3a9c79df8a92234ac88726745651 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Mon, 12 Oct 2026 09:49:45 -0400 Subject: [PATCH 45/65] ordering: import the GPU-hybrid METIS NodeND prototype MetisExact.jl: bitwise-faithful Julia translation of METIS 5.1.0's NodeND path (all-default options, the PR #107 configuration), validated perm- and iperm-identical to libmetis on lap2d/lap3d and the case78484 KKT dumps up to n = 2.09M. GpuND.jl: KernelAbstractions kernels for the pipeline stages (hashed-handshake HEM matching with 2-hop, device-resident contraction), stage-validated against the exact port. Hybrid.jl: nodend_hybrid = device coarsening chain + exact host separator/refinement/MMD, threaded sub-threshold subtrees; fill 1.003-1.015x exact on the benchmark set. Smoke: bench/ordering/nodend_smoke.jl (lap2d60: ratio 1.0000, SMOKE OK). Base for the one-shot ordering-bottleneck work: parallel node refinement and level-batched recursion. Co-Authored-By: Claude Fable 5 --- bench/ordering/GpuND.jl | 326 +++++ bench/ordering/Hybrid.jl | 198 +++ bench/ordering/MetisExact.jl | 2411 ++++++++++++++++++++++++++++++++ bench/ordering/gpu_nodend.jl | 29 + bench/ordering/nodend_smoke.jl | 43 + 5 files changed, 3007 insertions(+) create mode 100644 bench/ordering/GpuND.jl create mode 100644 bench/ordering/Hybrid.jl create mode 100644 bench/ordering/MetisExact.jl create mode 100644 bench/ordering/gpu_nodend.jl create mode 100644 bench/ordering/nodend_smoke.jl diff --git a/bench/ordering/GpuND.jl b/bench/ordering/GpuND.jl new file mode 100644 index 00000000..11c07770 --- /dev/null +++ b/bench/ordering/GpuND.jl @@ -0,0 +1,326 @@ +module GpuND + +# M2: device kernels for the NodeND pipeline, written on KernelAbstractions +# against the exact host port (MetisExact.jl) as the stage oracle. +# +# Parallelization contract, stage by stage: +# matching — hashed-handshake HEM: heaviest-edge preference, maxvwgt +# respected, per-round hash tie-breaking. Deterministic given +# (graph, seed, rounds) but NOT the sequential SHEM order, so +# it is validated by invariants + end quality, not bitwise. +# contraction — given a matching it is order-free: validated ROW-SET +# identical (sorted (id, wgt) rows) against the host +# CreateCoarseGraph fed the same match array. +# edge order — GPU coarse rows are sorted by coarse id (canonical), +# where METIS's are hash-insertion-ordered; downstream GPU +# stages define their own deterministic order. +# +# All indices 0-based in device arrays, matching the host port. + +using SparseDirectSolver.KernelAbstractions +const KA = KernelAbstractions + +export gpu_match, gpu_contract + +# --------------------------------------------------------------------------- +# matching: propose / resolve rounds + +@kernel function propose_kernel!(prop, @Const(xadj), @Const(adjncy), @Const(adjwgt), + @Const(vwgt), @Const(match), @Const(maxvwgt), salt::UInt64) + gi = @index(Global) + v0 = gi - 1 # 0-based vertex + v = Int32(v0) + if match[v + 1] == Int32(-2) # -2 = unmatched (device sentinel) + best = Int32(-1) + bw = Int32(-1) + bh = UInt64(0) + mv = maxvwgt[v + 1] # per-vertex cap (batched: per subgraph) + if mv > Int32(0) && vwgt[v + 1] < mv + for p in (xadj[v + 1] + 1):xadj[v + 2] + u = adjncy[p] + w = adjwgt[p] + if u != v && match[u + 1] == Int32(-2) && vwgt[v + 1] + vwgt[u + 1] <= mv + hu = (UInt64(u) * 0x9e3779b97f4a7c15 + salt) >> 1 + if w > bw || (w == bw && (best == Int32(-1) || hu > bh)) + best = u + bw = w + bh = hu + end + end + end + end + prop[v + 1] = best + else + prop[v + 1] = Int32(-1) + end +end + +@kernel function resolve_kernel!(match, @Const(prop)) + gi = @index(Global) + v0 = gi - 1 + v = Int32(v0) + u = prop[v + 1] + if u != Int32(-1) && prop[u + 1] == v && match[v + 1] == Int32(-2) + # mutual proposal; both sides see it symmetrically, no atomics needed + match[v + 1] = u + end +end + +""" + gpu_match(backend, xadj, adjncy, adjwgt, vwgt, maxvwgt; rounds = 8, seed = 1) -> match + +Heavy-edge matching by hashed mutual proposals. `match[v+1]` is the partner +(0-based) or `v` for a singleton. Input arrays are device vectors (Int32, +0-based ids); the result is a device vector. +""" +function gpu_match(backend, xadj, adjncy, adjwgt, vwgt, maxvwgt; + rounds::Int = 8, seed::Integer = 1) + n = length(xadj) - 1 + if maxvwgt isa Integer # scalar cap -> per-vertex vector + mv = KA.allocate(backend, Int32, n) + fill!(mv, Int32(maxvwgt)) + maxvwgt = mv + end + match = KA.allocate(backend, Int32, n) + fill!(match, Int32(-2)) + prop = KA.allocate(backend, Int32, n) + pk = propose_kernel!(backend) + rk = resolve_kernel!(backend) + for r in 1:rounds + salt = UInt64(seed) * 0xbf58476d1ce4e5b9 + UInt64(r) * 0x94d049bb133111eb + pk(prop, xadj, adjncy, adjwgt, vwgt, match, maxvwgt, salt; ndrange = n) + rk(match, prop; ndrange = n) + end + # 2-hop matching for hub-dense graphs (METIS Match_2HopAny semantics): + # unmatched low-degree vertices pair through a common neighbour. Parallel + # form: per-neighbour atomic-min "two smallest unmatched" registers, then + # the same mutual-proposal resolve as the handshake. Gated like METIS at + # >10% unmatched. + nun = count_unmatched(backend, match) + if nun > n ÷ 10 + minu = KA.allocate(backend, Int32, n) + min2u = KA.allocate(backend, Int32, n) + for (rnds, maxdeg) in ((2, 2), (2, 3), (3, typemax(Int32))) + for _ in 1:rnds + fill!(minu, typemax(Int32)) + fill!(min2u, typemax(Int32)) + minu_kernel!(backend)(minu, xadj, adjncy, match, maxvwgt, Int32(maxdeg); ndrange = n) + min2_kernel!(backend)(min2u, minu, xadj, adjncy, match, maxvwgt, Int32(maxdeg); ndrange = n) + twohop_propose_kernel!(backend)(prop, minu, min2u, xadj, adjncy, match, maxvwgt, + Int32(maxdeg); ndrange = n) + resolve_kernel!(backend)(match, prop; ndrange = n) + end + nun = count_unmatched(backend, match) + nun > n ÷ 10 || break + end + end + # leftovers become singletons [KERNEL] + singles_kernel!(backend)(match; ndrange = n) + KA.synchronize(backend) + return match +end + +function count_unmatched(backend, match) + f = KA.allocate(backend, Int32, length(match)) + unmatched_flag_kernel!(backend)(f, match; ndrange = length(match)) + return Int(sum(f)) # the reduction synchronizes +end + +@kernel function unmatched_flag_kernel!(f, @Const(match)) + gi = @index(Global) + f[gi] = match[gi] == Int32(-2) ? Int32(1) : Int32(0) +end + +@kernel function minu_kernel!(minu, @Const(xadj), @Const(adjncy), @Const(match), @Const(maxvwgt), maxdeg::Int32) + gi = @index(Global) + v0 = gi - 1 + v = Int32(v0) + if match[v + 1] == Int32(-2) && maxvwgt[v + 1] > Int32(0) && xadj[v + 2] - xadj[v + 1] < maxdeg + for p in (xadj[v + 1] + 1):xadj[v + 2] + c = adjncy[p] + KA.@atomic minu[c + 1] min v + end + end +end + +@kernel function min2_kernel!(min2u, @Const(minu), @Const(xadj), @Const(adjncy), + @Const(match), @Const(maxvwgt), maxdeg::Int32) + gi = @index(Global) + v0 = gi - 1 + v = Int32(v0) + if match[v + 1] == Int32(-2) && maxvwgt[v + 1] > Int32(0) && xadj[v + 2] - xadj[v + 1] < maxdeg + for p in (xadj[v + 1] + 1):xadj[v + 2] + c = adjncy[p] + if minu[c + 1] != v + KA.@atomic min2u[c + 1] min v + end + end + end +end + +@kernel function twohop_propose_kernel!(prop, @Const(minu), @Const(min2u), @Const(xadj), + @Const(adjncy), @Const(match), @Const(maxvwgt), maxdeg::Int32) + gi = @index(Global) + v0 = gi - 1 + v = Int32(v0) + best = Int32(-1) + if match[v + 1] == Int32(-2) && maxvwgt[v + 1] > Int32(0) && xadj[v + 2] - xadj[v + 1] < maxdeg + for p in (xadj[v + 1] + 1):xadj[v + 2] + c = adjncy[p] + u = minu[c + 1] == v ? min2u[c + 1] : minu[c + 1] + if u != typemax(Int32) && u != v && match[u + 1] == Int32(-2) + (best == Int32(-1) || u < best) && (best = u) + end + end + end + prop[v + 1] = best +end + +@kernel function singles_kernel!(match) + gi = @index(Global) + v0 = gi - 1 + if match[v0 + 1] == Int32(-2) + match[v0 + 1] = Int32(v0) + end +end + +# --------------------------------------------------------------------------- +# contraction: coarse ids by scan over pair representatives, then +# pack (coarse row, coarse col) into UInt64 keys, sort, reduce duplicates. + +@kernel function repflag_kernel!(flag, @Const(match)) + gi = @index(Global) + v0 = gi - 1 + flag[v0 + 1] = Int32(v0) <= match[v0 + 1] ? Int32(1) : Int32(0) +end + +@kernel function cmap_kernel!(cmap, @Const(match), @Const(repid)) + gi = @index(Global) + v0 = gi - 1 + u = match[v0 + 1] + if Int32(v0) <= u + cmap[v0 + 1] = repid[v0 + 1] + else + cmap[v0 + 1] = repid[u + 1] + end +end + +@kernel function packkeys_kernel!(keys, vals, @Const(xadj), @Const(adjncy), + @Const(adjwgt), @Const(cmap)) + gi = @index(Global) + v0 = gi - 1 + cv = cmap[v0 + 1] + for p in (xadj[v0 + 1] + 1):xadj[v0 + 2] + cu = cmap[adjncy[p] + 1] + if cu == cv + keys[p] = typemax(UInt64) # self edge: sorts to the end + vals[p] = Int32(0) + else + keys[p] = (UInt64(cv) << 32) | UInt64(cu) + vals[p] = adjwgt[p] + end + end +end + +@kernel function boundary_kernel!(isnew, @Const(keys)) + i = @index(Global) + k = keys[i] + if k == typemax(UInt64) + isnew[i] = Int32(0) + elseif i == 1 + isnew[i] = Int32(1) + else + isnew[i] = keys[i - 1] == k ? Int32(0) : Int32(1) + end +end + +@kernel function cvwgt_kernel!(cvwgt, @Const(vwgt), @Const(match), @Const(cmap)) + gi = @index(Global) + v0 = gi - 1 + u = match[v0 + 1] + if Int32(v0) <= u + w = vwgt[v0 + 1] + u != Int32(v0) && (w += vwgt[u + 1]) + cvwgt[cmap[v0 + 1] + 1] = w + end +end + +@kernel function scatter_edges_kernel!(cadjncy, cadjwgt, rowcount, @Const(keys), + @Const(segsum), @Const(isnew), @Const(pos)) + i = @index(Global) + if isnew[i] == Int32(1) + j = pos[i] # output slot (1-based) + k = keys[i] + cadjncy[j] = Int32(k & 0xffffffff) + cadjwgt[j] = segsum[i] + KA.@atomic rowcount[Int(k >> 32) + 2] += Int32(1) + end +end + +""" + gpu_contract(backend, xadj, adjncy, adjwgt, vwgt, match) + -> (cn, cxadj, cadjncy, cadjwgt, cvwgt, cmap) + +Contract a matching. Coarse rows come out sorted by coarse id. v2: fully +device-resident — sortperm + cumsum through GPUArrays, segmented reduction by +Int32 atomics (deterministic: integer adds commute), flags/scatters as +kernels. The representative scan is a cumsum on device too. +""" +function gpu_contract(backend, xadj, adjncy, adjwgt, vwgt, match) + n = length(xadj) - 1 + m = length(adjncy) + flag = KA.allocate(backend, Int32, n) + repflag_kernel!(backend)(flag, match; ndrange = n) + KA.synchronize(backend) + incl = cumsum(flag) # inclusive scan (GPUArrays / Base) + cn = Int(Array(view(incl, n:n))[1]) + repid = KA.allocate(backend, Int32, n) + exclusive_kernel!(backend)(repid, incl, flag; ndrange = n) + cmap = KA.allocate(backend, Int32, n) + cmap_kernel!(backend)(cmap, match, repid; ndrange = n) + keys = KA.allocate(backend, UInt64, m) + vals = KA.allocate(backend, Int32, m) + packkeys_kernel!(backend)(keys, vals, xadj, adjncy, adjwgt, cmap; ndrange = n) + KA.synchronize(backend) + p = sortperm(keys) + keys = keys[p] + vals = vals[p] + isnew = KA.allocate(backend, Int32, m) + boundary_kernel!(backend)(isnew, keys; ndrange = m) + KA.synchronize(backend) + segid = cumsum(isnew) # segment index per element (1-based) + nseg = Int(Array(view(segid, m:m))[1]) + cadjncy = KA.allocate(backend, Int32, nseg) + cadjwgt = KA.allocate(backend, Int32, nseg) + fill!(cadjwgt, Int32(0)) + rowcount = KA.allocate(backend, Int32, Int(cn) + 1) + fill!(rowcount, Int32(0)) + segfill_kernel!(backend)(cadjncy, cadjwgt, rowcount, keys, vals, isnew, segid; ndrange = m) + KA.synchronize(backend) + cxadj = cumsum(rowcount) # rowcount[1] = 0 stays 0 + cvwgt = KA.allocate(backend, Int32, Int(cn)) + cvwgt_kernel!(backend)(cvwgt, vwgt, match, cmap; ndrange = n) + KA.synchronize(backend) + return Int(cn), cxadj, cadjncy, cadjwgt, cvwgt, cmap +end + +@kernel function exclusive_kernel!(excl, @Const(incl), @Const(flag)) + gi = @index(Global) + excl[gi] = incl[gi] - flag[gi] +end + +@kernel function segfill_kernel!(cadjncy, cadjwgt, rowcount, @Const(keys), @Const(vals), + @Const(isnew), @Const(segid)) + gi = @index(Global) + k = keys[gi] + if k != typemax(UInt64) + sid = segid[gi] + KA.@atomic cadjwgt[sid] += vals[gi] + if isnew[gi] == Int32(1) + cadjncy[sid] = Int32(k & 0xffffffff) + KA.@atomic rowcount[Int(k >> 32) + 2] += Int32(1) + end + end +end + +end # module diff --git a/bench/ordering/Hybrid.jl b/bench/ordering/Hybrid.jl new file mode 100644 index 00000000..a24b9469 --- /dev/null +++ b/bench/ordering/Hybrid.jl @@ -0,0 +1,198 @@ +# Hybrid NodeND v1: the coarsening chain runs through the GpuND kernels +# (device-formulated matching + contraction), everything else through the +# bitwise-exact host port (initial separator, FM node refinement, split, MMD, +# recursion). Quality gate: nnz_L / schedule depth vs MetisExact on the same +# graphs — the cost of parallel matching is the ONLY delta by construction. +module Hybrid + +using SparseDirectSolver.KernelAbstractions +const KA = KernelAbstractions +using ..MetisExact +using ..GpuND +using Random +const ME = MetisExact + +export nodend_hybrid + +# device coarsening chain following CoarsenGraph's control flow. The device +# arrays stay resident across levels (each level's contraction output feeds +# the next level's matching directly); only the per-level host GraphT copies +# needed by the host refinement are downloaded. +function coarsen_gpu!(backend, ctrl::ME.CtrlT, graph::ME.GraphT; seed::Integer = 1) + ctrl.maxvwgt = trunc(Int, 1.5 * graph.tvwgt / ctrl.CoarsenTo) + dev(x, T) = (y = KA.allocate(backend, T, length(x)); copyto!(y, T.(x)); y) + n = graph.nvtxs + dx = dev(view(graph.xadj, 1:(n + 1)), Int32) + da = dev(view(graph.adjncy, 1:graph.nedges), Int32) + dw = dev(view(graph.adjwgt, 1:graph.nedges), Int32) + dv = dev(view(graph.vwgt, 1:n), Int32) + level = 0 + while true + match = GpuND.gpu_match(backend, dx, da, dw, dv, ctrl.maxvwgt; + seed = seed + level) + cn, cx, ca, cw, cv, cmap = GpuND.gpu_contract(backend, dx, da, dw, dv, match) + cgraph = ME.CreateGraph() + cgraph.nvtxs = cn + cgraph.nedges = length(ca) + cgraph.xadj = Vector{Int}(Array(cx)) + cgraph.adjncy = Vector{Int}(Array(ca)) + cgraph.adjwgt = Vector{Int}(Array(cw)) + cgraph.vwgt = Vector{Int}(Array(cv)) + ME.SetupGraph_tvwgt!(cgraph) + graph.cmap = Vector{Int}(Array(cmap)) + cgraph.finer = graph + graph.coarser = cgraph + graph = cgraph + dx, da, dw, dv = cx, ca, cw, cv # stay on the device + level += 1 + (graph.nvtxs > ctrl.CoarsenTo && + graph.nvtxs < ME.COARSEN_FRACTION * (graph.finer::ME.GraphT).nvtxs && + graph.nedges > graph.nvtxs ÷ 2) || break + end + return graph +end + +# below this size a subgraph's bisection runs entirely on the host exact +# port: small graphs pay GPU launch/transfer latency without filling the +# device, and the deep recursion has thousands of them +const GPU_MIN_NVTXS = Ref(50_000) + +# L1-shaped bisection with the device coarsening +function node_bisection!(backend, ctrl::ME.CtrlT, graph::ME.GraphT, niparts::Int) + if graph.nvtxs < GPU_MIN_NVTXS[] + ME.MlevelNodeBisectionL2!(ctrl, graph, niparts) + return nothing + end + ctrl.CoarsenTo = graph.nvtxs ÷ 8 + ctrl.CoarsenTo > 100 && (ctrl.CoarsenTo = 100) + ctrl.CoarsenTo < 40 && (ctrl.CoarsenTo = 40) + cgraph = coarsen_gpu!(backend, ctrl, graph) + niparts = max(1, cgraph.nvtxs <= ctrl.CoarsenTo ? niparts ÷ 2 : niparts) + ME.InitSeparator!(ctrl, cgraph, niparts) + ME.Refine2WayNode!(ctrl, graph, cgraph) + return nothing +end + +# MlevelNodeBisectionMultiple semantics: nseps tries, keep the lightest separator +function node_bisection_multiple!(backend, ctrl::ME.CtrlT, graph::ME.GraphT) + if ctrl.nseps == 1 || graph.nvtxs < (ctrl.compress ? 1000 : 2000) + node_bisection!(backend, ctrl, graph, ME.LARGENIPARTS) + return nothing + end + bestwhere = Vector{Int}(undef, graph.nvtxs) + mincut = graph.tvwgt + for i in 0:(ctrl.nseps - 1) + node_bisection!(backend, ctrl, graph, ME.LARGENIPARTS) + if i == 0 || graph.mincut < mincut + mincut = graph.mincut + i < ctrl.nseps - 1 && copyto!(bestwhere, graph.where_) + end + mincut == 0 && break + end + if mincut != graph.mincut + copyto!(graph.where_, bestwhere) + ME.Compute2WayNodePartitionParams!(ctrl, graph) + end + return nothing +end + +_copy_ctrl(c::ME.CtrlT) = ME.CtrlT(c.ctype, c.rtype, c.iptype, c.nseps, c.niter, c.ufactor, + c.compress, c.ccorder, c.pfactor, c.no2hop, c.seed, + c.CoarsenTo, c.maxvwgt, c.ubfactors, copy(c.pijbm)) + +function dissect!(backend, ctrl::ME.CtrlT, graph::ME.GraphT, order::Vector{Int}, lastvtx::Int) + node_bisection_multiple!(backend, ctrl, graph) + nbnd = graph.nbnd + bndind = graph.bndind + label = graph.label + for i in 0:(nbnd - 1) + lastvtx -= 1 + order[label[bndind[i + 1] + 1] + 1] = lastvtx + end + lgraph, rgraph = ME.SplitGraphOrder(ctrl, graph) + tasks = Task[] + for (g, lv) in ((lgraph, lastvtx - rgraph.nvtxs), (rgraph, lastvtx)) + if g.nvtxs > ME.MMDSWITCH && g.nedges > 0 + if g.nvtxs < GPU_MIN_NVTXS[] && ME.RNG_TASK[] && Threads.nthreads() > 1 + c = _copy_ctrl(ctrl) + let g = g, lv = lv, c = c + push!(tasks, Threads.@spawn begin + Random.seed!(Random.TaskLocalRNG(), UInt32(hash((g.nvtxs, lv)) % UInt32)) + ME.MlevelNestedDissection!(c, g, order, lv) + end) + end + else + dissect!(backend, ctrl, g, order, lv) + end + else + ME.MMDOrder!(ctrl, g, order, lv) + end + end + foreach(wait, tasks) + return nothing +end + +""" + nodend_hybrid(backend, A; seed = -1) -> (perm, iperm) 1-based + +NodeND with the coarsening chain on the GpuND kernels and the rest on the +exact host port. Compression as in METIS_NodeND. +""" +function nodend_hybrid(backend, A; seed::Int = -1) + n = size(A, 1) + xadj = Vector{Int}(undef, n + 1) + adjncy = Int[] + rws = MetisExact.SparseArrays.rowvals(A) + xadj[1] = 0 + for j in 1:n + for p in MetisExact.SparseArrays.nzrange(A, j) + i = rws[p] + i != j && push!(adjncy, i - 1) + end + xadj[j + 1] = length(adjncy) + end + ctrl = ME.CtrlT(ME.METIS_CTYPE_SHEM, ME.METIS_RTYPE_SEP1SIDED, ME.METIS_IPTYPE_EDGE, + 1, 10, 200, true, false, 0.0, false, seed, 100, 0, + Float32(Float64(Float32(1.0 + 0.001 * 200)) + 0.0000499), Float32[]) + ME.RNG_TASK[] = Threads.nthreads() > 1 # threaded subtrees want task RNG + ME.InitRandom(ctrl.seed) + nnvtxs = 0 + graph = nothing + cptr = Int[] + cind = Int[] + if ctrl.compress + cptr = Vector{Int}(undef, n + 1) + cind = Vector{Int}(undef, n) + graph = ME.CompressGraph(n, xadj, adjncy, nothing, cptr, cind) + if graph === nothing + ctrl.compress = false + else + nnvtxs = graph.nvtxs + cfactor = 1.0 * n / nnvtxs + cfactor > 1.5 && ctrl.nseps == 1 && (ctrl.nseps = 2) + end + end + graph === nothing && (graph = ME.SetupGraph(n, xadj, adjncy, nothing)) + iperm = Vector{Int}(undef, n) + perm = Vector{Int}(undef, n) + dissect!(backend, ctrl, graph, iperm, graph.nvtxs) + if ctrl.compress + for i in 0:(nnvtxs - 1) + perm[iperm[i + 1] + 1] = i + end + l = 0 + for ii in 0:(nnvtxs - 1) + i = perm[ii + 1] + for j in cptr[i + 1]:(cptr[i + 2] - 1) + iperm[cind[j + 1] + 1] = l + l += 1 + end + end + end + for i in 0:(n - 1) + perm[iperm[i + 1] + 1] = i + end + return perm .+ 1, iperm .+ 1 +end + +end # module diff --git a/bench/ordering/MetisExact.jl b/bench/ordering/MetisExact.jl new file mode 100644 index 00000000..3d1b2b48 --- /dev/null +++ b/bench/ordering/MetisExact.jl @@ -0,0 +1,2411 @@ +module MetisExact + +using SparseArrays +using Libdl +using Random + +# Faithful Julia translation of METIS 5.1.0 (METIS_jll 5.1.4+0 = upstream +# 5.1.0 at amontoison/METIS e827ffe, build-system patches only), NodeND path +# with OMETIS defaults, idx_t = Int32 semantics, 0-based ids kept internally +# (C `a[i]` with value index i becomes `a[i + 1]`). Goal: bitwise-identical +# perm/iperm to libmetis before anything is moved to the GPU. +# +# Source of truth: ~/git/scratch/vixen-metis/metis-src/METIS/{libmetis,GKlib}. +# Every section names the C file and function it translates. + +export InitRandom, irand, irandInRange, irandArrayPermute! + +# --------------------------------------------------------------------------- +# GKlib/random.c — METIS_jll is built WITHOUT -DUSE_GKRAND (GKlib option +# default OFF, Yggdrasil does not set it), so the Mersenne-Twister code is +# compiled out and gk_randinit/gk_randint32 are libc srand()/rand(). We call +# the same libc, which is exact by construction on any host, and matches the +# shipped binary because both resolve to the process' C library. + +# Two RNG modes. :libc reproduces the shipped binary bitwise (process-global +# libc rand). RNG_TASK[] = true switches to Julia's task-local RNG so +# independent subtrees can run on threads deterministically (each task seeds +# its own stream); orderings are then METIS-quality but not bitwise-libmetis. +const RNG_TASK = Ref(false) + +function isrand(seed) + if RNG_TASK[] + Random.seed!(Random.TaskLocalRNG(), UInt32(seed % UInt32)) + else + ccall(:srand, Cvoid, (Cuint,), Cuint(seed % UInt32)) + end + return nothing +end +irand() = RNG_TASK[] ? Int(rand(Random.TaskLocalRNG(), UInt32) & 0x7fffffff) : + Int(ccall(:rand, Cint, ())) +irandInRange(max) = Int(mod(irand(), max)) + +# libmetis/util.c InitRandom +InitRandom(seed) = isrand(seed == -1 ? 4321 : seed) + +# gk_mkrandom.h randArrayPermute: p is 1-based storage of 0-based values when +# flag == 1; swaps are position swaps so the caller's base does not matter. +function irandArrayPermute!(n::Int, p::AbstractVector, nshuffles::Int, flag::Int) + if flag == 1 + for i in 1:n + p[i] = i - 1 + end + end + if n < 10 + for _ in 1:n + v = irandInRange(n) + 1 + u = irandInRange(n) + 1 + p[v], p[u] = p[u], p[v] + end + else + for _ in 1:nshuffles + v = irandInRange(n - 3) + 1 + u = irandInRange(n - 3) + 1 + # the template swaps (v+k, u+2), (v+1, u+3), (v+2, u+0), (v+3, u+1) + p[v], p[u + 2] = p[u + 2], p[v] + p[v + 1], p[u + 3] = p[u + 3], p[v + 1] + p[v + 2], p[u] = p[u], p[v + 2] + p[v + 3], p[u + 1] = p[u + 1], p[v + 3] + end + end + return p +end + + +# --------------------------------------------------------------------------- +# GKlib/gk_mksort.h GK_MKQSORT — the glibc-derived quicksort. Tie order is +# observable downstream (compression keys, matching orders), so the exact +# algorithm is translated, pointers becoming 1-based indices. + +const _QSORT_MAX_THRESH = 4 + +function gk_qsort!(v::AbstractVector, n::Int, lt::F) where {F} + n == 0 && return v + if n > _QSORT_MAX_THRESH + lo = 1 + hi = n + stack = Vector{Tuple{Int, Int}}() # (_lo, _hi); C pushes a sentinel + push!(stack, (0, 0)) + while true + mid = lo + ((hi - lo) >> 1) + if lt(v[mid], v[lo]) + v[mid], v[lo] = v[lo], v[mid] + end + jump = false + if lt(v[hi], v[mid]) + v[mid], v[hi] = v[hi], v[mid] + else + jump = true + end + if !jump && lt(v[mid], v[lo]) + v[mid], v[lo] = v[lo], v[mid] + end + left = lo + 1 + right = hi - 1 + while true + while lt(v[left], v[mid]) + left += 1 + end + while lt(v[mid], v[right]) + right -= 1 + end + if left < right + v[left], v[right] = v[right], v[left] + if mid == left + mid = right + elseif mid == right + mid = left + end + left += 1 + right -= 1 + elseif left == right + left += 1 + right -= 1 + break + end + left <= right || break + end + if right - lo <= _QSORT_MAX_THRESH + if hi - left <= _QSORT_MAX_THRESH + lo, hi = pop!(stack) + lo == 0 && break # popped the sentinel: done + else + lo = left + end + elseif hi - left <= _QSORT_MAX_THRESH + hi = right + elseif right - lo > hi - left + push!(stack, (lo, right)) + lo = left + else + push!(stack, (left, hi)) + hi = right + end + end + end + # insertion-sort tail + thresh = min(1 + _QSORT_MAX_THRESH, n) + tmp = 1 + for run in 2:thresh + lt(v[run], v[tmp]) && (tmp = run) + end + if tmp != 1 + v[tmp], v[1] = v[1], v[tmp] + end + run = 2 + while (run += 1) <= n + tmp = run - 1 + while lt(v[run], v[tmp]) + tmp -= 1 + end + tmp += 1 + if tmp != run + x = v[run] + for k in run:-1:(tmp + 1) + v[k] = v[k - 1] + end + v[tmp] = x + end + end + return v +end + +# libmetis/gklib.c — key/value pairs as tuples (key, val) +ikvsorti!(n::Int, v) = gk_qsort!(v, n, (a, b) -> a[1] < b[1]) +ikvsortii!(n::Int, v) = gk_qsort!(v, n, (a, b) -> a[1] < b[1] || (a[1] == b[1] && a[2] < b[2])) + +# libmetis/bucketsort.c BucketSortKeysInc (counting sort; keys in [0, max]) +function BucketSortKeysInc(n::Int, max::Int, keys, tperm, perm) + counts = zeros(Int, max + 2) + for i in 0:(n - 1) + counts[keys[i + 1] + 1] += 1 + end + for i in 1:max # MAKECSR over 0..max + counts[i + 1] += counts[i] + end + for i in (max + 1):-1:1 + counts[i + 1] = counts[i] + end + counts[1] = 0 + for ii in 0:(n - 1) + i = tperm[ii + 1] + perm[counts[keys[i + 1] + 1] + 1] = i + counts[keys[i + 1] + 1] += 1 + end + return perm +end + +# --------------------------------------------------------------------------- +# libmetis/struct.h graph_t (ncon = 1 fields only) — ids and offsets stay +# 0-based as in C; every array is a 1-based Julia Vector indexed [x + 1]. + +const NOVALUE = Int[] +const DBG = Ref(false) +const PROF = Dict{Symbol, Float64}() +const PROF_LOCK = ReentrantLock() +prof!(k, t) = (lock(() -> (PROF[k] = get(PROF, k, 0.0) + t), PROF_LOCK); nothing) + +mutable struct GraphT + nvtxs::Int + nedges::Int + xadj::Vector{Int} + vwgt::Vector{Int} + adjncy::Vector{Int} + adjwgt::Vector{Int} + tvwgt::Int + invtvwgt::Float32 # real_t = float32 in the shipped binary + label::Vector{Int} + cmap::Vector{Int} + # partition/refinement data + where_::Vector{Int} + pwgts::Vector{Int} + nbnd::Int + bndptr::Vector{Int} + bndind::Vector{Int} + id::Vector{Int} + ed::Vector{Int} + nrinfo::Vector{NTuple{2, Int}} # nrinfo_t.edegrees[2] + mincut::Int + coarser::Union{Nothing, GraphT} + finer::Union{Nothing, GraphT} +end + +CreateGraph() = GraphT(-1, -1, NOVALUE, NOVALUE, NOVALUE, NOVALUE, 0, 0.0f0, + NOVALUE, NOVALUE, NOVALUE, NOVALUE, -1, NOVALUE, NOVALUE, + NOVALUE, NOVALUE, NTuple{2, Int}[], -1, nothing, nothing) + +# libmetis/graph.c SetupGraph for OMETIS (ncon = 1, objtype = NODE) +function SetupGraph(nvtxs::Int, xadj::Vector{Int}, adjncy::Vector{Int}, + vwgt::Union{Nothing, Vector{Int}}) + graph = CreateGraph() + graph.nvtxs = nvtxs + graph.nedges = xadj[nvtxs + 1] + graph.xadj = xadj + graph.adjncy = adjncy + graph.vwgt = vwgt === nothing ? ones(Int, nvtxs) : vwgt + graph.adjwgt = ones(Int, graph.nedges) + SetupGraph_tvwgt!(graph) + SetupGraph_label!(graph) + return graph +end + +function SetupGraph_tvwgt!(graph::GraphT) + graph.tvwgt = sum(view(graph.vwgt, 1:graph.nvtxs); init = 0) + graph.invtvwgt = Float32(1.0 / (graph.tvwgt > 0 ? graph.tvwgt : 1)) + return nothing +end + +function SetupGraph_label!(graph::GraphT) + graph.label = collect(0:(graph.nvtxs - 1)) + return nothing +end + +function SetupSplitGraph(graph::GraphT, snvtxs::Int, snedges::Int) + sgraph = CreateGraph() + sgraph.nvtxs = snvtxs + sgraph.nedges = snedges + sgraph.xadj = Vector{Int}(undef, snvtxs + 1) + sgraph.vwgt = Vector{Int}(undef, snvtxs) + sgraph.adjncy = Vector{Int}(undef, snedges) + sgraph.adjwgt = Vector{Int}(undef, snedges) + sgraph.label = Vector{Int}(undef, snvtxs) + return sgraph +end + +# --------------------------------------------------------------------------- +# libmetis/compress.c CompressGraph — identical-adjacency vertices merge. +# Returns nothing when compression is below COMPRESSION_FRACTION. + +const COMPRESSION_FRACTION = 0.85 + +function CompressGraph(nvtxs::Int, xadj::Vector{Int}, adjncy::Vector{Int}, + vwgt::Union{Nothing, Vector{Int}}, cptr::Vector{Int}, cind::Vector{Int}) + mark = fill(-1, nvtxs) + map_ = fill(-1, nvtxs) + keys = Vector{Tuple{Int, Int}}(undef, nvtxs) + for i in 0:(nvtxs - 1) + k = 0 + for j in xadj[i + 1]:(xadj[i + 2] - 1) + k += adjncy[j + 1] + end + keys[i + 1] = (k + i, i) + end + ikvsorti!(nvtxs, keys) + l = 0 + cptr[1] = 0 + cnvtxs = 0 + for i in 0:(nvtxs - 1) + ii = keys[i + 1][2] + if map_[ii + 1] == -1 + mark[ii + 1] = i + for j in xadj[ii + 1]:(xadj[ii + 2] - 1) + mark[adjncy[j + 1] + 1] = i + end + map_[ii + 1] = cnvtxs + l += 1 + cind[l] = ii + for jj in (i + 1):(nvtxs - 1) + iii = keys[jj + 1][2] + if keys[i + 1][1] != keys[jj + 1][1] || + xadj[ii + 2] - xadj[ii + 1] != xadj[iii + 2] - xadj[iii + 1] + break + end + if map_[iii + 1] == -1 + ok = true + for p in xadj[iii + 1]:(xadj[iii + 2] - 1) + if mark[adjncy[p + 1] + 1] != i + ok = false + break + end + end + if ok + map_[iii + 1] = cnvtxs + l += 1 + cind[l] = iii + end + end + end + cnvtxs += 1 + cptr[cnvtxs + 1] = l + end + end + cnvtxs >= COMPRESSION_FRACTION * nvtxs && return nothing + graph = CreateGraph() + cnedges = 0 + for i in 0:(cnvtxs - 1) + ii = cind[cptr[i + 1] + 1] + cnedges += xadj[ii + 2] - xadj[ii + 1] + end + cxadj = Vector{Int}(undef, cnvtxs + 1) + cvwgt = zeros(Int, cnvtxs) + cadjncy = Vector{Int}(undef, cnedges) + fill!(mark, -1) + l = 0 + cxadj[1] = 0 + for i in 0:(cnvtxs - 1) + mark[i + 1] = i # remove diagonal in compressed graph + for j in cptr[i + 1]:(cptr[i + 2] - 1) + ii = cind[j + 1] + cvwgt[i + 1] += vwgt === nothing ? 1 : vwgt[ii + 1] + for jj in xadj[ii + 1]:(xadj[ii + 2] - 1) + k = map_[adjncy[jj + 1] + 1] + if mark[k + 1] != i + mark[k + 1] = i + l += 1 + cadjncy[l] = k + end + end + end + cxadj[i + 2] = l + end + graph.nvtxs = cnvtxs + graph.nedges = l + graph.xadj = cxadj + graph.vwgt = cvwgt + graph.adjncy = cadjncy + graph.adjwgt = ones(Int, l) + SetupGraph_tvwgt!(graph) + SetupGraph_label!(graph) + return graph +end + +# --------------------------------------------------------------------------- +# GKlib/gk_mkpqueue.h instantiated as rpq (libmetis/gklib.c: +# GK_MKPQUEUE(rpq, rpq_t, rkv_t, real_t, idx_t, ..., key_gt)): a MAX-heap +# with real_t = Float32 keys and idx_t vals. Heap layout (which of several +# equal-key items surfaces first) is observable through FM, so the sift +# logic is translated verbatim. KEY_LT(a, b) = a > b. + +mutable struct RPQ + nnodes::Int + heap_key::Vector{Float32} + heap_val::Vector{Int} + locator::Vector{Int} # by node id (0-based), -1 = absent +end + +rpqCreate(maxnodes::Int) = RPQ(0, Vector{Float32}(undef, maxnodes), + Vector{Int}(undef, maxnodes), fill(-1, maxnodes)) +rpqLength(q::RPQ) = q.nnodes + +@inline _rpq_lt(a::Float32, b::Float32) = a > b # KEY_LT = key_gt (max-heap) + +function rpqReset(q::RPQ) + for i in q.nnodes:-1:1 + q.locator[q.heap_val[i] + 1] = -1 + end + q.nnodes = 0 + return nothing +end + +function rpqInsert(q::RPQ, node::Int, key::Float32) + i = q.nnodes # 0-based heap position + q.nnodes += 1 + while i > 0 + j = (i - 1) >> 1 + if _rpq_lt(key, q.heap_key[j + 1]) + q.heap_key[i + 1] = q.heap_key[j + 1] + q.heap_val[i + 1] = q.heap_val[j + 1] + q.locator[q.heap_val[i + 1] + 1] = i + i = j + else + break + end + end + q.heap_key[i + 1] = key + q.heap_val[i + 1] = node + q.locator[node + 1] = i + return nothing +end + +function rpqDelete(q::RPQ, node::Int) + i = q.locator[node + 1] + q.locator[node + 1] = -1 + q.nnodes -= 1 + if q.nnodes > 0 && q.heap_val[q.nnodes + 1] != node + node = q.heap_val[q.nnodes + 1] + newkey = q.heap_key[q.nnodes + 1] + oldkey = q.heap_key[i + 1] + if _rpq_lt(newkey, oldkey) # filter up + while i > 0 + j = (i - 1) >> 1 + if _rpq_lt(newkey, q.heap_key[j + 1]) + q.heap_key[i + 1] = q.heap_key[j + 1] + q.heap_val[i + 1] = q.heap_val[j + 1] + q.locator[q.heap_val[i + 1] + 1] = i + i = j + else + break + end + end + else # filter down + nnodes = q.nnodes + while (j = (i << 1) + 1) < nnodes + if _rpq_lt(q.heap_key[j + 1], newkey) + if j + 1 < nnodes && _rpq_lt(q.heap_key[j + 2], q.heap_key[j + 1]) + j += 1 + end + q.heap_key[i + 1] = q.heap_key[j + 1] + q.heap_val[i + 1] = q.heap_val[j + 1] + q.locator[q.heap_val[i + 1] + 1] = i + i = j + elseif j + 1 < nnodes && _rpq_lt(q.heap_key[j + 2], newkey) + j += 1 + q.heap_key[i + 1] = q.heap_key[j + 1] + q.heap_val[i + 1] = q.heap_val[j + 1] + q.locator[q.heap_val[i + 1] + 1] = i + i = j + else + break + end + end + end + q.heap_key[i + 1] = newkey + q.heap_val[i + 1] = node + q.locator[node + 1] = i + end + return nothing +end + +function rpqUpdate(q::RPQ, node::Int, newkey::Float32) + i = q.locator[node + 1] + oldkey = q.heap_key[i + 1] + if _rpq_lt(newkey, oldkey) # filter up + while i > 0 + j = (i - 1) >> 1 + if _rpq_lt(newkey, q.heap_key[j + 1]) + q.heap_key[i + 1] = q.heap_key[j + 1] + q.heap_val[i + 1] = q.heap_val[j + 1] + q.locator[q.heap_val[i + 1] + 1] = i + i = j + else + break + end + end + else # filter down + nnodes = q.nnodes + while (j = (i << 1) + 1) < nnodes + if _rpq_lt(q.heap_key[j + 1], newkey) + if j + 1 < nnodes && _rpq_lt(q.heap_key[j + 2], q.heap_key[j + 1]) + j += 1 + end + q.heap_key[i + 1] = q.heap_key[j + 1] + q.heap_val[i + 1] = q.heap_val[j + 1] + q.locator[q.heap_val[i + 1] + 1] = i + i = j + elseif j + 1 < nnodes && _rpq_lt(q.heap_key[j + 2], newkey) + j += 1 + q.heap_key[i + 1] = q.heap_key[j + 1] + q.heap_val[i + 1] = q.heap_val[j + 1] + q.locator[q.heap_val[i + 1] + 1] = i + i = j + else + break + end + end + end + q.heap_key[i + 1] = newkey + q.heap_val[i + 1] = node + q.locator[node + 1] = i + return nothing +end + +function rpqGetTop(q::RPQ) + q.nnodes == 0 && return -1 + q.nnodes -= 1 + vtx = q.heap_val[1] + q.locator[vtx + 1] = -1 + i = q.nnodes + if i > 0 + key = q.heap_key[i + 1] + node = q.heap_val[i + 1] + i = 0 + while (j = 2 * i + 1) < q.nnodes + if _rpq_lt(q.heap_key[j + 1], key) + if j + 1 < q.nnodes && _rpq_lt(q.heap_key[j + 2], q.heap_key[j + 1]) + j += 1 + end + q.heap_key[i + 1] = q.heap_key[j + 1] + q.heap_val[i + 1] = q.heap_val[j + 1] + q.locator[q.heap_val[i + 1] + 1] = i + i = j + elseif j + 1 < q.nnodes && _rpq_lt(q.heap_key[j + 2], key) + j += 1 + q.heap_key[i + 1] = q.heap_key[j + 1] + q.heap_val[i + 1] = q.heap_val[j + 1] + q.locator[q.heap_val[i + 1] + 1] = i + i = j + else + break + end + end + q.heap_key[i + 1] = key + q.heap_val[i + 1] = node + q.locator[node + 1] = i + end + return vtx +end + + +# --------------------------------------------------------------------------- +# ctrl_t subset for METIS_OP_OMETIS (libmetis/options.c SetupCtrl). All +# values that the OMETIS path reads. idx arithmetic stays in Int64: every C +# idx_t intermediate on this path fits Int32 (checked per use), so Int64 +# arithmetic is value-identical. + +mutable struct CtrlT + ctype::Int # METIS_CTYPE_SHEM = 2 + rtype::Int # METIS_RTYPE_SEP1SIDED = 3 / SEP2SIDED = 4 + iptype::Int # METIS_IPTYPE_EDGE = 2 + nseps::Int + niter::Int + ufactor::Int + compress::Bool + ccorder::Bool + pfactor::Float64 + no2hop::Bool + seed::Int + CoarsenTo::Int + maxvwgt::Int + ubfactors::Float32 # real_t: I2RUBFACTOR(ufactor) + 0.0000499 + pijbm::Vector{Float32} # real_t: Setup2WayBalMultipliers (len 2) +end + +const METIS_CTYPE_RM = 0 +const METIS_CTYPE_SHEM = 1 +const METIS_RTYPE_FM = 0 +const METIS_RTYPE_SEP2SIDED = 3 +const METIS_RTYPE_SEP1SIDED = 2 +const METIS_IPTYPE_EDGE = 2 +const METIS_IPTYPE_NODE = 3 +const UNMATCHED = -1 +const COARSEN_FRACTION = 0.85 +const UNMATCHEDFOR2HOP = 0.10 +const HTLENGTH = (1 << 11) - 1 +const MMDSWITCH = 120 +const LARGENIPARTS = 7 +const SMALLNIPARTS = 5 +const IDX_MAX = 2147483647 + +# --------------------------------------------------------------------------- +# libmetis/coarsen.c + +function CoarsenGraph(ctrl::CtrlT, graph::GraphT) + eqewgts = true + for i in 1:(graph.nedges - 1) + if graph.adjwgt[1] != graph.adjwgt[i + 1] + eqewgts = false + break + end + end + ctrl.maxvwgt = trunc(Int, 1.5 * graph.tvwgt / ctrl.CoarsenTo) + while true + DBG[] && println(lpad(graph.nvtxs, 10), lpad(graph.nedges, 11), + lpad(sum(view(graph.adjwgt, 1:graph.nedges); init = 0), 11), + " [", ctrl.CoarsenTo, "] [", lpad(ctrl.maxvwgt, 9), ":", lpad(graph.tvwgt, 8), " ]") + isempty(graph.cmap) && (graph.cmap = Vector{Int}(undef, graph.nvtxs)) + if ctrl.ctype == METIS_CTYPE_RM || eqewgts || graph.nedges == 0 + Match_RM(ctrl, graph) + else + Match_SHEM(ctrl, graph) + end + graph = graph.coarser::GraphT + eqewgts = false + (graph.nvtxs > ctrl.CoarsenTo && + graph.nvtxs < COARSEN_FRACTION * (graph.finer::GraphT).nvtxs && + graph.nedges > graph.nvtxs ÷ 2) || break + end + DBG[] && println(lpad(graph.nvtxs, 10), lpad(graph.nedges, 11), + lpad(sum(view(graph.adjwgt, 1:graph.nedges); init = 0), 11), + " [", ctrl.CoarsenTo, "] [", lpad(ctrl.maxvwgt, 9), ":", lpad(graph.tvwgt, 8), " ]") + return graph +end + +function CoarsenGraphNlevels(ctrl::CtrlT, graph::GraphT, nlevels::Int) + eqewgts = true + for i in 1:(graph.nedges - 1) + if graph.adjwgt[1] != graph.adjwgt[i + 1] + eqewgts = false + break + end + end + ctrl.maxvwgt = trunc(Int, 1.5 * graph.tvwgt / ctrl.CoarsenTo) + for _ in 1:nlevels + isempty(graph.cmap) && (graph.cmap = Vector{Int}(undef, graph.nvtxs)) + if ctrl.ctype == METIS_CTYPE_RM || eqewgts || graph.nedges == 0 + Match_RM(ctrl, graph) + else + Match_SHEM(ctrl, graph) + end + graph = graph.coarser::GraphT + eqewgts = false + if graph.nvtxs < ctrl.CoarsenTo || + graph.nvtxs > COARSEN_FRACTION * (graph.finer::GraphT).nvtxs || + graph.nedges < graph.nvtxs ÷ 2 + break + end + end + return graph +end + +function Match_RM(ctrl::CtrlT, graph::GraphT) + nvtxs = graph.nvtxs + xadj = graph.xadj + vwgt = graph.vwgt + adjncy = graph.adjncy + cmap = graph.cmap + maxvwgt = ctrl.maxvwgt + nunmatched = 0 + match = fill(UNMATCHED, nvtxs) + perm = Vector{Int}(undef, nvtxs) + irandArrayPermute!(nvtxs, perm, nvtxs ÷ 8, 1) + cnvtxs = 0 + last_unmatched = 0 # 0-based position + for pi in 0:(nvtxs - 1) + i = perm[pi + 1] + match[i + 1] == UNMATCHED || continue + maxidx = i + if vwgt[i + 1] < maxvwgt + if xadj[i + 1] == xadj[i + 2] # island vertex + last_unmatched = max(pi, last_unmatched) + 1 + while last_unmatched < nvtxs + j = perm[last_unmatched + 1] + if match[j + 1] == UNMATCHED + maxidx = j + break + end + last_unmatched += 1 + end + else + for j in xadj[i + 1]:(xadj[i + 2] - 1) + k = adjncy[j + 1] + if match[k + 1] == UNMATCHED && vwgt[i + 1] + vwgt[k + 1] <= maxvwgt + maxidx = k + break + end + end + if maxidx == i && 3 * vwgt[i + 1] < maxvwgt + nunmatched += 1 + maxidx = UNMATCHED + end + end + end + if maxidx != UNMATCHED + cmap[i + 1] = cmap[maxidx + 1] = cnvtxs + cnvtxs += 1 + match[i + 1] = maxidx + match[maxidx + 1] = i + end + end + if !ctrl.no2hop && nunmatched > UNMATCHEDFOR2HOP * nvtxs + cnvtxs = Match_2Hop(ctrl, graph, perm, match, cnvtxs, nunmatched) + end + cnvtxs = 0 + for i in 0:(nvtxs - 1) + if match[i + 1] == UNMATCHED + match[i + 1] = i + cmap[i + 1] = cnvtxs + cnvtxs += 1 + elseif i <= match[i + 1] + cmap[i + 1] = cmap[match[i + 1] + 1] = cnvtxs + cnvtxs += 1 + end + end + CreateCoarseGraph(ctrl, graph, cnvtxs, match) + return cnvtxs +end + +function Match_SHEM(ctrl::CtrlT, graph::GraphT) + nvtxs = graph.nvtxs + xadj = graph.xadj + vwgt = graph.vwgt + adjncy = graph.adjncy + adjwgt = graph.adjwgt + cmap = graph.cmap + maxvwgt = ctrl.maxvwgt + nunmatched = 0 + match = fill(UNMATCHED, nvtxs) + perm = Vector{Int}(undef, nvtxs) + tperm = Vector{Int}(undef, nvtxs) + degrees = Vector{Int}(undef, nvtxs) + irandArrayPermute!(nvtxs, tperm, nvtxs ÷ 8, 1) + avgdegree = trunc(Int, 0.7 * (xadj[nvtxs + 1] ÷ nvtxs)) + for i in 0:(nvtxs - 1) + d = xadj[i + 2] - xadj[i + 1] + degrees[i + 1] = d > avgdegree ? avgdegree : d + end + BucketSortKeysInc(nvtxs, avgdegree, degrees, tperm, perm) + cnvtxs = 0 + last_unmatched = 0 + for pi in 0:(nvtxs - 1) + i = perm[pi + 1] + match[i + 1] == UNMATCHED || continue + maxidx = i + maxwgt = -1 + if vwgt[i + 1] < maxvwgt + if xadj[i + 1] == xadj[i + 2] # island vertex + last_unmatched = max(pi, last_unmatched) + 1 + while last_unmatched < nvtxs + j = perm[last_unmatched + 1] + if match[j + 1] == UNMATCHED + maxidx = j + break + end + last_unmatched += 1 + end + else + for j in xadj[i + 1]:(xadj[i + 2] - 1) + k = adjncy[j + 1] + if match[k + 1] == UNMATCHED && maxwgt < adjwgt[j + 1] && + vwgt[i + 1] + vwgt[k + 1] <= maxvwgt + maxidx = k + maxwgt = adjwgt[j + 1] + end + end + if maxidx == i && 3 * vwgt[i + 1] < maxvwgt + nunmatched += 1 + maxidx = UNMATCHED + end + end + end + if maxidx != UNMATCHED + cmap[i + 1] = cmap[maxidx + 1] = cnvtxs + cnvtxs += 1 + match[i + 1] = maxidx + match[maxidx + 1] = i + end + end + if !ctrl.no2hop && nunmatched > UNMATCHEDFOR2HOP * nvtxs + cnvtxs = Match_2Hop(ctrl, graph, perm, match, cnvtxs, nunmatched) + end + cnvtxs = 0 + for i in 0:(nvtxs - 1) + if match[i + 1] == UNMATCHED + match[i + 1] = i + cmap[i + 1] = cnvtxs + cnvtxs += 1 + elseif i <= match[i + 1] + cmap[i + 1] = cmap[match[i + 1] + 1] = cnvtxs + cnvtxs += 1 + end + end + CreateCoarseGraph(ctrl, graph, cnvtxs, match) + return cnvtxs +end + +function Match_2Hop(ctrl::CtrlT, graph::GraphT, perm, match, cnvtxs::Int, nunmatched::Int) + cnvtxs, nunmatched = Match_2HopAny(ctrl, graph, perm, match, cnvtxs, nunmatched, 2) + cnvtxs, nunmatched = Match_2HopAll(ctrl, graph, perm, match, cnvtxs, nunmatched, 64) + if nunmatched > 1.5 * UNMATCHEDFOR2HOP * graph.nvtxs + cnvtxs, nunmatched = Match_2HopAny(ctrl, graph, perm, match, cnvtxs, nunmatched, 3) + end + if nunmatched > 2.0 * UNMATCHEDFOR2HOP * graph.nvtxs + cnvtxs, nunmatched = Match_2HopAny(ctrl, graph, perm, match, cnvtxs, nunmatched, graph.nvtxs) + end + return cnvtxs +end + +function Match_2HopAny(ctrl::CtrlT, graph::GraphT, perm, match, cnvtxs::Int, + nunmatched::Int, maxdegree::Int) + nvtxs = graph.nvtxs + xadj = graph.xadj + adjncy = graph.adjncy + cmap = graph.cmap + colptr = zeros(Int, nvtxs + 1) + for i in 0:(nvtxs - 1) + if match[i + 1] == UNMATCHED && xadj[i + 2] - xadj[i + 1] < maxdegree + for j in xadj[i + 1]:(xadj[i + 2] - 1) + colptr[adjncy[j + 1] + 1] += 1 + end + end + end + for i in 1:(nvtxs - 1) # MAKECSR + colptr[i + 1] += colptr[i] + end + for i in nvtxs:-1:1 + colptr[i + 1] = colptr[i] + end + colptr[1] = 0 + rowind = Vector{Int}(undef, colptr[nvtxs + 1]) + for pi in 0:(nvtxs - 1) + i = perm[pi + 1] + if match[i + 1] == UNMATCHED && xadj[i + 2] - xadj[i + 1] < maxdegree + for j in xadj[i + 1]:(xadj[i + 2] - 1) + c = adjncy[j + 1] + rowind[colptr[c + 1] + 1] = i + colptr[c + 1] += 1 + end + end + end + for i in nvtxs:-1:1 # SHIFTCSR + colptr[i + 1] = colptr[i] + end + colptr[1] = 0 + for pi in 0:(nvtxs - 1) + i = perm[pi + 1] + colptr[i + 2] - colptr[i + 1] < 2 && continue + jj = colptr[i + 2] + j = colptr[i + 1] + while j < jj + if match[rowind[j + 1] + 1] == UNMATCHED + jj -= 1 + while jj > j + if match[rowind[jj + 1] + 1] == UNMATCHED + cmap[rowind[j + 1] + 1] = cmap[rowind[jj + 1] + 1] = cnvtxs + cnvtxs += 1 + match[rowind[j + 1] + 1] = rowind[jj + 1] + match[rowind[jj + 1] + 1] = rowind[j + 1] + nunmatched -= 2 + break + end + jj -= 1 + end + end + j += 1 + end + end + return cnvtxs, nunmatched +end + +function Match_2HopAll(ctrl::CtrlT, graph::GraphT, perm, match, cnvtxs::Int, + nunmatched::Int, maxdegree::Int) + nvtxs = graph.nvtxs + xadj = graph.xadj + adjncy = graph.adjncy + cmap = graph.cmap + mask = IDX_MAX ÷ maxdegree + keys = Vector{Tuple{Int, Int}}(undef, nunmatched) + ncand = 0 + for pi in 0:(nvtxs - 1) + i = perm[pi + 1] + idegree = xadj[i + 2] - xadj[i + 1] + if match[i + 1] == UNMATCHED && idegree > 1 && idegree < maxdegree + k = 0 + for j in xadj[i + 1]:(xadj[i + 2] - 1) + k += adjncy[j + 1] % mask + end + ncand += 1 + keys[ncand] = ((k % mask) * maxdegree + idegree, i) + end + end + ikvsorti!(ncand, keys) + mark = zeros(Int, nvtxs) + for pi in 0:(ncand - 1) + i = keys[pi + 1][2] + match[i + 1] == UNMATCHED || continue + for j in xadj[i + 1]:(xadj[i + 2] - 1) + mark[adjncy[j + 1] + 1] = i + end + for pk in (pi + 1):(ncand - 1) + k = keys[pk + 1][2] + match[k + 1] == UNMATCHED || continue + keys[pi + 1][1] != keys[pk + 1][1] && break + xadj[i + 2] - xadj[i + 1] != xadj[k + 2] - xadj[k + 1] && break + allmatch = true + for jj in xadj[k + 1]:(xadj[k + 2] - 1) + if mark[adjncy[jj + 1] + 1] != i + allmatch = false + break + end + end + if allmatch + cmap[i + 1] = cmap[k + 1] = cnvtxs + cnvtxs += 1 + match[i + 1] = k + match[k + 1] = i + nunmatched -= 2 + break + end + end + end + return cnvtxs, nunmatched +end + +function SetupCoarseGraph(graph::GraphT, cnvtxs::Int) + cgraph = CreateGraph() + cgraph.nvtxs = cnvtxs + cgraph.finer = graph + graph.coarser = cgraph + cgraph.xadj = Vector{Int}(undef, cnvtxs + 1) + cgraph.adjncy = Vector{Int}(undef, graph.nedges) + cgraph.adjwgt = Vector{Int}(undef, graph.nedges) + cgraph.vwgt = Vector{Int}(undef, cnvtxs) + return cgraph +end + +function CreateCoarseGraph(ctrl::CtrlT, graph::GraphT, cnvtxs::Int, match) + mask = HTLENGTH + if cnvtxs < 2 * mask || graph.nedges ÷ graph.nvtxs > mask ÷ 20 + return CreateCoarseGraphNoMask(ctrl, graph, cnvtxs, match) + end + nvtxs = graph.nvtxs + xadj = graph.xadj + for v in 0:(nvtxs - 1) + if xadj[v + 2] - xadj[v + 1] > (mask >> 3) + return CreateCoarseGraphNoMask(ctrl, graph, cnvtxs, match) + end + end + vwgt = graph.vwgt + adjncy = graph.adjncy + adjwgt = graph.adjwgt + cmap = graph.cmap + cgraph = SetupCoarseGraph(graph, cnvtxs) + cxadj = cgraph.xadj + cvwgt = cgraph.vwgt + cadjncy = cgraph.adjncy + cadjwgt = cgraph.adjwgt + htable = fill(-1, mask + 1) + cxadj[1] = 0 + cnvtxs = 0 + cnedges = 0 # base of the current coarse row + for v in 0:(nvtxs - 1) + u = match[v + 1] + u < v && continue + cvwgt[cnvtxs + 1] = vwgt[v + 1] + nedges = 0 + for j in xadj[v + 1]:(xadj[v + 2] - 1) + k = cmap[adjncy[j + 1] + 1] + kk = k & mask + m = htable[kk + 1] + if m == -1 + cadjncy[cnedges + nedges + 1] = k + cadjwgt[cnedges + nedges + 1] = adjwgt[j + 1] + htable[kk + 1] = nedges + nedges += 1 + elseif cadjncy[cnedges + m + 1] == k + cadjwgt[cnedges + m + 1] += adjwgt[j + 1] + else + jj = 0 + while jj < nedges + if cadjncy[cnedges + jj + 1] == k + cadjwgt[cnedges + jj + 1] += adjwgt[j + 1] + break + end + jj += 1 + end + if jj == nedges + cadjncy[cnedges + nedges + 1] = k + cadjwgt[cnedges + nedges + 1] = adjwgt[j + 1] + nedges += 1 + end + end + end + if v != u + cvwgt[cnvtxs + 1] += vwgt[u + 1] + for j in xadj[u + 1]:(xadj[u + 2] - 1) + k = cmap[adjncy[j + 1] + 1] + kk = k & mask + m = htable[kk + 1] + if m == -1 + cadjncy[cnedges + nedges + 1] = k + cadjwgt[cnedges + nedges + 1] = adjwgt[j + 1] + htable[kk + 1] = nedges + nedges += 1 + elseif cadjncy[cnedges + m + 1] == k + cadjwgt[cnedges + m + 1] += adjwgt[j + 1] + else + jj = 0 + while jj < nedges + if cadjncy[cnedges + jj + 1] == k + cadjwgt[cnedges + jj + 1] += adjwgt[j + 1] + break + end + jj += 1 + end + if jj == nedges + cadjncy[cnedges + nedges + 1] = k + cadjwgt[cnedges + nedges + 1] = adjwgt[j + 1] + nedges += 1 + end + end + end + # remove the contracted adjacency weight + jj = htable[(cnvtxs & mask) + 1] + if jj >= 0 && cadjncy[cnedges + jj + 1] != cnvtxs + jj = 0 + while jj < nedges + cadjncy[cnedges + jj + 1] == cnvtxs && break + jj += 1 + end + end + if jj >= 0 && jj < nedges && cadjncy[cnedges + jj + 1] == cnvtxs + nedges -= 1 + cadjncy[cnedges + jj + 1] = cadjncy[cnedges + nedges + 1] + cadjwgt[cnedges + jj + 1] = cadjwgt[cnedges + nedges + 1] + end + end + for j in 0:(nedges - 1) + htable[(cadjncy[cnedges + j + 1] & mask) + 1] = -1 + end + htable[(cnvtxs & mask) + 1] = -1 + cnedges += nedges + cnvtxs += 1 + cxadj[cnvtxs + 1] = cnedges + end + cgraph.nedges = cnedges + SetupGraph_tvwgt!(cgraph) + return nothing +end + +function CreateCoarseGraphNoMask(ctrl::CtrlT, graph::GraphT, cnvtxs::Int, match) + nvtxs = graph.nvtxs + xadj = graph.xadj + vwgt = graph.vwgt + adjncy = graph.adjncy + adjwgt = graph.adjwgt + cmap = graph.cmap + cgraph = SetupCoarseGraph(graph, cnvtxs) + cxadj = cgraph.xadj + cvwgt = cgraph.vwgt + cadjncy = cgraph.adjncy + cadjwgt = cgraph.adjwgt + htable = fill(-1, cnvtxs) + cxadj[1] = 0 + cnvtxs = 0 + cnedges = 0 + for v in 0:(nvtxs - 1) + u = match[v + 1] + u < v && continue + cvwgt[cnvtxs + 1] = vwgt[v + 1] + nedges = 0 + for j in xadj[v + 1]:(xadj[v + 2] - 1) + k = cmap[adjncy[j + 1] + 1] + m = htable[k + 1] + if m == -1 + cadjncy[cnedges + nedges + 1] = k + cadjwgt[cnedges + nedges + 1] = adjwgt[j + 1] + htable[k + 1] = nedges + nedges += 1 + else + cadjwgt[cnedges + m + 1] += adjwgt[j + 1] + end + end + if v != u + cvwgt[cnvtxs + 1] += vwgt[u + 1] + for j in xadj[u + 1]:(xadj[u + 2] - 1) + k = cmap[adjncy[j + 1] + 1] + m = htable[k + 1] + if m == -1 + cadjncy[cnedges + nedges + 1] = k + cadjwgt[cnedges + nedges + 1] = adjwgt[j + 1] + htable[k + 1] = nedges + nedges += 1 + else + cadjwgt[cnedges + m + 1] += adjwgt[j + 1] + end + end + j = htable[cnvtxs + 1] + if j != -1 + nedges -= 1 + cadjncy[cnedges + j + 1] = cadjncy[cnedges + nedges + 1] + cadjwgt[cnedges + j + 1] = cadjwgt[cnedges + nedges + 1] + htable[cnvtxs + 1] = -1 + end + end + for j in 0:(nedges - 1) + htable[cadjncy[cnedges + j + 1] + 1] = -1 + end + cnedges += nedges + cnvtxs += 1 + cxadj[cnvtxs + 1] = cnedges + end + cgraph.nedges = cnedges + SetupGraph_tvwgt!(cgraph) + return nothing +end + + +rpqSeeTopVal(q::RPQ) = q.nnodes == 0 ? -1 : q.heap_val[1] + +# --------------------------------------------------------------------------- +# libmetis/refine.c (2-way edge-cut params; used at the coarsest level only) + +function Allocate2WayPartitionMemory!(graph::GraphT) + graph.pwgts = Vector{Int}(undef, 2) + graph.where_ = Vector{Int}(undef, graph.nvtxs) + graph.bndptr = Vector{Int}(undef, graph.nvtxs) + graph.bndind = Vector{Int}(undef, graph.nvtxs) + graph.id = Vector{Int}(undef, graph.nvtxs) + graph.ed = Vector{Int}(undef, graph.nvtxs) + return nothing +end + +function Compute2WayPartitionParams!(ctrl::CtrlT, graph::GraphT) + nvtxs = graph.nvtxs + xadj = graph.xadj + vwgt = graph.vwgt + adjncy = graph.adjncy + adjwgt = graph.adjwgt + where_ = graph.where_ + id = graph.id + ed = graph.ed + pwgts = graph.pwgts + fill!(pwgts, 0) + bndptr = graph.bndptr + fill!(bndptr, -1) + bndind = graph.bndind + for i in 0:(nvtxs - 1) + pwgts[where_[i + 1] + 1] += vwgt[i + 1] + end + nbnd = 0 + mincut = 0 + for i in 0:(nvtxs - 1) + istart = xadj[i + 1] + iend = xadj[i + 2] + me = where_[i + 1] + tid = 0 + ted = 0 + for j in istart:(iend - 1) + if me == where_[adjncy[j + 1] + 1] + tid += adjwgt[j + 1] + else + ted += adjwgt[j + 1] + end + end + id[i + 1] = tid + ed[i + 1] = ted + if ted > 0 || istart == iend + bndind[nbnd + 1] = i # BNDInsert + bndptr[i + 1] = nbnd + nbnd += 1 + mincut += ted + end + end + graph.mincut = mincut ÷ 2 + graph.nbnd = nbnd + return nothing +end + +# --------------------------------------------------------------------------- +# libmetis/fm.c FM_2WayRefine / FM_2WayCutRefine (ncon = 1) + +FM_2WayRefine!(ctrl::CtrlT, graph::GraphT, ntpwgts, niter::Int) = + FM_2WayCutRefine!(ctrl, graph, ntpwgts, niter) + +function FM_2WayCutRefine!(ctrl::CtrlT, graph::GraphT, ntpwgts, niter::Int) + nvtxs = graph.nvtxs + xadj = graph.xadj + vwgt = graph.vwgt + adjncy = graph.adjncy + adjwgt = graph.adjwgt + where_ = graph.where_ + id = graph.id + ed = graph.ed + pwgts = graph.pwgts + bndptr = graph.bndptr + bndind = graph.bndind + moved = fill(-1, nvtxs) + swaps = Vector{Int}(undef, nvtxs) + perm = Vector{Int}(undef, nvtxs) + tpwgts0 = trunc(Int, graph.tvwgt * ntpwgts[1]) + tpwgts = (tpwgts0, graph.tvwgt - tpwgts0) + limit = trunc(Int, min(max(0.01 * nvtxs, 15.0), 100.0)) + avgvwgt = min((pwgts[1] + pwgts[2]) ÷ 20, 2 * (pwgts[1] + pwgts[2]) ÷ nvtxs) + queues = (rpqCreate(nvtxs), rpqCreate(nvtxs)) + origdiff = abs(tpwgts[1] - pwgts[1]) + for _pass in 0:(niter - 1) + rpqReset(queues[1]) + rpqReset(queues[2]) + mincutorder = -1 + newcut = mincut = initcut = graph.mincut + mindiff = abs(tpwgts[1] - pwgts[1]) + nbnd = graph.nbnd + irandArrayPermute!(nbnd, perm, nbnd, 1) + for ii in 0:(nbnd - 1) + i = perm[ii + 1] + b = bndind[i + 1] + rpqInsert(queues[where_[b + 1] + 1], b, Float32(ed[b + 1] - id[b + 1])) + end + nswaps = 0 + while nswaps < nvtxs + from = tpwgts[1] - pwgts[1] < tpwgts[2] - pwgts[2] ? 0 : 1 + to = (from + 1) % 2 + higain = rpqGetTop(queues[from + 1]) + higain == -1 && break + newcut -= ed[higain + 1] - id[higain + 1] + pwgts[to + 1] += vwgt[higain + 1] + pwgts[from + 1] -= vwgt[higain + 1] + if (newcut < mincut && abs(tpwgts[1] - pwgts[1]) <= origdiff + avgvwgt) || + (newcut == mincut && abs(tpwgts[1] - pwgts[1]) < mindiff) + mincut = newcut + mindiff = abs(tpwgts[1] - pwgts[1]) + mincutorder = nswaps + elseif nswaps - mincutorder > limit + newcut += ed[higain + 1] - id[higain + 1] + pwgts[from + 1] += vwgt[higain + 1] + pwgts[to + 1] -= vwgt[higain + 1] + break + end + where_[higain + 1] = to + moved[higain + 1] = nswaps + swaps[nswaps + 1] = higain + id[higain + 1], ed[higain + 1] = ed[higain + 1], id[higain + 1] + if ed[higain + 1] == 0 && xadj[higain + 1] < xadj[higain + 2] + bndind[bndptr[higain + 1] + 1] = bndind[nbnd] # BNDDelete + bndptr[bndind[nbnd] + 1] = bndptr[higain + 1] + nbnd -= 1 + bndptr[higain + 1] = -1 + end + for j in xadj[higain + 1]:(xadj[higain + 2] - 1) + k = adjncy[j + 1] + kwgt = to == where_[k + 1] ? adjwgt[j + 1] : -adjwgt[j + 1] + id[k + 1] += kwgt + ed[k + 1] -= kwgt + if bndptr[k + 1] != -1 + if ed[k + 1] == 0 + bndind[bndptr[k + 1] + 1] = bndind[nbnd] + bndptr[bndind[nbnd] + 1] = bndptr[k + 1] + nbnd -= 1 + bndptr[k + 1] = -1 + moved[k + 1] == -1 && rpqDelete(queues[where_[k + 1] + 1], k) + elseif moved[k + 1] == -1 + rpqUpdate(queues[where_[k + 1] + 1], k, Float32(ed[k + 1] - id[k + 1])) + end + elseif ed[k + 1] > 0 + bndind[nbnd + 1] = k + bndptr[k + 1] = nbnd + nbnd += 1 + moved[k + 1] == -1 && rpqInsert(queues[where_[k + 1] + 1], k, Float32(ed[k + 1] - id[k + 1])) + end + end + nswaps += 1 + end + for i in 0:(nswaps - 1) + moved[swaps[i + 1] + 1] = -1 + end + nswaps -= 1 + while nswaps > mincutorder + higain = swaps[nswaps + 1] + to = where_[higain + 1] = (where_[higain + 1] + 1) % 2 + id[higain + 1], ed[higain + 1] = ed[higain + 1], id[higain + 1] + if ed[higain + 1] == 0 && bndptr[higain + 1] != -1 && xadj[higain + 1] < xadj[higain + 2] + bndind[bndptr[higain + 1] + 1] = bndind[nbnd] + bndptr[bndind[nbnd] + 1] = bndptr[higain + 1] + nbnd -= 1 + bndptr[higain + 1] = -1 + elseif ed[higain + 1] > 0 && bndptr[higain + 1] == -1 + bndind[nbnd + 1] = higain + bndptr[higain + 1] = nbnd + nbnd += 1 + end + pwgts[to + 1] += vwgt[higain + 1] + pwgts[((to + 1) % 2) + 1] -= vwgt[higain + 1] + for j in xadj[higain + 1]:(xadj[higain + 2] - 1) + k = adjncy[j + 1] + kwgt = to == where_[k + 1] ? adjwgt[j + 1] : -adjwgt[j + 1] + id[k + 1] += kwgt + ed[k + 1] -= kwgt + if bndptr[k + 1] != -1 && ed[k + 1] == 0 + bndind[bndptr[k + 1] + 1] = bndind[nbnd] + bndptr[bndind[nbnd] + 1] = bndptr[k + 1] + nbnd -= 1 + bndptr[k + 1] = -1 + end + if bndptr[k + 1] == -1 && ed[k + 1] > 0 + bndind[nbnd + 1] = k + bndptr[k + 1] = nbnd + nbnd += 1 + end + end + nswaps -= 1 + end + graph.mincut = mincut + graph.nbnd = nbnd + (mincutorder <= 0 || mincut == initcut) && break + end + return nothing +end + +# --------------------------------------------------------------------------- +# libmetis/balance.c (ncon = 1) + +# mcutil.c ComputeLoadImbalanceDiff (ncon = 1, nparts = 2), real_t = Float32 +function ComputeLoadImbalanceDiff(graph::GraphT, pijbm::Vector{Float32}, ub::Float32) + mx = -1.0f0 + for j in 1:2 + cur = Float32(graph.pwgts[j]) * pijbm[j] - ub + cur > mx && (mx = cur) + end + return mx +end + +function Balance2Way!(ctrl::CtrlT, graph::GraphT, ntpwgts) + ComputeLoadImbalanceDiff(graph, ctrl.pijbm, ctrl.ubfactors) <= 0 && return nothing + abs(trunc(Int, ntpwgts[1] * Float32(graph.tvwgt) - graph.pwgts[1])) < + 3 * graph.tvwgt ÷ graph.nvtxs && return nothing + if graph.nbnd > 0 + Bnd2WayBalance!(ctrl, graph, ntpwgts) + else + General2WayBalance!(ctrl, graph, ntpwgts) + end + return nothing +end + +function Bnd2WayBalance!(ctrl::CtrlT, graph::GraphT, ntpwgts) + nvtxs = graph.nvtxs + xadj = graph.xadj + vwgt = graph.vwgt + adjncy = graph.adjncy + adjwgt = graph.adjwgt + where_ = graph.where_ + id = graph.id + ed = graph.ed + pwgts = graph.pwgts + bndptr = graph.bndptr + bndind = graph.bndind + moved = fill(-1, nvtxs) + perm = Vector{Int}(undef, nvtxs) + tpwgts0 = trunc(Int, graph.tvwgt * ntpwgts[1]) + tpwgts = (tpwgts0, graph.tvwgt - tpwgts0) + mindiff = abs(tpwgts[1] - pwgts[1]) + from = pwgts[1] < tpwgts[1] ? 1 : 0 + to = (from + 1) % 2 + queue = rpqCreate(nvtxs) + nbnd = graph.nbnd + irandArrayPermute!(nbnd, perm, nbnd ÷ 5, 1) + for ii in 0:(nbnd - 1) + i = perm[ii + 1] + b = bndind[i + 1] + if where_[b + 1] == from && vwgt[b + 1] <= mindiff + rpqInsert(queue, b, Float32(ed[b + 1] - id[b + 1])) + end + end + mincut = graph.mincut + for _ in 0:(nvtxs - 1) + higain = rpqGetTop(queue) + higain == -1 && break + pwgts[to + 1] + vwgt[higain + 1] > tpwgts[to + 1] && break + mincut -= ed[higain + 1] - id[higain + 1] + pwgts[to + 1] += vwgt[higain + 1] + pwgts[from + 1] -= vwgt[higain + 1] + where_[higain + 1] = to + moved[higain + 1] = 1 + id[higain + 1], ed[higain + 1] = ed[higain + 1], id[higain + 1] + if ed[higain + 1] == 0 && xadj[higain + 1] < xadj[higain + 2] + bndind[bndptr[higain + 1] + 1] = bndind[nbnd] + bndptr[bndind[nbnd] + 1] = bndptr[higain + 1] + nbnd -= 1 + bndptr[higain + 1] = -1 + end + for j in xadj[higain + 1]:(xadj[higain + 2] - 1) + k = adjncy[j + 1] + kwgt = to == where_[k + 1] ? adjwgt[j + 1] : -adjwgt[j + 1] + id[k + 1] += kwgt + ed[k + 1] -= kwgt + if bndptr[k + 1] != -1 + if ed[k + 1] == 0 + bndind[bndptr[k + 1] + 1] = bndind[nbnd] + bndptr[bndind[nbnd] + 1] = bndptr[k + 1] + nbnd -= 1 + bndptr[k + 1] = -1 + if moved[k + 1] == -1 && where_[k + 1] == from && vwgt[k + 1] <= mindiff + rpqDelete(queue, k) + end + elseif moved[k + 1] == -1 && where_[k + 1] == from && vwgt[k + 1] <= mindiff + rpqUpdate(queue, k, Float32(ed[k + 1] - id[k + 1])) + end + elseif ed[k + 1] > 0 + bndind[nbnd + 1] = k + bndptr[k + 1] = nbnd + nbnd += 1 + if moved[k + 1] == -1 && where_[k + 1] == from && vwgt[k + 1] <= mindiff + rpqInsert(queue, k, Float32(ed[k + 1] - id[k + 1])) + end + end + end + end + graph.mincut = mincut + graph.nbnd = nbnd + return nothing +end + +function General2WayBalance!(ctrl::CtrlT, graph::GraphT, ntpwgts) + nvtxs = graph.nvtxs + xadj = graph.xadj + vwgt = graph.vwgt + adjncy = graph.adjncy + adjwgt = graph.adjwgt + where_ = graph.where_ + id = graph.id + ed = graph.ed + pwgts = graph.pwgts + bndptr = graph.bndptr + bndind = graph.bndind + moved = fill(-1, nvtxs) + perm = Vector{Int}(undef, nvtxs) + tpwgts0 = trunc(Int, graph.tvwgt * ntpwgts[1]) + tpwgts = (tpwgts0, graph.tvwgt - tpwgts0) + mindiff = abs(tpwgts[1] - pwgts[1]) + from = pwgts[1] < tpwgts[1] ? 1 : 0 + to = (from + 1) % 2 + queue = rpqCreate(nvtxs) + irandArrayPermute!(nvtxs, perm, nvtxs ÷ 5, 1) + for ii in 0:(nvtxs - 1) + i = perm[ii + 1] + if where_[i + 1] == from && vwgt[i + 1] <= mindiff + rpqInsert(queue, i, Float32(ed[i + 1] - id[i + 1])) + end + end + mincut = graph.mincut + nbnd = graph.nbnd + for _ in 0:(nvtxs - 1) + higain = rpqGetTop(queue) + higain == -1 && break + pwgts[to + 1] + vwgt[higain + 1] > tpwgts[to + 1] && break + mincut -= ed[higain + 1] - id[higain + 1] + pwgts[to + 1] += vwgt[higain + 1] + pwgts[from + 1] -= vwgt[higain + 1] + where_[higain + 1] = to + moved[higain + 1] = 1 + id[higain + 1], ed[higain + 1] = ed[higain + 1], id[higain + 1] + if ed[higain + 1] == 0 && bndptr[higain + 1] != -1 && xadj[higain + 1] < xadj[higain + 2] + bndind[bndptr[higain + 1] + 1] = bndind[nbnd] + bndptr[bndind[nbnd] + 1] = bndptr[higain + 1] + nbnd -= 1 + bndptr[higain + 1] = -1 + end + if ed[higain + 1] > 0 && bndptr[higain + 1] == -1 + bndind[nbnd + 1] = higain + bndptr[higain + 1] = nbnd + nbnd += 1 + end + for j in xadj[higain + 1]:(xadj[higain + 2] - 1) + k = adjncy[j + 1] + kwgt = to == where_[k + 1] ? adjwgt[j + 1] : -adjwgt[j + 1] + id[k + 1] += kwgt + ed[k + 1] -= kwgt + if moved[k + 1] == -1 && where_[k + 1] == from && vwgt[k + 1] <= mindiff + rpqUpdate(queue, k, Float32(ed[k + 1] - id[k + 1])) + end + if ed[k + 1] == 0 && bndptr[k + 1] != -1 + bndind[bndptr[k + 1] + 1] = bndind[nbnd] + bndptr[bndind[nbnd] + 1] = bndptr[k + 1] + nbnd -= 1 + bndptr[k + 1] = -1 + elseif ed[k + 1] > 0 && bndptr[k + 1] == -1 + bndind[nbnd + 1] = k + bndptr[k + 1] = nbnd + nbnd += 1 + end + end + end + graph.mincut = mincut + graph.nbnd = nbnd + return nothing +end + + +# --------------------------------------------------------------------------- +# libmetis/initpart.c (iptype = EDGE path) + +function RandomBisection!(ctrl::CtrlT, graph::GraphT, ntpwgts, niparts::Int) + nvtxs = graph.nvtxs + vwgt = graph.vwgt + Allocate2WayPartitionMemory!(graph) + where_ = graph.where_ + bestwhere = Vector{Int}(undef, nvtxs) + perm = Vector{Int}(undef, nvtxs) + zeromaxpwgt = trunc(Int, ctrl.ubfactors * Float32(graph.tvwgt) * ntpwgts[1]) + bestcut = 0 + for inbfs in 0:(niparts - 1) + fill!(where_, 1) + if inbfs > 0 + irandArrayPermute!(nvtxs, perm, nvtxs ÷ 2, 1) + pw0 = 0 + for ii in 0:(nvtxs - 1) + i = perm[ii + 1] + if pw0 + vwgt[i + 1] < zeromaxpwgt + where_[i + 1] = 0 + pw0 += vwgt[i + 1] + pw0 > zeromaxpwgt && break + end + end + end + Compute2WayPartitionParams!(ctrl, graph) + Balance2Way!(ctrl, graph, ntpwgts) + FM_2WayRefine!(ctrl, graph, ntpwgts, 4) + if inbfs == 0 || bestcut > graph.mincut + bestcut = graph.mincut + copyto!(bestwhere, where_) + bestcut == 0 && break + end + end + graph.mincut = bestcut + copyto!(where_, bestwhere) + return nothing +end + +function GrowBisection!(ctrl::CtrlT, graph::GraphT, ntpwgts, niparts::Int) + nvtxs = graph.nvtxs + xadj = graph.xadj + vwgt = graph.vwgt + adjncy = graph.adjncy + Allocate2WayPartitionMemory!(graph) + where_ = graph.where_ + bestwhere = Vector{Int}(undef, nvtxs) + queue = Vector{Int}(undef, nvtxs) + touched = Vector{Int}(undef, nvtxs) + onemaxpwgt = trunc(Int, ctrl.ubfactors * Float32(graph.tvwgt) * ntpwgts[2]) + oneminpwgt = trunc(Int, (1.0 / ctrl.ubfactors) * graph.tvwgt * ntpwgts[2]) + bestcut = 0 + for inbfs in 0:(niparts - 1) + fill!(where_, 1) + fill!(touched, 0) + pw0 = 0 + pw1 = graph.tvwgt + queue[1] = irandInRange(nvtxs) + touched[queue[1] + 1] = 1 + first = 0 + last = 1 + nleft = nvtxs - 1 + drain = false + while true + if first == last # empty: disconnected + (nleft == 0 || drain) && break + k = irandInRange(nleft) + i = 0 + while i < nvtxs + if touched[i + 1] == 0 + k == 0 && break + k -= 1 + end + i += 1 + end + queue[1] = i + touched[i + 1] = 1 + first = 0 + last = 1 + nleft -= 1 + end + i = queue[first + 1] + first += 1 + if pw0 > 0 && pw1 - vwgt[i + 1] < oneminpwgt + drain = true + continue + end + where_[i + 1] = 0 + pw0 += vwgt[i + 1] + pw1 -= vwgt[i + 1] + pw1 <= onemaxpwgt && break + drain = false + for j in xadj[i + 1]:(xadj[i + 2] - 1) + k = adjncy[j + 1] + if touched[k + 1] == 0 + queue[last + 1] = k + last += 1 + touched[k + 1] = 1 + nleft -= 1 + end + end + end + pw1 == 0 && (where_[irandInRange(nvtxs) + 1] = 1) + pw0 == 0 && (where_[irandInRange(nvtxs) + 1] = 0) + Compute2WayPartitionParams!(ctrl, graph) + Balance2Way!(ctrl, graph, ntpwgts) + FM_2WayRefine!(ctrl, graph, ntpwgts, ctrl.niter) + if inbfs == 0 || bestcut > graph.mincut + bestcut = graph.mincut + copyto!(bestwhere, where_) + bestcut == 0 && break + end + end + graph.mincut = bestcut + copyto!(where_, bestwhere) + return nothing +end + +function InitSeparator!(ctrl::CtrlT, graph::GraphT, niparts::Int) + ntpwgts = (0.5f0, 0.5f0) + # Setup2WayBalMultipliers + ctrl.pijbm = Float32[graph.invtvwgt / ntpwgts[1], graph.invtvwgt / ntpwgts[2]] + if graph.nedges == 0 + RandomBisection!(ctrl, graph, ntpwgts, niparts) + else + GrowBisection!(ctrl, graph, ntpwgts, niparts) + end + Compute2WayPartitionParams!(ctrl, graph) + ConstructSeparator!(ctrl, graph) + DBG[] && println("Initial Sep: ", graph.mincut) + return nothing +end + +# --------------------------------------------------------------------------- +# libmetis/srefine.c + +function Allocate2WayNodePartitionMemory!(graph::GraphT) + graph.pwgts = Vector{Int}(undef, 3) + graph.where_ = Vector{Int}(undef, graph.nvtxs) + graph.bndptr = Vector{Int}(undef, graph.nvtxs) + graph.bndind = Vector{Int}(undef, graph.nvtxs) + graph.nrinfo = Vector{NTuple{2, Int}}(undef, graph.nvtxs) + return nothing +end + +function Compute2WayNodePartitionParams!(ctrl::CtrlT, graph::GraphT) + nvtxs = graph.nvtxs + xadj = graph.xadj + vwgt = graph.vwgt + adjncy = graph.adjncy + where_ = graph.where_ + rinfo = graph.nrinfo + pwgts = graph.pwgts + fill!(pwgts, 0) + bndind = graph.bndind + bndptr = graph.bndptr + fill!(bndptr, -1) + nbnd = 0 + for i in 0:(nvtxs - 1) + me = where_[i + 1] + pwgts[me + 1] += vwgt[i + 1] + if me == 2 + bndind[nbnd + 1] = i + bndptr[i + 1] = nbnd + nbnd += 1 + e0 = 0 + e1 = 0 + for j in xadj[i + 1]:(xadj[i + 2] - 1) + other = where_[adjncy[j + 1] + 1] + if other != 2 + other == 0 ? (e0 += vwgt[adjncy[j + 1] + 1]) : (e1 += vwgt[adjncy[j + 1] + 1]) + end + end + rinfo[i + 1] = (e0, e1) + end + end + graph.mincut = pwgts[3] + graph.nbnd = nbnd + return nothing +end + +function Project2WayNodePartition!(ctrl::CtrlT, graph::GraphT) + cgraph = graph.coarser::GraphT + cwhere = cgraph.where_ + nvtxs = graph.nvtxs + cmap = graph.cmap + Allocate2WayNodePartitionMemory!(graph) + where_ = graph.where_ + for i in 0:(nvtxs - 1) + where_[i + 1] = cwhere[cmap[i + 1] + 1] + end + graph.coarser = nothing + Compute2WayNodePartitionParams!(ctrl, graph) + return nothing +end + +function Refine2WayNode!(ctrl::CtrlT, orggraph::GraphT, graph::GraphT) + if graph === orggraph + Compute2WayNodePartitionParams!(ctrl, graph) + else + while true + graph = graph.finer::GraphT + Project2WayNodePartition!(ctrl, graph) + FM_2WayNodeBalance!(ctrl, graph) + if ctrl.rtype == METIS_RTYPE_SEP2SIDED + FM_2WayNodeRefine2Sided!(ctrl, graph, ctrl.niter) + else + FM_2WayNodeRefine1Sided!(ctrl, graph, ctrl.niter) + end + graph === orggraph && break + end + end + return nothing +end + +# --------------------------------------------------------------------------- +# libmetis/separator.c ConstructSeparator (iptype = EDGE) + +function ConstructSeparator!(ctrl::CtrlT, graph::GraphT) + nvtxs = graph.nvtxs + xadj = graph.xadj + nbnd = graph.nbnd + bndind = graph.bndind + where_ = copy(graph.where_) + for i in 0:(nbnd - 1) + j = bndind[i + 1] + xadj[j + 2] - xadj[j + 1] > 0 && (where_[j + 1] = 2) + end + Allocate2WayNodePartitionMemory!(graph) + copyto!(graph.where_, where_) + Compute2WayNodePartitionParams!(ctrl, graph) + FM_2WayNodeRefine2Sided!(ctrl, graph, 1) + FM_2WayNodeRefine1Sided!(ctrl, graph, 4) + return nothing +end + + +# --------------------------------------------------------------------------- +# libmetis/sfm.c — node-separator FM + +function FM_2WayNodeRefine2Sided!(ctrl::CtrlT, graph::GraphT, niter::Int) + nvtxs = graph.nvtxs + xadj = graph.xadj + adjncy = graph.adjncy + vwgt = graph.vwgt + bndind = graph.bndind + bndptr = graph.bndptr + where_ = graph.where_ + pwgts = graph.pwgts + rinfo = graph.nrinfo + queues = (rpqCreate(nvtxs), rpqCreate(nvtxs)) + moved = Vector{Int}(undef, nvtxs) + swaps = Vector{Int}(undef, nvtxs) + mptr = Vector{Int}(undef, nvtxs + 1) + mind = Vector{Int}(undef, 2 * nvtxs) + mult = Float32(0.5 * ctrl.ubfactors) + badmaxpwgt = trunc(Int, mult * (pwgts[1] + pwgts[2] + pwgts[3])) + for pass in 0:(niter - 1) + fill!(moved, -1) + rpqReset(queues[1]) + rpqReset(queues[2]) + mincutorder = -1 + initcut = mincut = graph.mincut + nbnd = graph.nbnd + irandArrayPermute!(nbnd, swaps, nbnd, 1) + for ii in 0:(nbnd - 1) + i = bndind[swaps[ii + 1] + 1] + rpqInsert(queues[1], i, Float32(vwgt[i + 1] - rinfo[i + 1][2])) + rpqInsert(queues[2], i, Float32(vwgt[i + 1] - rinfo[i + 1][1])) + end + limit = ctrl.compress ? min(5 * nbnd, 400) : min(2 * nbnd, 300) + mptr[1] = 0 + nmind = 0 + mindiff = abs(pwgts[1] - pwgts[2]) + to = pwgts[1] < pwgts[2] ? 0 : 1 + nswaps = 0 + while nswaps < nvtxs + u0 = rpqSeeTopVal(queues[1]) + u1 = rpqSeeTopVal(queues[2]) + if u0 != -1 && u1 != -1 + g0 = vwgt[u0 + 1] - rinfo[u0 + 1][2] + g1 = vwgt[u1 + 1] - rinfo[u1 + 1][1] + to = g0 > g1 ? 0 : (g0 < g1 ? 1 : pass % 2) + u = (u0, u1) + if pwgts[to + 1] + vwgt[u[to + 1] + 1] > badmaxpwgt + to = (to + 1) % 2 + end + elseif u0 == -1 && u1 == -1 + break + elseif u0 != -1 && pwgts[1] + vwgt[u0 + 1] <= badmaxpwgt + to = 0 + elseif u1 != -1 && pwgts[2] + vwgt[u1 + 1] <= badmaxpwgt + to = 1 + else + break + end + other = (to + 1) % 2 + higain = rpqGetTop(queues[to + 1]) + moved[higain + 1] == -1 && rpqDelete(queues[other + 1], higain) + if nmind + xadj[higain + 2] - xadj[higain + 1] >= 2 * nvtxs - 1 + break + end + eo = rinfo[higain + 1][other + 1] + pwgts[3] -= vwgt[higain + 1] - eo + newdiff = abs(pwgts[to + 1] + vwgt[higain + 1] - (pwgts[other + 1] - eo)) + if pwgts[3] < mincut || (pwgts[3] == mincut && newdiff < mindiff) + mincut = pwgts[3] + mincutorder = nswaps + mindiff = newdiff + elseif nswaps - mincutorder > 2 * limit || + (nswaps - mincutorder > limit && pwgts[3] > 1.10 * mincut) + pwgts[3] += vwgt[higain + 1] - eo + break + end + bndind[bndptr[higain + 1] + 1] = bndind[nbnd] # BNDDelete + bndptr[bndind[nbnd] + 1] = bndptr[higain + 1] + nbnd -= 1 + bndptr[higain + 1] = -1 + pwgts[to + 1] += vwgt[higain + 1] + where_[higain + 1] = to + moved[higain + 1] = nswaps + swaps[nswaps + 1] = higain + for j in xadj[higain + 1]:(xadj[higain + 2] - 1) + k = adjncy[j + 1] + if where_[k + 1] == 2 + oldgain = vwgt[k + 1] - rinfo[k + 1][to + 1] + rinfo[k + 1] = to == 0 ? (rinfo[k + 1][1] + vwgt[higain + 1], rinfo[k + 1][2]) : + (rinfo[k + 1][1], rinfo[k + 1][2] + vwgt[higain + 1]) + if moved[k + 1] == -1 || moved[k + 1] == -(2 + other) + rpqUpdate(queues[other + 1], k, Float32(oldgain - vwgt[higain + 1])) + end + elseif where_[k + 1] == other + bndind[nbnd + 1] = k # BNDInsert + bndptr[k + 1] = nbnd + nbnd += 1 + nmind += 1 + mind[nmind] = k + where_[k + 1] = 2 + pwgts[other + 1] -= vwgt[k + 1] + e0 = 0 + e1 = 0 + for jj in xadj[k + 1]:(xadj[k + 2] - 1) + kk = adjncy[jj + 1] + if where_[kk + 1] != 2 + where_[kk + 1] == 0 ? (e0 += vwgt[kk + 1]) : (e1 += vwgt[kk + 1]) + else + oldgain = vwgt[kk + 1] - rinfo[kk + 1][other + 1] + rinfo[kk + 1] = other == 0 ? (rinfo[kk + 1][1] - vwgt[k + 1], rinfo[kk + 1][2]) : + (rinfo[kk + 1][1], rinfo[kk + 1][2] - vwgt[k + 1]) + if moved[kk + 1] == -1 || moved[kk + 1] == -(2 + to) + rpqUpdate(queues[to + 1], kk, Float32(oldgain + vwgt[k + 1])) + end + end + end + rinfo[k + 1] = (e0, e1) + if moved[k + 1] == -1 + rpqInsert(queues[to + 1], k, Float32(vwgt[k + 1] - (other == 0 ? e0 : e1))) + moved[k + 1] = -(2 + to) + end + end + end + mptr[nswaps + 2] = nmind + nswaps += 1 + end + nswaps -= 1 + while nswaps > mincutorder + higain = swaps[nswaps + 1] + to = where_[higain + 1] + other = (to + 1) % 2 + pwgts[3] += vwgt[higain + 1] + pwgts[to + 1] -= vwgt[higain + 1] + where_[higain + 1] = 2 + bndind[nbnd + 1] = higain + bndptr[higain + 1] = nbnd + nbnd += 1 + e0 = 0 + e1 = 0 + for j in xadj[higain + 1]:(xadj[higain + 2] - 1) + k = adjncy[j + 1] + if where_[k + 1] == 2 + rinfo[k + 1] = to == 0 ? (rinfo[k + 1][1] - vwgt[higain + 1], rinfo[k + 1][2]) : + (rinfo[k + 1][1], rinfo[k + 1][2] - vwgt[higain + 1]) + else + where_[k + 1] == 0 ? (e0 += vwgt[k + 1]) : (e1 += vwgt[k + 1]) + end + end + rinfo[higain + 1] = (e0, e1) + for j in mptr[nswaps + 1]:(mptr[nswaps + 2] - 1) + k = mind[j + 1] + where_[k + 1] = other + pwgts[other + 1] += vwgt[k + 1] + pwgts[3] -= vwgt[k + 1] + bndind[bndptr[k + 1] + 1] = bndind[nbnd] + bndptr[bndind[nbnd] + 1] = bndptr[k + 1] + nbnd -= 1 + bndptr[k + 1] = -1 + for jj in xadj[k + 1]:(xadj[k + 2] - 1) + kk = adjncy[jj + 1] + if where_[kk + 1] == 2 + rinfo[kk + 1] = other == 0 ? (rinfo[kk + 1][1] + vwgt[k + 1], rinfo[kk + 1][2]) : + (rinfo[kk + 1][1], rinfo[kk + 1][2] + vwgt[k + 1]) + end + end + end + nswaps -= 1 + end + graph.mincut = mincut + graph.nbnd = nbnd + (mincutorder == -1 || mincut >= initcut) && break + end + return nothing +end + +function FM_2WayNodeRefine1Sided!(ctrl::CtrlT, graph::GraphT, niter::Int) + nvtxs = graph.nvtxs + xadj = graph.xadj + adjncy = graph.adjncy + vwgt = graph.vwgt + bndind = graph.bndind + bndptr = graph.bndptr + where_ = graph.where_ + pwgts = graph.pwgts + rinfo = graph.nrinfo + queue = rpqCreate(nvtxs) + swaps = Vector{Int}(undef, nvtxs) + mptr = Vector{Int}(undef, nvtxs + 1) + mind = Vector{Int}(undef, 2 * nvtxs) + mult = Float32(0.5 * ctrl.ubfactors) + badmaxpwgt = trunc(Int, mult * (pwgts[1] + pwgts[2] + pwgts[3])) + to = pwgts[1] < pwgts[2] ? 1 : 0 + for pass in 0:(2 * niter - 1) + other = to + to = (to + 1) % 2 + rpqReset(queue) + mincutorder = -1 + initcut = mincut = graph.mincut + nbnd = graph.nbnd + irandArrayPermute!(nbnd, swaps, nbnd, 1) + for ii in 0:(nbnd - 1) + i = bndind[swaps[ii + 1] + 1] + rpqInsert(queue, i, Float32(vwgt[i + 1] - rinfo[i + 1][other + 1])) + end + limit = ctrl.compress ? min(5 * nbnd, 500) : min(3 * nbnd, 300) + mptr[1] = 0 + nmind = 0 + mindiff = abs(pwgts[1] - pwgts[2]) + nswaps = 0 + while nswaps < nvtxs + higain = rpqGetTop(queue) + higain == -1 && break + if nmind + xadj[higain + 2] - xadj[higain + 1] >= 2 * nvtxs - 1 + break + end + pwgts[to + 1] + vwgt[higain + 1] > badmaxpwgt && break + eo = rinfo[higain + 1][other + 1] + pwgts[3] -= vwgt[higain + 1] - eo + newdiff = abs(pwgts[to + 1] + vwgt[higain + 1] - (pwgts[other + 1] - eo)) + if pwgts[3] < mincut || (pwgts[3] == mincut && newdiff < mindiff) + mincut = pwgts[3] + mincutorder = nswaps + mindiff = newdiff + elseif nswaps - mincutorder > 3 * limit || + (nswaps - mincutorder > limit && pwgts[3] > 1.10 * mincut) + pwgts[3] += vwgt[higain + 1] - eo + break + end + bndind[bndptr[higain + 1] + 1] = bndind[nbnd] + bndptr[bndind[nbnd] + 1] = bndptr[higain + 1] + nbnd -= 1 + bndptr[higain + 1] = -1 + pwgts[to + 1] += vwgt[higain + 1] + where_[higain + 1] = to + swaps[nswaps + 1] = higain + for j in xadj[higain + 1]:(xadj[higain + 2] - 1) + k = adjncy[j + 1] + if where_[k + 1] == 2 + rinfo[k + 1] = to == 0 ? (rinfo[k + 1][1] + vwgt[higain + 1], rinfo[k + 1][2]) : + (rinfo[k + 1][1], rinfo[k + 1][2] + vwgt[higain + 1]) + elseif where_[k + 1] == other + bndind[nbnd + 1] = k + bndptr[k + 1] = nbnd + nbnd += 1 + nmind += 1 + mind[nmind] = k + where_[k + 1] = 2 + pwgts[other + 1] -= vwgt[k + 1] + e0 = 0 + e1 = 0 + for jj in xadj[k + 1]:(xadj[k + 2] - 1) + kk = adjncy[jj + 1] + if where_[kk + 1] != 2 + where_[kk + 1] == 0 ? (e0 += vwgt[kk + 1]) : (e1 += vwgt[kk + 1]) + else + rinfo[kk + 1] = other == 0 ? (rinfo[kk + 1][1] - vwgt[k + 1], rinfo[kk + 1][2]) : + (rinfo[kk + 1][1], rinfo[kk + 1][2] - vwgt[k + 1]) + rpqUpdate(queue, kk, Float32(vwgt[kk + 1] - rinfo[kk + 1][other + 1])) + end + end + rinfo[k + 1] = (e0, e1) + rpqInsert(queue, k, Float32(vwgt[k + 1] - (other == 0 ? e0 : e1))) + end + end + mptr[nswaps + 2] = nmind + nswaps += 1 + end + nswaps -= 1 + while nswaps > mincutorder + higain = swaps[nswaps + 1] + pwgts[3] += vwgt[higain + 1] + pwgts[to + 1] -= vwgt[higain + 1] + where_[higain + 1] = 2 + bndind[nbnd + 1] = higain + bndptr[higain + 1] = nbnd + nbnd += 1 + e0 = 0 + e1 = 0 + for j in xadj[higain + 1]:(xadj[higain + 2] - 1) + k = adjncy[j + 1] + if where_[k + 1] == 2 + rinfo[k + 1] = to == 0 ? (rinfo[k + 1][1] - vwgt[higain + 1], rinfo[k + 1][2]) : + (rinfo[k + 1][1], rinfo[k + 1][2] - vwgt[higain + 1]) + else + where_[k + 1] == 0 ? (e0 += vwgt[k + 1]) : (e1 += vwgt[k + 1]) + end + end + rinfo[higain + 1] = (e0, e1) + for j in mptr[nswaps + 1]:(mptr[nswaps + 2] - 1) + k = mind[j + 1] + where_[k + 1] = other + pwgts[other + 1] += vwgt[k + 1] + pwgts[3] -= vwgt[k + 1] + bndind[bndptr[k + 1] + 1] = bndind[nbnd] + bndptr[bndind[nbnd] + 1] = bndptr[k + 1] + nbnd -= 1 + bndptr[k + 1] = -1 + for jj in xadj[k + 1]:(xadj[k + 2] - 1) + kk = adjncy[jj + 1] + if where_[kk + 1] == 2 + rinfo[kk + 1] = other == 0 ? (rinfo[kk + 1][1] + vwgt[k + 1], rinfo[kk + 1][2]) : + (rinfo[kk + 1][1], rinfo[kk + 1][2] + vwgt[k + 1]) + end + end + end + nswaps -= 1 + end + graph.mincut = mincut + graph.nbnd = nbnd + pass % 2 == 1 && (mincutorder == -1 || mincut >= initcut) && break + end + return nothing +end + +function FM_2WayNodeBalance!(ctrl::CtrlT, graph::GraphT) + nvtxs = graph.nvtxs + xadj = graph.xadj + adjncy = graph.adjncy + vwgt = graph.vwgt + bndind = graph.bndind + bndptr = graph.bndptr + where_ = graph.where_ + pwgts = graph.pwgts + rinfo = graph.nrinfo + mult = Float32(0.5 * ctrl.ubfactors) + badmaxpwgt = trunc(Int, mult * (pwgts[1] + pwgts[2])) + max(pwgts[1], pwgts[2]) < badmaxpwgt && return nothing + abs(pwgts[1] - pwgts[2]) < 3 * graph.tvwgt ÷ nvtxs && return nothing + to = pwgts[1] < pwgts[2] ? 0 : 1 + other = (to + 1) % 2 + queue = rpqCreate(nvtxs) + perm = Vector{Int}(undef, nvtxs) + moved = fill(-1, nvtxs) + nbnd = graph.nbnd + irandArrayPermute!(nbnd, perm, nbnd, 1) + for ii in 0:(nbnd - 1) + i = bndind[perm[ii + 1] + 1] + rpqInsert(queue, i, Float32(vwgt[i + 1] - rinfo[i + 1][other + 1])) + end + for _ in 0:(nvtxs - 1) + higain = rpqGetTop(queue) + higain == -1 && break + moved[higain + 1] = 1 + gain = vwgt[higain + 1] - rinfo[higain + 1][other + 1] + badmaxpwgt = trunc(Int, mult * (pwgts[1] + pwgts[2])) + pwgts[to + 1] > pwgts[other + 1] && break + gain < 0 && pwgts[other + 1] < badmaxpwgt && break + pwgts[to + 1] + vwgt[higain + 1] > badmaxpwgt && continue + pwgts[3] -= gain + bndind[bndptr[higain + 1] + 1] = bndind[nbnd] + bndptr[bndind[nbnd] + 1] = bndptr[higain + 1] + nbnd -= 1 + bndptr[higain + 1] = -1 + pwgts[to + 1] += vwgt[higain + 1] + where_[higain + 1] = to + for j in xadj[higain + 1]:(xadj[higain + 2] - 1) + k = adjncy[j + 1] + if where_[k + 1] == 2 + rinfo[k + 1] = to == 0 ? (rinfo[k + 1][1] + vwgt[higain + 1], rinfo[k + 1][2]) : + (rinfo[k + 1][1], rinfo[k + 1][2] + vwgt[higain + 1]) + elseif where_[k + 1] == other + bndind[nbnd + 1] = k + bndptr[k + 1] = nbnd + nbnd += 1 + where_[k + 1] = 2 + pwgts[other + 1] -= vwgt[k + 1] + e0 = 0 + e1 = 0 + for jj in xadj[k + 1]:(xadj[k + 2] - 1) + kk = adjncy[jj + 1] + if where_[kk + 1] != 2 + where_[kk + 1] == 0 ? (e0 += vwgt[kk + 1]) : (e1 += vwgt[kk + 1]) + else + oldgain = vwgt[kk + 1] - rinfo[kk + 1][other + 1] + rinfo[kk + 1] = other == 0 ? (rinfo[kk + 1][1] - vwgt[k + 1], rinfo[kk + 1][2]) : + (rinfo[kk + 1][1], rinfo[kk + 1][2] - vwgt[k + 1]) + moved[kk + 1] == -1 && rpqUpdate(queue, kk, Float32(oldgain + vwgt[k + 1])) + end + end + rinfo[k + 1] = (e0, e1) + rpqInsert(queue, k, Float32(vwgt[k + 1] - (other == 0 ? e0 : e1))) + end + end + end + graph.mincut = pwgts[3] + graph.nbnd = nbnd + return nothing +end + + +# --------------------------------------------------------------------------- +# libmetis/ometis.c — bisection drivers, split, MMD leaves, NodeND + +function MlevelNodeBisectionMultiple!(ctrl::CtrlT, graph::GraphT) + if ctrl.nseps == 1 || graph.nvtxs < (ctrl.compress ? 1000 : 2000) + MlevelNodeBisectionL2!(ctrl, graph, LARGENIPARTS) + return nothing + end + bestwhere = Vector{Int}(undef, graph.nvtxs) + mincut = graph.tvwgt + for i in 0:(ctrl.nseps - 1) + MlevelNodeBisectionL2!(ctrl, graph, LARGENIPARTS) + if i == 0 || graph.mincut < mincut + mincut = graph.mincut + i < ctrl.nseps - 1 && copyto!(bestwhere, graph.where_) + end + mincut == 0 && break + end + if mincut != graph.mincut + copyto!(graph.where_, bestwhere) + Compute2WayNodePartitionParams!(ctrl, graph) + end + return nothing +end + +function MlevelNodeBisectionL2!(ctrl::CtrlT, graph::GraphT, niparts::Int) + if graph.nvtxs < 5000 + MlevelNodeBisectionL1!(ctrl, graph, niparts) + return nothing + end + nruns = 5 + ctrl.CoarsenTo = max(100, graph.nvtxs ÷ 30) + t = @elapsed (cgraph = CoarsenGraphNlevels(ctrl, graph, 4)) + prof!(:coarsen, t) + bestwhere = Vector{Int}(undef, cgraph.nvtxs) + mincut = graph.tvwgt + for i in 0:(nruns - 1) + MlevelNodeBisectionL1!(ctrl, cgraph, trunc(Int, 0.7 * niparts)) + if i == 0 || cgraph.mincut < mincut + mincut = cgraph.mincut + i < nruns - 1 && copyto!(bestwhere, cgraph.where_) + end + mincut == 0 && break + end + if mincut != cgraph.mincut + copyto!(cgraph.where_, bestwhere) + Compute2WayNodePartitionParams!(ctrl, cgraph) + end + t = @elapsed Refine2WayNode!(ctrl, graph, cgraph) + prof!(:refine_l2, t) + return nothing +end + +function MlevelNodeBisectionL1!(ctrl::CtrlT, graph::GraphT, niparts::Int) + ctrl.CoarsenTo = graph.nvtxs ÷ 8 + ctrl.CoarsenTo > 100 && (ctrl.CoarsenTo = 100) + ctrl.CoarsenTo < 40 && (ctrl.CoarsenTo = 40) + t = @elapsed (cgraph = CoarsenGraph(ctrl, graph)) + prof!(:coarsen, t) + niparts = max(1, cgraph.nvtxs <= ctrl.CoarsenTo ? niparts ÷ 2 : niparts) + t = @elapsed InitSeparator!(ctrl, cgraph, niparts) + prof!(:initsep, t) + t = @elapsed Refine2WayNode!(ctrl, graph, cgraph) + prof!(:refine, t) + return nothing +end + +# SplitGraphOrder: tri-section into the left/right subgraphs (separator out) +function SplitGraphOrder(ctrl::CtrlT, graph::GraphT) + nvtxs = graph.nvtxs + xadj = graph.xadj + vwgt = graph.vwgt + adjncy = graph.adjncy + label = graph.label + where_ = graph.where_ + bndptr = graph.bndptr + bndind = graph.bndind + rename = Vector{Int}(undef, nvtxs) + snvtxs = [0, 0, 0] + snedges = [0, 0, 0] + for i in 0:(nvtxs - 1) + k = where_[i + 1] + rename[i + 1] = snvtxs[k + 1] + snvtxs[k + 1] += 1 + snedges[k + 1] += xadj[i + 2] - xadj[i + 1] + end + lgraph = SetupSplitGraph(graph, snvtxs[1], snedges[1]) + rgraph = SetupSplitGraph(graph, snvtxs[2], snedges[2]) + # mark the boundary vertices of the two partitions through bndptr + for ii in 0:(graph.nbnd - 1) + i = bndind[ii + 1] + for j in xadj[i + 1]:(xadj[i + 2] - 1) + bndptr[adjncy[j + 1] + 1] = 1 + end + end + sn = [0, 0] + se = [0, 0] + lgraph.xadj[1] = 0 + rgraph.xadj[1] = 0 + sg = (lgraph, rgraph) + for i in 0:(nvtxs - 1) + mypart = where_[i + 1] + mypart == 2 && continue + g = sg[mypart + 1] + istart = xadj[i + 1] + iend = xadj[i + 2] + if bndptr[i + 1] == -1 # interior vertex + l = se[mypart + 1] + for j in istart:(iend - 1) + l += 1 + g.adjncy[l] = adjncy[j + 1] + end + se[mypart + 1] = l + else + l = se[mypart + 1] + for j in istart:(iend - 1) + k = adjncy[j + 1] + if where_[k + 1] == mypart + l += 1 + g.adjncy[l] = k + end + end + se[mypart + 1] = l + end + g.vwgt[sn[mypart + 1] + 1] = vwgt[i + 1] + g.label[sn[mypart + 1] + 1] = label[i + 1] + sn[mypart + 1] += 1 + g.xadj[sn[mypart + 1] + 1] = se[mypart + 1] + end + for mypart in 0:1 + g = sg[mypart + 1] + for i in 1:se[mypart + 1] + g.adjwgt[i] = 1 + g.adjncy[i] = rename[g.adjncy[i] + 1] + end + g.nvtxs = sn[mypart + 1] + g.nedges = se[mypart + 1] + SetupGraph_tvwgt!(g) + end + return lgraph, rgraph +end + +# MMDOrder: multiple minimum degree on a small piece, through the exported +# libmetis__genmmd of the shipped binary (pure arrays; exact by construction) +const GENMMD_PTR = Ref{Ptr{Cvoid}}(C_NULL) # set via init_libmetis!(path) + +function init_libmetis!(path::AbstractString) + h = Libdl.dlopen(path) + GENMMD_PTR[] = Libdl.dlsym(h, :libmetis__genmmd) + return nothing +end + +function MMDOrder!(ctrl::CtrlT, graph::GraphT, order::Vector{Int}, lastvtx::Int) + t0 = time() + nvtxs = graph.nvtxs + # 1-based CSR copies for the Fortran-style genmmd + xadj1 = Vector{Int32}(undef, nvtxs + 1) + for i in 1:(nvtxs + 1) + xadj1[i] = Int32(graph.xadj[i] + 1) + end + adjncy1 = Vector{Int32}(undef, max(graph.nedges, 1)) + for i in 1:graph.nedges + adjncy1[i] = Int32(graph.adjncy[i] + 1) + end + perm = Vector{Int32}(undef, nvtxs + 5) + iperm = Vector{Int32}(undef, nvtxs + 5) + head = Vector{Int32}(undef, nvtxs + 5) + qsize = Vector{Int32}(undef, nvtxs + 5) + list = Vector{Int32}(undef, nvtxs + 5) + marker = Vector{Int32}(undef, nvtxs + 5) + nofsub = Ref{Int32}(0) + ccall(GENMMD_PTR[], Cvoid, + (Int32, Ptr{Int32}, Ptr{Int32}, Ptr{Int32}, Ptr{Int32}, Int32, + Ptr{Int32}, Ptr{Int32}, Ptr{Int32}, Ptr{Int32}, Int32, Ref{Int32}), + Int32(nvtxs), xadj1, adjncy1, iperm, perm, Int32(1), + head, qsize, list, marker, Int32(IDX_MAX), nofsub) + label = graph.label + firstvtx = lastvtx - nvtxs + for i in 0:(nvtxs - 1) + order[label[i + 1] + 1] = firstvtx + Int(iperm[i + 1]) - 1 + end + prof!(:mmd, time() - t0) + return nothing +end + +function MlevelNestedDissection!(ctrl::CtrlT, graph::GraphT, order::Vector{Int}, lastvtx::Int) + MlevelNodeBisectionMultiple!(ctrl, graph) + DBG[] && println("Nvtxs: ", lpad(graph.nvtxs, 6), ", [", lpad(graph.pwgts[1], 6), + lpad(graph.pwgts[2], 7), lpad(graph.pwgts[3], 7), "]") + nbnd = graph.nbnd + bndind = graph.bndind + label = graph.label + for i in 0:(nbnd - 1) + lastvtx -= 1 + order[label[bndind[i + 1] + 1] + 1] = lastvtx + end + t = @elapsed ((lgraph, rgraph) = SplitGraphOrder(ctrl, graph)) + prof!(:split, t) + if lgraph.nvtxs > MMDSWITCH && lgraph.nedges > 0 + MlevelNestedDissection!(ctrl, lgraph, order, lastvtx - rgraph.nvtxs) + else + MMDOrder!(ctrl, lgraph, order, lastvtx - rgraph.nvtxs) + end + if rgraph.nvtxs > MMDSWITCH && rgraph.nedges > 0 + MlevelNestedDissection!(ctrl, rgraph, order, lastvtx) + else + MMDOrder!(ctrl, rgraph, order, lastvtx) + end + return nothing +end + +""" + NodeND(nvtxs, xadj, adjncy; vwgt = nothing, seed = -1) -> (perm, iperm) + +libmetis METIS_NodeND with OMETIS defaults, 0-based CSR in, 0-based perm and +iperm out. ccorder and pfactor are not translated (defaults are off). +""" +function NodeND(nvtxs::Int, xadj::Vector{Int}, adjncy::Vector{Int}; + vwgt::Union{Nothing, Vector{Int}} = nothing, seed::Int = -1) + ctrl = CtrlT(METIS_CTYPE_SHEM, METIS_RTYPE_SEP1SIDED, METIS_IPTYPE_EDGE, + 1, 10, 200, true, false, 0.0, false, seed, 100, 0, + Float32(Float64(Float32(1.0 + 0.001 * 200)) + 0.0000499), Float32[]) + InitRandom(ctrl.seed) + nnvtxs = 0 + graph = nothing + cptr = Int[] + cind = Int[] + if ctrl.compress + cptr = Vector{Int}(undef, nvtxs + 1) + cind = Vector{Int}(undef, nvtxs) + graph = CompressGraph(nvtxs, xadj, adjncy, vwgt, cptr, cind) + if graph === nothing + ctrl.compress = false + else + nnvtxs = graph.nvtxs + cfactor = 1.0 * nvtxs / nnvtxs + cfactor > 1.5 && ctrl.nseps == 1 && (ctrl.nseps = 2) + end + end + graph === nothing && (graph = SetupGraph(nvtxs, xadj, adjncy, vwgt)) + iperm = Vector{Int}(undef, nvtxs) + perm = Vector{Int}(undef, nvtxs) + MlevelNestedDissection!(ctrl, graph, iperm, graph.nvtxs) + if ctrl.compress # uncompress the ordering + for i in 0:(nnvtxs - 1) + perm[iperm[i + 1] + 1] = i + end + l = 0 + for ii in 0:(nnvtxs - 1) + i = perm[ii + 1] + for j in cptr[i + 1]:(cptr[i + 2] - 1) + iperm[cind[j + 1] + 1] = l + l += 1 + end + end + end + for i in 0:(nvtxs - 1) + perm[iperm[i + 1] + 1] = i + end + return perm, iperm +end + +""" + nodend_exact(A::SparseMatrixCSC; seed = -1) -> (perm, iperm) 1-based + +The `Metis.permutation(A)` replacement: symmetric pattern of `A`, diagonal +dropped, vwgt = NULL, all-default options. +""" +function nodend_exact(A; seed::Int = -1) + n = size(A, 1) + xadj = Vector{Int}(undef, n + 1) + adjncy = Int[] + rows = SparseArrays.rowvals(A) + xadj[1] = 0 + for j in 1:n + for p in SparseArrays.nzrange(A, j) + i = rows[p] + i != j && push!(adjncy, i - 1) + end + xadj[j + 1] = length(adjncy) + end + perm, iperm = NodeND(n, xadj, adjncy; seed) + return perm .+ 1, iperm .+ 1 +end + +end # module diff --git a/bench/ordering/gpu_nodend.jl b/bench/ordering/gpu_nodend.jl new file mode 100644 index 00000000..7ffdc14d --- /dev/null +++ b/bench/ordering/gpu_nodend.jl @@ -0,0 +1,29 @@ +# GPU-hybrid METIS NodeND — loader for the ordering prototype (bench-level, +# stock src untouched). +# +# MetisExact.jl bitwise-faithful Julia translation of METIS 5.1.0's NodeND +# path (all-default options, the PR #107 configuration); +# validated perm- and iperm-identical to libmetis on lap2d/ +# lap3d and the case78484 KKT dumps up to n = 2.09M. +# GpuND.jl KernelAbstractions device kernels for the pipeline stages +# (hashed-handshake HEM matching incl. 2-hop, device-resident +# contraction), stage-validated against the exact port. +# Hybrid.jl nodend_hybrid(backend, A): device coarsening chain + exact +# host initial separator / FM refinement / MMD, threaded +# sub-threshold subtrees. Fill quality 1.003–1.015 nnz_L vs +# exact METIS on the benchmark set. +# +# Usage (any KA backend; CUDABackend for the GPU, CPU() works for smoke): +# include("bench/ordering/gpu_nodend.jl") +# perm, iperm = Hybrid.nodend_hybrid(backend, A) # A symmetric sparse +# +# MetisExact ccalls libc rand() and the binary's genmmd, so libmetis must be +# initialized once per process (Metis.jl's jll carries it): + +using Metis + +include(joinpath(@__DIR__, "MetisExact.jl")) +include(joinpath(@__DIR__, "GpuND.jl")) +include(joinpath(@__DIR__, "Hybrid.jl")) + +MetisExact.init_libmetis!(Metis.libmetis) diff --git a/bench/ordering/nodend_smoke.jl b/bench/ordering/nodend_smoke.jl new file mode 100644 index 00000000..17a6d70b --- /dev/null +++ b/bench/ordering/nodend_smoke.jl @@ -0,0 +1,43 @@ +# Smoke + quality gate for the GPU-hybrid NodeND prototype: a 2-D Laplacian +# ordered by the exact port and by the hybrid, scored by nnz_L through SDS's +# own symbolic analysis. Passes = both perms valid and the hybrid within 5% +# fill of the exact port (measured 1.003–1.015 on the benchmark set; 5% is +# the alarm threshold, not the expectation). +# julia +1.13 --project=bench bench/ordering/nodend_smoke.jl [grid=60] +using SparseDirectSolver, SparseArrays, LinearAlgebra, Printf +const SDS = SparseDirectSolver +const KA = SDS.KernelAbstractions + +include(joinpath(@__DIR__, "gpu_nodend.jl")) + +laplacian2d(nx, ny) = begin + Tx = spdiagm(-1 => -ones(nx - 1), 0 => 4ones(nx), 1 => -ones(nx - 1)) + Ty = spdiagm(-1 => -ones(ny - 1), 1 => -ones(ny - 1)) + Ix = sparse(1.0I, nx, nx); Iy = sparse(1.0I, ny, ny) + kron(Iy, Tx) + kron(Ty, Ix) +end + +function nnzL(A, perm) + n = size(A, 1) + Lt = sparse(transpose(tril(A))) + P = SDS.SymmetricPattern(Vector{Int}(Lt.colptr), Vector{Int}(Lt.rowval), n, + SDS.STRUCTURE_SPD; view = SDS.VIEW_LOWER, index = SDS.INDEX_ONE) + Int(SDS.evaluate_ordering(P, perm).nnz_L) +end + +g = isempty(ARGS) ? 60 : parse(Int, ARGS[1]) +A = laplacian2d(g, g) +n = size(A, 1) +println("lap2d $(g)x$(g) n = $n") + +pe, _ = MetisExact.nodend_exact(A) +@assert isperm(pe) +ph, _ = Hybrid.nodend_hybrid(KA.CPU(), A) +@assert isperm(ph) + +le = nnzL(A, pe) +lh = nnzL(A, ph) +ratio = lh / le +@printf "nnz_L exact %d hybrid %d ratio %.4f\n" le lh ratio +@assert ratio < 1.05 "hybrid fill regressed beyond the 5% alarm threshold" +println("SMOKE OK") From b3bd565fedcdbae5e02faa26709fbfbc4ec23ee8 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Mon, 12 Oct 2026 10:33:36 -0400 Subject: [PATCH 46/65] ordering: device node-separator refinement kernels (GpuRefine.jl), off by default Round-based parallel approximation of FM_2WayNodeRefine1Sided + FM_2WayNodeBalance: fixed-direction wavefront micro-rounds (gain, local-max conflict filter, budgeted selection, apply, pull), light-side balance rounds with the serial guards, pass-level global-best tracking. Hybrid.jl walks the uncoarsening chain with exact host FM below REFINE_HOST_BELOW (50k) and device rounds above (full-device rounds left a coarse level at sides 90k/55k; the split fixed it). Measured on the 78k-bus condensed KKT (session GV100, interleaved): split mode nnz_L 15.245M vs host-FM 14.796M (1.030x), flops 1.661G vs 1.398G (1.19x), schedule 28 vs 24 levels, t_ord at parity under load ~90. lap2d 300^2 smoke: 0.987x exact. GPU_REFINE stays FALSE: on the runtime gate the device top-chain refinement saves only the ~0.36s host-FM share while charging the numeric phase, so it loses net. The kernels are groundwork for level-batched sub-bisections, where projection/balance at scale is the missing piece and refinement stays host-exact per level. Co-Authored-By: Claude Fable 5 --- bench/ordering/GpuRefine.jl | 267 +++++++++++++++++++++++++++++++++ bench/ordering/Hybrid.jl | 80 +++++++++- bench/ordering/gpu_nodend.jl | 1 + bench/ordering/nodend_smoke.jl | 14 +- 4 files changed, 356 insertions(+), 6 deletions(-) create mode 100644 bench/ordering/GpuRefine.jl diff --git a/bench/ordering/GpuRefine.jl b/bench/ordering/GpuRefine.jl new file mode 100644 index 00000000..16ca1b23 --- /dev/null +++ b/bench/ordering/GpuRefine.jl @@ -0,0 +1,267 @@ +module GpuRefine + +# Device node-separator refinement: a round-based, data-parallel approximation +# of METIS's FM_2WayNodeRefine1Sided (MetisExact.jl:1911), so the uncoarsening +# walk of a bisection can stay on the device instead of round-tripping to the +# host FM at every level. This is the prerequisite for level-batched +# sub-bisections (the Batched.jl dead-end failed on exactly these host trips). +# +# Semantics relative to the serial FM: +# * a PASS moves separator vertices to ONE side (`to`), alternating sides +# across passes, exactly like the 1-sided FM; +# * within a pass, moves are selected in parallel: every separator vertex +# with gain = vwgt[v] - Σ_{u∈N(v), where[u]==other} vwgt[u] ≥ 0 moves, +# subject to a balance budget (badmaxpwgt, METIS's cap). The serial code's +# priority order, incremental rinfo and negative-gain hill-climb with +# best-prefix rollback are replaced by whole-pass accept/revert: the pass +# is kept only if it reduces the separator weight (ties: improves balance). +# * separator validity is preserved for ANY subset of movers: all movers go +# to the same side, and every other-side neighbor of a mover is pulled +# into the separator in the same pass, so no side-to-side edge can appear. +# +# Known approximation costs (accepted; the quality gate is end nnz_L): +# * concurrent movers overcount shared pulled neighbors in their gains; +# * no negative-gain hill-climbing; +# * under a binding balance budget the atomic claim makes the selected +# subset nondeterministic (the budget rarely binds in the ND chain). + +using SparseDirectSolver.KernelAbstractions +const KA = KernelAbstractions +using SparseDirectSolver.Atomix + +export refine_rounds!, project_where! + +@kernel function _project_kernel!(fwhere, @Const(cwhere), @Const(cmap)) + v = @index(Global) + @inbounds fwhere[v] = cwhere[cmap[v] + 1] +end + +# gain of moving separator vertex v to `to` (other = 1 - to); non-separator +# vertices get typemin so the selection predicate is a single compare +@kernel function _gains_kernel!(gain, @Const(xadj), @Const(adjncy), @Const(vwgt), + @Const(where_), other::Int32) + v = @index(Global) + @inbounds begin + if where_[v] == Int32(2) + g = Int32(vwgt[v]) + for p in (xadj[v] + 1):xadj[v + 1] + u = adjncy[p] + 1 + where_[u] == other && (g -= vwgt[u]) + end + gain[v] = g + else + gain[v] = typemin(Int32) + end + end +end + +# candidate filter: gain ≥ 0 AND locally maximal among separator neighbors +# (ties by hashed id). Adjacent separator vertices therefore never move in +# the same pass, which removes the dominant gain-overcount (shared pulled +# neighbors of adjacent movers) and is the standard conflict rule of +# round-based refinement (Jet / mt-KaHyPar style). +@kernel function _filter_kernel!(cand, @Const(gain), @Const(xadj), @Const(adjncy), + @Const(where_), seed::UInt32, thr::Int32) + v = @index(Global) + @inbounds begin + c = Int32(0) + g = gain[v] + if g >= thr + hv = (UInt32(v) * 0x9e3779b9) ⊻ seed + c = Int32(1) + for p in (xadj[v] + 1):xadj[v + 1] + u = adjncy[p] + 1 + where_[u] == Int32(2) || continue + gu = gain[u] + gu < thr && continue + hu = (UInt32(u) * 0x9e3779b9) ⊻ seed + if gu > g || (gu == g && hu > hv) + c = Int32(0) + break + end + end + end + cand[v] = c + end +end + +# select movers among candidates under the balance budget. budget[1] starts +# at badmaxpwgt - pwgts[to]; an atomic claim keeps pwgts[to] under cap. +@kernel function _select_kernel!(mask, budget, nmov, @Const(cand), @Const(vwgt)) + v = @index(Global) + @inbounds begin + m = Int32(0) + if cand[v] == Int32(1) + left = (Atomix.@atomic budget[1] += Int32(-vwgt[v])) + if left >= Int32(0) + m = Int32(1) + Atomix.@atomic nmov[1] += Int32(1) + end + end + mask[v] = m + end +end + +@kernel function _apply_kernel!(where_, @Const(mask), to::Int32) + v = @index(Global) + @inbounds mask[v] == Int32(1) && (where_[v] = to) +end + +# pull: any `other`-side vertex adjacent to a mover enters the separator +@kernel function _pull_kernel!(where_, @Const(xadj), @Const(adjncy), @Const(mask), + other::Int32) + v = @index(Global) + @inbounds if where_[v] == other + for p in (xadj[v] + 1):xadj[v + 1] + if mask[adjncy[p] + 1] == Int32(1) + where_[v] = Int32(2) + break + end + end + end +end + +@kernel function _pwgts_kernel!(pwgts, @Const(where_), @Const(vwgt)) + v = @index(Global) + @inbounds Atomix.@atomic pwgts[where_[v] + 1] += Int32(vwgt[v]) +end + +function _pwgts!(backend, dpw, dwhere, dvw, n) + fill!(dpw, Int32(0)) + _pwgts_kernel!(backend)(dpw, dwhere, dvw; ndrange = n) + KA.synchronize(backend) + return Int.(Array(dpw)) +end + +""" + project_where!(backend, fwhere, cwhere, cmap, n) + +`fwhere[v] = cwhere[cmap[v]+1]` on the device (METIS Project2WayNodePartition +without the host parameter rebuild). +""" +function project_where!(backend, fwhere, cwhere, cmap, n::Integer) + n > 0 && _project_kernel!(backend)(fwhere, cwhere, cmap; ndrange = Int(n)) + KA.synchronize(backend) + return fwhere +end + +""" + refine_rounds!(backend, dx, da, dv, dwhere, n, ubfactors; niter = 10) + -> (pwgts, npasses) + +Round-based 1-sided node refinement of `dwhere` (0/1/2 Int32, device) against +the device CSR (`dx`, `da` 0-based ids, `dv` weights). Runs up to `2niter` +alternating passes; each pass is accepted only if the separator weight drops +(ties: balance improves), reverted otherwise; stops after an even/odd pair +without improvement, like the serial FM's termination. +""" +function refine_rounds!(backend, dx, da, dv, dwhere, n::Integer, ubfactors::Real; + niter::Int = 10, patience::Int = 4) + n == 0 && return (Int[0, 0, 0], 0) + gain = KA.allocate(backend, Int32, n) + cand = KA.allocate(backend, Int32, n) + mask = KA.allocate(backend, Int32, n) + best = KA.allocate(backend, Int32, n) + budget = KA.allocate(backend, Int32, 1) + dpw = KA.allocate(backend, Int32, 3) + pw = _pwgts!(backend, dpw, dwhere, dv, n) + badmaxpwgt = trunc(Int, 0.5 * ubfactors * (pw[1] + pw[2] + pw[3])) + to = pw[1] < pw[2] ? 1 : 0 + copyto!(best, dwhere) # global best, restored at exit + best_pw = pw + since_best = 0 + npasses = 0 + nmov = KA.allocate(backend, Int32, 1) + nmov_h = Int32[0] + for pass in 0:(2 * niter - 1) + other, to = to, (to + 1) % 2 + # a PASS is a wavefront sweep at fixed direction, like the serial + # 1-sided FM where pulled vertices join the queue mid-pass: repeat + # micro-rounds until no admissible mover remains + for micro in 0:199 + _gains_kernel!(backend)(gain, dx, da, dv, dwhere, Int32(other); ndrange = Int(n)) + _filter_kernel!(backend)(cand, gain, dx, da, dwhere, + UInt32(2654435761) + UInt32(64 * pass + micro), + Int32(0); ndrange = Int(n)) + pwt = _pwgts!(backend, dpw, dwhere, dv, n) + copyto!(budget, Int32[max(badmaxpwgt - pwt[to + 1], 0)]) + fill!(nmov, Int32(0)) + _select_kernel!(backend)(mask, budget, nmov, cand, dv; ndrange = Int(n)) + KA.synchronize(backend) + copyto!(nmov_h, nmov) + nmov_h[1] == Int32(0) && break + _apply_kernel!(backend)(dwhere, mask, Int32(to); ndrange = Int(n)) + _pull_kernel!(backend)(dwhere, dx, da, mask, Int32(other); ndrange = Int(n)) + KA.synchronize(backend) + end + pw = _pwgts!(backend, dpw, dwhere, dv, n) + npasses += 1 + # hill-climb with a global-best record, like the serial FM's rollback: + # keep walking through non-improving passes (bounded by `patience` and + # the serial code's 1.10x escape hatch), remember the best state seen + if pw[3] < best_pw[3] || + (pw[3] == best_pw[3] && abs(pw[1] - pw[2]) < abs(best_pw[1] - best_pw[2])) + copyto!(best, dwhere) + best_pw = pw + since_best = 0 + else + since_best += 1 + (since_best > patience || pw[3] > 1.10 * best_pw[3]) && break + end + end + copyto!(dwhere, best) + return (best_pw, npasses) +end + +""" + balance_rounds!(backend, dx, da, dv, dwhere, n, ubfactors) -> pwgts + +Parallel FM_2WayNodeBalance: rounds of moves toward the LIGHT side. Positive +gains move freely; when none remain and the heavy side still exceeds METIS's +cap (0.5·ubfactors·(pw1+pw2)), the maximum-gain class moves even at negative +gain. Stops when the sides cross, the cap is satisfied with no free moves, or +nothing is admissible. +""" +function balance_rounds!(backend, dx, da, dv, dwhere, n::Integer, ubfactors::Real; + maxrounds::Int = 64) + gain = KA.allocate(backend, Int32, n) + cand = KA.allocate(backend, Int32, n) + mask = KA.allocate(backend, Int32, n) + budget = KA.allocate(backend, Int32, 1) + nmov = KA.allocate(backend, Int32, 1) + nmov_h = Int32[0] + dpw = KA.allocate(backend, Int32, 3) + pw = _pwgts!(backend, dpw, dwhere, dv, n) + badmax = trunc(Int, 0.5 * ubfactors * (pw[1] + pw[2])) + max(pw[1], pw[2]) < badmax && return pw + to = pw[1] < pw[2] ? 0 : 1 + other = 1 - to + for r in 1:maxrounds + pw[to + 1] >= pw[other + 1] && break + _gains_kernel!(backend)(gain, dx, da, dv, dwhere, Int32(other); ndrange = Int(n)) + KA.synchronize(backend) + gmax = maximum(gain) + gmax == typemin(Int32) && break # no separator vertices + thr = Int32(0) + if gmax < Int32(0) + pw[other + 1] <= badmax && break # balanced enough, only bad moves left + thr = gmax # admit the best (negative) class only + end + _filter_kernel!(backend)(cand, gain, dx, da, dwhere, + UInt32(0x85ebca6b) + UInt32(r), thr; ndrange = Int(n)) + b = max(badmax - pw[to + 1], 0) + b == 0 && break + copyto!(budget, Int32[b]) + fill!(nmov, Int32(0)) + _select_kernel!(backend)(mask, budget, nmov, cand, dv; ndrange = Int(n)) + KA.synchronize(backend) + copyto!(nmov_h, nmov) + nmov_h[1] == Int32(0) && break + _apply_kernel!(backend)(dwhere, mask, Int32(to); ndrange = Int(n)) + _pull_kernel!(backend)(dwhere, dx, da, mask, Int32(other); ndrange = Int(n)) + pw = _pwgts!(backend, dpw, dwhere, dv, n) + end + return pw +end + +end # module diff --git a/bench/ordering/Hybrid.jl b/bench/ordering/Hybrid.jl index a24b9469..604ca46d 100644 --- a/bench/ordering/Hybrid.jl +++ b/bench/ordering/Hybrid.jl @@ -18,7 +18,8 @@ export nodend_hybrid # arrays stay resident across levels (each level's contraction output feeds # the next level's matching directly); only the per-level host GraphT copies # needed by the host refinement are downloaded. -function coarsen_gpu!(backend, ctrl::ME.CtrlT, graph::ME.GraphT; seed::Integer = 1) +function coarsen_gpu!(backend, ctrl::ME.CtrlT, graph::ME.GraphT; seed::Integer = 1, + keep_levels::Union{Nothing, Vector{Any}} = nothing) ctrl.maxvwgt = trunc(Int, 1.5 * graph.tvwgt / ctrl.CoarsenTo) dev(x, T) = (y = KA.allocate(backend, T, length(x)); copyto!(y, T.(x)); y) n = graph.nvtxs @@ -31,6 +32,9 @@ function coarsen_gpu!(backend, ctrl::ME.CtrlT, graph::ME.GraphT; seed::Integer = match = GpuND.gpu_match(backend, dx, da, dw, dv, ctrl.maxvwgt; seed = seed + level) cn, cx, ca, cw, cv, cmap = GpuND.gpu_contract(backend, dx, da, dw, dv, match) + # the finer level's CSR + its fine->coarse map, for device uncoarsening + keep_levels === nothing || + push!(keep_levels, (dx = dx, da = da, dv = dv, n = graph.nvtxs, cmap = cmap)) cgraph = ME.CreateGraph() cgraph.nvtxs = cn cgraph.nedges = length(ca) @@ -57,6 +61,71 @@ end # device, and the deep recursion has thousands of them const GPU_MIN_NVTXS = Ref(50_000) +# device uncoarsening: project + round-based parallel refinement per level +# (GpuRefine.jl) instead of the host FM walk. Off by default until the fill +# quality gate passes on the benchmark set. +const GPU_REFINE = Ref(false) +# :rounds — device wavefront refinement at every level; +# :project — device projection only, one exact host balance+FM at the finest +const GPU_REFINE_MODE = Ref(:rounds) +const GPU_REFINE_TRACE = Ref(false) # print per-level separator weights + +function refine_chain_gpu!(backend, ctrl::ME.CtrlT, graph::ME.GraphT, cgraph::ME.GraphT, + levels::Vector{Any}) + # chain[k] is the graph whose CSR/cmap is levels[k]; chain[end] = cgraph + chain = ME.GraphT[graph] + while chain[end] !== cgraph + push!(chain, chain[end].coarser::ME.GraphT) + end + # coarse half: EXACT host project/balance/refine below the size threshold + # (serial FM wins where graphs are tiny and every vertex is an aggregate; + # job_35: device rounds left the n=108 level at sides 90k/55k) + k = length(chain) - 1 + while k >= 1 && chain[k].nvtxs < REFINE_HOST_BELOW[] + g = chain[k] + ME.Project2WayNodePartition!(ctrl, g) + ME.FM_2WayNodeBalance!(ctrl, g) + ME.FM_2WayNodeRefine1Sided!(ctrl, g, ctrl.niter) + GPU_REFINE_TRACE[] && + println(" host n=", g.nvtxs, " sep=", g.pwgts[3], " sides=", + g.pwgts[1], "/", g.pwgts[2]) + k -= 1 + end + k == 0 && return nothing # whole chain refined on host + # fine half: device projection + balance + wavefront rounds + src = chain[k + 1] + cw = KA.allocate(backend, Int32, src.nvtxs) + copyto!(cw, Int32.(view(src.where_, 1:src.nvtxs))) + for j in k:-1:1 + L = levels[j] + fw = KA.allocate(backend, Int32, L.n) + Main.GpuRefine.project_where!(backend, fw, cw, L.cmap, L.n) + if GPU_REFINE_MODE[] == :rounds + Main.GpuRefine.balance_rounds!(backend, L.dx, L.da, L.dv, fw, L.n, + Float64(ctrl.ubfactors)) + pw, np = Main.GpuRefine.refine_rounds!(backend, L.dx, L.da, L.dv, fw, L.n, + Float64(ctrl.ubfactors); niter = ctrl.niter) + GPU_REFINE_TRACE[] && + println(" dev n=", L.n, " sep=", pw[3], " sides=", pw[1], "/", pw[2], + " passes=", np) + end + cw = fw + end + ME.Allocate2WayNodePartitionMemory!(graph) + copyto!(view(graph.where_, 1:graph.nvtxs), Int.(Array(cw))) + graph.coarser = nothing + ME.Compute2WayNodePartitionParams!(ctrl, graph) + if GPU_REFINE_MODE[] == :project + ME.FM_2WayNodeBalance!(ctrl, graph) + ME.FM_2WayNodeRefine1Sided!(ctrl, graph, ctrl.niter) + end + return nothing +end + +# device refinement runs only at levels at least this large; smaller levels +# use the exact serial walk above (they cost microseconds on the host) +const REFINE_HOST_BELOW = Ref(50_000) + # L1-shaped bisection with the device coarsening function node_bisection!(backend, ctrl::ME.CtrlT, graph::ME.GraphT, niparts::Int) if graph.nvtxs < GPU_MIN_NVTXS[] @@ -66,10 +135,15 @@ function node_bisection!(backend, ctrl::ME.CtrlT, graph::ME.GraphT, niparts::Int ctrl.CoarsenTo = graph.nvtxs ÷ 8 ctrl.CoarsenTo > 100 && (ctrl.CoarsenTo = 100) ctrl.CoarsenTo < 40 && (ctrl.CoarsenTo = 40) - cgraph = coarsen_gpu!(backend, ctrl, graph) + levels = GPU_REFINE[] ? Any[] : nothing + cgraph = coarsen_gpu!(backend, ctrl, graph; keep_levels = levels) niparts = max(1, cgraph.nvtxs <= ctrl.CoarsenTo ? niparts ÷ 2 : niparts) ME.InitSeparator!(ctrl, cgraph, niparts) - ME.Refine2WayNode!(ctrl, graph, cgraph) + if levels === nothing + ME.Refine2WayNode!(ctrl, graph, cgraph) + else + refine_chain_gpu!(backend, ctrl, graph, cgraph, levels) + end return nothing end diff --git a/bench/ordering/gpu_nodend.jl b/bench/ordering/gpu_nodend.jl index 7ffdc14d..a8c7d9fd 100644 --- a/bench/ordering/gpu_nodend.jl +++ b/bench/ordering/gpu_nodend.jl @@ -24,6 +24,7 @@ using Metis include(joinpath(@__DIR__, "MetisExact.jl")) include(joinpath(@__DIR__, "GpuND.jl")) +include(joinpath(@__DIR__, "GpuRefine.jl")) include(joinpath(@__DIR__, "Hybrid.jl")) MetisExact.init_libmetis!(Metis.libmetis) diff --git a/bench/ordering/nodend_smoke.jl b/bench/ordering/nodend_smoke.jl index 17a6d70b..25377d4b 100644 --- a/bench/ordering/nodend_smoke.jl +++ b/bench/ordering/nodend_smoke.jl @@ -37,7 +37,15 @@ ph, _ = Hybrid.nodend_hybrid(KA.CPU(), A) le = nnzL(A, pe) lh = nnzL(A, ph) -ratio = lh / le -@printf "nnz_L exact %d hybrid %d ratio %.4f\n" le lh ratio -@assert ratio < 1.05 "hybrid fill regressed beyond the 5% alarm threshold" +@printf "nnz_L exact %d hybrid %d ratio %.4f\n" le lh lh / le +@assert lh / le < 1.05 "hybrid fill regressed beyond the 5% alarm threshold" + +# device-refinement path (only engages above Hybrid.GPU_MIN_NVTXS) +Hybrid.GPU_REFINE[] = true +pg, _ = Hybrid.nodend_hybrid(KA.CPU(), A) +Hybrid.GPU_REFINE[] = false +@assert isperm(pg) +lg = nnzL(A, pg) +@printf "nnz_L gpu-refine %d ratio vs exact %.4f (engaged: %s)\n" lg lg / le (n >= Hybrid.GPU_MIN_NVTXS[] ? "yes" : "no — below threshold") +n >= Hybrid.GPU_MIN_NVTXS[] && @assert lg / le < 1.08 "gpu-refine fill beyond alarm threshold" println("SMOKE OK") From 9a7b7d468b8db51aa020b9e2b7c8bf4b2b0e9985 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Mon, 12 Oct 2026 10:44:43 -0400 Subject: [PATCH 47/65] MadNLP interface as a package extension; the fused pipeline moves into src - src/fused.jl: the fused factorization (Cholesky + no-pivot LDLt) and the partitioned-inverse/fused solve as the package submodule SparseDirectSolver.Fused, together with the SDSTuning bundle and the per-architecture default_tuning table (they parameterize these kernels, so they live here, not in the MadNLP glue). - ext/SparseDirectSolverMadNLPExt.jl: the MadNLP.AbstractLinearSolver implementation (SDSSolver, SDSSolverOptions: reordering, tuning, user permutation, on-disk permutation cache), loading when MadNLP is present (new weakdep, compat 0.8). Works against both the registered MadNLP 0.8 and the dev branch (solve_linear_system! defined conditionally). Accessors SparseDirectSolver.madnlp_solver() / madnlp_solver_options() are the stable entry points. - bench/e2e/MadNLPSDS.jl and bench/e2e/SDSFused.jl retire; the drivers and benches use the extension and SparseDirectSolver.Fused. Gates: full suite green (CPU + CUDA); extension loads against registered MadNLP (SDSSolver <: AbstractLinearSolver); end-to-end 78k ACOPF on the GV100 through the extension: SOLVE_SUCCEEDED, iterations and objective identical to cuDSS (101, 1.53139916e7), wall 18.95 s vs 24.24 s. Co-Authored-By: Claude Fable 5 --- Project.toml | 3 + bench/e2e/amd_baseline.jl | 3 +- bench/e2e/amd_sweep.jl | 3 +- bench/e2e/fresh_machine.jl | 6 +- bench/e2e/fresh_machine.sh | 2 +- bench/e2e/ldl_fused_race.jl | 3 +- bench/e2e/ldl_fused_test.jl | 3 +- bench/e2e/run_amd.jl | 5 +- bench/e2e/run_gv100.jl | 6 +- .../SparseDirectSolverMadNLPExt.jl | 115 ++++++------------ src/SparseDirectSolver.jl | 30 +++++ bench/e2e/SDSFused.jl => src/fused.jl | 65 ++++++++-- 12 files changed, 141 insertions(+), 103 deletions(-) rename bench/e2e/MadNLPSDS.jl => ext/SparseDirectSolverMadNLPExt.jl (56%) rename bench/e2e/SDSFused.jl => src/fused.jl (96%) diff --git a/Project.toml b/Project.toml index e0d44461..50bb65f5 100644 --- a/Project.toml +++ b/Project.toml @@ -18,6 +18,7 @@ SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" AMDGPU = "21141c5a-9bdb-4563-92ae-f87d6854732e" CUDACore = "bd0ed864-bdfe-4181-a5ed-ce625a5fdea2" Krylov = "ba0b0d4f-ebba-5204-a429-3ac8c609bfb7" +MadNLP = "2621e9c9-9eb4-46b1-8089-e8c72242dfb6" Metis = "2679e427-3c69-5b7f-982b-ece356f1e94b" cuBLAS = "182d3088-87b7-4494-8cad-fc6afaa545bc" cuSOLVER = "887afef0-6a32-4de5-add4-7827692ba8fc" @@ -27,6 +28,7 @@ cuSPARSE = "b26da814-b3bc-49ef-b0ee-c816305aa060" SparseDirectSolverAMDGPUExt = "AMDGPU" SparseDirectSolverCUDAExt = ["CUDACore", "cuBLAS", "cuSOLVER", "cuSPARSE"] SparseDirectSolverKrylovExt = "Krylov" +SparseDirectSolverMadNLPExt = "MadNLP" SparseDirectSolverMetisExt = "Metis" [compat] @@ -39,6 +41,7 @@ CliqueTrees = "1" GPUArrays = "11, 12" GPUArraysCore = "0.2" KernelAbstractions = "0.9" +MadNLP = "0.8" Krylov = "0.10" LinearAlgebra = "1.13" Metis = "1" diff --git a/bench/e2e/amd_baseline.jl b/bench/e2e/amd_baseline.jl index 643ab093..b294946d 100644 --- a/bench/e2e/amd_baseline.jl +++ b/bench/e2e/amd_baseline.jl @@ -5,8 +5,7 @@ using SparseDirectSolver, SparseArrays, LinearAlgebra, Random, Statistics, Printf using AMDGPU const SDS = SparseDirectSolver -include(joinpath(@__DIR__, "SDSFused.jl")) -using .SDSFused +const SDSFused = SparseDirectSolver.Fused const BD = joinpath(@__DIR__, "..") include(joinpath(BD, "matrices.jl")) using .BenchMatrices: read_mtx diff --git a/bench/e2e/amd_sweep.jl b/bench/e2e/amd_sweep.jl index 513ede63..ce4ede78 100644 --- a/bench/e2e/amd_sweep.jl +++ b/bench/e2e/amd_sweep.jl @@ -4,8 +4,7 @@ using SparseDirectSolver, SparseArrays, LinearAlgebra, Random, Statistics, Printf using AMDGPU const SDS = SparseDirectSolver -include(joinpath(@__DIR__, "SDSFused.jl")) -using .SDSFused +const SDSFused = SparseDirectSolver.Fused const BD = joinpath(@__DIR__, "..") include(joinpath(BD, "matrices.jl")) using .BenchMatrices: read_mtx diff --git a/bench/e2e/fresh_machine.jl b/bench/e2e/fresh_machine.jl index fb6a6aff..98537d30 100644 --- a/bench/e2e/fresh_machine.jl +++ b/bench/e2e/fresh_machine.jl @@ -61,8 +61,8 @@ end # the MadNLP linear-solver wrapper (SDSSolver: the fused kernels with per-arch # tuning), shipped with the package under bench/e2e -include(joinpath(pkgdir(SparseDirectSolver), "bench", "e2e", "MadNLPSDS.jl")) -using .MadNLPSDS +# the MadNLP interface is the SparseDirectSolverMadNLPExt package extension +const SDSSolver = SparseDirectSolver.madnlp_solver() println("device: ", BACKEND == "cuda" ? CUDA.name(CUDA.device()) : string(AMDGPU.device())) @@ -72,7 +72,7 @@ m, _ = ac_opf_model("pglib_opf_case78484_epigrids.m"; # SparseCondensedKKTSystem is required (the SPD Cholesky path); MadNLP defaults # equality/fixed-variable treatment correctly for it. tol: condensed default is 1e-4. # -# All performance knobs live in one bundle: pass sds_tuning = MadNLPSDS.SDSTuning(; ...) +# All performance knobs live in one bundle: pass sds_tuning = SparseDirectSolver.Fused.SDSTuning(; ...) # to override the measured per-architecture defaults (see bench/ARCH-TUNING.md); # sds_user_perm = perm (1-based, length n) bypasses the built-in reordering entirely. sol = madnlp(m; linear_solver = SDSSolver, diff --git a/bench/e2e/fresh_machine.sh b/bench/e2e/fresh_machine.sh index 94e329ac..47a5d72b 100755 --- a/bench/e2e/fresh_machine.sh +++ b/bench/e2e/fresh_machine.sh @@ -77,7 +77,7 @@ if [ "${SKIP_TESTS:-0}" != "1" ]; then fi # --- 4. MadNLP workflow: 78k-bus ACOPF, cuDSS vs SDS on the same GPU ----------- -# bench/e2e: MadNLPSDS.jl defines the MadNLP.AbstractLinearSolver wrappers +# ext/SparseDirectSolverMadNLPExt.jl defines the MadNLP.AbstractLinearSolver wrapper # (SDSSolver = stock library; SDSProtoSolver = PR #107 prototype kernels). # NVIDIA (run_gv100.jl): MadNLPGPU.CUDSSSolver vs SDSProtoSolver. # AMD (run_amd.jl): SDSSolver vs SDSProtoSolver. diff --git a/bench/e2e/ldl_fused_race.jl b/bench/e2e/ldl_fused_race.jl index 049f2a3e..b7d9b1e5 100644 --- a/bench/e2e/ldl_fused_race.jl +++ b/bench/e2e/ldl_fused_race.jl @@ -4,8 +4,7 @@ using SparseDirectSolver, SparseArrays, LinearAlgebra, Random, Statistics, Printf using CUDA, CUDA.CUSPARSE const SDS = SparseDirectSolver -include(joinpath(@__DIR__, "SDSFused.jl")) -using .SDSFused +const SDSFused = SparseDirectSolver.Fused const BD = joinpath(@__DIR__, "..") include(joinpath(BD, "matrices.jl")) using .BenchMatrices: read_mtx diff --git a/bench/e2e/ldl_fused_test.jl b/bench/e2e/ldl_fused_test.jl index 1549aca6..9dc07fbd 100644 --- a/bench/e2e/ldl_fused_test.jl +++ b/bench/e2e/ldl_fused_test.jl @@ -4,8 +4,7 @@ using SparseDirectSolver, SparseArrays, LinearAlgebra, Random, Statistics, Printf using CUDA, CUDA.CUSPARSE const SDS = SparseDirectSolver -include(joinpath(@__DIR__, "SDSFused.jl")) -using .SDSFused +const SDSFused = SparseDirectSolver.Fused const BD = joinpath(@__DIR__, "..") include(joinpath(BD, "matrices.jl")) diff --git a/bench/e2e/run_amd.jl b/bench/e2e/run_amd.jl index eb158f72..da11a48a 100644 --- a/bench/e2e/run_amd.jl +++ b/bench/e2e/run_amd.jl @@ -8,8 +8,9 @@ using AMDGPU using MadNLP, MadNLPGPU using ExaModels, ExaModelsPower -include(joinpath(@__DIR__, "MadNLPSDS.jl")) -using .MadNLPSDS +using SparseDirectSolver +# the MadNLP interface is the SparseDirectSolverMadNLPExt package extension +const SDSSolver = SparseDirectSolver.madnlp_solver() const CASE = "pglib_opf_case78484_epigrids.m" diff --git a/bench/e2e/run_gv100.jl b/bench/e2e/run_gv100.jl index 2e7ff9fa..a0b307ca 100644 --- a/bench/e2e/run_gv100.jl +++ b/bench/e2e/run_gv100.jl @@ -8,8 +8,9 @@ using CUDA, CUDSS using MadNLP, MadNLPGPU using ExaModels, ExaModelsPower -include(joinpath(@__DIR__, "MadNLPSDS.jl")) -using .MadNLPSDS +using SparseDirectSolver +# the MadNLP interface is the SparseDirectSolverMadNLPExt package extension +const SDSSolver = SparseDirectSolver.madnlp_solver() const CASE = "pglib_opf_case78484_epigrids.m" @@ -24,7 +25,6 @@ function run_one(label, model, linear_solver; warm = true) c = sol.counters @printf "%-16s status %-22s iters %3d obj %.8e\n" label string(sol.status) c.k sol.objective @printf " wall %7.2f s solver %7.2f s linsolve %7.2f s eval %7.2f s init %7.2f s\n" c.total_time c.solver_time c.linear_solver_time c.eval_function_time c.init_time - @printf " factorizations %d backsolves %d\n" c.factorization_cnt c.backsolve_cnt flush(stdout) return sol end diff --git a/bench/e2e/MadNLPSDS.jl b/ext/SparseDirectSolverMadNLPExt.jl similarity index 56% rename from bench/e2e/MadNLPSDS.jl rename to ext/SparseDirectSolverMadNLPExt.jl index 77f4f4c3..5016f3ff 100644 --- a/bench/e2e/MadNLPSDS.jl +++ b/ext/SparseDirectSolverMadNLPExt.jl @@ -1,67 +1,24 @@ -# MadNLP linear-solver interface to SparseDirectSolver.jl (prototype). +# MadNLP linear-solver interface to SparseDirectSolver.jl, as a package +# extension (loads when MadNLP and SparseDirectSolver are both present). # Backend-agnostic: accepts any device CSC (CUSPARSE.CuSparseMatrixCSC, # rocSPARSE.ROCSparseMatrixCSC) whose colPtr/rowVal/nzVal are device vectors. # The CSC arrays of MadNLP's lower-triangular KKT matrix are read as the CSR # of the upper triangle, exactly as MadNLPGPU's CUDSSSolver does (view 'U'). -module MadNLPSDS +# Access from user code: SparseDirectSolver.madnlp_solver(). +module SparseDirectSolverMadNLPExt using LinearAlgebra, SparseArrays using MadNLP using SparseDirectSolver const SDS = SparseDirectSolver const KA = SDS.KernelAbstractions - -include("SDSFused.jl") -using .SDSFused - -""" - SDSTuning - -Every performance knob of the fused pipeline in one immutable bundle, so a -platform's tuning is a single value (see bench/ARCH-TUNING.md for how each -field was measured and the per-architecture champions). All fields are -runtime parameters — no environment variables, no recompilation. -""" -Base.@kwdef struct SDSTuning - wides::Symbol = :auto # :auto | :host | :merged - subtree_budgets::Vector{Int} = [16384] # regime-A local-memory class(es) - subtree_parallelism::Int = 16384 - regime_c_width::Int = 32 - regime_c_rows::Int = 64 - amalgamation::NamedTuple = (max_width = 48, zero_fraction = 0.25, min_width = 8) - workgroup::Int = 128 # fused-kernel workgroup size - spin_cap::Int = 128 # dependency-counter backoff cap - nd_nseps::Int = 4 # METIS knobs (78k-ACOPF champions) - nd_seed::Int = 3 -end - -""" - default_tuning(backend) -> SDSTuning - -The measured per-architecture defaults — the single place they live. -2048-threads/SM NVIDIA parts (sm_70/80/90): host wides, 16K budgets. -1536-thread parts (sm_86/89, Blackwell workstation): merged wides, 8K budgets. -AMD (gfx906-measured): merged, 8K, workgroup 256, spin cap 16. -""" -function default_tuning(backend) - if occursin("CUDABackend", string(typeof(backend))) - M = Base.moduleroot(parentmodule(typeof(backend))) # CUDACore under the CUDA 6 split - small = M.attribute(M.device(), M.DEVICE_ATTRIBUTE_MAX_THREADS_PER_MULTIPROCESSOR) < 2048 - return small ? SDSTuning(; wides = :merged, subtree_budgets = [8192]) : - SDSTuning(; wides = :host) - end - return SDSTuning(; wides = :merged, subtree_budgets = [8192], workgroup = 256, spin_cap = 16) -end - -_resolve(t::SDSTuning, backend) = t.wides === :auto ? - SDSTuning(t; wides = default_tuning(backend).wides) : t -SDSTuning(t::SDSTuning; wides) = SDSTuning(wides, t.subtree_budgets, t.subtree_parallelism, - t.regime_c_width, t.regime_c_rows, t.amalgamation, t.workgroup, t.spin_cap, t.nd_nseps, t.nd_seed) +const Fused = SDS.Fused +const SDSTuning = Fused.SDSTuning Base.@kwdef mutable struct SDSSolverOptions <: MadNLP.AbstractOptions sds_reordering::String = "algo4" # native ND # ALL performance knobs live in the tuning bundle; nothing (the default) - # selects the measured per-architecture profile from default_tuning(backend) + # selects the measured per-architecture profile from Fused.default_tuning sds_tuning::Union{Nothing, SDSTuning} = nothing # a user permutation bypasses the reordering entirely (1-based, length n) sds_user_perm::Union{Nothing, Vector{Int}} = nothing @@ -69,8 +26,6 @@ Base.@kwdef mutable struct SDSSolverOptions <: MadNLP.AbstractOptions sds_perm_cache::String = joinpath(homedir(), ".julia", "sds_perm_cache") end -# merged in-kernel wides win on parts with < 2048 threads/SM (and on AMD); the CUDA -# module is reached through the backend's parent module, so this file stays backend-free function _perm_cache_file(opt::SDSSolverOptions, t::SDSTuning, csc) (opt.sds_perm_cache == "" || opt.sds_user_perm !== nothing) && return nothing n = size(csc, 1) @@ -80,7 +35,7 @@ function _perm_cache_file(opt::SDSSolverOptions, t::SDSTuning, csc) end # shared solver construction knobs: the custom reordering (nd knobs or a user -# permutation) and the amalgamation, applied before the analysis +# permutation) and the amalgamation, applied before the analysis; # returns the cache file to WRITE after the analysis (nothing: no write needed) function _apply_reordering!(s, opt::SDSSolverOptions, t::SDSTuning, csc) s.options.amalgamation = t.amalgamation @@ -112,10 +67,14 @@ function _save_perm(cf, s) return nothing end -# --------------------------------------------------------------------------- -# THE solver: fused factorization + partitioned-inverse/fused solve (PR #107 kernels, -# October 2026 per-architecture tuning; the stock-library wrapper was retired after -# the fused path won on every platform — see bench/PERFORMANCE-2026-10.md) +""" + SDSSolver(csc; opt = SDSSolverOptions(), logger = MadNLP.MadNLPLogger()) + +The MadNLP linear solver backed by the fused SparseDirectSolver pipeline +(factorization mega-kernel + partitioned-inverse solve), with per-architecture +tuning from `Fused.default_tuning`. Obtain the type with +`SparseDirectSolver.madnlp_solver()`. +""" mutable struct SDSSolver{T} <: MadNLP.AbstractLinearSolver{T} inner::Any tril::Any @@ -140,11 +99,11 @@ function SDSSolver( T = eltype(csc.nzVal) n = size(csc, 1) backend = KA.get_backend(csc.nzVal) - SDSFused.BACKEND[] = backend - t = _resolve(something(opt.sds_tuning, default_tuning(backend)), backend) + Fused.BACKEND[] = backend + t = Fused.resolve_tuning(something(opt.sds_tuning, Fused.default_tuning(backend)), backend) s = SDS.DirectSolver(csc.colPtr, csc.rowVal, csc.nzVal, "SPD", 'U') s.options.regime_c_width = t.regime_c_width - s.options.regime_c_rows = t.regime_c_rows # prototype kernels own BOTH phases: no coupling conflict + s.options.regime_c_rows = t.regime_c_rows # the fused kernels own BOTH phases: no coupling conflict s.options.subtree_parallelism = t.subtree_parallelism s.options.subtree_budgets = copy(t.subtree_budgets) cf = _apply_reordering!(s, opt, t, csc) @@ -156,9 +115,9 @@ function SDSSolver( SDS.execute!("solve", s, x, b; asynchronous = false) # allocates the solve workspace nzval = SDS._factor_values(s) merged = t.wides === :merged - fp = SDSFused.build_fused_plan(s; inkernel_wides = merged, merge_segments = merged, - wgf = t.workgroup, scap = t.spin_cap) - inv11, inv_ptr, invertf, _ = SDSFused.build_inverse(s) + fp = Fused.build_fused_plan(s; inkernel_wides = merged, merge_segments = merged, + wgf = t.workgroup, scap = t.spin_cap) + inv11, inv_ptr, invertf, _ = Fused.build_inverse(s) # solve-side plan state (module globals, single solver instance) plan0 = s.workspace.plan nodes0 = s.symbolic.schedule.group_nodes @@ -169,11 +128,11 @@ function SDSSolver( append!(sub_ids, Int32.(nodes0[plan0.first[k]:plan0.last[k]])) end sort!(sub_ids; by = e -> stp[e + 1] - stp[e], rev = true) - SDSFused.SSUB[] = SDSFused.DVEC(sub_ids) - SDSFused.NSUB[] = length(sub_ids) - SDSFused.INV11[] = inv11 - SDSFused.INVPTR[] = inv_ptr - fz1 = SDSFused.build_fused(s; kstart = 1, wcap = 256) + Fused.SSUB[] = Fused.DVEC(sub_ids) + Fused.NSUB[] = length(sub_ids) + Fused.INV11[] = inv11 + Fused.INVPTR[] = inv_ptr + fz1 = Fused.build_fused(s; kstart = 1, wcap = 256) segs = [(fz1, 64)] fz = (segs, true) M = SDSSolver{T}(s, csc, x, b, nzval, fp, fz, inv11, inv_ptr, invertf, 0, opt, logger) @@ -182,20 +141,23 @@ function SDSSolver( end function MadNLP.factorize!(M::SDSSolver) - M.last_info = Int(SDSFused.refact_fused!(M.inner, M.nzval, M.fp)) + M.last_info = Int(Fused.refact_fused!(M.inner, M.nzval, M.fp)) M.invert!() return M end -function MadNLP.solve_linear_system!(M::SDSSolver{T}, xb) where {T} +function _solve!(M::SDSSolver, xb) copyto!(M.b, xb) - SDSFused.algo2_solve!(M.x, M.inner, M.b, M.inv11, M.inv_ptr, M.fz) + Fused.algo2_solve!(M.x, M.inner, M.b, M.inv11, M.inv_ptr, M.fz) copyto!(xb, M.x) KA.synchronize(KA.get_backend(xb)) return xb end -MadNLP.solve!(M::SDSSolver, xb::AbstractVector) = MadNLP.solve_linear_system!(M, xb) +MadNLP.solve!(M::SDSSolver, xb::AbstractVector) = _solve!(M, xb) +# the dev MadNLP routes through solve_linear_system!; the registered 0.8 does not have it +isdefined(MadNLP, :solve_linear_system!) && + @eval MadNLP.solve_linear_system!(M::SDSSolver, xb) = _solve!(M, xb) MadNLP.input_type(::Type{SDSSolver}) = :csc MadNLP.default_options(::Type{SDSSolver}) = SDSSolverOptions() MadNLP.is_inertia(::SDSSolver) = true @@ -204,12 +166,11 @@ function MadNLP.inertia(M::SDSSolver) return M.last_info == 0 ? (n, 0, 0) : (n - 2, 1, 1) end MadNLP.improve!(::SDSSolver) = false -MadNLP.introduce(::SDSSolver) = "SparseDirectSolver.jl (PR #107 prototype kernels)" +MadNLP.introduce(::SDSSolver) = "SparseDirectSolver.jl (fused pipeline)" MadNLP.is_supported(::Type{SDSSolver}, ::Type{Float32}) = true MadNLP.is_supported(::Type{SDSSolver}, ::Type{Float64}) = true -const SDSProtoSolver = SDSSolver # the former name; the prototype is the solver now - -export SDSSolver, SDSSolverOptions, SDSProtoSolver +SparseDirectSolver.madnlp_solver() = SDSSolver +SparseDirectSolver.madnlp_solver_options() = SDSSolverOptions -end # module +end # module \ No newline at end of file diff --git a/src/SparseDirectSolver.jl b/src/SparseDirectSolver.jl index d358b05f..94448e76 100644 --- a/src/SparseDirectSolver.jl +++ b/src/SparseDirectSolver.jl @@ -82,6 +82,36 @@ include("solve/refinement.jl") include("solver.jl") include("generic.jl") +# the fused pipeline (factorization mega-kernel + partitioned-inverse solve) +# and its per-architecture tuning bundle; consumed by the MadNLP extension +include("fused.jl") + +""" + madnlp_solver() -> Type + +The MadNLP linear-solver type backed by the fused pipeline (`SDSSolver` in the +`SparseDirectSolverMadNLPExt` package extension). Available once MadNLP is +loaded in the same session: + + using MadNLP, SparseDirectSolver + madnlp(model; linear_solver = SparseDirectSolver.madnlp_solver(), + kkt_system = MadNLP.SparseCondensedKKTSystem) + +Options type: [`madnlp_solver_options`](@ref); tuning bundle: +`SparseDirectSolver.Fused.SDSTuning` (per-architecture defaults are selected +automatically). +""" +function madnlp_solver end + +""" + madnlp_solver_options() -> Type + +The options type of [`madnlp_solver`](@ref) (`SDSSolverOptions`: reordering, +tuning bundle, user permutation, on-disk permutation cache). Requires MadNLP +to be loaded. +""" +function madnlp_solver_options end + __init__() = _init_log_level() end # module SparseDirectSolver diff --git a/bench/e2e/SDSFused.jl b/src/fused.jl similarity index 96% rename from bench/e2e/SDSFused.jl rename to src/fused.jl index 45b96835..b70028d7 100644 --- a/bench/e2e/SDSFused.jl +++ b/src/fused.jl @@ -1,15 +1,62 @@ -# Prototype kernels of PR #107 assembled as a backend-portable module, so the -# MadNLP wrapper can run the fused factorization and the algo1/fused solve on -# CUDA and AMDGPU alike. Single-solver-instance (module-global plan state). -module SDSFused - -using LinearAlgebra, SparseArrays, Statistics, Printf -using SparseDirectSolver -const SDS = SparseDirectSolver +# The fused pipeline (PR #107 kernels + the October 2026 campaign), assembled +# as a backend-portable submodule: the fused factorization (Cholesky and +# no-pivot LDL^T) and the partitioned-inverse/fused solve, on CUDA and AMDGPU +# alike. Single-solver-instance (module-global plan state). Consumed by the +# MadNLP package extension (ext/SparseDirectSolverMadNLPExt.jl); the tuning +# bundle `SDSTuning` / `default_tuning` lives here because it parameterizes +# these kernels, not the MadNLP glue. +module Fused + +using LinearAlgebra, SparseArrays +const SDS = Base.parentmodule(@__MODULE__) # the enclosing SparseDirectSolver module const KA = SDS.KernelAbstractions const Atomix = SDS.Atomix using .KA: @kernel, @index, @localmem, @synchronize, @Const, @uniform +""" + SDSTuning + +Every performance knob of the fused pipeline in one immutable bundle, so a +platform's tuning is a single value (see bench/ARCH-TUNING.md for how each +field was measured and the per-architecture champions). All fields are +runtime parameters — no environment variables, no recompilation. +""" +Base.@kwdef struct SDSTuning + wides::Symbol = :auto # :auto | :host | :merged + subtree_budgets::Vector{Int} = [16384] # regime-A local-memory class(es) + subtree_parallelism::Int = 16384 + regime_c_width::Int = 32 + regime_c_rows::Int = 64 + amalgamation::NamedTuple = (max_width = 48, zero_fraction = 0.25, min_width = 8) + workgroup::Int = 128 # fused-kernel workgroup size + spin_cap::Int = 128 # dependency-counter backoff cap + nd_nseps::Int = 4 # METIS knobs (78k-ACOPF champions) + nd_seed::Int = 3 +end + +""" + default_tuning(backend) -> SDSTuning + +The measured per-architecture defaults — the single place they live. +2048-threads/SM NVIDIA parts (sm_70/80/90): host wides, 16K budgets. +1536-thread parts (sm_86/89, Blackwell workstation): merged wides, 8K budgets. +AMD (gfx906-measured): merged, 8K, workgroup 256, spin cap 16. +""" +function default_tuning(backend) + if occursin("CUDABackend", string(typeof(backend))) + M = Base.moduleroot(parentmodule(typeof(backend))) # CUDACore under the CUDA 6 split + small = M.attribute(M.device(), M.DEVICE_ATTRIBUTE_MAX_THREADS_PER_MULTIPROCESSOR) < 2048 + return small ? SDSTuning(; wides = :merged, subtree_budgets = [8192]) : + SDSTuning(; wides = :host) + end + return SDSTuning(; wides = :merged, subtree_budgets = [8192], workgroup = 256, spin_cap = 16) +end + +resolve_tuning(t::SDSTuning, backend) = t.wides === :auto ? + SDSTuning(t; wides = default_tuning(backend).wides) : t +SDSTuning(t::SDSTuning; wides) = SDSTuning(wides, t.subtree_budgets, t.subtree_parallelism, + t.regime_c_width, t.regime_c_rows, t.amalgamation, t.workgroup, t.spin_cap, t.nd_nseps, t.nd_seed) + const BACKEND = Ref{Any}(nothing) DVEC(v) = (out = KA.allocate(BACKEND[], eltype(v), size(v)); copyto!(out, v); out) DVEC(::Type{T}, v) where {T} = (out = KA.allocate(BACKEND[], T, length(v)); copyto!(out, T.(v)); out) @@ -612,7 +659,7 @@ function build_fused(s; kstart::Int = 1, wcap::Int = MW, merged::Bool = (kstart arrived_base_h[v] = Int32(cnt) end done_base_h = Int32[in_range[v] ? 0 : 1 for v in 1:ns] - @printf "fused: entries %d:%d of %d, %d fronts\n" i1 i2 length(plan.kind) length(flist_h) + println("fused: entries ", i1, ":", i2, " of ", length(plan.kind), ", ", length(flist_h), " fronts") return (; i1, i2, flist = DVEC(flist_h), arrived = KA.zeros(BACKEND[], Int32, ns), arrived_base = DVEC(arrived_base_h), done = KA.zeros(BACKEND[], Int32, ns), done_base = DVEC(done_base_h), nchild = DVEC(nchild_h), nfused = length(flist_h)) From 68614e31c2a5d4e0324c134202819a7379c1a359 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Mon, 12 Oct 2026 12:34:15 -0400 Subject: [PATCH 48/65] ordering: level-batched block coarsening (GpuBatch.jl), validated on real blocks Block-diagonal batching of independent sub-bisection graphs: one device CSR carries a whole recursion level; GpuND matching/contraction apply unchanged (blocks are disconnected components, so proposals cannot cross blocks), and per-block CoarsenTo freezing rides on the per-vertex matching-cap vector (cap 0 = identity). Replaces the 2026-10-09 host-resliced dead-end: the batch stays device-resident across levels; the host sees nb counters per level. Validated on the 20 real sub-50k blocks of the 78k-bus condensed KKT recursion (671947 vertices total, median 31.5k): the full batched coarsening chain to per-block CoarsenTo (coarse n = 1875, 20/20 frozen) runs in 0.31 s warm on the GV100 (two consistent runs: 0.31/0.35 s), with no cross-block edges and per-block weight conservation checked on device before and after. Co-Authored-By: Claude Fable 5 --- bench/ordering/GpuBatch.jl | 177 +++++++++++++++++++++++++++++++++++ bench/ordering/gpu_nodend.jl | 1 + 2 files changed, 178 insertions(+) create mode 100644 bench/ordering/GpuBatch.jl diff --git a/bench/ordering/GpuBatch.jl b/bench/ordering/GpuBatch.jl new file mode 100644 index 00000000..0861c1dd --- /dev/null +++ b/bench/ordering/GpuBatch.jl @@ -0,0 +1,177 @@ +module GpuBatch + +# Level-batched sub-bisections: the ~80% of NodeND time that is thousands of +# small host bisections, re-expressed so a whole recursion level of blocks is +# ONE block-diagonal device graph, coarsened in single kernel passes. Blocks +# are disconnected components of the batch, so GpuND's matching/contraction +# apply unchanged: matching proposals follow edges and cannot cross blocks, +# and contraction preserves the block structure by construction. Per-block +# control (CoarsenTo, freezing a finished block) rides on the per-vertex +# maxvwgt vector gpu_match already takes (cap 0 = no matches = identity). +# +# The 2026-10-09 Batched.jl attempt is the documented dead-end this replaces: +# it re-sliced every block to a host GraphT at every level (12x regression). +# Here the batch stays device-resident across levels; the host sees only +# per-block counters (nb Int32s per level). + +using SparseDirectSolver.KernelAbstractions +const KA = KernelAbstractions +using SparseDirectSolver.Atomix +using ..MetisExact, ..GpuND +const ME = MetisExact + +export build_batch, coarsen_batch!, batch_invariants + +struct Batch + n::Int # total vertices + nb::Int # blocks + dx; da; dw; dv # device CSR, GpuND conventions (0-based ids) + bid # device Int32, 1-based block id per vertex + nv_b::Vector{Int} # host per-block vertex counts + tvw_b::Vector{Int} # host per-block total vertex weight +end + +""" + build_batch(backend, graphs::Vector{ME.GraphT}) -> Batch + +Concatenate independent subgraphs into one block-diagonal device CSR +(vertex ids shifted; no edges are added, so blocks stay disconnected). +""" +function build_batch(backend, graphs::Vector{ME.GraphT}) + nb = length(graphs) + n = sum(g.nvtxs for g in graphs) + ne = sum(g.nedges for g in graphs) + xadj = Vector{Int32}(undef, n + 1) + adj = Vector{Int32}(undef, ne) + adjw = Vector{Int32}(undef, ne) + vw = Vector{Int32}(undef, n) + bid = Vector{Int32}(undef, n) + nv_b = Vector{Int}(undef, nb) + tvw_b = zeros(Int, nb) + xadj[1] = 0 + off = 0 + voff = 0 + for (b, g) in enumerate(graphs) + nv_b[b] = g.nvtxs + for i in 1:g.nvtxs + vw[voff + i] = Int32(g.vwgt[i]) + tvw_b[b] += g.vwgt[i] + bid[voff + i] = Int32(b) + xadj[voff + i + 1] = Int32(off + g.xadj[i + 1]) + end + for p in 1:g.nedges + adj[off + p] = Int32(g.adjncy[p] + voff) + adjw[off + p] = Int32(g.adjwgt[p]) + end + off += g.nedges + voff += g.nvtxs + end + dev(x) = (y = KA.allocate(backend, Int32, length(x)); copyto!(y, x); y) + Batch(n, nb, dev(xadj), dev(adj), dev(adjw), dev(vw), dev(bid), nv_b, tvw_b) +end + +@kernel function _mv_kernel!(mv, @Const(bid), @Const(caps)) + v = @index(Global) + @inbounds mv[v] = caps[bid[v]] +end + +# every fine vertex of a coarse vertex carries the same block id +@kernel function _bidmap_kernel!(bidc, @Const(bid), @Const(cmap)) + v = @index(Global) + @inbounds bidc[cmap[v] + 1] = bid[v] +end + +@kernel function _bcount_kernel!(cnt, @Const(bid)) + v = @index(Global) + @inbounds Atomix.@atomic cnt[bid[v]] += Int32(1) +end + +@kernel function _bwsum_kernel!(ws, @Const(bid), @Const(vwgt)) + v = @index(Global) + @inbounds Atomix.@atomic ws[bid[v]] += Int32(vwgt[v]) +end + +@kernel function _crossedge_kernel!(bad, @Const(xadj), @Const(adjncy), @Const(bid)) + v = @index(Global) + @inbounds for p in (xadj[v] + 1):xadj[v + 1] + bid[adjncy[p] + 1] != bid[v] && (bad[1] = Int32(1)) + end +end + +function _percount(backend, bid, nb, n) + cnt = KA.allocate(backend, Int32, nb) + fill!(cnt, Int32(0)) + _bcount_kernel!(backend)(cnt, bid; ndrange = n) + KA.synchronize(backend) + return Int.(Array(cnt)) +end + +""" + batch_invariants(backend, B) -> (cross_block_edges::Bool, nv, wsum) + +Structural checks: no edge crosses blocks; per-block vertex counts and weight +sums (the weight sums must be invariant under every contraction level). +""" +function batch_invariants(backend, B::Batch) + bad = KA.allocate(backend, Int32, 1) + fill!(bad, Int32(0)) + _crossedge_kernel!(backend)(bad, B.dx, B.da, B.bid; ndrange = B.n) + ws = KA.allocate(backend, Int32, B.nb) + fill!(ws, Int32(0)) + _bwsum_kernel!(backend)(ws, B.bid, B.dv; ndrange = B.n) + KA.synchronize(backend) + return (Array(bad)[1] != 0, _percount(backend, B.bid, B.nb, B.n), Int.(Array(ws))) +end + +""" + coarsen_batch!(backend, B; seed = 1, levels = nothing, maxlevels = 40) + -> (B_coarse, frozen_done) + +Coarsen every block of the batch simultaneously until each reaches its METIS +CoarsenTo (clamp(nvtxs/8, 40, 100)) or stalls (shrink > COARSEN_FRACTION), +freezing finished blocks via a zero matching cap. `levels`, when a vector, is +filled with (dx, da, dv, n, cmap, bid) per level, finest first, for device +uncoarsening. +""" +function coarsen_batch!(backend, B::Batch; seed::Integer = 1, + levels::Union{Nothing, Vector{Any}} = nothing, + maxlevels::Int = 40) + nb = B.nb + ct_b = [clamp(B.nv_b[b] ÷ 8, 40, 100) for b in 1:nb] + frozen = [B.nv_b[b] <= ct_b[b] for b in 1:nb] + nv = copy(B.nv_b) + dx, da, dw, dv, bid, n = B.dx, B.da, B.dw, B.dv, B.bid, B.n + caps = KA.allocate(backend, Int32, nb) + caps_h = Vector{Int32}(undef, nb) + level = 0 + while !all(frozen) && level < maxlevels + for b in 1:nb + caps_h[b] = frozen[b] ? Int32(0) : + Int32(max(trunc(Int, 1.5 * B.tvw_b[b] / ct_b[b]), 1)) + end + copyto!(caps, caps_h) + mv = KA.allocate(backend, Int32, n) + _mv_kernel!(backend)(mv, bid, caps; ndrange = n) + KA.synchronize(backend) + match = GpuND.gpu_match(backend, dx, da, dw, dv, mv; seed = seed + level) + cn, cx, ca, cw, cv, cmap = GpuND.gpu_contract(backend, dx, da, dw, dv, match) + levels === nothing || + push!(levels, (dx = dx, da = da, dv = dv, n = n, cmap = cmap, bid = bid)) + bidc = KA.allocate(backend, Int32, cn) + _bidmap_kernel!(backend)(bidc, bid, cmap; ndrange = n) + KA.synchronize(backend) + nvc = _percount(backend, bidc, nb, cn) + for b in 1:nb + frozen[b] && continue + if nvc[b] <= ct_b[b] || nvc[b] > ME.COARSEN_FRACTION * nv[b] + frozen[b] = true # done, or stalled at this size + end + end + nv = nvc + dx, da, dw, dv, bid, n = cx, ca, cw, cv, bidc, cn + level += 1 + end + return (Batch(n, nb, dx, da, dw, dv, bid, nv, copy(B.tvw_b)), frozen) +end + +end # module diff --git a/bench/ordering/gpu_nodend.jl b/bench/ordering/gpu_nodend.jl index a8c7d9fd..a969a7ab 100644 --- a/bench/ordering/gpu_nodend.jl +++ b/bench/ordering/gpu_nodend.jl @@ -26,5 +26,6 @@ include(joinpath(@__DIR__, "MetisExact.jl")) include(joinpath(@__DIR__, "GpuND.jl")) include(joinpath(@__DIR__, "GpuRefine.jl")) include(joinpath(@__DIR__, "Hybrid.jl")) +include(joinpath(@__DIR__, "GpuBatch.jl")) MetisExact.init_libmetis!(Metis.libmetis) From 0cf1b7c11e6e079f1b7f45a0f183a04d95b97555 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:37:30 +0000 Subject: [PATCH 49/65] Cleanup of the consolidation branch (PR #126) * Metis is a dependency: the Metis extension moves into src/symbolic/ordering.jl as `nd_algorithm`; `ND_PROVIDER`, `nd_available` and the `nd_available` ordering-stats field are gone, with the tests, the test environment and the docs following. * bench/ordering (the GPU NodeND prototype, referenced by nothing) is removed. * Fused: no module-global plan state (the backend comes from the solver, the inverse buffers are arguments of the sweeps, the merged tier's subtree list lives in the solve plan); the element type comes from the factor instead of hard-coded Float64; stdout prints go through the package logger; dead code removed (`algo1_solve!`, `waiter_kernel!` with its undefined `scap`, the `if true` wrapper, the unused `nstreams`/`debug`/`s2`/`nblocks`); the per-architecture tuning keys on a backend hook (`max_threads_per_multiprocessor`, defined by the CUDA extension) instead of module reflection. * The MadNLP extension, the e2e bench scripts and the bench write-ups follow (retired file names, the `main` branch in the reproduction command). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XYWifvvbufNiXqVYuvCrtp --- Project.toml | 5 +- README.md | 7 +- bench/ARCH-TUNING.md | 2 +- bench/PERFORMANCE-2026-10.md | 6 +- bench/README.md | 4 +- bench/e2e/amd_baseline.jl | 1 - bench/e2e/amd_sweep.jl | 1 - bench/e2e/fresh_machine.sh | 12 +- bench/e2e/ldl_fused_race.jl | 1 - bench/e2e/ldl_fused_test.jl | 14 - bench/ordering/GpuBatch.jl | 177 -- bench/ordering/GpuND.jl | 326 ---- bench/ordering/GpuRefine.jl | 267 --- bench/ordering/Hybrid.jl | 272 ---- bench/ordering/MetisExact.jl | 2411 ---------------------------- bench/ordering/gpu_nodend.jl | 31 - bench/ordering/nodend_smoke.jl | 51 - docs/src/index.md | 8 +- docs/src/man/parameters.md | 2 +- ext/SparseDirectSolverCUDAExt.jl | 8 + ext/SparseDirectSolverMadNLPExt.jl | 24 +- ext/SparseDirectSolverMetisExt.jl | 30 - src/SparseDirectSolver.jl | 4 +- src/dense/capabilities.jl | 10 + src/fused.jl | 350 ++-- src/symbolic/ordering.jl | 66 +- src/types.jl | 2 +- test/Project.toml | 2 - test/runtests.jl | 1 - test/test_symbolic_etree.jl | 33 +- test/test_symbolic_pairs.jl | 2 +- 31 files changed, 240 insertions(+), 3890 deletions(-) delete mode 100644 bench/ordering/GpuBatch.jl delete mode 100644 bench/ordering/GpuND.jl delete mode 100644 bench/ordering/GpuRefine.jl delete mode 100644 bench/ordering/Hybrid.jl delete mode 100644 bench/ordering/MetisExact.jl delete mode 100644 bench/ordering/gpu_nodend.jl delete mode 100644 bench/ordering/nodend_smoke.jl delete mode 100644 ext/SparseDirectSolverMetisExt.jl diff --git a/Project.toml b/Project.toml index 50bb65f5..0064136d 100644 --- a/Project.toml +++ b/Project.toml @@ -12,6 +12,7 @@ GPUArrays = "0c68f7d7-f131-5f86-a1c3-88cf8149b2d7" GPUArraysCore = "46192b85-c4d5-4398-a991-12ede77f4527" KernelAbstractions = "63c18a36-062a-441e-b654-da1e3ab1ce7c" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" +Metis = "2679e427-3c69-5b7f-982b-ece356f1e94b" SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" [weakdeps] @@ -19,7 +20,6 @@ AMDGPU = "21141c5a-9bdb-4563-92ae-f87d6854732e" CUDACore = "bd0ed864-bdfe-4181-a5ed-ce625a5fdea2" Krylov = "ba0b0d4f-ebba-5204-a429-3ac8c609bfb7" MadNLP = "2621e9c9-9eb4-46b1-8089-e8c72242dfb6" -Metis = "2679e427-3c69-5b7f-982b-ece356f1e94b" cuBLAS = "182d3088-87b7-4494-8cad-fc6afaa545bc" cuSOLVER = "887afef0-6a32-4de5-add4-7827692ba8fc" cuSPARSE = "b26da814-b3bc-49ef-b0ee-c816305aa060" @@ -29,7 +29,6 @@ SparseDirectSolverAMDGPUExt = "AMDGPU" SparseDirectSolverCUDAExt = ["CUDACore", "cuBLAS", "cuSOLVER", "cuSPARSE"] SparseDirectSolverKrylovExt = "Krylov" SparseDirectSolverMadNLPExt = "MadNLP" -SparseDirectSolverMetisExt = "Metis" [compat] AMD = "0.5" @@ -41,9 +40,9 @@ CliqueTrees = "1" GPUArrays = "11, 12" GPUArraysCore = "0.2" KernelAbstractions = "0.9" -MadNLP = "0.8" Krylov = "0.10" LinearAlgebra = "1.13" +MadNLP = "0.8" Metis = "1" SparseArrays = "1.13" cuBLAS = "6" diff --git a/README.md b/README.md index 5e83efb7..e630a55f 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ oneAPI, Metal and the KernelAbstractions CPU backend. **Status: v0.1 under construction.** Working today, on the CPU backend, on CUDA and on AMDGPU (ROCm): -* host symbolic analysis: AMD or nested dissection (METIS through the Metis - extension) ordering, elimination tree, supernodes with GPU-tuned +* host symbolic analysis: AMD or nested dissection (METIS) ordering, + elimination tree, supernodes with GPU-tuned amalgamation, static factor layout and device assembly maps; for symmetric indefinite matrices the analysis pairs structurally zero pivots with a partner (`pivot_pairs`, MA57-style compressed-graph ordering) so that the @@ -108,8 +108,7 @@ Pkg.add(url = "https://github.com/exanauts/SparseDirectSolver.jl") Loading CUDA.jl enables the CUDA extension (`CuSparseMatrixCSR`/`CuSparseMatrixCSC` constructors, cuBLAS/cuSOLVER dense kernels); loading AMDGPU.jl enables the AMDGPU extension (`ROCSparseMatrixCSR`/`ROCSparseMatrixCSC` constructors, -rocBLAS/rocSOLVER dense kernels; ROCm must be found by AMDGPU.jl). Loading Metis.jl enables nested -dissection ordering; without it the ordering is AMD. +rocBLAS/rocSOLVER dense kernels; ROCm must be found by AMDGPU.jl). ## Usage diff --git a/bench/ARCH-TUNING.md b/bench/ARCH-TUNING.md index 325f197d..15474cc0 100644 --- a/bench/ARCH-TUNING.md +++ b/bench/ARCH-TUNING.md @@ -88,7 +88,7 @@ champions. Workload throughout: the 78k-bus ACOPF condensed KKT | RTX 3080 | sm_86 | 1536 | merged | 32.4 ms (10 GB: kernel-level only) | — | | Radeon VII | gfx906 | wave64 | merged, bud 8K, WGF 256, cap 16 | **40.0 ms** | — | -All of this is bundled: `MadNLPSDS.SDSTuning` holds every knob as a runtime +All of this is bundled: `SparseDirectSolver.Fused.SDSTuning` holds every knob as a runtime field, `default_tuning(backend)` is the single per-architecture defaults table, and `madnlp(m; ..., sds_tuning = SDSTuning(; workgroup = 256))` overrides it. The portable kernel takes workgroup size and spin cap per plan — nothing is diff --git a/bench/PERFORMANCE-2026-10.md b/bench/PERFORMANCE-2026-10.md index 40b814af..4867cf32 100644 --- a/bench/PERFORMANCE-2026-10.md +++ b/bench/PERFORMANCE-2026-10.md @@ -159,10 +159,10 @@ factor deltas, not info alone. One command on a fresh GPU machine (NVIDIA or AMD, autodetected): ```bash -julia +1.13 --startup-file=no -i -e 'include(download("https://raw.githubusercontent.com/exanauts/SparseDirectSolver.jl/custom-reordering/bench/e2e/fresh_machine.jl"))' +julia +1.13 --startup-file=no -i -e 'include(download("https://raw.githubusercontent.com/exanauts/SparseDirectSolver.jl/main/bench/e2e/fresh_machine.jl"))' ``` Kernel-level benchmarks: the scripts listed in `bench/ARCH-TUNING.md` against `bench/data/kkt_pglib_opf_case78484_epigrids_condensed_10.mtx`. Per-arch -defaults: `MadNLPSDS.default_tuning`; override any knob with -`madnlp(m; sds_tuning = MadNLPSDS.SDSTuning(; ...))`. +defaults: `SparseDirectSolver.Fused.default_tuning`; override any knob with +`madnlp(m; sds_tuning = SparseDirectSolver.Fused.SDSTuning(; ...))`. diff --git a/bench/README.md b/bench/README.md index 3de3e0f7..66a49e7b 100644 --- a/bench/README.md +++ b/bench/README.md @@ -22,7 +22,9 @@ Baselines every later milestone is measured against (PLAN.md §5 M0, §7). The | `order_search.jl`, `get_cudss_perm.jl` | PR #107 ordering study: METIS knob sweep and schedule depth; extraction of cuDSS's reordering through the raw C API for replay under `user_perm` | | `ordering_chooser.jl` | T22 (issue #108): AMD vs ND per harness matrix and dump (schedule depth, fundamental and column-etree depth, nnz(L), flops) and the choice of the T22 and T05 cost models; host only, no GPU: `julia --project=bench bench/ordering_chooser.jl` (`SDS_BENCH_SUITESPARSE=0` skips MatrixDepot) | | `amd_proto.jl`, `amd/Project.toml` | the solve prototype on ROCm (ROCArray, raw-array constructor, `Threads.atomic_fence`); environment `bench/amd/` | -| `e2e/MadNLPSDS.jl`, `e2e/SDSFused.jl`, `e2e/run_gv100.jl`, `e2e/run_amd.jl` | MadNLP `AbstractLinearSolver` over the public API (prototype of the External task) and the prototype kernels packaged backend-portably; full ACOPF interior-point runs on CUDA and AMD; environment `bench/e2e/` (MadNLP, MadNLPGPU, ExaModels, ExaModelsPower) | +| `e2e/run_gv100.jl`, `e2e/run_amd.jl`, `e2e/fresh_machine.{jl,sh}` | full ACOPF interior-point runs through the MadNLP extension (`SparseDirectSolver.madnlp_solver()`, the fused pipeline of `src/fused.jl`) on CUDA and AMD, and the one-file fresh-machine workflow; environment `bench/e2e/` (MadNLP, MadNLPGPU, ExaModels, ExaModelsPower) | +| `e2e/ldl_fused_test.jl`, `e2e/ldl_fused_race.jl`, `e2e/amd_baseline.jl`, `e2e/amd_sweep.jl` | gates and timings of the fused Cholesky/LDLᵀ refactorization and the fused solve against the stock kernels on the 78k-bus condensed KKT (CUDA; the `amd_*` scripts on ROCm), see `PERFORMANCE-2026-10.md` and `ARCH-TUNING.md` | +| `e2e/SDSCPU.jl`, `e2e/cpu_fast_test.jl`, `e2e/hsl_cpu_bench.jl`, `e2e/ma27_bench.jl`, `e2e/ma57_bench.jl` | the task-parallel CPU refactorization prototype over the stock schedule and its HSL (MA27/MA57/MA86 through libcoinhsl) and CHOLMOD references | | `pivot_pairs.jl` | 2×2 pivot pairs of the `S` analysis (issue #66): `pivot_pairs` = `none`/`default`/`all` on the K2 dumps and the KKT generators, with nnz(L), zero/perturbed/2×2 pivots, max abs L and factor error of the CPU reference LDLᵀ; package environment: `julia --project=. bench/pivot_pairs.jl [--only=case118,...] [--generators=false]` | ## Commands diff --git a/bench/e2e/amd_baseline.jl b/bench/e2e/amd_baseline.jl index b294946d..bf7b65cc 100644 --- a/bench/e2e/amd_baseline.jl +++ b/bench/e2e/amd_baseline.jl @@ -17,7 +17,6 @@ Lh = tril(A) Random.seed!(666) bh = rand(n) S3 = (p = Vector{Int32}(undef, n); read!(joinpath(BD, "perm_cand3.bin"), p); Int.(p)) -SDSFused.BACKEND[] = AMDGPU.ROCBackend() sync() = AMDGPU.synchronize() # build via plain device arrays (AMDGPU has no CuSparseMatrixCSR; DirectSolver takes raw triplets) diff --git a/bench/e2e/amd_sweep.jl b/bench/e2e/amd_sweep.jl index ce4ede78..c008e9c6 100644 --- a/bench/e2e/amd_sweep.jl +++ b/bench/e2e/amd_sweep.jl @@ -16,7 +16,6 @@ Lh = tril(A) Random.seed!(666) bh = rand(n) S3 = (p = Vector{Int32}(undef, n); read!(joinpath(BD, "perm_cand3.bin"), p); Int.(p)) -SDSFused.BACKEND[] = AMDGPU.ROCBackend() sync() = AMDGPU.synchronize() say(x...) = (println(x...); flush(stdout)) diff --git a/bench/e2e/fresh_machine.sh b/bench/e2e/fresh_machine.sh index 47a5d72b..ae9ee124 100755 --- a/bench/e2e/fresh_machine.sh +++ b/bench/e2e/fresh_machine.sh @@ -9,13 +9,13 @@ # Everything is non-interactive (nothing reads the terminal), so the intended use # is detached from the login session (the suites + workflow run for an hour or more): # -# curl -fsSL https://raw.githubusercontent.com/exanauts/SparseDirectSolver.jl/divfree-chol/bench/e2e/fresh_machine.sh -o fresh_machine.sh +# curl -fsSL https://raw.githubusercontent.com/exanauts/SparseDirectSolver.jl/main/bench/e2e/fresh_machine.sh -o fresh_machine.sh # nohup bash fresh_machine.sh > sds-fresh.log 2>&1 & # then log out; tail -f sds-fresh.log # # or under Slurm: sbatch -p --gres=gpu:1 -c 8 --mem=64G -t 120 --wrap "bash fresh_machine.sh" # # Knobs (env vars): -# SDS_BRANCH=divfree-chol git ref to test (default: the division-free kernels PR) +# SDS_BRANCH=main git ref to test # SDS_DIR=$HOME/sds-test checkout location # SKIP_TESTS=1 skip the test suites, run only the MadNLP workflow # CPU_ONLY_TESTS=1 run only the CPU suite (no functional GPU needed for it) @@ -28,7 +28,7 @@ set -euo pipefail exec < /dev/null # never read the terminal: safe to detach -SDS_BRANCH="${SDS_BRANCH:-divfree-chol}" +SDS_BRANCH="${SDS_BRANCH:-main}" SDS_DIR="${SDS_DIR:-$HOME/sds-test}" if [ -z "${SDS_BACKEND:-}" ]; then @@ -78,9 +78,9 @@ fi # --- 4. MadNLP workflow: 78k-bus ACOPF, cuDSS vs SDS on the same GPU ----------- # ext/SparseDirectSolverMadNLPExt.jl defines the MadNLP.AbstractLinearSolver wrapper -# (SDSSolver = stock library; SDSProtoSolver = PR #107 prototype kernels). -# NVIDIA (run_gv100.jl): MadNLPGPU.CUDSSSolver vs SDSProtoSolver. -# AMD (run_amd.jl): SDSSolver vs SDSProtoSolver. +# (SparseDirectSolver.madnlp_solver(): the fused pipeline). +# NVIDIA (run_gv100.jl): MadNLPGPU.CUDSSSolver vs SparseDirectSolver. +# AMD (run_amd.jl): SparseDirectSolver. # Each prints wall/solver/linear-solver times, iteration counts and objectives # (convergence parity is the workflow test: same iterations, same objective). echo "== MadNLP + SDS workflow via $RUNNER (downloads the pglib case on first use)" diff --git a/bench/e2e/ldl_fused_race.jl b/bench/e2e/ldl_fused_race.jl index b7d9b1e5..427cc986 100644 --- a/bench/e2e/ldl_fused_race.jl +++ b/bench/e2e/ldl_fused_race.jl @@ -16,7 +16,6 @@ Lh = tril(A) Random.seed!(666) bh = rand(n) S3 = (p = Vector{Int32}(undef, n); read!(joinpath(BD, "perm_cand3.bin"), p); Int.(p)) -SDSFused.BACKEND[] = CUDA.CUDABackend() function setup(structure) bd = CuArray(bh); xd = similar(bd) diff --git a/bench/e2e/ldl_fused_test.jl b/bench/e2e/ldl_fused_test.jl index 9dc07fbd..dfebec05 100644 --- a/bench/e2e/ldl_fused_test.jl +++ b/bench/e2e/ldl_fused_test.jl @@ -17,7 +17,6 @@ Lh = tril(A) Random.seed!(666) bh = rand(n) S3 = (p = Vector{Int32}(undef, n); read!(joinpath(BD, "perm_cand3.bin"), p); Int.(p)) -SDSFused.BACKEND[] = CUDA.CUDABackend() function setup(structure) bd = CuArray(bh); xd = similar(bd) @@ -57,19 +56,6 @@ flush(stdout) # --- fused solve (inverse path + D pass) --- inv11, inv_ptr, invertf, _ = SDSFused.build_inverse(s) -plan0 = s.workspace.plan -nodes0 = s.symbolic.schedule.group_nodes -stp = Array(s.symbolic.subtree_ptr) -sub_ids = Int32[] -for k in eachindex(plan0.kind) - plan0.kind[k] == SDS.SOLVE_SUBTREES || continue - append!(sub_ids, Int32.(nodes0[plan0.first[k]:plan0.last[k]])) -end -sort!(sub_ids; by = e -> stp[e + 1] - stp[e], rev = true) -SDSFused.SSUB[] = SDSFused.DVEC(sub_ids) -SDSFused.NSUB[] = length(sub_ids) -SDSFused.INV11[] = inv11 -SDSFused.INVPTR[] = inv_ptr fz1 = SDSFused.build_fused(s; kstart = 1, wcap = 256) fz = ([(fz1, 64)], true) invertf() diff --git a/bench/ordering/GpuBatch.jl b/bench/ordering/GpuBatch.jl deleted file mode 100644 index 0861c1dd..00000000 --- a/bench/ordering/GpuBatch.jl +++ /dev/null @@ -1,177 +0,0 @@ -module GpuBatch - -# Level-batched sub-bisections: the ~80% of NodeND time that is thousands of -# small host bisections, re-expressed so a whole recursion level of blocks is -# ONE block-diagonal device graph, coarsened in single kernel passes. Blocks -# are disconnected components of the batch, so GpuND's matching/contraction -# apply unchanged: matching proposals follow edges and cannot cross blocks, -# and contraction preserves the block structure by construction. Per-block -# control (CoarsenTo, freezing a finished block) rides on the per-vertex -# maxvwgt vector gpu_match already takes (cap 0 = no matches = identity). -# -# The 2026-10-09 Batched.jl attempt is the documented dead-end this replaces: -# it re-sliced every block to a host GraphT at every level (12x regression). -# Here the batch stays device-resident across levels; the host sees only -# per-block counters (nb Int32s per level). - -using SparseDirectSolver.KernelAbstractions -const KA = KernelAbstractions -using SparseDirectSolver.Atomix -using ..MetisExact, ..GpuND -const ME = MetisExact - -export build_batch, coarsen_batch!, batch_invariants - -struct Batch - n::Int # total vertices - nb::Int # blocks - dx; da; dw; dv # device CSR, GpuND conventions (0-based ids) - bid # device Int32, 1-based block id per vertex - nv_b::Vector{Int} # host per-block vertex counts - tvw_b::Vector{Int} # host per-block total vertex weight -end - -""" - build_batch(backend, graphs::Vector{ME.GraphT}) -> Batch - -Concatenate independent subgraphs into one block-diagonal device CSR -(vertex ids shifted; no edges are added, so blocks stay disconnected). -""" -function build_batch(backend, graphs::Vector{ME.GraphT}) - nb = length(graphs) - n = sum(g.nvtxs for g in graphs) - ne = sum(g.nedges for g in graphs) - xadj = Vector{Int32}(undef, n + 1) - adj = Vector{Int32}(undef, ne) - adjw = Vector{Int32}(undef, ne) - vw = Vector{Int32}(undef, n) - bid = Vector{Int32}(undef, n) - nv_b = Vector{Int}(undef, nb) - tvw_b = zeros(Int, nb) - xadj[1] = 0 - off = 0 - voff = 0 - for (b, g) in enumerate(graphs) - nv_b[b] = g.nvtxs - for i in 1:g.nvtxs - vw[voff + i] = Int32(g.vwgt[i]) - tvw_b[b] += g.vwgt[i] - bid[voff + i] = Int32(b) - xadj[voff + i + 1] = Int32(off + g.xadj[i + 1]) - end - for p in 1:g.nedges - adj[off + p] = Int32(g.adjncy[p] + voff) - adjw[off + p] = Int32(g.adjwgt[p]) - end - off += g.nedges - voff += g.nvtxs - end - dev(x) = (y = KA.allocate(backend, Int32, length(x)); copyto!(y, x); y) - Batch(n, nb, dev(xadj), dev(adj), dev(adjw), dev(vw), dev(bid), nv_b, tvw_b) -end - -@kernel function _mv_kernel!(mv, @Const(bid), @Const(caps)) - v = @index(Global) - @inbounds mv[v] = caps[bid[v]] -end - -# every fine vertex of a coarse vertex carries the same block id -@kernel function _bidmap_kernel!(bidc, @Const(bid), @Const(cmap)) - v = @index(Global) - @inbounds bidc[cmap[v] + 1] = bid[v] -end - -@kernel function _bcount_kernel!(cnt, @Const(bid)) - v = @index(Global) - @inbounds Atomix.@atomic cnt[bid[v]] += Int32(1) -end - -@kernel function _bwsum_kernel!(ws, @Const(bid), @Const(vwgt)) - v = @index(Global) - @inbounds Atomix.@atomic ws[bid[v]] += Int32(vwgt[v]) -end - -@kernel function _crossedge_kernel!(bad, @Const(xadj), @Const(adjncy), @Const(bid)) - v = @index(Global) - @inbounds for p in (xadj[v] + 1):xadj[v + 1] - bid[adjncy[p] + 1] != bid[v] && (bad[1] = Int32(1)) - end -end - -function _percount(backend, bid, nb, n) - cnt = KA.allocate(backend, Int32, nb) - fill!(cnt, Int32(0)) - _bcount_kernel!(backend)(cnt, bid; ndrange = n) - KA.synchronize(backend) - return Int.(Array(cnt)) -end - -""" - batch_invariants(backend, B) -> (cross_block_edges::Bool, nv, wsum) - -Structural checks: no edge crosses blocks; per-block vertex counts and weight -sums (the weight sums must be invariant under every contraction level). -""" -function batch_invariants(backend, B::Batch) - bad = KA.allocate(backend, Int32, 1) - fill!(bad, Int32(0)) - _crossedge_kernel!(backend)(bad, B.dx, B.da, B.bid; ndrange = B.n) - ws = KA.allocate(backend, Int32, B.nb) - fill!(ws, Int32(0)) - _bwsum_kernel!(backend)(ws, B.bid, B.dv; ndrange = B.n) - KA.synchronize(backend) - return (Array(bad)[1] != 0, _percount(backend, B.bid, B.nb, B.n), Int.(Array(ws))) -end - -""" - coarsen_batch!(backend, B; seed = 1, levels = nothing, maxlevels = 40) - -> (B_coarse, frozen_done) - -Coarsen every block of the batch simultaneously until each reaches its METIS -CoarsenTo (clamp(nvtxs/8, 40, 100)) or stalls (shrink > COARSEN_FRACTION), -freezing finished blocks via a zero matching cap. `levels`, when a vector, is -filled with (dx, da, dv, n, cmap, bid) per level, finest first, for device -uncoarsening. -""" -function coarsen_batch!(backend, B::Batch; seed::Integer = 1, - levels::Union{Nothing, Vector{Any}} = nothing, - maxlevels::Int = 40) - nb = B.nb - ct_b = [clamp(B.nv_b[b] ÷ 8, 40, 100) for b in 1:nb] - frozen = [B.nv_b[b] <= ct_b[b] for b in 1:nb] - nv = copy(B.nv_b) - dx, da, dw, dv, bid, n = B.dx, B.da, B.dw, B.dv, B.bid, B.n - caps = KA.allocate(backend, Int32, nb) - caps_h = Vector{Int32}(undef, nb) - level = 0 - while !all(frozen) && level < maxlevels - for b in 1:nb - caps_h[b] = frozen[b] ? Int32(0) : - Int32(max(trunc(Int, 1.5 * B.tvw_b[b] / ct_b[b]), 1)) - end - copyto!(caps, caps_h) - mv = KA.allocate(backend, Int32, n) - _mv_kernel!(backend)(mv, bid, caps; ndrange = n) - KA.synchronize(backend) - match = GpuND.gpu_match(backend, dx, da, dw, dv, mv; seed = seed + level) - cn, cx, ca, cw, cv, cmap = GpuND.gpu_contract(backend, dx, da, dw, dv, match) - levels === nothing || - push!(levels, (dx = dx, da = da, dv = dv, n = n, cmap = cmap, bid = bid)) - bidc = KA.allocate(backend, Int32, cn) - _bidmap_kernel!(backend)(bidc, bid, cmap; ndrange = n) - KA.synchronize(backend) - nvc = _percount(backend, bidc, nb, cn) - for b in 1:nb - frozen[b] && continue - if nvc[b] <= ct_b[b] || nvc[b] > ME.COARSEN_FRACTION * nv[b] - frozen[b] = true # done, or stalled at this size - end - end - nv = nvc - dx, da, dw, dv, bid, n = cx, ca, cw, cv, bidc, cn - level += 1 - end - return (Batch(n, nb, dx, da, dw, dv, bid, nv, copy(B.tvw_b)), frozen) -end - -end # module diff --git a/bench/ordering/GpuND.jl b/bench/ordering/GpuND.jl deleted file mode 100644 index 11c07770..00000000 --- a/bench/ordering/GpuND.jl +++ /dev/null @@ -1,326 +0,0 @@ -module GpuND - -# M2: device kernels for the NodeND pipeline, written on KernelAbstractions -# against the exact host port (MetisExact.jl) as the stage oracle. -# -# Parallelization contract, stage by stage: -# matching — hashed-handshake HEM: heaviest-edge preference, maxvwgt -# respected, per-round hash tie-breaking. Deterministic given -# (graph, seed, rounds) but NOT the sequential SHEM order, so -# it is validated by invariants + end quality, not bitwise. -# contraction — given a matching it is order-free: validated ROW-SET -# identical (sorted (id, wgt) rows) against the host -# CreateCoarseGraph fed the same match array. -# edge order — GPU coarse rows are sorted by coarse id (canonical), -# where METIS's are hash-insertion-ordered; downstream GPU -# stages define their own deterministic order. -# -# All indices 0-based in device arrays, matching the host port. - -using SparseDirectSolver.KernelAbstractions -const KA = KernelAbstractions - -export gpu_match, gpu_contract - -# --------------------------------------------------------------------------- -# matching: propose / resolve rounds - -@kernel function propose_kernel!(prop, @Const(xadj), @Const(adjncy), @Const(adjwgt), - @Const(vwgt), @Const(match), @Const(maxvwgt), salt::UInt64) - gi = @index(Global) - v0 = gi - 1 # 0-based vertex - v = Int32(v0) - if match[v + 1] == Int32(-2) # -2 = unmatched (device sentinel) - best = Int32(-1) - bw = Int32(-1) - bh = UInt64(0) - mv = maxvwgt[v + 1] # per-vertex cap (batched: per subgraph) - if mv > Int32(0) && vwgt[v + 1] < mv - for p in (xadj[v + 1] + 1):xadj[v + 2] - u = adjncy[p] - w = adjwgt[p] - if u != v && match[u + 1] == Int32(-2) && vwgt[v + 1] + vwgt[u + 1] <= mv - hu = (UInt64(u) * 0x9e3779b97f4a7c15 + salt) >> 1 - if w > bw || (w == bw && (best == Int32(-1) || hu > bh)) - best = u - bw = w - bh = hu - end - end - end - end - prop[v + 1] = best - else - prop[v + 1] = Int32(-1) - end -end - -@kernel function resolve_kernel!(match, @Const(prop)) - gi = @index(Global) - v0 = gi - 1 - v = Int32(v0) - u = prop[v + 1] - if u != Int32(-1) && prop[u + 1] == v && match[v + 1] == Int32(-2) - # mutual proposal; both sides see it symmetrically, no atomics needed - match[v + 1] = u - end -end - -""" - gpu_match(backend, xadj, adjncy, adjwgt, vwgt, maxvwgt; rounds = 8, seed = 1) -> match - -Heavy-edge matching by hashed mutual proposals. `match[v+1]` is the partner -(0-based) or `v` for a singleton. Input arrays are device vectors (Int32, -0-based ids); the result is a device vector. -""" -function gpu_match(backend, xadj, adjncy, adjwgt, vwgt, maxvwgt; - rounds::Int = 8, seed::Integer = 1) - n = length(xadj) - 1 - if maxvwgt isa Integer # scalar cap -> per-vertex vector - mv = KA.allocate(backend, Int32, n) - fill!(mv, Int32(maxvwgt)) - maxvwgt = mv - end - match = KA.allocate(backend, Int32, n) - fill!(match, Int32(-2)) - prop = KA.allocate(backend, Int32, n) - pk = propose_kernel!(backend) - rk = resolve_kernel!(backend) - for r in 1:rounds - salt = UInt64(seed) * 0xbf58476d1ce4e5b9 + UInt64(r) * 0x94d049bb133111eb - pk(prop, xadj, adjncy, adjwgt, vwgt, match, maxvwgt, salt; ndrange = n) - rk(match, prop; ndrange = n) - end - # 2-hop matching for hub-dense graphs (METIS Match_2HopAny semantics): - # unmatched low-degree vertices pair through a common neighbour. Parallel - # form: per-neighbour atomic-min "two smallest unmatched" registers, then - # the same mutual-proposal resolve as the handshake. Gated like METIS at - # >10% unmatched. - nun = count_unmatched(backend, match) - if nun > n ÷ 10 - minu = KA.allocate(backend, Int32, n) - min2u = KA.allocate(backend, Int32, n) - for (rnds, maxdeg) in ((2, 2), (2, 3), (3, typemax(Int32))) - for _ in 1:rnds - fill!(minu, typemax(Int32)) - fill!(min2u, typemax(Int32)) - minu_kernel!(backend)(minu, xadj, adjncy, match, maxvwgt, Int32(maxdeg); ndrange = n) - min2_kernel!(backend)(min2u, minu, xadj, adjncy, match, maxvwgt, Int32(maxdeg); ndrange = n) - twohop_propose_kernel!(backend)(prop, minu, min2u, xadj, adjncy, match, maxvwgt, - Int32(maxdeg); ndrange = n) - resolve_kernel!(backend)(match, prop; ndrange = n) - end - nun = count_unmatched(backend, match) - nun > n ÷ 10 || break - end - end - # leftovers become singletons [KERNEL] - singles_kernel!(backend)(match; ndrange = n) - KA.synchronize(backend) - return match -end - -function count_unmatched(backend, match) - f = KA.allocate(backend, Int32, length(match)) - unmatched_flag_kernel!(backend)(f, match; ndrange = length(match)) - return Int(sum(f)) # the reduction synchronizes -end - -@kernel function unmatched_flag_kernel!(f, @Const(match)) - gi = @index(Global) - f[gi] = match[gi] == Int32(-2) ? Int32(1) : Int32(0) -end - -@kernel function minu_kernel!(minu, @Const(xadj), @Const(adjncy), @Const(match), @Const(maxvwgt), maxdeg::Int32) - gi = @index(Global) - v0 = gi - 1 - v = Int32(v0) - if match[v + 1] == Int32(-2) && maxvwgt[v + 1] > Int32(0) && xadj[v + 2] - xadj[v + 1] < maxdeg - for p in (xadj[v + 1] + 1):xadj[v + 2] - c = adjncy[p] - KA.@atomic minu[c + 1] min v - end - end -end - -@kernel function min2_kernel!(min2u, @Const(minu), @Const(xadj), @Const(adjncy), - @Const(match), @Const(maxvwgt), maxdeg::Int32) - gi = @index(Global) - v0 = gi - 1 - v = Int32(v0) - if match[v + 1] == Int32(-2) && maxvwgt[v + 1] > Int32(0) && xadj[v + 2] - xadj[v + 1] < maxdeg - for p in (xadj[v + 1] + 1):xadj[v + 2] - c = adjncy[p] - if minu[c + 1] != v - KA.@atomic min2u[c + 1] min v - end - end - end -end - -@kernel function twohop_propose_kernel!(prop, @Const(minu), @Const(min2u), @Const(xadj), - @Const(adjncy), @Const(match), @Const(maxvwgt), maxdeg::Int32) - gi = @index(Global) - v0 = gi - 1 - v = Int32(v0) - best = Int32(-1) - if match[v + 1] == Int32(-2) && maxvwgt[v + 1] > Int32(0) && xadj[v + 2] - xadj[v + 1] < maxdeg - for p in (xadj[v + 1] + 1):xadj[v + 2] - c = adjncy[p] - u = minu[c + 1] == v ? min2u[c + 1] : minu[c + 1] - if u != typemax(Int32) && u != v && match[u + 1] == Int32(-2) - (best == Int32(-1) || u < best) && (best = u) - end - end - end - prop[v + 1] = best -end - -@kernel function singles_kernel!(match) - gi = @index(Global) - v0 = gi - 1 - if match[v0 + 1] == Int32(-2) - match[v0 + 1] = Int32(v0) - end -end - -# --------------------------------------------------------------------------- -# contraction: coarse ids by scan over pair representatives, then -# pack (coarse row, coarse col) into UInt64 keys, sort, reduce duplicates. - -@kernel function repflag_kernel!(flag, @Const(match)) - gi = @index(Global) - v0 = gi - 1 - flag[v0 + 1] = Int32(v0) <= match[v0 + 1] ? Int32(1) : Int32(0) -end - -@kernel function cmap_kernel!(cmap, @Const(match), @Const(repid)) - gi = @index(Global) - v0 = gi - 1 - u = match[v0 + 1] - if Int32(v0) <= u - cmap[v0 + 1] = repid[v0 + 1] - else - cmap[v0 + 1] = repid[u + 1] - end -end - -@kernel function packkeys_kernel!(keys, vals, @Const(xadj), @Const(adjncy), - @Const(adjwgt), @Const(cmap)) - gi = @index(Global) - v0 = gi - 1 - cv = cmap[v0 + 1] - for p in (xadj[v0 + 1] + 1):xadj[v0 + 2] - cu = cmap[adjncy[p] + 1] - if cu == cv - keys[p] = typemax(UInt64) # self edge: sorts to the end - vals[p] = Int32(0) - else - keys[p] = (UInt64(cv) << 32) | UInt64(cu) - vals[p] = adjwgt[p] - end - end -end - -@kernel function boundary_kernel!(isnew, @Const(keys)) - i = @index(Global) - k = keys[i] - if k == typemax(UInt64) - isnew[i] = Int32(0) - elseif i == 1 - isnew[i] = Int32(1) - else - isnew[i] = keys[i - 1] == k ? Int32(0) : Int32(1) - end -end - -@kernel function cvwgt_kernel!(cvwgt, @Const(vwgt), @Const(match), @Const(cmap)) - gi = @index(Global) - v0 = gi - 1 - u = match[v0 + 1] - if Int32(v0) <= u - w = vwgt[v0 + 1] - u != Int32(v0) && (w += vwgt[u + 1]) - cvwgt[cmap[v0 + 1] + 1] = w - end -end - -@kernel function scatter_edges_kernel!(cadjncy, cadjwgt, rowcount, @Const(keys), - @Const(segsum), @Const(isnew), @Const(pos)) - i = @index(Global) - if isnew[i] == Int32(1) - j = pos[i] # output slot (1-based) - k = keys[i] - cadjncy[j] = Int32(k & 0xffffffff) - cadjwgt[j] = segsum[i] - KA.@atomic rowcount[Int(k >> 32) + 2] += Int32(1) - end -end - -""" - gpu_contract(backend, xadj, adjncy, adjwgt, vwgt, match) - -> (cn, cxadj, cadjncy, cadjwgt, cvwgt, cmap) - -Contract a matching. Coarse rows come out sorted by coarse id. v2: fully -device-resident — sortperm + cumsum through GPUArrays, segmented reduction by -Int32 atomics (deterministic: integer adds commute), flags/scatters as -kernels. The representative scan is a cumsum on device too. -""" -function gpu_contract(backend, xadj, adjncy, adjwgt, vwgt, match) - n = length(xadj) - 1 - m = length(adjncy) - flag = KA.allocate(backend, Int32, n) - repflag_kernel!(backend)(flag, match; ndrange = n) - KA.synchronize(backend) - incl = cumsum(flag) # inclusive scan (GPUArrays / Base) - cn = Int(Array(view(incl, n:n))[1]) - repid = KA.allocate(backend, Int32, n) - exclusive_kernel!(backend)(repid, incl, flag; ndrange = n) - cmap = KA.allocate(backend, Int32, n) - cmap_kernel!(backend)(cmap, match, repid; ndrange = n) - keys = KA.allocate(backend, UInt64, m) - vals = KA.allocate(backend, Int32, m) - packkeys_kernel!(backend)(keys, vals, xadj, adjncy, adjwgt, cmap; ndrange = n) - KA.synchronize(backend) - p = sortperm(keys) - keys = keys[p] - vals = vals[p] - isnew = KA.allocate(backend, Int32, m) - boundary_kernel!(backend)(isnew, keys; ndrange = m) - KA.synchronize(backend) - segid = cumsum(isnew) # segment index per element (1-based) - nseg = Int(Array(view(segid, m:m))[1]) - cadjncy = KA.allocate(backend, Int32, nseg) - cadjwgt = KA.allocate(backend, Int32, nseg) - fill!(cadjwgt, Int32(0)) - rowcount = KA.allocate(backend, Int32, Int(cn) + 1) - fill!(rowcount, Int32(0)) - segfill_kernel!(backend)(cadjncy, cadjwgt, rowcount, keys, vals, isnew, segid; ndrange = m) - KA.synchronize(backend) - cxadj = cumsum(rowcount) # rowcount[1] = 0 stays 0 - cvwgt = KA.allocate(backend, Int32, Int(cn)) - cvwgt_kernel!(backend)(cvwgt, vwgt, match, cmap; ndrange = n) - KA.synchronize(backend) - return Int(cn), cxadj, cadjncy, cadjwgt, cvwgt, cmap -end - -@kernel function exclusive_kernel!(excl, @Const(incl), @Const(flag)) - gi = @index(Global) - excl[gi] = incl[gi] - flag[gi] -end - -@kernel function segfill_kernel!(cadjncy, cadjwgt, rowcount, @Const(keys), @Const(vals), - @Const(isnew), @Const(segid)) - gi = @index(Global) - k = keys[gi] - if k != typemax(UInt64) - sid = segid[gi] - KA.@atomic cadjwgt[sid] += vals[gi] - if isnew[gi] == Int32(1) - cadjncy[sid] = Int32(k & 0xffffffff) - KA.@atomic rowcount[Int(k >> 32) + 2] += Int32(1) - end - end -end - -end # module diff --git a/bench/ordering/GpuRefine.jl b/bench/ordering/GpuRefine.jl deleted file mode 100644 index 16ca1b23..00000000 --- a/bench/ordering/GpuRefine.jl +++ /dev/null @@ -1,267 +0,0 @@ -module GpuRefine - -# Device node-separator refinement: a round-based, data-parallel approximation -# of METIS's FM_2WayNodeRefine1Sided (MetisExact.jl:1911), so the uncoarsening -# walk of a bisection can stay on the device instead of round-tripping to the -# host FM at every level. This is the prerequisite for level-batched -# sub-bisections (the Batched.jl dead-end failed on exactly these host trips). -# -# Semantics relative to the serial FM: -# * a PASS moves separator vertices to ONE side (`to`), alternating sides -# across passes, exactly like the 1-sided FM; -# * within a pass, moves are selected in parallel: every separator vertex -# with gain = vwgt[v] - Σ_{u∈N(v), where[u]==other} vwgt[u] ≥ 0 moves, -# subject to a balance budget (badmaxpwgt, METIS's cap). The serial code's -# priority order, incremental rinfo and negative-gain hill-climb with -# best-prefix rollback are replaced by whole-pass accept/revert: the pass -# is kept only if it reduces the separator weight (ties: improves balance). -# * separator validity is preserved for ANY subset of movers: all movers go -# to the same side, and every other-side neighbor of a mover is pulled -# into the separator in the same pass, so no side-to-side edge can appear. -# -# Known approximation costs (accepted; the quality gate is end nnz_L): -# * concurrent movers overcount shared pulled neighbors in their gains; -# * no negative-gain hill-climbing; -# * under a binding balance budget the atomic claim makes the selected -# subset nondeterministic (the budget rarely binds in the ND chain). - -using SparseDirectSolver.KernelAbstractions -const KA = KernelAbstractions -using SparseDirectSolver.Atomix - -export refine_rounds!, project_where! - -@kernel function _project_kernel!(fwhere, @Const(cwhere), @Const(cmap)) - v = @index(Global) - @inbounds fwhere[v] = cwhere[cmap[v] + 1] -end - -# gain of moving separator vertex v to `to` (other = 1 - to); non-separator -# vertices get typemin so the selection predicate is a single compare -@kernel function _gains_kernel!(gain, @Const(xadj), @Const(adjncy), @Const(vwgt), - @Const(where_), other::Int32) - v = @index(Global) - @inbounds begin - if where_[v] == Int32(2) - g = Int32(vwgt[v]) - for p in (xadj[v] + 1):xadj[v + 1] - u = adjncy[p] + 1 - where_[u] == other && (g -= vwgt[u]) - end - gain[v] = g - else - gain[v] = typemin(Int32) - end - end -end - -# candidate filter: gain ≥ 0 AND locally maximal among separator neighbors -# (ties by hashed id). Adjacent separator vertices therefore never move in -# the same pass, which removes the dominant gain-overcount (shared pulled -# neighbors of adjacent movers) and is the standard conflict rule of -# round-based refinement (Jet / mt-KaHyPar style). -@kernel function _filter_kernel!(cand, @Const(gain), @Const(xadj), @Const(adjncy), - @Const(where_), seed::UInt32, thr::Int32) - v = @index(Global) - @inbounds begin - c = Int32(0) - g = gain[v] - if g >= thr - hv = (UInt32(v) * 0x9e3779b9) ⊻ seed - c = Int32(1) - for p in (xadj[v] + 1):xadj[v + 1] - u = adjncy[p] + 1 - where_[u] == Int32(2) || continue - gu = gain[u] - gu < thr && continue - hu = (UInt32(u) * 0x9e3779b9) ⊻ seed - if gu > g || (gu == g && hu > hv) - c = Int32(0) - break - end - end - end - cand[v] = c - end -end - -# select movers among candidates under the balance budget. budget[1] starts -# at badmaxpwgt - pwgts[to]; an atomic claim keeps pwgts[to] under cap. -@kernel function _select_kernel!(mask, budget, nmov, @Const(cand), @Const(vwgt)) - v = @index(Global) - @inbounds begin - m = Int32(0) - if cand[v] == Int32(1) - left = (Atomix.@atomic budget[1] += Int32(-vwgt[v])) - if left >= Int32(0) - m = Int32(1) - Atomix.@atomic nmov[1] += Int32(1) - end - end - mask[v] = m - end -end - -@kernel function _apply_kernel!(where_, @Const(mask), to::Int32) - v = @index(Global) - @inbounds mask[v] == Int32(1) && (where_[v] = to) -end - -# pull: any `other`-side vertex adjacent to a mover enters the separator -@kernel function _pull_kernel!(where_, @Const(xadj), @Const(adjncy), @Const(mask), - other::Int32) - v = @index(Global) - @inbounds if where_[v] == other - for p in (xadj[v] + 1):xadj[v + 1] - if mask[adjncy[p] + 1] == Int32(1) - where_[v] = Int32(2) - break - end - end - end -end - -@kernel function _pwgts_kernel!(pwgts, @Const(where_), @Const(vwgt)) - v = @index(Global) - @inbounds Atomix.@atomic pwgts[where_[v] + 1] += Int32(vwgt[v]) -end - -function _pwgts!(backend, dpw, dwhere, dvw, n) - fill!(dpw, Int32(0)) - _pwgts_kernel!(backend)(dpw, dwhere, dvw; ndrange = n) - KA.synchronize(backend) - return Int.(Array(dpw)) -end - -""" - project_where!(backend, fwhere, cwhere, cmap, n) - -`fwhere[v] = cwhere[cmap[v]+1]` on the device (METIS Project2WayNodePartition -without the host parameter rebuild). -""" -function project_where!(backend, fwhere, cwhere, cmap, n::Integer) - n > 0 && _project_kernel!(backend)(fwhere, cwhere, cmap; ndrange = Int(n)) - KA.synchronize(backend) - return fwhere -end - -""" - refine_rounds!(backend, dx, da, dv, dwhere, n, ubfactors; niter = 10) - -> (pwgts, npasses) - -Round-based 1-sided node refinement of `dwhere` (0/1/2 Int32, device) against -the device CSR (`dx`, `da` 0-based ids, `dv` weights). Runs up to `2niter` -alternating passes; each pass is accepted only if the separator weight drops -(ties: balance improves), reverted otherwise; stops after an even/odd pair -without improvement, like the serial FM's termination. -""" -function refine_rounds!(backend, dx, da, dv, dwhere, n::Integer, ubfactors::Real; - niter::Int = 10, patience::Int = 4) - n == 0 && return (Int[0, 0, 0], 0) - gain = KA.allocate(backend, Int32, n) - cand = KA.allocate(backend, Int32, n) - mask = KA.allocate(backend, Int32, n) - best = KA.allocate(backend, Int32, n) - budget = KA.allocate(backend, Int32, 1) - dpw = KA.allocate(backend, Int32, 3) - pw = _pwgts!(backend, dpw, dwhere, dv, n) - badmaxpwgt = trunc(Int, 0.5 * ubfactors * (pw[1] + pw[2] + pw[3])) - to = pw[1] < pw[2] ? 1 : 0 - copyto!(best, dwhere) # global best, restored at exit - best_pw = pw - since_best = 0 - npasses = 0 - nmov = KA.allocate(backend, Int32, 1) - nmov_h = Int32[0] - for pass in 0:(2 * niter - 1) - other, to = to, (to + 1) % 2 - # a PASS is a wavefront sweep at fixed direction, like the serial - # 1-sided FM where pulled vertices join the queue mid-pass: repeat - # micro-rounds until no admissible mover remains - for micro in 0:199 - _gains_kernel!(backend)(gain, dx, da, dv, dwhere, Int32(other); ndrange = Int(n)) - _filter_kernel!(backend)(cand, gain, dx, da, dwhere, - UInt32(2654435761) + UInt32(64 * pass + micro), - Int32(0); ndrange = Int(n)) - pwt = _pwgts!(backend, dpw, dwhere, dv, n) - copyto!(budget, Int32[max(badmaxpwgt - pwt[to + 1], 0)]) - fill!(nmov, Int32(0)) - _select_kernel!(backend)(mask, budget, nmov, cand, dv; ndrange = Int(n)) - KA.synchronize(backend) - copyto!(nmov_h, nmov) - nmov_h[1] == Int32(0) && break - _apply_kernel!(backend)(dwhere, mask, Int32(to); ndrange = Int(n)) - _pull_kernel!(backend)(dwhere, dx, da, mask, Int32(other); ndrange = Int(n)) - KA.synchronize(backend) - end - pw = _pwgts!(backend, dpw, dwhere, dv, n) - npasses += 1 - # hill-climb with a global-best record, like the serial FM's rollback: - # keep walking through non-improving passes (bounded by `patience` and - # the serial code's 1.10x escape hatch), remember the best state seen - if pw[3] < best_pw[3] || - (pw[3] == best_pw[3] && abs(pw[1] - pw[2]) < abs(best_pw[1] - best_pw[2])) - copyto!(best, dwhere) - best_pw = pw - since_best = 0 - else - since_best += 1 - (since_best > patience || pw[3] > 1.10 * best_pw[3]) && break - end - end - copyto!(dwhere, best) - return (best_pw, npasses) -end - -""" - balance_rounds!(backend, dx, da, dv, dwhere, n, ubfactors) -> pwgts - -Parallel FM_2WayNodeBalance: rounds of moves toward the LIGHT side. Positive -gains move freely; when none remain and the heavy side still exceeds METIS's -cap (0.5·ubfactors·(pw1+pw2)), the maximum-gain class moves even at negative -gain. Stops when the sides cross, the cap is satisfied with no free moves, or -nothing is admissible. -""" -function balance_rounds!(backend, dx, da, dv, dwhere, n::Integer, ubfactors::Real; - maxrounds::Int = 64) - gain = KA.allocate(backend, Int32, n) - cand = KA.allocate(backend, Int32, n) - mask = KA.allocate(backend, Int32, n) - budget = KA.allocate(backend, Int32, 1) - nmov = KA.allocate(backend, Int32, 1) - nmov_h = Int32[0] - dpw = KA.allocate(backend, Int32, 3) - pw = _pwgts!(backend, dpw, dwhere, dv, n) - badmax = trunc(Int, 0.5 * ubfactors * (pw[1] + pw[2])) - max(pw[1], pw[2]) < badmax && return pw - to = pw[1] < pw[2] ? 0 : 1 - other = 1 - to - for r in 1:maxrounds - pw[to + 1] >= pw[other + 1] && break - _gains_kernel!(backend)(gain, dx, da, dv, dwhere, Int32(other); ndrange = Int(n)) - KA.synchronize(backend) - gmax = maximum(gain) - gmax == typemin(Int32) && break # no separator vertices - thr = Int32(0) - if gmax < Int32(0) - pw[other + 1] <= badmax && break # balanced enough, only bad moves left - thr = gmax # admit the best (negative) class only - end - _filter_kernel!(backend)(cand, gain, dx, da, dwhere, - UInt32(0x85ebca6b) + UInt32(r), thr; ndrange = Int(n)) - b = max(badmax - pw[to + 1], 0) - b == 0 && break - copyto!(budget, Int32[b]) - fill!(nmov, Int32(0)) - _select_kernel!(backend)(mask, budget, nmov, cand, dv; ndrange = Int(n)) - KA.synchronize(backend) - copyto!(nmov_h, nmov) - nmov_h[1] == Int32(0) && break - _apply_kernel!(backend)(dwhere, mask, Int32(to); ndrange = Int(n)) - _pull_kernel!(backend)(dwhere, dx, da, mask, Int32(other); ndrange = Int(n)) - pw = _pwgts!(backend, dpw, dwhere, dv, n) - end - return pw -end - -end # module diff --git a/bench/ordering/Hybrid.jl b/bench/ordering/Hybrid.jl deleted file mode 100644 index 604ca46d..00000000 --- a/bench/ordering/Hybrid.jl +++ /dev/null @@ -1,272 +0,0 @@ -# Hybrid NodeND v1: the coarsening chain runs through the GpuND kernels -# (device-formulated matching + contraction), everything else through the -# bitwise-exact host port (initial separator, FM node refinement, split, MMD, -# recursion). Quality gate: nnz_L / schedule depth vs MetisExact on the same -# graphs — the cost of parallel matching is the ONLY delta by construction. -module Hybrid - -using SparseDirectSolver.KernelAbstractions -const KA = KernelAbstractions -using ..MetisExact -using ..GpuND -using Random -const ME = MetisExact - -export nodend_hybrid - -# device coarsening chain following CoarsenGraph's control flow. The device -# arrays stay resident across levels (each level's contraction output feeds -# the next level's matching directly); only the per-level host GraphT copies -# needed by the host refinement are downloaded. -function coarsen_gpu!(backend, ctrl::ME.CtrlT, graph::ME.GraphT; seed::Integer = 1, - keep_levels::Union{Nothing, Vector{Any}} = nothing) - ctrl.maxvwgt = trunc(Int, 1.5 * graph.tvwgt / ctrl.CoarsenTo) - dev(x, T) = (y = KA.allocate(backend, T, length(x)); copyto!(y, T.(x)); y) - n = graph.nvtxs - dx = dev(view(graph.xadj, 1:(n + 1)), Int32) - da = dev(view(graph.adjncy, 1:graph.nedges), Int32) - dw = dev(view(graph.adjwgt, 1:graph.nedges), Int32) - dv = dev(view(graph.vwgt, 1:n), Int32) - level = 0 - while true - match = GpuND.gpu_match(backend, dx, da, dw, dv, ctrl.maxvwgt; - seed = seed + level) - cn, cx, ca, cw, cv, cmap = GpuND.gpu_contract(backend, dx, da, dw, dv, match) - # the finer level's CSR + its fine->coarse map, for device uncoarsening - keep_levels === nothing || - push!(keep_levels, (dx = dx, da = da, dv = dv, n = graph.nvtxs, cmap = cmap)) - cgraph = ME.CreateGraph() - cgraph.nvtxs = cn - cgraph.nedges = length(ca) - cgraph.xadj = Vector{Int}(Array(cx)) - cgraph.adjncy = Vector{Int}(Array(ca)) - cgraph.adjwgt = Vector{Int}(Array(cw)) - cgraph.vwgt = Vector{Int}(Array(cv)) - ME.SetupGraph_tvwgt!(cgraph) - graph.cmap = Vector{Int}(Array(cmap)) - cgraph.finer = graph - graph.coarser = cgraph - graph = cgraph - dx, da, dw, dv = cx, ca, cw, cv # stay on the device - level += 1 - (graph.nvtxs > ctrl.CoarsenTo && - graph.nvtxs < ME.COARSEN_FRACTION * (graph.finer::ME.GraphT).nvtxs && - graph.nedges > graph.nvtxs ÷ 2) || break - end - return graph -end - -# below this size a subgraph's bisection runs entirely on the host exact -# port: small graphs pay GPU launch/transfer latency without filling the -# device, and the deep recursion has thousands of them -const GPU_MIN_NVTXS = Ref(50_000) - -# device uncoarsening: project + round-based parallel refinement per level -# (GpuRefine.jl) instead of the host FM walk. Off by default until the fill -# quality gate passes on the benchmark set. -const GPU_REFINE = Ref(false) -# :rounds — device wavefront refinement at every level; -# :project — device projection only, one exact host balance+FM at the finest -const GPU_REFINE_MODE = Ref(:rounds) -const GPU_REFINE_TRACE = Ref(false) # print per-level separator weights - -function refine_chain_gpu!(backend, ctrl::ME.CtrlT, graph::ME.GraphT, cgraph::ME.GraphT, - levels::Vector{Any}) - # chain[k] is the graph whose CSR/cmap is levels[k]; chain[end] = cgraph - chain = ME.GraphT[graph] - while chain[end] !== cgraph - push!(chain, chain[end].coarser::ME.GraphT) - end - # coarse half: EXACT host project/balance/refine below the size threshold - # (serial FM wins where graphs are tiny and every vertex is an aggregate; - # job_35: device rounds left the n=108 level at sides 90k/55k) - k = length(chain) - 1 - while k >= 1 && chain[k].nvtxs < REFINE_HOST_BELOW[] - g = chain[k] - ME.Project2WayNodePartition!(ctrl, g) - ME.FM_2WayNodeBalance!(ctrl, g) - ME.FM_2WayNodeRefine1Sided!(ctrl, g, ctrl.niter) - GPU_REFINE_TRACE[] && - println(" host n=", g.nvtxs, " sep=", g.pwgts[3], " sides=", - g.pwgts[1], "/", g.pwgts[2]) - k -= 1 - end - k == 0 && return nothing # whole chain refined on host - # fine half: device projection + balance + wavefront rounds - src = chain[k + 1] - cw = KA.allocate(backend, Int32, src.nvtxs) - copyto!(cw, Int32.(view(src.where_, 1:src.nvtxs))) - for j in k:-1:1 - L = levels[j] - fw = KA.allocate(backend, Int32, L.n) - Main.GpuRefine.project_where!(backend, fw, cw, L.cmap, L.n) - if GPU_REFINE_MODE[] == :rounds - Main.GpuRefine.balance_rounds!(backend, L.dx, L.da, L.dv, fw, L.n, - Float64(ctrl.ubfactors)) - pw, np = Main.GpuRefine.refine_rounds!(backend, L.dx, L.da, L.dv, fw, L.n, - Float64(ctrl.ubfactors); niter = ctrl.niter) - GPU_REFINE_TRACE[] && - println(" dev n=", L.n, " sep=", pw[3], " sides=", pw[1], "/", pw[2], - " passes=", np) - end - cw = fw - end - ME.Allocate2WayNodePartitionMemory!(graph) - copyto!(view(graph.where_, 1:graph.nvtxs), Int.(Array(cw))) - graph.coarser = nothing - ME.Compute2WayNodePartitionParams!(ctrl, graph) - if GPU_REFINE_MODE[] == :project - ME.FM_2WayNodeBalance!(ctrl, graph) - ME.FM_2WayNodeRefine1Sided!(ctrl, graph, ctrl.niter) - end - return nothing -end - -# device refinement runs only at levels at least this large; smaller levels -# use the exact serial walk above (they cost microseconds on the host) -const REFINE_HOST_BELOW = Ref(50_000) - -# L1-shaped bisection with the device coarsening -function node_bisection!(backend, ctrl::ME.CtrlT, graph::ME.GraphT, niparts::Int) - if graph.nvtxs < GPU_MIN_NVTXS[] - ME.MlevelNodeBisectionL2!(ctrl, graph, niparts) - return nothing - end - ctrl.CoarsenTo = graph.nvtxs ÷ 8 - ctrl.CoarsenTo > 100 && (ctrl.CoarsenTo = 100) - ctrl.CoarsenTo < 40 && (ctrl.CoarsenTo = 40) - levels = GPU_REFINE[] ? Any[] : nothing - cgraph = coarsen_gpu!(backend, ctrl, graph; keep_levels = levels) - niparts = max(1, cgraph.nvtxs <= ctrl.CoarsenTo ? niparts ÷ 2 : niparts) - ME.InitSeparator!(ctrl, cgraph, niparts) - if levels === nothing - ME.Refine2WayNode!(ctrl, graph, cgraph) - else - refine_chain_gpu!(backend, ctrl, graph, cgraph, levels) - end - return nothing -end - -# MlevelNodeBisectionMultiple semantics: nseps tries, keep the lightest separator -function node_bisection_multiple!(backend, ctrl::ME.CtrlT, graph::ME.GraphT) - if ctrl.nseps == 1 || graph.nvtxs < (ctrl.compress ? 1000 : 2000) - node_bisection!(backend, ctrl, graph, ME.LARGENIPARTS) - return nothing - end - bestwhere = Vector{Int}(undef, graph.nvtxs) - mincut = graph.tvwgt - for i in 0:(ctrl.nseps - 1) - node_bisection!(backend, ctrl, graph, ME.LARGENIPARTS) - if i == 0 || graph.mincut < mincut - mincut = graph.mincut - i < ctrl.nseps - 1 && copyto!(bestwhere, graph.where_) - end - mincut == 0 && break - end - if mincut != graph.mincut - copyto!(graph.where_, bestwhere) - ME.Compute2WayNodePartitionParams!(ctrl, graph) - end - return nothing -end - -_copy_ctrl(c::ME.CtrlT) = ME.CtrlT(c.ctype, c.rtype, c.iptype, c.nseps, c.niter, c.ufactor, - c.compress, c.ccorder, c.pfactor, c.no2hop, c.seed, - c.CoarsenTo, c.maxvwgt, c.ubfactors, copy(c.pijbm)) - -function dissect!(backend, ctrl::ME.CtrlT, graph::ME.GraphT, order::Vector{Int}, lastvtx::Int) - node_bisection_multiple!(backend, ctrl, graph) - nbnd = graph.nbnd - bndind = graph.bndind - label = graph.label - for i in 0:(nbnd - 1) - lastvtx -= 1 - order[label[bndind[i + 1] + 1] + 1] = lastvtx - end - lgraph, rgraph = ME.SplitGraphOrder(ctrl, graph) - tasks = Task[] - for (g, lv) in ((lgraph, lastvtx - rgraph.nvtxs), (rgraph, lastvtx)) - if g.nvtxs > ME.MMDSWITCH && g.nedges > 0 - if g.nvtxs < GPU_MIN_NVTXS[] && ME.RNG_TASK[] && Threads.nthreads() > 1 - c = _copy_ctrl(ctrl) - let g = g, lv = lv, c = c - push!(tasks, Threads.@spawn begin - Random.seed!(Random.TaskLocalRNG(), UInt32(hash((g.nvtxs, lv)) % UInt32)) - ME.MlevelNestedDissection!(c, g, order, lv) - end) - end - else - dissect!(backend, ctrl, g, order, lv) - end - else - ME.MMDOrder!(ctrl, g, order, lv) - end - end - foreach(wait, tasks) - return nothing -end - -""" - nodend_hybrid(backend, A; seed = -1) -> (perm, iperm) 1-based - -NodeND with the coarsening chain on the GpuND kernels and the rest on the -exact host port. Compression as in METIS_NodeND. -""" -function nodend_hybrid(backend, A; seed::Int = -1) - n = size(A, 1) - xadj = Vector{Int}(undef, n + 1) - adjncy = Int[] - rws = MetisExact.SparseArrays.rowvals(A) - xadj[1] = 0 - for j in 1:n - for p in MetisExact.SparseArrays.nzrange(A, j) - i = rws[p] - i != j && push!(adjncy, i - 1) - end - xadj[j + 1] = length(adjncy) - end - ctrl = ME.CtrlT(ME.METIS_CTYPE_SHEM, ME.METIS_RTYPE_SEP1SIDED, ME.METIS_IPTYPE_EDGE, - 1, 10, 200, true, false, 0.0, false, seed, 100, 0, - Float32(Float64(Float32(1.0 + 0.001 * 200)) + 0.0000499), Float32[]) - ME.RNG_TASK[] = Threads.nthreads() > 1 # threaded subtrees want task RNG - ME.InitRandom(ctrl.seed) - nnvtxs = 0 - graph = nothing - cptr = Int[] - cind = Int[] - if ctrl.compress - cptr = Vector{Int}(undef, n + 1) - cind = Vector{Int}(undef, n) - graph = ME.CompressGraph(n, xadj, adjncy, nothing, cptr, cind) - if graph === nothing - ctrl.compress = false - else - nnvtxs = graph.nvtxs - cfactor = 1.0 * n / nnvtxs - cfactor > 1.5 && ctrl.nseps == 1 && (ctrl.nseps = 2) - end - end - graph === nothing && (graph = ME.SetupGraph(n, xadj, adjncy, nothing)) - iperm = Vector{Int}(undef, n) - perm = Vector{Int}(undef, n) - dissect!(backend, ctrl, graph, iperm, graph.nvtxs) - if ctrl.compress - for i in 0:(nnvtxs - 1) - perm[iperm[i + 1] + 1] = i - end - l = 0 - for ii in 0:(nnvtxs - 1) - i = perm[ii + 1] - for j in cptr[i + 1]:(cptr[i + 2] - 1) - iperm[cind[j + 1] + 1] = l - l += 1 - end - end - end - for i in 0:(n - 1) - perm[iperm[i + 1] + 1] = i - end - return perm .+ 1, iperm .+ 1 -end - -end # module diff --git a/bench/ordering/MetisExact.jl b/bench/ordering/MetisExact.jl deleted file mode 100644 index 3d1b2b48..00000000 --- a/bench/ordering/MetisExact.jl +++ /dev/null @@ -1,2411 +0,0 @@ -module MetisExact - -using SparseArrays -using Libdl -using Random - -# Faithful Julia translation of METIS 5.1.0 (METIS_jll 5.1.4+0 = upstream -# 5.1.0 at amontoison/METIS e827ffe, build-system patches only), NodeND path -# with OMETIS defaults, idx_t = Int32 semantics, 0-based ids kept internally -# (C `a[i]` with value index i becomes `a[i + 1]`). Goal: bitwise-identical -# perm/iperm to libmetis before anything is moved to the GPU. -# -# Source of truth: ~/git/scratch/vixen-metis/metis-src/METIS/{libmetis,GKlib}. -# Every section names the C file and function it translates. - -export InitRandom, irand, irandInRange, irandArrayPermute! - -# --------------------------------------------------------------------------- -# GKlib/random.c — METIS_jll is built WITHOUT -DUSE_GKRAND (GKlib option -# default OFF, Yggdrasil does not set it), so the Mersenne-Twister code is -# compiled out and gk_randinit/gk_randint32 are libc srand()/rand(). We call -# the same libc, which is exact by construction on any host, and matches the -# shipped binary because both resolve to the process' C library. - -# Two RNG modes. :libc reproduces the shipped binary bitwise (process-global -# libc rand). RNG_TASK[] = true switches to Julia's task-local RNG so -# independent subtrees can run on threads deterministically (each task seeds -# its own stream); orderings are then METIS-quality but not bitwise-libmetis. -const RNG_TASK = Ref(false) - -function isrand(seed) - if RNG_TASK[] - Random.seed!(Random.TaskLocalRNG(), UInt32(seed % UInt32)) - else - ccall(:srand, Cvoid, (Cuint,), Cuint(seed % UInt32)) - end - return nothing -end -irand() = RNG_TASK[] ? Int(rand(Random.TaskLocalRNG(), UInt32) & 0x7fffffff) : - Int(ccall(:rand, Cint, ())) -irandInRange(max) = Int(mod(irand(), max)) - -# libmetis/util.c InitRandom -InitRandom(seed) = isrand(seed == -1 ? 4321 : seed) - -# gk_mkrandom.h randArrayPermute: p is 1-based storage of 0-based values when -# flag == 1; swaps are position swaps so the caller's base does not matter. -function irandArrayPermute!(n::Int, p::AbstractVector, nshuffles::Int, flag::Int) - if flag == 1 - for i in 1:n - p[i] = i - 1 - end - end - if n < 10 - for _ in 1:n - v = irandInRange(n) + 1 - u = irandInRange(n) + 1 - p[v], p[u] = p[u], p[v] - end - else - for _ in 1:nshuffles - v = irandInRange(n - 3) + 1 - u = irandInRange(n - 3) + 1 - # the template swaps (v+k, u+2), (v+1, u+3), (v+2, u+0), (v+3, u+1) - p[v], p[u + 2] = p[u + 2], p[v] - p[v + 1], p[u + 3] = p[u + 3], p[v + 1] - p[v + 2], p[u] = p[u], p[v + 2] - p[v + 3], p[u + 1] = p[u + 1], p[v + 3] - end - end - return p -end - - -# --------------------------------------------------------------------------- -# GKlib/gk_mksort.h GK_MKQSORT — the glibc-derived quicksort. Tie order is -# observable downstream (compression keys, matching orders), so the exact -# algorithm is translated, pointers becoming 1-based indices. - -const _QSORT_MAX_THRESH = 4 - -function gk_qsort!(v::AbstractVector, n::Int, lt::F) where {F} - n == 0 && return v - if n > _QSORT_MAX_THRESH - lo = 1 - hi = n - stack = Vector{Tuple{Int, Int}}() # (_lo, _hi); C pushes a sentinel - push!(stack, (0, 0)) - while true - mid = lo + ((hi - lo) >> 1) - if lt(v[mid], v[lo]) - v[mid], v[lo] = v[lo], v[mid] - end - jump = false - if lt(v[hi], v[mid]) - v[mid], v[hi] = v[hi], v[mid] - else - jump = true - end - if !jump && lt(v[mid], v[lo]) - v[mid], v[lo] = v[lo], v[mid] - end - left = lo + 1 - right = hi - 1 - while true - while lt(v[left], v[mid]) - left += 1 - end - while lt(v[mid], v[right]) - right -= 1 - end - if left < right - v[left], v[right] = v[right], v[left] - if mid == left - mid = right - elseif mid == right - mid = left - end - left += 1 - right -= 1 - elseif left == right - left += 1 - right -= 1 - break - end - left <= right || break - end - if right - lo <= _QSORT_MAX_THRESH - if hi - left <= _QSORT_MAX_THRESH - lo, hi = pop!(stack) - lo == 0 && break # popped the sentinel: done - else - lo = left - end - elseif hi - left <= _QSORT_MAX_THRESH - hi = right - elseif right - lo > hi - left - push!(stack, (lo, right)) - lo = left - else - push!(stack, (left, hi)) - hi = right - end - end - end - # insertion-sort tail - thresh = min(1 + _QSORT_MAX_THRESH, n) - tmp = 1 - for run in 2:thresh - lt(v[run], v[tmp]) && (tmp = run) - end - if tmp != 1 - v[tmp], v[1] = v[1], v[tmp] - end - run = 2 - while (run += 1) <= n - tmp = run - 1 - while lt(v[run], v[tmp]) - tmp -= 1 - end - tmp += 1 - if tmp != run - x = v[run] - for k in run:-1:(tmp + 1) - v[k] = v[k - 1] - end - v[tmp] = x - end - end - return v -end - -# libmetis/gklib.c — key/value pairs as tuples (key, val) -ikvsorti!(n::Int, v) = gk_qsort!(v, n, (a, b) -> a[1] < b[1]) -ikvsortii!(n::Int, v) = gk_qsort!(v, n, (a, b) -> a[1] < b[1] || (a[1] == b[1] && a[2] < b[2])) - -# libmetis/bucketsort.c BucketSortKeysInc (counting sort; keys in [0, max]) -function BucketSortKeysInc(n::Int, max::Int, keys, tperm, perm) - counts = zeros(Int, max + 2) - for i in 0:(n - 1) - counts[keys[i + 1] + 1] += 1 - end - for i in 1:max # MAKECSR over 0..max - counts[i + 1] += counts[i] - end - for i in (max + 1):-1:1 - counts[i + 1] = counts[i] - end - counts[1] = 0 - for ii in 0:(n - 1) - i = tperm[ii + 1] - perm[counts[keys[i + 1] + 1] + 1] = i - counts[keys[i + 1] + 1] += 1 - end - return perm -end - -# --------------------------------------------------------------------------- -# libmetis/struct.h graph_t (ncon = 1 fields only) — ids and offsets stay -# 0-based as in C; every array is a 1-based Julia Vector indexed [x + 1]. - -const NOVALUE = Int[] -const DBG = Ref(false) -const PROF = Dict{Symbol, Float64}() -const PROF_LOCK = ReentrantLock() -prof!(k, t) = (lock(() -> (PROF[k] = get(PROF, k, 0.0) + t), PROF_LOCK); nothing) - -mutable struct GraphT - nvtxs::Int - nedges::Int - xadj::Vector{Int} - vwgt::Vector{Int} - adjncy::Vector{Int} - adjwgt::Vector{Int} - tvwgt::Int - invtvwgt::Float32 # real_t = float32 in the shipped binary - label::Vector{Int} - cmap::Vector{Int} - # partition/refinement data - where_::Vector{Int} - pwgts::Vector{Int} - nbnd::Int - bndptr::Vector{Int} - bndind::Vector{Int} - id::Vector{Int} - ed::Vector{Int} - nrinfo::Vector{NTuple{2, Int}} # nrinfo_t.edegrees[2] - mincut::Int - coarser::Union{Nothing, GraphT} - finer::Union{Nothing, GraphT} -end - -CreateGraph() = GraphT(-1, -1, NOVALUE, NOVALUE, NOVALUE, NOVALUE, 0, 0.0f0, - NOVALUE, NOVALUE, NOVALUE, NOVALUE, -1, NOVALUE, NOVALUE, - NOVALUE, NOVALUE, NTuple{2, Int}[], -1, nothing, nothing) - -# libmetis/graph.c SetupGraph for OMETIS (ncon = 1, objtype = NODE) -function SetupGraph(nvtxs::Int, xadj::Vector{Int}, adjncy::Vector{Int}, - vwgt::Union{Nothing, Vector{Int}}) - graph = CreateGraph() - graph.nvtxs = nvtxs - graph.nedges = xadj[nvtxs + 1] - graph.xadj = xadj - graph.adjncy = adjncy - graph.vwgt = vwgt === nothing ? ones(Int, nvtxs) : vwgt - graph.adjwgt = ones(Int, graph.nedges) - SetupGraph_tvwgt!(graph) - SetupGraph_label!(graph) - return graph -end - -function SetupGraph_tvwgt!(graph::GraphT) - graph.tvwgt = sum(view(graph.vwgt, 1:graph.nvtxs); init = 0) - graph.invtvwgt = Float32(1.0 / (graph.tvwgt > 0 ? graph.tvwgt : 1)) - return nothing -end - -function SetupGraph_label!(graph::GraphT) - graph.label = collect(0:(graph.nvtxs - 1)) - return nothing -end - -function SetupSplitGraph(graph::GraphT, snvtxs::Int, snedges::Int) - sgraph = CreateGraph() - sgraph.nvtxs = snvtxs - sgraph.nedges = snedges - sgraph.xadj = Vector{Int}(undef, snvtxs + 1) - sgraph.vwgt = Vector{Int}(undef, snvtxs) - sgraph.adjncy = Vector{Int}(undef, snedges) - sgraph.adjwgt = Vector{Int}(undef, snedges) - sgraph.label = Vector{Int}(undef, snvtxs) - return sgraph -end - -# --------------------------------------------------------------------------- -# libmetis/compress.c CompressGraph — identical-adjacency vertices merge. -# Returns nothing when compression is below COMPRESSION_FRACTION. - -const COMPRESSION_FRACTION = 0.85 - -function CompressGraph(nvtxs::Int, xadj::Vector{Int}, adjncy::Vector{Int}, - vwgt::Union{Nothing, Vector{Int}}, cptr::Vector{Int}, cind::Vector{Int}) - mark = fill(-1, nvtxs) - map_ = fill(-1, nvtxs) - keys = Vector{Tuple{Int, Int}}(undef, nvtxs) - for i in 0:(nvtxs - 1) - k = 0 - for j in xadj[i + 1]:(xadj[i + 2] - 1) - k += adjncy[j + 1] - end - keys[i + 1] = (k + i, i) - end - ikvsorti!(nvtxs, keys) - l = 0 - cptr[1] = 0 - cnvtxs = 0 - for i in 0:(nvtxs - 1) - ii = keys[i + 1][2] - if map_[ii + 1] == -1 - mark[ii + 1] = i - for j in xadj[ii + 1]:(xadj[ii + 2] - 1) - mark[adjncy[j + 1] + 1] = i - end - map_[ii + 1] = cnvtxs - l += 1 - cind[l] = ii - for jj in (i + 1):(nvtxs - 1) - iii = keys[jj + 1][2] - if keys[i + 1][1] != keys[jj + 1][1] || - xadj[ii + 2] - xadj[ii + 1] != xadj[iii + 2] - xadj[iii + 1] - break - end - if map_[iii + 1] == -1 - ok = true - for p in xadj[iii + 1]:(xadj[iii + 2] - 1) - if mark[adjncy[p + 1] + 1] != i - ok = false - break - end - end - if ok - map_[iii + 1] = cnvtxs - l += 1 - cind[l] = iii - end - end - end - cnvtxs += 1 - cptr[cnvtxs + 1] = l - end - end - cnvtxs >= COMPRESSION_FRACTION * nvtxs && return nothing - graph = CreateGraph() - cnedges = 0 - for i in 0:(cnvtxs - 1) - ii = cind[cptr[i + 1] + 1] - cnedges += xadj[ii + 2] - xadj[ii + 1] - end - cxadj = Vector{Int}(undef, cnvtxs + 1) - cvwgt = zeros(Int, cnvtxs) - cadjncy = Vector{Int}(undef, cnedges) - fill!(mark, -1) - l = 0 - cxadj[1] = 0 - for i in 0:(cnvtxs - 1) - mark[i + 1] = i # remove diagonal in compressed graph - for j in cptr[i + 1]:(cptr[i + 2] - 1) - ii = cind[j + 1] - cvwgt[i + 1] += vwgt === nothing ? 1 : vwgt[ii + 1] - for jj in xadj[ii + 1]:(xadj[ii + 2] - 1) - k = map_[adjncy[jj + 1] + 1] - if mark[k + 1] != i - mark[k + 1] = i - l += 1 - cadjncy[l] = k - end - end - end - cxadj[i + 2] = l - end - graph.nvtxs = cnvtxs - graph.nedges = l - graph.xadj = cxadj - graph.vwgt = cvwgt - graph.adjncy = cadjncy - graph.adjwgt = ones(Int, l) - SetupGraph_tvwgt!(graph) - SetupGraph_label!(graph) - return graph -end - -# --------------------------------------------------------------------------- -# GKlib/gk_mkpqueue.h instantiated as rpq (libmetis/gklib.c: -# GK_MKPQUEUE(rpq, rpq_t, rkv_t, real_t, idx_t, ..., key_gt)): a MAX-heap -# with real_t = Float32 keys and idx_t vals. Heap layout (which of several -# equal-key items surfaces first) is observable through FM, so the sift -# logic is translated verbatim. KEY_LT(a, b) = a > b. - -mutable struct RPQ - nnodes::Int - heap_key::Vector{Float32} - heap_val::Vector{Int} - locator::Vector{Int} # by node id (0-based), -1 = absent -end - -rpqCreate(maxnodes::Int) = RPQ(0, Vector{Float32}(undef, maxnodes), - Vector{Int}(undef, maxnodes), fill(-1, maxnodes)) -rpqLength(q::RPQ) = q.nnodes - -@inline _rpq_lt(a::Float32, b::Float32) = a > b # KEY_LT = key_gt (max-heap) - -function rpqReset(q::RPQ) - for i in q.nnodes:-1:1 - q.locator[q.heap_val[i] + 1] = -1 - end - q.nnodes = 0 - return nothing -end - -function rpqInsert(q::RPQ, node::Int, key::Float32) - i = q.nnodes # 0-based heap position - q.nnodes += 1 - while i > 0 - j = (i - 1) >> 1 - if _rpq_lt(key, q.heap_key[j + 1]) - q.heap_key[i + 1] = q.heap_key[j + 1] - q.heap_val[i + 1] = q.heap_val[j + 1] - q.locator[q.heap_val[i + 1] + 1] = i - i = j - else - break - end - end - q.heap_key[i + 1] = key - q.heap_val[i + 1] = node - q.locator[node + 1] = i - return nothing -end - -function rpqDelete(q::RPQ, node::Int) - i = q.locator[node + 1] - q.locator[node + 1] = -1 - q.nnodes -= 1 - if q.nnodes > 0 && q.heap_val[q.nnodes + 1] != node - node = q.heap_val[q.nnodes + 1] - newkey = q.heap_key[q.nnodes + 1] - oldkey = q.heap_key[i + 1] - if _rpq_lt(newkey, oldkey) # filter up - while i > 0 - j = (i - 1) >> 1 - if _rpq_lt(newkey, q.heap_key[j + 1]) - q.heap_key[i + 1] = q.heap_key[j + 1] - q.heap_val[i + 1] = q.heap_val[j + 1] - q.locator[q.heap_val[i + 1] + 1] = i - i = j - else - break - end - end - else # filter down - nnodes = q.nnodes - while (j = (i << 1) + 1) < nnodes - if _rpq_lt(q.heap_key[j + 1], newkey) - if j + 1 < nnodes && _rpq_lt(q.heap_key[j + 2], q.heap_key[j + 1]) - j += 1 - end - q.heap_key[i + 1] = q.heap_key[j + 1] - q.heap_val[i + 1] = q.heap_val[j + 1] - q.locator[q.heap_val[i + 1] + 1] = i - i = j - elseif j + 1 < nnodes && _rpq_lt(q.heap_key[j + 2], newkey) - j += 1 - q.heap_key[i + 1] = q.heap_key[j + 1] - q.heap_val[i + 1] = q.heap_val[j + 1] - q.locator[q.heap_val[i + 1] + 1] = i - i = j - else - break - end - end - end - q.heap_key[i + 1] = newkey - q.heap_val[i + 1] = node - q.locator[node + 1] = i - end - return nothing -end - -function rpqUpdate(q::RPQ, node::Int, newkey::Float32) - i = q.locator[node + 1] - oldkey = q.heap_key[i + 1] - if _rpq_lt(newkey, oldkey) # filter up - while i > 0 - j = (i - 1) >> 1 - if _rpq_lt(newkey, q.heap_key[j + 1]) - q.heap_key[i + 1] = q.heap_key[j + 1] - q.heap_val[i + 1] = q.heap_val[j + 1] - q.locator[q.heap_val[i + 1] + 1] = i - i = j - else - break - end - end - else # filter down - nnodes = q.nnodes - while (j = (i << 1) + 1) < nnodes - if _rpq_lt(q.heap_key[j + 1], newkey) - if j + 1 < nnodes && _rpq_lt(q.heap_key[j + 2], q.heap_key[j + 1]) - j += 1 - end - q.heap_key[i + 1] = q.heap_key[j + 1] - q.heap_val[i + 1] = q.heap_val[j + 1] - q.locator[q.heap_val[i + 1] + 1] = i - i = j - elseif j + 1 < nnodes && _rpq_lt(q.heap_key[j + 2], newkey) - j += 1 - q.heap_key[i + 1] = q.heap_key[j + 1] - q.heap_val[i + 1] = q.heap_val[j + 1] - q.locator[q.heap_val[i + 1] + 1] = i - i = j - else - break - end - end - end - q.heap_key[i + 1] = newkey - q.heap_val[i + 1] = node - q.locator[node + 1] = i - return nothing -end - -function rpqGetTop(q::RPQ) - q.nnodes == 0 && return -1 - q.nnodes -= 1 - vtx = q.heap_val[1] - q.locator[vtx + 1] = -1 - i = q.nnodes - if i > 0 - key = q.heap_key[i + 1] - node = q.heap_val[i + 1] - i = 0 - while (j = 2 * i + 1) < q.nnodes - if _rpq_lt(q.heap_key[j + 1], key) - if j + 1 < q.nnodes && _rpq_lt(q.heap_key[j + 2], q.heap_key[j + 1]) - j += 1 - end - q.heap_key[i + 1] = q.heap_key[j + 1] - q.heap_val[i + 1] = q.heap_val[j + 1] - q.locator[q.heap_val[i + 1] + 1] = i - i = j - elseif j + 1 < q.nnodes && _rpq_lt(q.heap_key[j + 2], key) - j += 1 - q.heap_key[i + 1] = q.heap_key[j + 1] - q.heap_val[i + 1] = q.heap_val[j + 1] - q.locator[q.heap_val[i + 1] + 1] = i - i = j - else - break - end - end - q.heap_key[i + 1] = key - q.heap_val[i + 1] = node - q.locator[node + 1] = i - end - return vtx -end - - -# --------------------------------------------------------------------------- -# ctrl_t subset for METIS_OP_OMETIS (libmetis/options.c SetupCtrl). All -# values that the OMETIS path reads. idx arithmetic stays in Int64: every C -# idx_t intermediate on this path fits Int32 (checked per use), so Int64 -# arithmetic is value-identical. - -mutable struct CtrlT - ctype::Int # METIS_CTYPE_SHEM = 2 - rtype::Int # METIS_RTYPE_SEP1SIDED = 3 / SEP2SIDED = 4 - iptype::Int # METIS_IPTYPE_EDGE = 2 - nseps::Int - niter::Int - ufactor::Int - compress::Bool - ccorder::Bool - pfactor::Float64 - no2hop::Bool - seed::Int - CoarsenTo::Int - maxvwgt::Int - ubfactors::Float32 # real_t: I2RUBFACTOR(ufactor) + 0.0000499 - pijbm::Vector{Float32} # real_t: Setup2WayBalMultipliers (len 2) -end - -const METIS_CTYPE_RM = 0 -const METIS_CTYPE_SHEM = 1 -const METIS_RTYPE_FM = 0 -const METIS_RTYPE_SEP2SIDED = 3 -const METIS_RTYPE_SEP1SIDED = 2 -const METIS_IPTYPE_EDGE = 2 -const METIS_IPTYPE_NODE = 3 -const UNMATCHED = -1 -const COARSEN_FRACTION = 0.85 -const UNMATCHEDFOR2HOP = 0.10 -const HTLENGTH = (1 << 11) - 1 -const MMDSWITCH = 120 -const LARGENIPARTS = 7 -const SMALLNIPARTS = 5 -const IDX_MAX = 2147483647 - -# --------------------------------------------------------------------------- -# libmetis/coarsen.c - -function CoarsenGraph(ctrl::CtrlT, graph::GraphT) - eqewgts = true - for i in 1:(graph.nedges - 1) - if graph.adjwgt[1] != graph.adjwgt[i + 1] - eqewgts = false - break - end - end - ctrl.maxvwgt = trunc(Int, 1.5 * graph.tvwgt / ctrl.CoarsenTo) - while true - DBG[] && println(lpad(graph.nvtxs, 10), lpad(graph.nedges, 11), - lpad(sum(view(graph.adjwgt, 1:graph.nedges); init = 0), 11), - " [", ctrl.CoarsenTo, "] [", lpad(ctrl.maxvwgt, 9), ":", lpad(graph.tvwgt, 8), " ]") - isempty(graph.cmap) && (graph.cmap = Vector{Int}(undef, graph.nvtxs)) - if ctrl.ctype == METIS_CTYPE_RM || eqewgts || graph.nedges == 0 - Match_RM(ctrl, graph) - else - Match_SHEM(ctrl, graph) - end - graph = graph.coarser::GraphT - eqewgts = false - (graph.nvtxs > ctrl.CoarsenTo && - graph.nvtxs < COARSEN_FRACTION * (graph.finer::GraphT).nvtxs && - graph.nedges > graph.nvtxs ÷ 2) || break - end - DBG[] && println(lpad(graph.nvtxs, 10), lpad(graph.nedges, 11), - lpad(sum(view(graph.adjwgt, 1:graph.nedges); init = 0), 11), - " [", ctrl.CoarsenTo, "] [", lpad(ctrl.maxvwgt, 9), ":", lpad(graph.tvwgt, 8), " ]") - return graph -end - -function CoarsenGraphNlevels(ctrl::CtrlT, graph::GraphT, nlevels::Int) - eqewgts = true - for i in 1:(graph.nedges - 1) - if graph.adjwgt[1] != graph.adjwgt[i + 1] - eqewgts = false - break - end - end - ctrl.maxvwgt = trunc(Int, 1.5 * graph.tvwgt / ctrl.CoarsenTo) - for _ in 1:nlevels - isempty(graph.cmap) && (graph.cmap = Vector{Int}(undef, graph.nvtxs)) - if ctrl.ctype == METIS_CTYPE_RM || eqewgts || graph.nedges == 0 - Match_RM(ctrl, graph) - else - Match_SHEM(ctrl, graph) - end - graph = graph.coarser::GraphT - eqewgts = false - if graph.nvtxs < ctrl.CoarsenTo || - graph.nvtxs > COARSEN_FRACTION * (graph.finer::GraphT).nvtxs || - graph.nedges < graph.nvtxs ÷ 2 - break - end - end - return graph -end - -function Match_RM(ctrl::CtrlT, graph::GraphT) - nvtxs = graph.nvtxs - xadj = graph.xadj - vwgt = graph.vwgt - adjncy = graph.adjncy - cmap = graph.cmap - maxvwgt = ctrl.maxvwgt - nunmatched = 0 - match = fill(UNMATCHED, nvtxs) - perm = Vector{Int}(undef, nvtxs) - irandArrayPermute!(nvtxs, perm, nvtxs ÷ 8, 1) - cnvtxs = 0 - last_unmatched = 0 # 0-based position - for pi in 0:(nvtxs - 1) - i = perm[pi + 1] - match[i + 1] == UNMATCHED || continue - maxidx = i - if vwgt[i + 1] < maxvwgt - if xadj[i + 1] == xadj[i + 2] # island vertex - last_unmatched = max(pi, last_unmatched) + 1 - while last_unmatched < nvtxs - j = perm[last_unmatched + 1] - if match[j + 1] == UNMATCHED - maxidx = j - break - end - last_unmatched += 1 - end - else - for j in xadj[i + 1]:(xadj[i + 2] - 1) - k = adjncy[j + 1] - if match[k + 1] == UNMATCHED && vwgt[i + 1] + vwgt[k + 1] <= maxvwgt - maxidx = k - break - end - end - if maxidx == i && 3 * vwgt[i + 1] < maxvwgt - nunmatched += 1 - maxidx = UNMATCHED - end - end - end - if maxidx != UNMATCHED - cmap[i + 1] = cmap[maxidx + 1] = cnvtxs - cnvtxs += 1 - match[i + 1] = maxidx - match[maxidx + 1] = i - end - end - if !ctrl.no2hop && nunmatched > UNMATCHEDFOR2HOP * nvtxs - cnvtxs = Match_2Hop(ctrl, graph, perm, match, cnvtxs, nunmatched) - end - cnvtxs = 0 - for i in 0:(nvtxs - 1) - if match[i + 1] == UNMATCHED - match[i + 1] = i - cmap[i + 1] = cnvtxs - cnvtxs += 1 - elseif i <= match[i + 1] - cmap[i + 1] = cmap[match[i + 1] + 1] = cnvtxs - cnvtxs += 1 - end - end - CreateCoarseGraph(ctrl, graph, cnvtxs, match) - return cnvtxs -end - -function Match_SHEM(ctrl::CtrlT, graph::GraphT) - nvtxs = graph.nvtxs - xadj = graph.xadj - vwgt = graph.vwgt - adjncy = graph.adjncy - adjwgt = graph.adjwgt - cmap = graph.cmap - maxvwgt = ctrl.maxvwgt - nunmatched = 0 - match = fill(UNMATCHED, nvtxs) - perm = Vector{Int}(undef, nvtxs) - tperm = Vector{Int}(undef, nvtxs) - degrees = Vector{Int}(undef, nvtxs) - irandArrayPermute!(nvtxs, tperm, nvtxs ÷ 8, 1) - avgdegree = trunc(Int, 0.7 * (xadj[nvtxs + 1] ÷ nvtxs)) - for i in 0:(nvtxs - 1) - d = xadj[i + 2] - xadj[i + 1] - degrees[i + 1] = d > avgdegree ? avgdegree : d - end - BucketSortKeysInc(nvtxs, avgdegree, degrees, tperm, perm) - cnvtxs = 0 - last_unmatched = 0 - for pi in 0:(nvtxs - 1) - i = perm[pi + 1] - match[i + 1] == UNMATCHED || continue - maxidx = i - maxwgt = -1 - if vwgt[i + 1] < maxvwgt - if xadj[i + 1] == xadj[i + 2] # island vertex - last_unmatched = max(pi, last_unmatched) + 1 - while last_unmatched < nvtxs - j = perm[last_unmatched + 1] - if match[j + 1] == UNMATCHED - maxidx = j - break - end - last_unmatched += 1 - end - else - for j in xadj[i + 1]:(xadj[i + 2] - 1) - k = adjncy[j + 1] - if match[k + 1] == UNMATCHED && maxwgt < adjwgt[j + 1] && - vwgt[i + 1] + vwgt[k + 1] <= maxvwgt - maxidx = k - maxwgt = adjwgt[j + 1] - end - end - if maxidx == i && 3 * vwgt[i + 1] < maxvwgt - nunmatched += 1 - maxidx = UNMATCHED - end - end - end - if maxidx != UNMATCHED - cmap[i + 1] = cmap[maxidx + 1] = cnvtxs - cnvtxs += 1 - match[i + 1] = maxidx - match[maxidx + 1] = i - end - end - if !ctrl.no2hop && nunmatched > UNMATCHEDFOR2HOP * nvtxs - cnvtxs = Match_2Hop(ctrl, graph, perm, match, cnvtxs, nunmatched) - end - cnvtxs = 0 - for i in 0:(nvtxs - 1) - if match[i + 1] == UNMATCHED - match[i + 1] = i - cmap[i + 1] = cnvtxs - cnvtxs += 1 - elseif i <= match[i + 1] - cmap[i + 1] = cmap[match[i + 1] + 1] = cnvtxs - cnvtxs += 1 - end - end - CreateCoarseGraph(ctrl, graph, cnvtxs, match) - return cnvtxs -end - -function Match_2Hop(ctrl::CtrlT, graph::GraphT, perm, match, cnvtxs::Int, nunmatched::Int) - cnvtxs, nunmatched = Match_2HopAny(ctrl, graph, perm, match, cnvtxs, nunmatched, 2) - cnvtxs, nunmatched = Match_2HopAll(ctrl, graph, perm, match, cnvtxs, nunmatched, 64) - if nunmatched > 1.5 * UNMATCHEDFOR2HOP * graph.nvtxs - cnvtxs, nunmatched = Match_2HopAny(ctrl, graph, perm, match, cnvtxs, nunmatched, 3) - end - if nunmatched > 2.0 * UNMATCHEDFOR2HOP * graph.nvtxs - cnvtxs, nunmatched = Match_2HopAny(ctrl, graph, perm, match, cnvtxs, nunmatched, graph.nvtxs) - end - return cnvtxs -end - -function Match_2HopAny(ctrl::CtrlT, graph::GraphT, perm, match, cnvtxs::Int, - nunmatched::Int, maxdegree::Int) - nvtxs = graph.nvtxs - xadj = graph.xadj - adjncy = graph.adjncy - cmap = graph.cmap - colptr = zeros(Int, nvtxs + 1) - for i in 0:(nvtxs - 1) - if match[i + 1] == UNMATCHED && xadj[i + 2] - xadj[i + 1] < maxdegree - for j in xadj[i + 1]:(xadj[i + 2] - 1) - colptr[adjncy[j + 1] + 1] += 1 - end - end - end - for i in 1:(nvtxs - 1) # MAKECSR - colptr[i + 1] += colptr[i] - end - for i in nvtxs:-1:1 - colptr[i + 1] = colptr[i] - end - colptr[1] = 0 - rowind = Vector{Int}(undef, colptr[nvtxs + 1]) - for pi in 0:(nvtxs - 1) - i = perm[pi + 1] - if match[i + 1] == UNMATCHED && xadj[i + 2] - xadj[i + 1] < maxdegree - for j in xadj[i + 1]:(xadj[i + 2] - 1) - c = adjncy[j + 1] - rowind[colptr[c + 1] + 1] = i - colptr[c + 1] += 1 - end - end - end - for i in nvtxs:-1:1 # SHIFTCSR - colptr[i + 1] = colptr[i] - end - colptr[1] = 0 - for pi in 0:(nvtxs - 1) - i = perm[pi + 1] - colptr[i + 2] - colptr[i + 1] < 2 && continue - jj = colptr[i + 2] - j = colptr[i + 1] - while j < jj - if match[rowind[j + 1] + 1] == UNMATCHED - jj -= 1 - while jj > j - if match[rowind[jj + 1] + 1] == UNMATCHED - cmap[rowind[j + 1] + 1] = cmap[rowind[jj + 1] + 1] = cnvtxs - cnvtxs += 1 - match[rowind[j + 1] + 1] = rowind[jj + 1] - match[rowind[jj + 1] + 1] = rowind[j + 1] - nunmatched -= 2 - break - end - jj -= 1 - end - end - j += 1 - end - end - return cnvtxs, nunmatched -end - -function Match_2HopAll(ctrl::CtrlT, graph::GraphT, perm, match, cnvtxs::Int, - nunmatched::Int, maxdegree::Int) - nvtxs = graph.nvtxs - xadj = graph.xadj - adjncy = graph.adjncy - cmap = graph.cmap - mask = IDX_MAX ÷ maxdegree - keys = Vector{Tuple{Int, Int}}(undef, nunmatched) - ncand = 0 - for pi in 0:(nvtxs - 1) - i = perm[pi + 1] - idegree = xadj[i + 2] - xadj[i + 1] - if match[i + 1] == UNMATCHED && idegree > 1 && idegree < maxdegree - k = 0 - for j in xadj[i + 1]:(xadj[i + 2] - 1) - k += adjncy[j + 1] % mask - end - ncand += 1 - keys[ncand] = ((k % mask) * maxdegree + idegree, i) - end - end - ikvsorti!(ncand, keys) - mark = zeros(Int, nvtxs) - for pi in 0:(ncand - 1) - i = keys[pi + 1][2] - match[i + 1] == UNMATCHED || continue - for j in xadj[i + 1]:(xadj[i + 2] - 1) - mark[adjncy[j + 1] + 1] = i - end - for pk in (pi + 1):(ncand - 1) - k = keys[pk + 1][2] - match[k + 1] == UNMATCHED || continue - keys[pi + 1][1] != keys[pk + 1][1] && break - xadj[i + 2] - xadj[i + 1] != xadj[k + 2] - xadj[k + 1] && break - allmatch = true - for jj in xadj[k + 1]:(xadj[k + 2] - 1) - if mark[adjncy[jj + 1] + 1] != i - allmatch = false - break - end - end - if allmatch - cmap[i + 1] = cmap[k + 1] = cnvtxs - cnvtxs += 1 - match[i + 1] = k - match[k + 1] = i - nunmatched -= 2 - break - end - end - end - return cnvtxs, nunmatched -end - -function SetupCoarseGraph(graph::GraphT, cnvtxs::Int) - cgraph = CreateGraph() - cgraph.nvtxs = cnvtxs - cgraph.finer = graph - graph.coarser = cgraph - cgraph.xadj = Vector{Int}(undef, cnvtxs + 1) - cgraph.adjncy = Vector{Int}(undef, graph.nedges) - cgraph.adjwgt = Vector{Int}(undef, graph.nedges) - cgraph.vwgt = Vector{Int}(undef, cnvtxs) - return cgraph -end - -function CreateCoarseGraph(ctrl::CtrlT, graph::GraphT, cnvtxs::Int, match) - mask = HTLENGTH - if cnvtxs < 2 * mask || graph.nedges ÷ graph.nvtxs > mask ÷ 20 - return CreateCoarseGraphNoMask(ctrl, graph, cnvtxs, match) - end - nvtxs = graph.nvtxs - xadj = graph.xadj - for v in 0:(nvtxs - 1) - if xadj[v + 2] - xadj[v + 1] > (mask >> 3) - return CreateCoarseGraphNoMask(ctrl, graph, cnvtxs, match) - end - end - vwgt = graph.vwgt - adjncy = graph.adjncy - adjwgt = graph.adjwgt - cmap = graph.cmap - cgraph = SetupCoarseGraph(graph, cnvtxs) - cxadj = cgraph.xadj - cvwgt = cgraph.vwgt - cadjncy = cgraph.adjncy - cadjwgt = cgraph.adjwgt - htable = fill(-1, mask + 1) - cxadj[1] = 0 - cnvtxs = 0 - cnedges = 0 # base of the current coarse row - for v in 0:(nvtxs - 1) - u = match[v + 1] - u < v && continue - cvwgt[cnvtxs + 1] = vwgt[v + 1] - nedges = 0 - for j in xadj[v + 1]:(xadj[v + 2] - 1) - k = cmap[adjncy[j + 1] + 1] - kk = k & mask - m = htable[kk + 1] - if m == -1 - cadjncy[cnedges + nedges + 1] = k - cadjwgt[cnedges + nedges + 1] = adjwgt[j + 1] - htable[kk + 1] = nedges - nedges += 1 - elseif cadjncy[cnedges + m + 1] == k - cadjwgt[cnedges + m + 1] += adjwgt[j + 1] - else - jj = 0 - while jj < nedges - if cadjncy[cnedges + jj + 1] == k - cadjwgt[cnedges + jj + 1] += adjwgt[j + 1] - break - end - jj += 1 - end - if jj == nedges - cadjncy[cnedges + nedges + 1] = k - cadjwgt[cnedges + nedges + 1] = adjwgt[j + 1] - nedges += 1 - end - end - end - if v != u - cvwgt[cnvtxs + 1] += vwgt[u + 1] - for j in xadj[u + 1]:(xadj[u + 2] - 1) - k = cmap[adjncy[j + 1] + 1] - kk = k & mask - m = htable[kk + 1] - if m == -1 - cadjncy[cnedges + nedges + 1] = k - cadjwgt[cnedges + nedges + 1] = adjwgt[j + 1] - htable[kk + 1] = nedges - nedges += 1 - elseif cadjncy[cnedges + m + 1] == k - cadjwgt[cnedges + m + 1] += adjwgt[j + 1] - else - jj = 0 - while jj < nedges - if cadjncy[cnedges + jj + 1] == k - cadjwgt[cnedges + jj + 1] += adjwgt[j + 1] - break - end - jj += 1 - end - if jj == nedges - cadjncy[cnedges + nedges + 1] = k - cadjwgt[cnedges + nedges + 1] = adjwgt[j + 1] - nedges += 1 - end - end - end - # remove the contracted adjacency weight - jj = htable[(cnvtxs & mask) + 1] - if jj >= 0 && cadjncy[cnedges + jj + 1] != cnvtxs - jj = 0 - while jj < nedges - cadjncy[cnedges + jj + 1] == cnvtxs && break - jj += 1 - end - end - if jj >= 0 && jj < nedges && cadjncy[cnedges + jj + 1] == cnvtxs - nedges -= 1 - cadjncy[cnedges + jj + 1] = cadjncy[cnedges + nedges + 1] - cadjwgt[cnedges + jj + 1] = cadjwgt[cnedges + nedges + 1] - end - end - for j in 0:(nedges - 1) - htable[(cadjncy[cnedges + j + 1] & mask) + 1] = -1 - end - htable[(cnvtxs & mask) + 1] = -1 - cnedges += nedges - cnvtxs += 1 - cxadj[cnvtxs + 1] = cnedges - end - cgraph.nedges = cnedges - SetupGraph_tvwgt!(cgraph) - return nothing -end - -function CreateCoarseGraphNoMask(ctrl::CtrlT, graph::GraphT, cnvtxs::Int, match) - nvtxs = graph.nvtxs - xadj = graph.xadj - vwgt = graph.vwgt - adjncy = graph.adjncy - adjwgt = graph.adjwgt - cmap = graph.cmap - cgraph = SetupCoarseGraph(graph, cnvtxs) - cxadj = cgraph.xadj - cvwgt = cgraph.vwgt - cadjncy = cgraph.adjncy - cadjwgt = cgraph.adjwgt - htable = fill(-1, cnvtxs) - cxadj[1] = 0 - cnvtxs = 0 - cnedges = 0 - for v in 0:(nvtxs - 1) - u = match[v + 1] - u < v && continue - cvwgt[cnvtxs + 1] = vwgt[v + 1] - nedges = 0 - for j in xadj[v + 1]:(xadj[v + 2] - 1) - k = cmap[adjncy[j + 1] + 1] - m = htable[k + 1] - if m == -1 - cadjncy[cnedges + nedges + 1] = k - cadjwgt[cnedges + nedges + 1] = adjwgt[j + 1] - htable[k + 1] = nedges - nedges += 1 - else - cadjwgt[cnedges + m + 1] += adjwgt[j + 1] - end - end - if v != u - cvwgt[cnvtxs + 1] += vwgt[u + 1] - for j in xadj[u + 1]:(xadj[u + 2] - 1) - k = cmap[adjncy[j + 1] + 1] - m = htable[k + 1] - if m == -1 - cadjncy[cnedges + nedges + 1] = k - cadjwgt[cnedges + nedges + 1] = adjwgt[j + 1] - htable[k + 1] = nedges - nedges += 1 - else - cadjwgt[cnedges + m + 1] += adjwgt[j + 1] - end - end - j = htable[cnvtxs + 1] - if j != -1 - nedges -= 1 - cadjncy[cnedges + j + 1] = cadjncy[cnedges + nedges + 1] - cadjwgt[cnedges + j + 1] = cadjwgt[cnedges + nedges + 1] - htable[cnvtxs + 1] = -1 - end - end - for j in 0:(nedges - 1) - htable[cadjncy[cnedges + j + 1] + 1] = -1 - end - cnedges += nedges - cnvtxs += 1 - cxadj[cnvtxs + 1] = cnedges - end - cgraph.nedges = cnedges - SetupGraph_tvwgt!(cgraph) - return nothing -end - - -rpqSeeTopVal(q::RPQ) = q.nnodes == 0 ? -1 : q.heap_val[1] - -# --------------------------------------------------------------------------- -# libmetis/refine.c (2-way edge-cut params; used at the coarsest level only) - -function Allocate2WayPartitionMemory!(graph::GraphT) - graph.pwgts = Vector{Int}(undef, 2) - graph.where_ = Vector{Int}(undef, graph.nvtxs) - graph.bndptr = Vector{Int}(undef, graph.nvtxs) - graph.bndind = Vector{Int}(undef, graph.nvtxs) - graph.id = Vector{Int}(undef, graph.nvtxs) - graph.ed = Vector{Int}(undef, graph.nvtxs) - return nothing -end - -function Compute2WayPartitionParams!(ctrl::CtrlT, graph::GraphT) - nvtxs = graph.nvtxs - xadj = graph.xadj - vwgt = graph.vwgt - adjncy = graph.adjncy - adjwgt = graph.adjwgt - where_ = graph.where_ - id = graph.id - ed = graph.ed - pwgts = graph.pwgts - fill!(pwgts, 0) - bndptr = graph.bndptr - fill!(bndptr, -1) - bndind = graph.bndind - for i in 0:(nvtxs - 1) - pwgts[where_[i + 1] + 1] += vwgt[i + 1] - end - nbnd = 0 - mincut = 0 - for i in 0:(nvtxs - 1) - istart = xadj[i + 1] - iend = xadj[i + 2] - me = where_[i + 1] - tid = 0 - ted = 0 - for j in istart:(iend - 1) - if me == where_[adjncy[j + 1] + 1] - tid += adjwgt[j + 1] - else - ted += adjwgt[j + 1] - end - end - id[i + 1] = tid - ed[i + 1] = ted - if ted > 0 || istart == iend - bndind[nbnd + 1] = i # BNDInsert - bndptr[i + 1] = nbnd - nbnd += 1 - mincut += ted - end - end - graph.mincut = mincut ÷ 2 - graph.nbnd = nbnd - return nothing -end - -# --------------------------------------------------------------------------- -# libmetis/fm.c FM_2WayRefine / FM_2WayCutRefine (ncon = 1) - -FM_2WayRefine!(ctrl::CtrlT, graph::GraphT, ntpwgts, niter::Int) = - FM_2WayCutRefine!(ctrl, graph, ntpwgts, niter) - -function FM_2WayCutRefine!(ctrl::CtrlT, graph::GraphT, ntpwgts, niter::Int) - nvtxs = graph.nvtxs - xadj = graph.xadj - vwgt = graph.vwgt - adjncy = graph.adjncy - adjwgt = graph.adjwgt - where_ = graph.where_ - id = graph.id - ed = graph.ed - pwgts = graph.pwgts - bndptr = graph.bndptr - bndind = graph.bndind - moved = fill(-1, nvtxs) - swaps = Vector{Int}(undef, nvtxs) - perm = Vector{Int}(undef, nvtxs) - tpwgts0 = trunc(Int, graph.tvwgt * ntpwgts[1]) - tpwgts = (tpwgts0, graph.tvwgt - tpwgts0) - limit = trunc(Int, min(max(0.01 * nvtxs, 15.0), 100.0)) - avgvwgt = min((pwgts[1] + pwgts[2]) ÷ 20, 2 * (pwgts[1] + pwgts[2]) ÷ nvtxs) - queues = (rpqCreate(nvtxs), rpqCreate(nvtxs)) - origdiff = abs(tpwgts[1] - pwgts[1]) - for _pass in 0:(niter - 1) - rpqReset(queues[1]) - rpqReset(queues[2]) - mincutorder = -1 - newcut = mincut = initcut = graph.mincut - mindiff = abs(tpwgts[1] - pwgts[1]) - nbnd = graph.nbnd - irandArrayPermute!(nbnd, perm, nbnd, 1) - for ii in 0:(nbnd - 1) - i = perm[ii + 1] - b = bndind[i + 1] - rpqInsert(queues[where_[b + 1] + 1], b, Float32(ed[b + 1] - id[b + 1])) - end - nswaps = 0 - while nswaps < nvtxs - from = tpwgts[1] - pwgts[1] < tpwgts[2] - pwgts[2] ? 0 : 1 - to = (from + 1) % 2 - higain = rpqGetTop(queues[from + 1]) - higain == -1 && break - newcut -= ed[higain + 1] - id[higain + 1] - pwgts[to + 1] += vwgt[higain + 1] - pwgts[from + 1] -= vwgt[higain + 1] - if (newcut < mincut && abs(tpwgts[1] - pwgts[1]) <= origdiff + avgvwgt) || - (newcut == mincut && abs(tpwgts[1] - pwgts[1]) < mindiff) - mincut = newcut - mindiff = abs(tpwgts[1] - pwgts[1]) - mincutorder = nswaps - elseif nswaps - mincutorder > limit - newcut += ed[higain + 1] - id[higain + 1] - pwgts[from + 1] += vwgt[higain + 1] - pwgts[to + 1] -= vwgt[higain + 1] - break - end - where_[higain + 1] = to - moved[higain + 1] = nswaps - swaps[nswaps + 1] = higain - id[higain + 1], ed[higain + 1] = ed[higain + 1], id[higain + 1] - if ed[higain + 1] == 0 && xadj[higain + 1] < xadj[higain + 2] - bndind[bndptr[higain + 1] + 1] = bndind[nbnd] # BNDDelete - bndptr[bndind[nbnd] + 1] = bndptr[higain + 1] - nbnd -= 1 - bndptr[higain + 1] = -1 - end - for j in xadj[higain + 1]:(xadj[higain + 2] - 1) - k = adjncy[j + 1] - kwgt = to == where_[k + 1] ? adjwgt[j + 1] : -adjwgt[j + 1] - id[k + 1] += kwgt - ed[k + 1] -= kwgt - if bndptr[k + 1] != -1 - if ed[k + 1] == 0 - bndind[bndptr[k + 1] + 1] = bndind[nbnd] - bndptr[bndind[nbnd] + 1] = bndptr[k + 1] - nbnd -= 1 - bndptr[k + 1] = -1 - moved[k + 1] == -1 && rpqDelete(queues[where_[k + 1] + 1], k) - elseif moved[k + 1] == -1 - rpqUpdate(queues[where_[k + 1] + 1], k, Float32(ed[k + 1] - id[k + 1])) - end - elseif ed[k + 1] > 0 - bndind[nbnd + 1] = k - bndptr[k + 1] = nbnd - nbnd += 1 - moved[k + 1] == -1 && rpqInsert(queues[where_[k + 1] + 1], k, Float32(ed[k + 1] - id[k + 1])) - end - end - nswaps += 1 - end - for i in 0:(nswaps - 1) - moved[swaps[i + 1] + 1] = -1 - end - nswaps -= 1 - while nswaps > mincutorder - higain = swaps[nswaps + 1] - to = where_[higain + 1] = (where_[higain + 1] + 1) % 2 - id[higain + 1], ed[higain + 1] = ed[higain + 1], id[higain + 1] - if ed[higain + 1] == 0 && bndptr[higain + 1] != -1 && xadj[higain + 1] < xadj[higain + 2] - bndind[bndptr[higain + 1] + 1] = bndind[nbnd] - bndptr[bndind[nbnd] + 1] = bndptr[higain + 1] - nbnd -= 1 - bndptr[higain + 1] = -1 - elseif ed[higain + 1] > 0 && bndptr[higain + 1] == -1 - bndind[nbnd + 1] = higain - bndptr[higain + 1] = nbnd - nbnd += 1 - end - pwgts[to + 1] += vwgt[higain + 1] - pwgts[((to + 1) % 2) + 1] -= vwgt[higain + 1] - for j in xadj[higain + 1]:(xadj[higain + 2] - 1) - k = adjncy[j + 1] - kwgt = to == where_[k + 1] ? adjwgt[j + 1] : -adjwgt[j + 1] - id[k + 1] += kwgt - ed[k + 1] -= kwgt - if bndptr[k + 1] != -1 && ed[k + 1] == 0 - bndind[bndptr[k + 1] + 1] = bndind[nbnd] - bndptr[bndind[nbnd] + 1] = bndptr[k + 1] - nbnd -= 1 - bndptr[k + 1] = -1 - end - if bndptr[k + 1] == -1 && ed[k + 1] > 0 - bndind[nbnd + 1] = k - bndptr[k + 1] = nbnd - nbnd += 1 - end - end - nswaps -= 1 - end - graph.mincut = mincut - graph.nbnd = nbnd - (mincutorder <= 0 || mincut == initcut) && break - end - return nothing -end - -# --------------------------------------------------------------------------- -# libmetis/balance.c (ncon = 1) - -# mcutil.c ComputeLoadImbalanceDiff (ncon = 1, nparts = 2), real_t = Float32 -function ComputeLoadImbalanceDiff(graph::GraphT, pijbm::Vector{Float32}, ub::Float32) - mx = -1.0f0 - for j in 1:2 - cur = Float32(graph.pwgts[j]) * pijbm[j] - ub - cur > mx && (mx = cur) - end - return mx -end - -function Balance2Way!(ctrl::CtrlT, graph::GraphT, ntpwgts) - ComputeLoadImbalanceDiff(graph, ctrl.pijbm, ctrl.ubfactors) <= 0 && return nothing - abs(trunc(Int, ntpwgts[1] * Float32(graph.tvwgt) - graph.pwgts[1])) < - 3 * graph.tvwgt ÷ graph.nvtxs && return nothing - if graph.nbnd > 0 - Bnd2WayBalance!(ctrl, graph, ntpwgts) - else - General2WayBalance!(ctrl, graph, ntpwgts) - end - return nothing -end - -function Bnd2WayBalance!(ctrl::CtrlT, graph::GraphT, ntpwgts) - nvtxs = graph.nvtxs - xadj = graph.xadj - vwgt = graph.vwgt - adjncy = graph.adjncy - adjwgt = graph.adjwgt - where_ = graph.where_ - id = graph.id - ed = graph.ed - pwgts = graph.pwgts - bndptr = graph.bndptr - bndind = graph.bndind - moved = fill(-1, nvtxs) - perm = Vector{Int}(undef, nvtxs) - tpwgts0 = trunc(Int, graph.tvwgt * ntpwgts[1]) - tpwgts = (tpwgts0, graph.tvwgt - tpwgts0) - mindiff = abs(tpwgts[1] - pwgts[1]) - from = pwgts[1] < tpwgts[1] ? 1 : 0 - to = (from + 1) % 2 - queue = rpqCreate(nvtxs) - nbnd = graph.nbnd - irandArrayPermute!(nbnd, perm, nbnd ÷ 5, 1) - for ii in 0:(nbnd - 1) - i = perm[ii + 1] - b = bndind[i + 1] - if where_[b + 1] == from && vwgt[b + 1] <= mindiff - rpqInsert(queue, b, Float32(ed[b + 1] - id[b + 1])) - end - end - mincut = graph.mincut - for _ in 0:(nvtxs - 1) - higain = rpqGetTop(queue) - higain == -1 && break - pwgts[to + 1] + vwgt[higain + 1] > tpwgts[to + 1] && break - mincut -= ed[higain + 1] - id[higain + 1] - pwgts[to + 1] += vwgt[higain + 1] - pwgts[from + 1] -= vwgt[higain + 1] - where_[higain + 1] = to - moved[higain + 1] = 1 - id[higain + 1], ed[higain + 1] = ed[higain + 1], id[higain + 1] - if ed[higain + 1] == 0 && xadj[higain + 1] < xadj[higain + 2] - bndind[bndptr[higain + 1] + 1] = bndind[nbnd] - bndptr[bndind[nbnd] + 1] = bndptr[higain + 1] - nbnd -= 1 - bndptr[higain + 1] = -1 - end - for j in xadj[higain + 1]:(xadj[higain + 2] - 1) - k = adjncy[j + 1] - kwgt = to == where_[k + 1] ? adjwgt[j + 1] : -adjwgt[j + 1] - id[k + 1] += kwgt - ed[k + 1] -= kwgt - if bndptr[k + 1] != -1 - if ed[k + 1] == 0 - bndind[bndptr[k + 1] + 1] = bndind[nbnd] - bndptr[bndind[nbnd] + 1] = bndptr[k + 1] - nbnd -= 1 - bndptr[k + 1] = -1 - if moved[k + 1] == -1 && where_[k + 1] == from && vwgt[k + 1] <= mindiff - rpqDelete(queue, k) - end - elseif moved[k + 1] == -1 && where_[k + 1] == from && vwgt[k + 1] <= mindiff - rpqUpdate(queue, k, Float32(ed[k + 1] - id[k + 1])) - end - elseif ed[k + 1] > 0 - bndind[nbnd + 1] = k - bndptr[k + 1] = nbnd - nbnd += 1 - if moved[k + 1] == -1 && where_[k + 1] == from && vwgt[k + 1] <= mindiff - rpqInsert(queue, k, Float32(ed[k + 1] - id[k + 1])) - end - end - end - end - graph.mincut = mincut - graph.nbnd = nbnd - return nothing -end - -function General2WayBalance!(ctrl::CtrlT, graph::GraphT, ntpwgts) - nvtxs = graph.nvtxs - xadj = graph.xadj - vwgt = graph.vwgt - adjncy = graph.adjncy - adjwgt = graph.adjwgt - where_ = graph.where_ - id = graph.id - ed = graph.ed - pwgts = graph.pwgts - bndptr = graph.bndptr - bndind = graph.bndind - moved = fill(-1, nvtxs) - perm = Vector{Int}(undef, nvtxs) - tpwgts0 = trunc(Int, graph.tvwgt * ntpwgts[1]) - tpwgts = (tpwgts0, graph.tvwgt - tpwgts0) - mindiff = abs(tpwgts[1] - pwgts[1]) - from = pwgts[1] < tpwgts[1] ? 1 : 0 - to = (from + 1) % 2 - queue = rpqCreate(nvtxs) - irandArrayPermute!(nvtxs, perm, nvtxs ÷ 5, 1) - for ii in 0:(nvtxs - 1) - i = perm[ii + 1] - if where_[i + 1] == from && vwgt[i + 1] <= mindiff - rpqInsert(queue, i, Float32(ed[i + 1] - id[i + 1])) - end - end - mincut = graph.mincut - nbnd = graph.nbnd - for _ in 0:(nvtxs - 1) - higain = rpqGetTop(queue) - higain == -1 && break - pwgts[to + 1] + vwgt[higain + 1] > tpwgts[to + 1] && break - mincut -= ed[higain + 1] - id[higain + 1] - pwgts[to + 1] += vwgt[higain + 1] - pwgts[from + 1] -= vwgt[higain + 1] - where_[higain + 1] = to - moved[higain + 1] = 1 - id[higain + 1], ed[higain + 1] = ed[higain + 1], id[higain + 1] - if ed[higain + 1] == 0 && bndptr[higain + 1] != -1 && xadj[higain + 1] < xadj[higain + 2] - bndind[bndptr[higain + 1] + 1] = bndind[nbnd] - bndptr[bndind[nbnd] + 1] = bndptr[higain + 1] - nbnd -= 1 - bndptr[higain + 1] = -1 - end - if ed[higain + 1] > 0 && bndptr[higain + 1] == -1 - bndind[nbnd + 1] = higain - bndptr[higain + 1] = nbnd - nbnd += 1 - end - for j in xadj[higain + 1]:(xadj[higain + 2] - 1) - k = adjncy[j + 1] - kwgt = to == where_[k + 1] ? adjwgt[j + 1] : -adjwgt[j + 1] - id[k + 1] += kwgt - ed[k + 1] -= kwgt - if moved[k + 1] == -1 && where_[k + 1] == from && vwgt[k + 1] <= mindiff - rpqUpdate(queue, k, Float32(ed[k + 1] - id[k + 1])) - end - if ed[k + 1] == 0 && bndptr[k + 1] != -1 - bndind[bndptr[k + 1] + 1] = bndind[nbnd] - bndptr[bndind[nbnd] + 1] = bndptr[k + 1] - nbnd -= 1 - bndptr[k + 1] = -1 - elseif ed[k + 1] > 0 && bndptr[k + 1] == -1 - bndind[nbnd + 1] = k - bndptr[k + 1] = nbnd - nbnd += 1 - end - end - end - graph.mincut = mincut - graph.nbnd = nbnd - return nothing -end - - -# --------------------------------------------------------------------------- -# libmetis/initpart.c (iptype = EDGE path) - -function RandomBisection!(ctrl::CtrlT, graph::GraphT, ntpwgts, niparts::Int) - nvtxs = graph.nvtxs - vwgt = graph.vwgt - Allocate2WayPartitionMemory!(graph) - where_ = graph.where_ - bestwhere = Vector{Int}(undef, nvtxs) - perm = Vector{Int}(undef, nvtxs) - zeromaxpwgt = trunc(Int, ctrl.ubfactors * Float32(graph.tvwgt) * ntpwgts[1]) - bestcut = 0 - for inbfs in 0:(niparts - 1) - fill!(where_, 1) - if inbfs > 0 - irandArrayPermute!(nvtxs, perm, nvtxs ÷ 2, 1) - pw0 = 0 - for ii in 0:(nvtxs - 1) - i = perm[ii + 1] - if pw0 + vwgt[i + 1] < zeromaxpwgt - where_[i + 1] = 0 - pw0 += vwgt[i + 1] - pw0 > zeromaxpwgt && break - end - end - end - Compute2WayPartitionParams!(ctrl, graph) - Balance2Way!(ctrl, graph, ntpwgts) - FM_2WayRefine!(ctrl, graph, ntpwgts, 4) - if inbfs == 0 || bestcut > graph.mincut - bestcut = graph.mincut - copyto!(bestwhere, where_) - bestcut == 0 && break - end - end - graph.mincut = bestcut - copyto!(where_, bestwhere) - return nothing -end - -function GrowBisection!(ctrl::CtrlT, graph::GraphT, ntpwgts, niparts::Int) - nvtxs = graph.nvtxs - xadj = graph.xadj - vwgt = graph.vwgt - adjncy = graph.adjncy - Allocate2WayPartitionMemory!(graph) - where_ = graph.where_ - bestwhere = Vector{Int}(undef, nvtxs) - queue = Vector{Int}(undef, nvtxs) - touched = Vector{Int}(undef, nvtxs) - onemaxpwgt = trunc(Int, ctrl.ubfactors * Float32(graph.tvwgt) * ntpwgts[2]) - oneminpwgt = trunc(Int, (1.0 / ctrl.ubfactors) * graph.tvwgt * ntpwgts[2]) - bestcut = 0 - for inbfs in 0:(niparts - 1) - fill!(where_, 1) - fill!(touched, 0) - pw0 = 0 - pw1 = graph.tvwgt - queue[1] = irandInRange(nvtxs) - touched[queue[1] + 1] = 1 - first = 0 - last = 1 - nleft = nvtxs - 1 - drain = false - while true - if first == last # empty: disconnected - (nleft == 0 || drain) && break - k = irandInRange(nleft) - i = 0 - while i < nvtxs - if touched[i + 1] == 0 - k == 0 && break - k -= 1 - end - i += 1 - end - queue[1] = i - touched[i + 1] = 1 - first = 0 - last = 1 - nleft -= 1 - end - i = queue[first + 1] - first += 1 - if pw0 > 0 && pw1 - vwgt[i + 1] < oneminpwgt - drain = true - continue - end - where_[i + 1] = 0 - pw0 += vwgt[i + 1] - pw1 -= vwgt[i + 1] - pw1 <= onemaxpwgt && break - drain = false - for j in xadj[i + 1]:(xadj[i + 2] - 1) - k = adjncy[j + 1] - if touched[k + 1] == 0 - queue[last + 1] = k - last += 1 - touched[k + 1] = 1 - nleft -= 1 - end - end - end - pw1 == 0 && (where_[irandInRange(nvtxs) + 1] = 1) - pw0 == 0 && (where_[irandInRange(nvtxs) + 1] = 0) - Compute2WayPartitionParams!(ctrl, graph) - Balance2Way!(ctrl, graph, ntpwgts) - FM_2WayRefine!(ctrl, graph, ntpwgts, ctrl.niter) - if inbfs == 0 || bestcut > graph.mincut - bestcut = graph.mincut - copyto!(bestwhere, where_) - bestcut == 0 && break - end - end - graph.mincut = bestcut - copyto!(where_, bestwhere) - return nothing -end - -function InitSeparator!(ctrl::CtrlT, graph::GraphT, niparts::Int) - ntpwgts = (0.5f0, 0.5f0) - # Setup2WayBalMultipliers - ctrl.pijbm = Float32[graph.invtvwgt / ntpwgts[1], graph.invtvwgt / ntpwgts[2]] - if graph.nedges == 0 - RandomBisection!(ctrl, graph, ntpwgts, niparts) - else - GrowBisection!(ctrl, graph, ntpwgts, niparts) - end - Compute2WayPartitionParams!(ctrl, graph) - ConstructSeparator!(ctrl, graph) - DBG[] && println("Initial Sep: ", graph.mincut) - return nothing -end - -# --------------------------------------------------------------------------- -# libmetis/srefine.c - -function Allocate2WayNodePartitionMemory!(graph::GraphT) - graph.pwgts = Vector{Int}(undef, 3) - graph.where_ = Vector{Int}(undef, graph.nvtxs) - graph.bndptr = Vector{Int}(undef, graph.nvtxs) - graph.bndind = Vector{Int}(undef, graph.nvtxs) - graph.nrinfo = Vector{NTuple{2, Int}}(undef, graph.nvtxs) - return nothing -end - -function Compute2WayNodePartitionParams!(ctrl::CtrlT, graph::GraphT) - nvtxs = graph.nvtxs - xadj = graph.xadj - vwgt = graph.vwgt - adjncy = graph.adjncy - where_ = graph.where_ - rinfo = graph.nrinfo - pwgts = graph.pwgts - fill!(pwgts, 0) - bndind = graph.bndind - bndptr = graph.bndptr - fill!(bndptr, -1) - nbnd = 0 - for i in 0:(nvtxs - 1) - me = where_[i + 1] - pwgts[me + 1] += vwgt[i + 1] - if me == 2 - bndind[nbnd + 1] = i - bndptr[i + 1] = nbnd - nbnd += 1 - e0 = 0 - e1 = 0 - for j in xadj[i + 1]:(xadj[i + 2] - 1) - other = where_[adjncy[j + 1] + 1] - if other != 2 - other == 0 ? (e0 += vwgt[adjncy[j + 1] + 1]) : (e1 += vwgt[adjncy[j + 1] + 1]) - end - end - rinfo[i + 1] = (e0, e1) - end - end - graph.mincut = pwgts[3] - graph.nbnd = nbnd - return nothing -end - -function Project2WayNodePartition!(ctrl::CtrlT, graph::GraphT) - cgraph = graph.coarser::GraphT - cwhere = cgraph.where_ - nvtxs = graph.nvtxs - cmap = graph.cmap - Allocate2WayNodePartitionMemory!(graph) - where_ = graph.where_ - for i in 0:(nvtxs - 1) - where_[i + 1] = cwhere[cmap[i + 1] + 1] - end - graph.coarser = nothing - Compute2WayNodePartitionParams!(ctrl, graph) - return nothing -end - -function Refine2WayNode!(ctrl::CtrlT, orggraph::GraphT, graph::GraphT) - if graph === orggraph - Compute2WayNodePartitionParams!(ctrl, graph) - else - while true - graph = graph.finer::GraphT - Project2WayNodePartition!(ctrl, graph) - FM_2WayNodeBalance!(ctrl, graph) - if ctrl.rtype == METIS_RTYPE_SEP2SIDED - FM_2WayNodeRefine2Sided!(ctrl, graph, ctrl.niter) - else - FM_2WayNodeRefine1Sided!(ctrl, graph, ctrl.niter) - end - graph === orggraph && break - end - end - return nothing -end - -# --------------------------------------------------------------------------- -# libmetis/separator.c ConstructSeparator (iptype = EDGE) - -function ConstructSeparator!(ctrl::CtrlT, graph::GraphT) - nvtxs = graph.nvtxs - xadj = graph.xadj - nbnd = graph.nbnd - bndind = graph.bndind - where_ = copy(graph.where_) - for i in 0:(nbnd - 1) - j = bndind[i + 1] - xadj[j + 2] - xadj[j + 1] > 0 && (where_[j + 1] = 2) - end - Allocate2WayNodePartitionMemory!(graph) - copyto!(graph.where_, where_) - Compute2WayNodePartitionParams!(ctrl, graph) - FM_2WayNodeRefine2Sided!(ctrl, graph, 1) - FM_2WayNodeRefine1Sided!(ctrl, graph, 4) - return nothing -end - - -# --------------------------------------------------------------------------- -# libmetis/sfm.c — node-separator FM - -function FM_2WayNodeRefine2Sided!(ctrl::CtrlT, graph::GraphT, niter::Int) - nvtxs = graph.nvtxs - xadj = graph.xadj - adjncy = graph.adjncy - vwgt = graph.vwgt - bndind = graph.bndind - bndptr = graph.bndptr - where_ = graph.where_ - pwgts = graph.pwgts - rinfo = graph.nrinfo - queues = (rpqCreate(nvtxs), rpqCreate(nvtxs)) - moved = Vector{Int}(undef, nvtxs) - swaps = Vector{Int}(undef, nvtxs) - mptr = Vector{Int}(undef, nvtxs + 1) - mind = Vector{Int}(undef, 2 * nvtxs) - mult = Float32(0.5 * ctrl.ubfactors) - badmaxpwgt = trunc(Int, mult * (pwgts[1] + pwgts[2] + pwgts[3])) - for pass in 0:(niter - 1) - fill!(moved, -1) - rpqReset(queues[1]) - rpqReset(queues[2]) - mincutorder = -1 - initcut = mincut = graph.mincut - nbnd = graph.nbnd - irandArrayPermute!(nbnd, swaps, nbnd, 1) - for ii in 0:(nbnd - 1) - i = bndind[swaps[ii + 1] + 1] - rpqInsert(queues[1], i, Float32(vwgt[i + 1] - rinfo[i + 1][2])) - rpqInsert(queues[2], i, Float32(vwgt[i + 1] - rinfo[i + 1][1])) - end - limit = ctrl.compress ? min(5 * nbnd, 400) : min(2 * nbnd, 300) - mptr[1] = 0 - nmind = 0 - mindiff = abs(pwgts[1] - pwgts[2]) - to = pwgts[1] < pwgts[2] ? 0 : 1 - nswaps = 0 - while nswaps < nvtxs - u0 = rpqSeeTopVal(queues[1]) - u1 = rpqSeeTopVal(queues[2]) - if u0 != -1 && u1 != -1 - g0 = vwgt[u0 + 1] - rinfo[u0 + 1][2] - g1 = vwgt[u1 + 1] - rinfo[u1 + 1][1] - to = g0 > g1 ? 0 : (g0 < g1 ? 1 : pass % 2) - u = (u0, u1) - if pwgts[to + 1] + vwgt[u[to + 1] + 1] > badmaxpwgt - to = (to + 1) % 2 - end - elseif u0 == -1 && u1 == -1 - break - elseif u0 != -1 && pwgts[1] + vwgt[u0 + 1] <= badmaxpwgt - to = 0 - elseif u1 != -1 && pwgts[2] + vwgt[u1 + 1] <= badmaxpwgt - to = 1 - else - break - end - other = (to + 1) % 2 - higain = rpqGetTop(queues[to + 1]) - moved[higain + 1] == -1 && rpqDelete(queues[other + 1], higain) - if nmind + xadj[higain + 2] - xadj[higain + 1] >= 2 * nvtxs - 1 - break - end - eo = rinfo[higain + 1][other + 1] - pwgts[3] -= vwgt[higain + 1] - eo - newdiff = abs(pwgts[to + 1] + vwgt[higain + 1] - (pwgts[other + 1] - eo)) - if pwgts[3] < mincut || (pwgts[3] == mincut && newdiff < mindiff) - mincut = pwgts[3] - mincutorder = nswaps - mindiff = newdiff - elseif nswaps - mincutorder > 2 * limit || - (nswaps - mincutorder > limit && pwgts[3] > 1.10 * mincut) - pwgts[3] += vwgt[higain + 1] - eo - break - end - bndind[bndptr[higain + 1] + 1] = bndind[nbnd] # BNDDelete - bndptr[bndind[nbnd] + 1] = bndptr[higain + 1] - nbnd -= 1 - bndptr[higain + 1] = -1 - pwgts[to + 1] += vwgt[higain + 1] - where_[higain + 1] = to - moved[higain + 1] = nswaps - swaps[nswaps + 1] = higain - for j in xadj[higain + 1]:(xadj[higain + 2] - 1) - k = adjncy[j + 1] - if where_[k + 1] == 2 - oldgain = vwgt[k + 1] - rinfo[k + 1][to + 1] - rinfo[k + 1] = to == 0 ? (rinfo[k + 1][1] + vwgt[higain + 1], rinfo[k + 1][2]) : - (rinfo[k + 1][1], rinfo[k + 1][2] + vwgt[higain + 1]) - if moved[k + 1] == -1 || moved[k + 1] == -(2 + other) - rpqUpdate(queues[other + 1], k, Float32(oldgain - vwgt[higain + 1])) - end - elseif where_[k + 1] == other - bndind[nbnd + 1] = k # BNDInsert - bndptr[k + 1] = nbnd - nbnd += 1 - nmind += 1 - mind[nmind] = k - where_[k + 1] = 2 - pwgts[other + 1] -= vwgt[k + 1] - e0 = 0 - e1 = 0 - for jj in xadj[k + 1]:(xadj[k + 2] - 1) - kk = adjncy[jj + 1] - if where_[kk + 1] != 2 - where_[kk + 1] == 0 ? (e0 += vwgt[kk + 1]) : (e1 += vwgt[kk + 1]) - else - oldgain = vwgt[kk + 1] - rinfo[kk + 1][other + 1] - rinfo[kk + 1] = other == 0 ? (rinfo[kk + 1][1] - vwgt[k + 1], rinfo[kk + 1][2]) : - (rinfo[kk + 1][1], rinfo[kk + 1][2] - vwgt[k + 1]) - if moved[kk + 1] == -1 || moved[kk + 1] == -(2 + to) - rpqUpdate(queues[to + 1], kk, Float32(oldgain + vwgt[k + 1])) - end - end - end - rinfo[k + 1] = (e0, e1) - if moved[k + 1] == -1 - rpqInsert(queues[to + 1], k, Float32(vwgt[k + 1] - (other == 0 ? e0 : e1))) - moved[k + 1] = -(2 + to) - end - end - end - mptr[nswaps + 2] = nmind - nswaps += 1 - end - nswaps -= 1 - while nswaps > mincutorder - higain = swaps[nswaps + 1] - to = where_[higain + 1] - other = (to + 1) % 2 - pwgts[3] += vwgt[higain + 1] - pwgts[to + 1] -= vwgt[higain + 1] - where_[higain + 1] = 2 - bndind[nbnd + 1] = higain - bndptr[higain + 1] = nbnd - nbnd += 1 - e0 = 0 - e1 = 0 - for j in xadj[higain + 1]:(xadj[higain + 2] - 1) - k = adjncy[j + 1] - if where_[k + 1] == 2 - rinfo[k + 1] = to == 0 ? (rinfo[k + 1][1] - vwgt[higain + 1], rinfo[k + 1][2]) : - (rinfo[k + 1][1], rinfo[k + 1][2] - vwgt[higain + 1]) - else - where_[k + 1] == 0 ? (e0 += vwgt[k + 1]) : (e1 += vwgt[k + 1]) - end - end - rinfo[higain + 1] = (e0, e1) - for j in mptr[nswaps + 1]:(mptr[nswaps + 2] - 1) - k = mind[j + 1] - where_[k + 1] = other - pwgts[other + 1] += vwgt[k + 1] - pwgts[3] -= vwgt[k + 1] - bndind[bndptr[k + 1] + 1] = bndind[nbnd] - bndptr[bndind[nbnd] + 1] = bndptr[k + 1] - nbnd -= 1 - bndptr[k + 1] = -1 - for jj in xadj[k + 1]:(xadj[k + 2] - 1) - kk = adjncy[jj + 1] - if where_[kk + 1] == 2 - rinfo[kk + 1] = other == 0 ? (rinfo[kk + 1][1] + vwgt[k + 1], rinfo[kk + 1][2]) : - (rinfo[kk + 1][1], rinfo[kk + 1][2] + vwgt[k + 1]) - end - end - end - nswaps -= 1 - end - graph.mincut = mincut - graph.nbnd = nbnd - (mincutorder == -1 || mincut >= initcut) && break - end - return nothing -end - -function FM_2WayNodeRefine1Sided!(ctrl::CtrlT, graph::GraphT, niter::Int) - nvtxs = graph.nvtxs - xadj = graph.xadj - adjncy = graph.adjncy - vwgt = graph.vwgt - bndind = graph.bndind - bndptr = graph.bndptr - where_ = graph.where_ - pwgts = graph.pwgts - rinfo = graph.nrinfo - queue = rpqCreate(nvtxs) - swaps = Vector{Int}(undef, nvtxs) - mptr = Vector{Int}(undef, nvtxs + 1) - mind = Vector{Int}(undef, 2 * nvtxs) - mult = Float32(0.5 * ctrl.ubfactors) - badmaxpwgt = trunc(Int, mult * (pwgts[1] + pwgts[2] + pwgts[3])) - to = pwgts[1] < pwgts[2] ? 1 : 0 - for pass in 0:(2 * niter - 1) - other = to - to = (to + 1) % 2 - rpqReset(queue) - mincutorder = -1 - initcut = mincut = graph.mincut - nbnd = graph.nbnd - irandArrayPermute!(nbnd, swaps, nbnd, 1) - for ii in 0:(nbnd - 1) - i = bndind[swaps[ii + 1] + 1] - rpqInsert(queue, i, Float32(vwgt[i + 1] - rinfo[i + 1][other + 1])) - end - limit = ctrl.compress ? min(5 * nbnd, 500) : min(3 * nbnd, 300) - mptr[1] = 0 - nmind = 0 - mindiff = abs(pwgts[1] - pwgts[2]) - nswaps = 0 - while nswaps < nvtxs - higain = rpqGetTop(queue) - higain == -1 && break - if nmind + xadj[higain + 2] - xadj[higain + 1] >= 2 * nvtxs - 1 - break - end - pwgts[to + 1] + vwgt[higain + 1] > badmaxpwgt && break - eo = rinfo[higain + 1][other + 1] - pwgts[3] -= vwgt[higain + 1] - eo - newdiff = abs(pwgts[to + 1] + vwgt[higain + 1] - (pwgts[other + 1] - eo)) - if pwgts[3] < mincut || (pwgts[3] == mincut && newdiff < mindiff) - mincut = pwgts[3] - mincutorder = nswaps - mindiff = newdiff - elseif nswaps - mincutorder > 3 * limit || - (nswaps - mincutorder > limit && pwgts[3] > 1.10 * mincut) - pwgts[3] += vwgt[higain + 1] - eo - break - end - bndind[bndptr[higain + 1] + 1] = bndind[nbnd] - bndptr[bndind[nbnd] + 1] = bndptr[higain + 1] - nbnd -= 1 - bndptr[higain + 1] = -1 - pwgts[to + 1] += vwgt[higain + 1] - where_[higain + 1] = to - swaps[nswaps + 1] = higain - for j in xadj[higain + 1]:(xadj[higain + 2] - 1) - k = adjncy[j + 1] - if where_[k + 1] == 2 - rinfo[k + 1] = to == 0 ? (rinfo[k + 1][1] + vwgt[higain + 1], rinfo[k + 1][2]) : - (rinfo[k + 1][1], rinfo[k + 1][2] + vwgt[higain + 1]) - elseif where_[k + 1] == other - bndind[nbnd + 1] = k - bndptr[k + 1] = nbnd - nbnd += 1 - nmind += 1 - mind[nmind] = k - where_[k + 1] = 2 - pwgts[other + 1] -= vwgt[k + 1] - e0 = 0 - e1 = 0 - for jj in xadj[k + 1]:(xadj[k + 2] - 1) - kk = adjncy[jj + 1] - if where_[kk + 1] != 2 - where_[kk + 1] == 0 ? (e0 += vwgt[kk + 1]) : (e1 += vwgt[kk + 1]) - else - rinfo[kk + 1] = other == 0 ? (rinfo[kk + 1][1] - vwgt[k + 1], rinfo[kk + 1][2]) : - (rinfo[kk + 1][1], rinfo[kk + 1][2] - vwgt[k + 1]) - rpqUpdate(queue, kk, Float32(vwgt[kk + 1] - rinfo[kk + 1][other + 1])) - end - end - rinfo[k + 1] = (e0, e1) - rpqInsert(queue, k, Float32(vwgt[k + 1] - (other == 0 ? e0 : e1))) - end - end - mptr[nswaps + 2] = nmind - nswaps += 1 - end - nswaps -= 1 - while nswaps > mincutorder - higain = swaps[nswaps + 1] - pwgts[3] += vwgt[higain + 1] - pwgts[to + 1] -= vwgt[higain + 1] - where_[higain + 1] = 2 - bndind[nbnd + 1] = higain - bndptr[higain + 1] = nbnd - nbnd += 1 - e0 = 0 - e1 = 0 - for j in xadj[higain + 1]:(xadj[higain + 2] - 1) - k = adjncy[j + 1] - if where_[k + 1] == 2 - rinfo[k + 1] = to == 0 ? (rinfo[k + 1][1] - vwgt[higain + 1], rinfo[k + 1][2]) : - (rinfo[k + 1][1], rinfo[k + 1][2] - vwgt[higain + 1]) - else - where_[k + 1] == 0 ? (e0 += vwgt[k + 1]) : (e1 += vwgt[k + 1]) - end - end - rinfo[higain + 1] = (e0, e1) - for j in mptr[nswaps + 1]:(mptr[nswaps + 2] - 1) - k = mind[j + 1] - where_[k + 1] = other - pwgts[other + 1] += vwgt[k + 1] - pwgts[3] -= vwgt[k + 1] - bndind[bndptr[k + 1] + 1] = bndind[nbnd] - bndptr[bndind[nbnd] + 1] = bndptr[k + 1] - nbnd -= 1 - bndptr[k + 1] = -1 - for jj in xadj[k + 1]:(xadj[k + 2] - 1) - kk = adjncy[jj + 1] - if where_[kk + 1] == 2 - rinfo[kk + 1] = other == 0 ? (rinfo[kk + 1][1] + vwgt[k + 1], rinfo[kk + 1][2]) : - (rinfo[kk + 1][1], rinfo[kk + 1][2] + vwgt[k + 1]) - end - end - end - nswaps -= 1 - end - graph.mincut = mincut - graph.nbnd = nbnd - pass % 2 == 1 && (mincutorder == -1 || mincut >= initcut) && break - end - return nothing -end - -function FM_2WayNodeBalance!(ctrl::CtrlT, graph::GraphT) - nvtxs = graph.nvtxs - xadj = graph.xadj - adjncy = graph.adjncy - vwgt = graph.vwgt - bndind = graph.bndind - bndptr = graph.bndptr - where_ = graph.where_ - pwgts = graph.pwgts - rinfo = graph.nrinfo - mult = Float32(0.5 * ctrl.ubfactors) - badmaxpwgt = trunc(Int, mult * (pwgts[1] + pwgts[2])) - max(pwgts[1], pwgts[2]) < badmaxpwgt && return nothing - abs(pwgts[1] - pwgts[2]) < 3 * graph.tvwgt ÷ nvtxs && return nothing - to = pwgts[1] < pwgts[2] ? 0 : 1 - other = (to + 1) % 2 - queue = rpqCreate(nvtxs) - perm = Vector{Int}(undef, nvtxs) - moved = fill(-1, nvtxs) - nbnd = graph.nbnd - irandArrayPermute!(nbnd, perm, nbnd, 1) - for ii in 0:(nbnd - 1) - i = bndind[perm[ii + 1] + 1] - rpqInsert(queue, i, Float32(vwgt[i + 1] - rinfo[i + 1][other + 1])) - end - for _ in 0:(nvtxs - 1) - higain = rpqGetTop(queue) - higain == -1 && break - moved[higain + 1] = 1 - gain = vwgt[higain + 1] - rinfo[higain + 1][other + 1] - badmaxpwgt = trunc(Int, mult * (pwgts[1] + pwgts[2])) - pwgts[to + 1] > pwgts[other + 1] && break - gain < 0 && pwgts[other + 1] < badmaxpwgt && break - pwgts[to + 1] + vwgt[higain + 1] > badmaxpwgt && continue - pwgts[3] -= gain - bndind[bndptr[higain + 1] + 1] = bndind[nbnd] - bndptr[bndind[nbnd] + 1] = bndptr[higain + 1] - nbnd -= 1 - bndptr[higain + 1] = -1 - pwgts[to + 1] += vwgt[higain + 1] - where_[higain + 1] = to - for j in xadj[higain + 1]:(xadj[higain + 2] - 1) - k = adjncy[j + 1] - if where_[k + 1] == 2 - rinfo[k + 1] = to == 0 ? (rinfo[k + 1][1] + vwgt[higain + 1], rinfo[k + 1][2]) : - (rinfo[k + 1][1], rinfo[k + 1][2] + vwgt[higain + 1]) - elseif where_[k + 1] == other - bndind[nbnd + 1] = k - bndptr[k + 1] = nbnd - nbnd += 1 - where_[k + 1] = 2 - pwgts[other + 1] -= vwgt[k + 1] - e0 = 0 - e1 = 0 - for jj in xadj[k + 1]:(xadj[k + 2] - 1) - kk = adjncy[jj + 1] - if where_[kk + 1] != 2 - where_[kk + 1] == 0 ? (e0 += vwgt[kk + 1]) : (e1 += vwgt[kk + 1]) - else - oldgain = vwgt[kk + 1] - rinfo[kk + 1][other + 1] - rinfo[kk + 1] = other == 0 ? (rinfo[kk + 1][1] - vwgt[k + 1], rinfo[kk + 1][2]) : - (rinfo[kk + 1][1], rinfo[kk + 1][2] - vwgt[k + 1]) - moved[kk + 1] == -1 && rpqUpdate(queue, kk, Float32(oldgain + vwgt[k + 1])) - end - end - rinfo[k + 1] = (e0, e1) - rpqInsert(queue, k, Float32(vwgt[k + 1] - (other == 0 ? e0 : e1))) - end - end - end - graph.mincut = pwgts[3] - graph.nbnd = nbnd - return nothing -end - - -# --------------------------------------------------------------------------- -# libmetis/ometis.c — bisection drivers, split, MMD leaves, NodeND - -function MlevelNodeBisectionMultiple!(ctrl::CtrlT, graph::GraphT) - if ctrl.nseps == 1 || graph.nvtxs < (ctrl.compress ? 1000 : 2000) - MlevelNodeBisectionL2!(ctrl, graph, LARGENIPARTS) - return nothing - end - bestwhere = Vector{Int}(undef, graph.nvtxs) - mincut = graph.tvwgt - for i in 0:(ctrl.nseps - 1) - MlevelNodeBisectionL2!(ctrl, graph, LARGENIPARTS) - if i == 0 || graph.mincut < mincut - mincut = graph.mincut - i < ctrl.nseps - 1 && copyto!(bestwhere, graph.where_) - end - mincut == 0 && break - end - if mincut != graph.mincut - copyto!(graph.where_, bestwhere) - Compute2WayNodePartitionParams!(ctrl, graph) - end - return nothing -end - -function MlevelNodeBisectionL2!(ctrl::CtrlT, graph::GraphT, niparts::Int) - if graph.nvtxs < 5000 - MlevelNodeBisectionL1!(ctrl, graph, niparts) - return nothing - end - nruns = 5 - ctrl.CoarsenTo = max(100, graph.nvtxs ÷ 30) - t = @elapsed (cgraph = CoarsenGraphNlevels(ctrl, graph, 4)) - prof!(:coarsen, t) - bestwhere = Vector{Int}(undef, cgraph.nvtxs) - mincut = graph.tvwgt - for i in 0:(nruns - 1) - MlevelNodeBisectionL1!(ctrl, cgraph, trunc(Int, 0.7 * niparts)) - if i == 0 || cgraph.mincut < mincut - mincut = cgraph.mincut - i < nruns - 1 && copyto!(bestwhere, cgraph.where_) - end - mincut == 0 && break - end - if mincut != cgraph.mincut - copyto!(cgraph.where_, bestwhere) - Compute2WayNodePartitionParams!(ctrl, cgraph) - end - t = @elapsed Refine2WayNode!(ctrl, graph, cgraph) - prof!(:refine_l2, t) - return nothing -end - -function MlevelNodeBisectionL1!(ctrl::CtrlT, graph::GraphT, niparts::Int) - ctrl.CoarsenTo = graph.nvtxs ÷ 8 - ctrl.CoarsenTo > 100 && (ctrl.CoarsenTo = 100) - ctrl.CoarsenTo < 40 && (ctrl.CoarsenTo = 40) - t = @elapsed (cgraph = CoarsenGraph(ctrl, graph)) - prof!(:coarsen, t) - niparts = max(1, cgraph.nvtxs <= ctrl.CoarsenTo ? niparts ÷ 2 : niparts) - t = @elapsed InitSeparator!(ctrl, cgraph, niparts) - prof!(:initsep, t) - t = @elapsed Refine2WayNode!(ctrl, graph, cgraph) - prof!(:refine, t) - return nothing -end - -# SplitGraphOrder: tri-section into the left/right subgraphs (separator out) -function SplitGraphOrder(ctrl::CtrlT, graph::GraphT) - nvtxs = graph.nvtxs - xadj = graph.xadj - vwgt = graph.vwgt - adjncy = graph.adjncy - label = graph.label - where_ = graph.where_ - bndptr = graph.bndptr - bndind = graph.bndind - rename = Vector{Int}(undef, nvtxs) - snvtxs = [0, 0, 0] - snedges = [0, 0, 0] - for i in 0:(nvtxs - 1) - k = where_[i + 1] - rename[i + 1] = snvtxs[k + 1] - snvtxs[k + 1] += 1 - snedges[k + 1] += xadj[i + 2] - xadj[i + 1] - end - lgraph = SetupSplitGraph(graph, snvtxs[1], snedges[1]) - rgraph = SetupSplitGraph(graph, snvtxs[2], snedges[2]) - # mark the boundary vertices of the two partitions through bndptr - for ii in 0:(graph.nbnd - 1) - i = bndind[ii + 1] - for j in xadj[i + 1]:(xadj[i + 2] - 1) - bndptr[adjncy[j + 1] + 1] = 1 - end - end - sn = [0, 0] - se = [0, 0] - lgraph.xadj[1] = 0 - rgraph.xadj[1] = 0 - sg = (lgraph, rgraph) - for i in 0:(nvtxs - 1) - mypart = where_[i + 1] - mypart == 2 && continue - g = sg[mypart + 1] - istart = xadj[i + 1] - iend = xadj[i + 2] - if bndptr[i + 1] == -1 # interior vertex - l = se[mypart + 1] - for j in istart:(iend - 1) - l += 1 - g.adjncy[l] = adjncy[j + 1] - end - se[mypart + 1] = l - else - l = se[mypart + 1] - for j in istart:(iend - 1) - k = adjncy[j + 1] - if where_[k + 1] == mypart - l += 1 - g.adjncy[l] = k - end - end - se[mypart + 1] = l - end - g.vwgt[sn[mypart + 1] + 1] = vwgt[i + 1] - g.label[sn[mypart + 1] + 1] = label[i + 1] - sn[mypart + 1] += 1 - g.xadj[sn[mypart + 1] + 1] = se[mypart + 1] - end - for mypart in 0:1 - g = sg[mypart + 1] - for i in 1:se[mypart + 1] - g.adjwgt[i] = 1 - g.adjncy[i] = rename[g.adjncy[i] + 1] - end - g.nvtxs = sn[mypart + 1] - g.nedges = se[mypart + 1] - SetupGraph_tvwgt!(g) - end - return lgraph, rgraph -end - -# MMDOrder: multiple minimum degree on a small piece, through the exported -# libmetis__genmmd of the shipped binary (pure arrays; exact by construction) -const GENMMD_PTR = Ref{Ptr{Cvoid}}(C_NULL) # set via init_libmetis!(path) - -function init_libmetis!(path::AbstractString) - h = Libdl.dlopen(path) - GENMMD_PTR[] = Libdl.dlsym(h, :libmetis__genmmd) - return nothing -end - -function MMDOrder!(ctrl::CtrlT, graph::GraphT, order::Vector{Int}, lastvtx::Int) - t0 = time() - nvtxs = graph.nvtxs - # 1-based CSR copies for the Fortran-style genmmd - xadj1 = Vector{Int32}(undef, nvtxs + 1) - for i in 1:(nvtxs + 1) - xadj1[i] = Int32(graph.xadj[i] + 1) - end - adjncy1 = Vector{Int32}(undef, max(graph.nedges, 1)) - for i in 1:graph.nedges - adjncy1[i] = Int32(graph.adjncy[i] + 1) - end - perm = Vector{Int32}(undef, nvtxs + 5) - iperm = Vector{Int32}(undef, nvtxs + 5) - head = Vector{Int32}(undef, nvtxs + 5) - qsize = Vector{Int32}(undef, nvtxs + 5) - list = Vector{Int32}(undef, nvtxs + 5) - marker = Vector{Int32}(undef, nvtxs + 5) - nofsub = Ref{Int32}(0) - ccall(GENMMD_PTR[], Cvoid, - (Int32, Ptr{Int32}, Ptr{Int32}, Ptr{Int32}, Ptr{Int32}, Int32, - Ptr{Int32}, Ptr{Int32}, Ptr{Int32}, Ptr{Int32}, Int32, Ref{Int32}), - Int32(nvtxs), xadj1, adjncy1, iperm, perm, Int32(1), - head, qsize, list, marker, Int32(IDX_MAX), nofsub) - label = graph.label - firstvtx = lastvtx - nvtxs - for i in 0:(nvtxs - 1) - order[label[i + 1] + 1] = firstvtx + Int(iperm[i + 1]) - 1 - end - prof!(:mmd, time() - t0) - return nothing -end - -function MlevelNestedDissection!(ctrl::CtrlT, graph::GraphT, order::Vector{Int}, lastvtx::Int) - MlevelNodeBisectionMultiple!(ctrl, graph) - DBG[] && println("Nvtxs: ", lpad(graph.nvtxs, 6), ", [", lpad(graph.pwgts[1], 6), - lpad(graph.pwgts[2], 7), lpad(graph.pwgts[3], 7), "]") - nbnd = graph.nbnd - bndind = graph.bndind - label = graph.label - for i in 0:(nbnd - 1) - lastvtx -= 1 - order[label[bndind[i + 1] + 1] + 1] = lastvtx - end - t = @elapsed ((lgraph, rgraph) = SplitGraphOrder(ctrl, graph)) - prof!(:split, t) - if lgraph.nvtxs > MMDSWITCH && lgraph.nedges > 0 - MlevelNestedDissection!(ctrl, lgraph, order, lastvtx - rgraph.nvtxs) - else - MMDOrder!(ctrl, lgraph, order, lastvtx - rgraph.nvtxs) - end - if rgraph.nvtxs > MMDSWITCH && rgraph.nedges > 0 - MlevelNestedDissection!(ctrl, rgraph, order, lastvtx) - else - MMDOrder!(ctrl, rgraph, order, lastvtx) - end - return nothing -end - -""" - NodeND(nvtxs, xadj, adjncy; vwgt = nothing, seed = -1) -> (perm, iperm) - -libmetis METIS_NodeND with OMETIS defaults, 0-based CSR in, 0-based perm and -iperm out. ccorder and pfactor are not translated (defaults are off). -""" -function NodeND(nvtxs::Int, xadj::Vector{Int}, adjncy::Vector{Int}; - vwgt::Union{Nothing, Vector{Int}} = nothing, seed::Int = -1) - ctrl = CtrlT(METIS_CTYPE_SHEM, METIS_RTYPE_SEP1SIDED, METIS_IPTYPE_EDGE, - 1, 10, 200, true, false, 0.0, false, seed, 100, 0, - Float32(Float64(Float32(1.0 + 0.001 * 200)) + 0.0000499), Float32[]) - InitRandom(ctrl.seed) - nnvtxs = 0 - graph = nothing - cptr = Int[] - cind = Int[] - if ctrl.compress - cptr = Vector{Int}(undef, nvtxs + 1) - cind = Vector{Int}(undef, nvtxs) - graph = CompressGraph(nvtxs, xadj, adjncy, vwgt, cptr, cind) - if graph === nothing - ctrl.compress = false - else - nnvtxs = graph.nvtxs - cfactor = 1.0 * nvtxs / nnvtxs - cfactor > 1.5 && ctrl.nseps == 1 && (ctrl.nseps = 2) - end - end - graph === nothing && (graph = SetupGraph(nvtxs, xadj, adjncy, vwgt)) - iperm = Vector{Int}(undef, nvtxs) - perm = Vector{Int}(undef, nvtxs) - MlevelNestedDissection!(ctrl, graph, iperm, graph.nvtxs) - if ctrl.compress # uncompress the ordering - for i in 0:(nnvtxs - 1) - perm[iperm[i + 1] + 1] = i - end - l = 0 - for ii in 0:(nnvtxs - 1) - i = perm[ii + 1] - for j in cptr[i + 1]:(cptr[i + 2] - 1) - iperm[cind[j + 1] + 1] = l - l += 1 - end - end - end - for i in 0:(nvtxs - 1) - perm[iperm[i + 1] + 1] = i - end - return perm, iperm -end - -""" - nodend_exact(A::SparseMatrixCSC; seed = -1) -> (perm, iperm) 1-based - -The `Metis.permutation(A)` replacement: symmetric pattern of `A`, diagonal -dropped, vwgt = NULL, all-default options. -""" -function nodend_exact(A; seed::Int = -1) - n = size(A, 1) - xadj = Vector{Int}(undef, n + 1) - adjncy = Int[] - rows = SparseArrays.rowvals(A) - xadj[1] = 0 - for j in 1:n - for p in SparseArrays.nzrange(A, j) - i = rows[p] - i != j && push!(adjncy, i - 1) - end - xadj[j + 1] = length(adjncy) - end - perm, iperm = NodeND(n, xadj, adjncy; seed) - return perm .+ 1, iperm .+ 1 -end - -end # module diff --git a/bench/ordering/gpu_nodend.jl b/bench/ordering/gpu_nodend.jl deleted file mode 100644 index a969a7ab..00000000 --- a/bench/ordering/gpu_nodend.jl +++ /dev/null @@ -1,31 +0,0 @@ -# GPU-hybrid METIS NodeND — loader for the ordering prototype (bench-level, -# stock src untouched). -# -# MetisExact.jl bitwise-faithful Julia translation of METIS 5.1.0's NodeND -# path (all-default options, the PR #107 configuration); -# validated perm- and iperm-identical to libmetis on lap2d/ -# lap3d and the case78484 KKT dumps up to n = 2.09M. -# GpuND.jl KernelAbstractions device kernels for the pipeline stages -# (hashed-handshake HEM matching incl. 2-hop, device-resident -# contraction), stage-validated against the exact port. -# Hybrid.jl nodend_hybrid(backend, A): device coarsening chain + exact -# host initial separator / FM refinement / MMD, threaded -# sub-threshold subtrees. Fill quality 1.003–1.015 nnz_L vs -# exact METIS on the benchmark set. -# -# Usage (any KA backend; CUDABackend for the GPU, CPU() works for smoke): -# include("bench/ordering/gpu_nodend.jl") -# perm, iperm = Hybrid.nodend_hybrid(backend, A) # A symmetric sparse -# -# MetisExact ccalls libc rand() and the binary's genmmd, so libmetis must be -# initialized once per process (Metis.jl's jll carries it): - -using Metis - -include(joinpath(@__DIR__, "MetisExact.jl")) -include(joinpath(@__DIR__, "GpuND.jl")) -include(joinpath(@__DIR__, "GpuRefine.jl")) -include(joinpath(@__DIR__, "Hybrid.jl")) -include(joinpath(@__DIR__, "GpuBatch.jl")) - -MetisExact.init_libmetis!(Metis.libmetis) diff --git a/bench/ordering/nodend_smoke.jl b/bench/ordering/nodend_smoke.jl deleted file mode 100644 index 25377d4b..00000000 --- a/bench/ordering/nodend_smoke.jl +++ /dev/null @@ -1,51 +0,0 @@ -# Smoke + quality gate for the GPU-hybrid NodeND prototype: a 2-D Laplacian -# ordered by the exact port and by the hybrid, scored by nnz_L through SDS's -# own symbolic analysis. Passes = both perms valid and the hybrid within 5% -# fill of the exact port (measured 1.003–1.015 on the benchmark set; 5% is -# the alarm threshold, not the expectation). -# julia +1.13 --project=bench bench/ordering/nodend_smoke.jl [grid=60] -using SparseDirectSolver, SparseArrays, LinearAlgebra, Printf -const SDS = SparseDirectSolver -const KA = SDS.KernelAbstractions - -include(joinpath(@__DIR__, "gpu_nodend.jl")) - -laplacian2d(nx, ny) = begin - Tx = spdiagm(-1 => -ones(nx - 1), 0 => 4ones(nx), 1 => -ones(nx - 1)) - Ty = spdiagm(-1 => -ones(ny - 1), 1 => -ones(ny - 1)) - Ix = sparse(1.0I, nx, nx); Iy = sparse(1.0I, ny, ny) - kron(Iy, Tx) + kron(Ty, Ix) -end - -function nnzL(A, perm) - n = size(A, 1) - Lt = sparse(transpose(tril(A))) - P = SDS.SymmetricPattern(Vector{Int}(Lt.colptr), Vector{Int}(Lt.rowval), n, - SDS.STRUCTURE_SPD; view = SDS.VIEW_LOWER, index = SDS.INDEX_ONE) - Int(SDS.evaluate_ordering(P, perm).nnz_L) -end - -g = isempty(ARGS) ? 60 : parse(Int, ARGS[1]) -A = laplacian2d(g, g) -n = size(A, 1) -println("lap2d $(g)x$(g) n = $n") - -pe, _ = MetisExact.nodend_exact(A) -@assert isperm(pe) -ph, _ = Hybrid.nodend_hybrid(KA.CPU(), A) -@assert isperm(ph) - -le = nnzL(A, pe) -lh = nnzL(A, ph) -@printf "nnz_L exact %d hybrid %d ratio %.4f\n" le lh lh / le -@assert lh / le < 1.05 "hybrid fill regressed beyond the 5% alarm threshold" - -# device-refinement path (only engages above Hybrid.GPU_MIN_NVTXS) -Hybrid.GPU_REFINE[] = true -pg, _ = Hybrid.nodend_hybrid(KA.CPU(), A) -Hybrid.GPU_REFINE[] = false -@assert isperm(pg) -lg = nnzL(A, pg) -@printf "nnz_L gpu-refine %d ratio vs exact %.4f (engaged: %s)\n" lg lg / le (n >= Hybrid.GPU_MIN_NVTXS[] ? "yes" : "no — below threshold") -n >= Hybrid.GPU_MIN_NVTXS[] && @assert lg / le < 1.08 "gpu-refine fill beyond alarm threshold" -println("SMOKE OK") diff --git a/docs/src/index.md b/docs/src/index.md index 6803149c..94978b44 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -25,8 +25,8 @@ oneAPI, Metal and the KernelAbstractions CPU backend. ## Features -* **Analysis on the host**: AMD or nested dissection (METIS, through the Metis - extension) ordering, elimination tree, supernodes with GPU-tuned +* **Analysis on the host**: AMD or nested dissection (METIS) ordering, + elimination tree, supernodes with GPU-tuned amalgamation, a static factor layout and device assembly maps. For symmetric indefinite matrices the analysis pairs structurally zero pivots with a partner so that the in-front pivoting can form the 2×2 blocks of KKT systems. @@ -59,8 +59,8 @@ Pkg.add(url = "https://github.com/exanauts/SparseDirectSolver.jl") Loading CUDA.jl enables the CUDA extension (`CuSparseMatrixCSR` and `CuSparseMatrixCSC` inputs, cuBLAS and cuSOLVER dense kernels); loading AMDGPU.jl enables the AMDGPU extension (`ROCSparseMatrixCSR` and -`ROCSparseMatrixCSC` inputs, rocBLAS and rocSOLVER dense kernels). Loading Metis.jl -enables nested dissection ordering; Krylov.jl enables FGMRES refinement. +`ROCSparseMatrixCSC` inputs, rocBLAS and rocSOLVER dense kernels); loading +Krylov.jl enables FGMRES refinement. ## Where to go next diff --git a/docs/src/man/parameters.md b/docs/src/man/parameters.md index 2f792ca1..55d77120 100644 --- a/docs/src/man/parameters.md +++ b/docs/src/man/parameters.md @@ -32,7 +32,7 @@ docstring. The ones that matter most in practice: | parameter | values | effect | | :--- | :--- | :--- | -| `reordering_alg` | `"default"`, `"algo1"`–`"algo5"` | automatic (AMD or, with `using Metis`, ND, whichever has the lower flops + 1e8 × supernodal schedule depth), AMD (`"algo3"`), nested dissection (`"algo4"`, needs `using Metis`), natural (`"algo5"`); see [`SparseDirectSolver.ReorderingAlg`](@ref) | +| `reordering_alg` | `"default"`, `"algo1"`–`"algo5"` | automatic (AMD or ND, whichever has the lower flops + 1e8 × supernodal schedule depth), AMD (`"algo3"`), nested dissection (`"algo4"`), natural (`"algo5"`); see [`SparseDirectSolver.ReorderingAlg`](@ref) | | `factorization_alg` | `"default"`, `"algo1"`, `"algo2"` | automatic, small-front kernels only, vendor dense calls for large fronts | | `matching_alg` | `"default"` (none), `"algo1"`–`"algo6"` | MC64 jobs 1–5 (`"algo6"`: automatic) before the ordering | | `pivot_type` | `'A'`, `'N'`, `'D'`, `'L'`, `'B'` | automatic, none, diagonal, local block, Bunch–Kaufman; global pivoting (`'C'`, `'R'`) is not supported | diff --git a/ext/SparseDirectSolverCUDAExt.jl b/ext/SparseDirectSolverCUDAExt.jl index d7c05f19..f551b5b6 100644 --- a/ext/SparseDirectSolverCUDAExt.jl +++ b/ext/SparseDirectSolverCUDAExt.jl @@ -79,6 +79,14 @@ therefore stop at the 48 KiB class on CUDA (issue #60). """ SparseDirectSolver.max_local_bytes(::CUDABackend) = 49152 +""" + max_threads_per_multiprocessor(::CUDABackend) -> Int + +`CU_DEVICE_ATTRIBUTE_MAX_THREADS_PER_MULTIPROCESSOR` of the current device. +""" +SparseDirectSolver.max_threads_per_multiprocessor(::CUDABackend) = + Int(CUDACore.attribute(CUDACore.device(), CUDACore.DEVICE_ATTRIBUTE_MAX_THREADS_PER_MULTIPROCESSOR)) + # --------------------------------------------------------------------------- # public API (T13, PLAN §3.1): solver constructors, update!, the LinearAlgebra layer diff --git a/ext/SparseDirectSolverMadNLPExt.jl b/ext/SparseDirectSolverMadNLPExt.jl index 5016f3ff..1ae4e1a4 100644 --- a/ext/SparseDirectSolverMadNLPExt.jl +++ b/ext/SparseDirectSolverMadNLPExt.jl @@ -99,7 +99,6 @@ function SDSSolver( T = eltype(csc.nzVal) n = size(csc, 1) backend = KA.get_backend(csc.nzVal) - Fused.BACKEND[] = backend t = Fused.resolve_tuning(something(opt.sds_tuning, Fused.default_tuning(backend)), backend) s = SDS.DirectSolver(csc.colPtr, csc.rowVal, csc.nzVal, "SPD", 'U') s.options.regime_c_width = t.regime_c_width @@ -118,23 +117,10 @@ function SDSSolver( fp = Fused.build_fused_plan(s; inkernel_wides = merged, merge_segments = merged, wgf = t.workgroup, scap = t.spin_cap) inv11, inv_ptr, invertf, _ = Fused.build_inverse(s) - # solve-side plan state (module globals, single solver instance) - plan0 = s.workspace.plan - nodes0 = s.symbolic.schedule.group_nodes - stp = Array(s.symbolic.subtree_ptr) - sub_ids = Int32[] - for k in eachindex(plan0.kind) - plan0.kind[k] == SDS.SOLVE_SUBTREES || continue - append!(sub_ids, Int32.(nodes0[plan0.first[k]:plan0.last[k]])) - end - sort!(sub_ids; by = e -> stp[e + 1] - stp[e], rev = true) - Fused.SSUB[] = Fused.DVEC(sub_ids) - Fused.NSUB[] = length(sub_ids) - Fused.INV11[] = inv11 - Fused.INVPTR[] = inv_ptr + # the solve plan: one merged dependency-counter sweep over the whole tree fz1 = Fused.build_fused(s; kstart = 1, wcap = 256) - segs = [(fz1, 64)] - fz = (segs, true) + fz1 === nothing && throw(SDS.NotSupportedError("fused solve: no plan entry with fronts of width <= 256")) + fz = ([(fz1, 64)], true) M = SDSSolver{T}(s, csc, x, b, nzval, fp, fz, inv11, inv_ptr, invertf, 0, opt, logger) MadNLP.factorize!(M) # prime the fused path once return M @@ -161,6 +147,8 @@ isdefined(MadNLP, :solve_linear_system!) && MadNLP.input_type(::Type{SDSSolver}) = :csc MadNLP.default_options(::Type{SDSSolver}) = SDSSolverOptions() MadNLP.is_inertia(::SDSSolver) = true +# a failed factorization is reported as an indefinite inertia, so that MadNLP +# regularizes and factorizes again function MadNLP.inertia(M::SDSSolver) n = size(M.tril, 1) return M.last_info == 0 ? (n, 0, 0) : (n - 2, 1, 1) @@ -173,4 +161,4 @@ MadNLP.is_supported(::Type{SDSSolver}, ::Type{Float64}) = true SparseDirectSolver.madnlp_solver() = SDSSolver SparseDirectSolver.madnlp_solver_options() = SDSSolverOptions -end # module \ No newline at end of file +end # module diff --git a/ext/SparseDirectSolverMetisExt.jl b/ext/SparseDirectSolverMetisExt.jl deleted file mode 100644 index a23c94c5..00000000 --- a/ext/SparseDirectSolverMetisExt.jl +++ /dev/null @@ -1,30 +0,0 @@ -""" - SparseDirectSolverMetisExt - -Nested-dissection ordering for SparseDirectSolver (PLAN §2.3 step 2). Loading -Metis.jl activates CliqueTrees' own Metis extension; this extension only -registers the algorithm object in `SparseDirectSolver.ND_PROVIDER`. -""" -module SparseDirectSolverMetisExt - -using SparseDirectSolver -using CliqueTrees -using Metis - -# METIS_NodeND with `ufactor = nd_ubfactor` and `nseps = nd_nseps` (-1: METIS -# defaults; values < 1 fall back to the default). `nd_nlevels` is cuDSS' -# *minimum* number of dissection levels; METIS_NodeND dissects recursively -# until the parts are small, so the minimum holds whenever the graph is large -# enough to be split that often. CliqueTrees' level-capped `ND{S}` (AMD below the -# cap) was 4–13× slower and gave 15–80% more fill on Laplacians (T05 Report). -function nd_algorithm(nlevels::Integer, ubfactor::Integer, nseps::Integer, seed::Integer) - return CliqueTrees.METIS(; ufactor = ubfactor, nseps = nseps >= 1 ? nseps : -1, - seed = seed >= 0 ? seed : -1) -end - -function __init__() - SparseDirectSolver.ND_PROVIDER[] = nd_algorithm - return nothing -end - -end # module SparseDirectSolverMetisExt diff --git a/src/SparseDirectSolver.jl b/src/SparseDirectSolver.jl index 94448e76..d31da183 100644 --- a/src/SparseDirectSolver.jl +++ b/src/SparseDirectSolver.jl @@ -16,8 +16,10 @@ using KernelAbstractions using LinearAlgebra using SparseArrays -# AMD.jl activates CliqueTrees' AMD extension (`CliqueTrees.AMD()` orderings) +# AMD.jl and Metis.jl activate CliqueTrees' AMD and METIS extensions +# (`CliqueTrees.AMD()` and `CliqueTrees.METIS()` orderings) import AMD +import Metis export SparseDirectSolverError, NotSupportedError, InvalidValueError, FactorizationError, InterruptedError export CONFIG_PARAMETERS, DATA_PARAMETERS, CUDSS08_DATA_PARAMETERS, EXTRA_PARAMETERS diff --git a/src/dense/capabilities.jl b/src/dense/capabilities.jl index 40a30ff3..ea5f5b51 100644 --- a/src/dense/capabilities.jl +++ b/src/dense/capabilities.jl @@ -99,6 +99,16 @@ per workgroup, 64 KiB on CDNA). max_local_bytes(backend) = 49152 max_local_bytes(::KernelAbstractions.CPU) = typemax(Int) +""" + max_threads_per_multiprocessor(backend) -> Int + +Resident work items per compute unit of the device `backend` runs on, `0` when +the backend does not report it. The CUDA extension reads the device attribute +(2048 on sm_70/80/90, 1536 on sm_86/89/120); the fused pipeline's +per-architecture tuning (`Fused.default_tuning`) keys on it. +""" +max_threads_per_multiprocessor(backend) = 0 + # --------------------------------------------------------------------------- # probes diff --git a/src/fused.jl b/src/fused.jl index b70028d7..79cc34c4 100644 --- a/src/fused.jl +++ b/src/fused.jl @@ -1,13 +1,14 @@ # The fused pipeline (PR #107 kernels + the October 2026 campaign), assembled # as a backend-portable submodule: the fused factorization (Cholesky and # no-pivot LDL^T) and the partitioned-inverse/fused solve, on CUDA and AMDGPU -# alike. Single-solver-instance (module-global plan state). Consumed by the -# MadNLP package extension (ext/SparseDirectSolverMadNLPExt.jl); the tuning -# bundle `SDSTuning` / `default_tuning` lives here because it parameterizes -# these kernels, not the MadNLP glue. +# alike. The plans are built on the solver's backend and carry their own device +# state (no module globals). Consumed by the MadNLP package extension +# (ext/SparseDirectSolverMadNLPExt.jl); the tuning bundle `SDSTuning` / +# `default_tuning` lives here because it parameterizes these kernels, not the +# MadNLP glue. module Fused -using LinearAlgebra, SparseArrays +using LinearAlgebra const SDS = Base.parentmodule(@__MODULE__) # the enclosing SparseDirectSolver module const KA = SDS.KernelAbstractions const Atomix = SDS.Atomix @@ -43,25 +44,30 @@ The measured per-architecture defaults — the single place they live. AMD (gfx906-measured): merged, 8K, workgroup 256, spin cap 16. """ function default_tuning(backend) - if occursin("CUDABackend", string(typeof(backend))) - M = Base.moduleroot(parentmodule(typeof(backend))) # CUDACore under the CUDA 6 split - small = M.attribute(M.device(), M.DEVICE_ATTRIBUTE_MAX_THREADS_PER_MULTIPROCESSOR) < 2048 - return small ? SDSTuning(; wides = :merged, subtree_budgets = [8192]) : - SDSTuning(; wides = :host) + # NVIDIA parts report their resident-thread count through the CUDA extension + threads = SDS.max_threads_per_multiprocessor(backend) + if threads > 0 + return threads < 2048 ? SDSTuning(; wides = :merged, subtree_budgets = [8192]) : + SDSTuning(; wides = :host) end return SDSTuning(; wides = :merged, subtree_budgets = [8192], workgroup = 256, spin_cap = 16) end -resolve_tuning(t::SDSTuning, backend) = t.wides === :auto ? - SDSTuning(t; wides = default_tuning(backend).wides) : t -SDSTuning(t::SDSTuning; wides) = SDSTuning(wides, t.subtree_budgets, t.subtree_parallelism, - t.regime_c_width, t.regime_c_rows, t.amalgamation, t.workgroup, t.spin_cap, t.nd_nseps, t.nd_seed) - -const BACKEND = Ref{Any}(nothing) -DVEC(v) = (out = KA.allocate(BACKEND[], eltype(v), size(v)); copyto!(out, v); out) -DVEC(::Type{T}, v) where {T} = (out = KA.allocate(BACKEND[], T, length(v)); copyto!(out, T.(v)); out) +""" + resolve_tuning(t::SDSTuning, backend) -> SDSTuning +`t` with `wides = :auto` replaced by the wide-front strategy of +`default_tuning(backend)`; any other `t` is returned as is. +""" +function resolve_tuning(t::SDSTuning, backend) + t.wides === :auto || return t + return SDSTuning(default_tuning(backend).wides, t.subtree_budgets, t.subtree_parallelism, + t.regime_c_width, t.regime_c_rows, t.amalgamation, t.workgroup, t.spin_cap, + t.nd_nseps, t.nd_seed) +end +# device copy of a host integer vector (empty vectors included) +_dvec(backend, v) = SDS._device_vec(backend, eltype(v), v) const WG = 32 const MW = 256 # max front width on the inverse path @@ -104,9 +110,10 @@ end @Const(subtree_ptr), @Const(subtree_nodes), @Const(super_ptr), @Const(rowptr), @Const(rowval), @Const(front_ptr), @Const(front_nrows), @Const(front_ncols), ::Val{SUB}, ::Val{WGV}) where {SUB, WGV} + @uniform TT = eltype(Y) li = @index(Local, Linear) g = @index(Group, Linear) - t = @localmem Float64 (MW,) + t = @localmem TT (MW,) @inbounds begin e = list[first + g - 1] a0 = SUB ? Int(subtree_ptr[e]) : 0 @@ -123,7 +130,7 @@ end w > MW && continue # t = inv(L11) * Y[c0+1 : c0+w] for kk in li:WGV:w - acc = zero(Float64) + acc = zero(TT) for j in 1:w acc += inv11[ip + (j - 1) * w + kk] * Y[c0 + j] end @@ -135,7 +142,7 @@ end end # L21 update from t (Y rows above `limit` belong to other workgroups: atomic) for i in (w + li):WGV:f - acc = zero(Float64) + acc = zero(TT) for j in 1:w acc += factor[p0 + (j - 1) * f + i] * t[j] end @@ -157,9 +164,10 @@ end @Const(subtree_ptr), @Const(subtree_nodes), @Const(super_ptr), @Const(rowptr), @Const(rowval), @Const(front_ptr), @Const(front_nrows), @Const(front_ncols), ::Val{SUB}, ::Val{WGV}) where {SUB, WGV} + @uniform TT = eltype(Y) li = @index(Local, Linear) g = @index(Group, Linear) - t = @localmem Float64 (MW,) + t = @localmem TT (MW,) @inbounds begin e = list[first + g - 1] a0 = SUB ? Int(subtree_ptr[e]) : 0 @@ -175,7 +183,7 @@ end w > MW && continue # t = Y[c0+k] - L21ᵀ x(rows below) for kk in li:WGV:w - acc = zero(Float64) + acc = zero(TT) col = p0 + (kk - 1) * f for i in (w + 1):f acc += factor[col + i] * Y[rowval[rp + i]] @@ -185,7 +193,7 @@ end @synchronize # x = inv(L11)ᵀ t (inv col-major: invᵀ[k, j] = inv11[(k-1)w + j], contiguous in j) for kk in li:WGV:w - acc = zero(Float64) + acc = zero(TT) cb = ip + (kk - 1) * w for j in 1:w acc += inv11[cb + j] * t[j] @@ -198,6 +206,9 @@ end end # --------------------------------------------------------------------------- +# per-front inverses of the L11 blocks (width <= MW) for the partitioned-inverse solve: +# returns the device buffer, its per-front offsets, a closure that (re)computes the +# inverses from the current factor, and the buffer size in bytes function build_inverse(s) S = s.symbolic sc = S.schedule @@ -215,61 +226,17 @@ function build_inverse(s) end end inv_ptr_h[ns + 1] = p - inv11 = KA.zeros(BACKEND[], Float64, p - 1) - inv_ptr = DVEC(inv_ptr_h) - ab = DVEC(Int32.(findall(v -> width[v] <= MW, 1:ns))) - backend = KA.get_backend(inv11) + backend = KA.get_backend(s.numeric.factor) + T = eltype(s.numeric.factor) + inv11 = KA.zeros(backend, T, p - 1) + inv_ptr = _dvec(backend, inv_ptr_h) + ab = _dvec(backend, Int32.(findall(v -> width[v] <= MW, 1:ns))) invert! = () -> invert_l11_kernel!(backend, WG)(inv11, s.numeric.factor, inv_ptr, ab, S.front_ptr, S.front_nrows, S.front_ncols, Val(WG); ndrange = WG * length(ab)) - return inv11, inv_ptr, invert!, sizeof(Float64) * (p - 1) + return inv11, inv_ptr, invert!, sizeof(T) * (p - 1) end -function algo1_solve!(x, s, b, inv11, inv_ptr) - S, N, ws = s.symbolic, s.numeric, s.workspace - bm = SDS.batch_map(N; nrhs = 1) - SDS.permute_rhs!(ws.Y, b, S.perm; transposed = false, bm) - plan = ws.plan - nodes = S.schedule.group_nodes - widths = S.schedule.width - p = SDS._solve_impls(N, S, :auto) - backend = KA.get_backend(ws.Y) - Yv = vec(ws.Y) - args(first, count) = (Yv, N.factor, inv11, inv_ptr, S.group_nodes, first, count, S.subtree_ptr, - S.subtree_nodes, S.super_ptr, S.rowptr, S.rowval, S.front_ptr, S.front_nrows, - S.front_ncols) - for k in eachindex(plan.kind) - a, b_ = plan.first[k], plan.last[k] - if plan.kind[k] == SDS.SOLVE_DENSE - any(widths[nodes[q]] <= MW for q in a:b_) && - fwd1_kernel!(backend, WG)(args(a, b_ - a + 1)..., Val(false), Val(WG); ndrange = WG * (b_ - a + 1)) - for q in a:b_ - widths[nodes[q]] > MW && SDS._fwd_dense!(ws, S, N, nodes[q], 1, false, p, SDS._SV_CHOLESKY) - end - else - sub = plan.kind[k] == SDS.SOLVE_SUBTREES - fwd1_kernel!(backend, WG)(args(a, b_ - a + 1)..., Val(sub), Val(WG); ndrange = WG * (b_ - a + 1)) - end - end - for k in reverse(eachindex(plan.kind)) - a, b_ = plan.first[k], plan.last[k] - if plan.kind[k] == SDS.SOLVE_DENSE - for q in b_:-1:a - widths[nodes[q]] > MW && SDS._bwd_dense!(ws, S, N, nodes[q], 1, p, SDS._SV_CHOLESKY) - end - any(widths[nodes[q]] <= MW for q in a:b_) && - bwd1_kernel!(backend, WG)(args(a, b_ - a + 1)..., Val(false), Val(WG); ndrange = WG * (b_ - a + 1)) - else - sub = plan.kind[k] == SDS.SOLVE_SUBTREES - bwd1_kernel!(backend, WG)(args(a, b_ - a + 1)..., Val(sub), Val(WG); ndrange = WG * (b_ - a + 1)) - end - end - SDS.unpermute_solution!(x, ws.Y, S.perm; transposed = false, bm) - return x -end - - - # --------------------------------------------------------------------------- # fused sweeps (algo2): one launch per direction over every front of width # <= MW below the first wide front, etree dependency counters instead of @@ -279,10 +246,11 @@ end @Const(snparent), arrived, @Const(nchild), @Const(super_ptr), @Const(rowptr), @Const(rowval), @Const(front_ptr), @Const(front_nrows), @Const(front_ncols), ::Val{WGV}) where {WGV} + @uniform TT = eltype(Y) li = @index(Local, Linear) g = @index(Group, Linear) - yb = @localmem Float64 (MW,) - t = @localmem Float64 (MW,) + yb = @localmem TT (MW,) + t = @localmem TT (MW,) @inbounds if g <= nsub e = Int(slist[g]) # regime-A subtree: serial walk, then signal a0 = Int(subtree_ptr[e]) @@ -298,7 +266,7 @@ end c0 = Int(super_ptr[v]) - 1 ip = Int(inv_ptr[v]) - 1 for kk in li:WGV:w - acc = zero(Float64) + acc = zero(TT) for j in 1:w acc += inv11[ip + (j - 1) * w + kk] * Y[c0 + j] end @@ -309,7 +277,7 @@ end Y[c0 + kk] = t[kk] end for i in (w + li):WGV:f - acc = zero(Float64) + acc = zero(TT) for j in 1:w acc += factor[p0 + (j - 1) * f + i] * t[j] end @@ -343,18 +311,18 @@ end for _ in 1:pause z += 1.0 end - z < 0 && (yb[1] = z) # keep the backoff loop alive + z < 0 && (yb[1] = TT(z)) # keep the backoff loop alive pause = min(pause << 1, Int32(128)) end end @synchronize Threads.atomic_fence() for kk in li:WGV:w # coherent read of the front's columns - yb[kk] = Atomix.@atomic Y[c0 + kk] += 0.0 + yb[kk] = Atomix.@atomic Y[c0 + kk] += zero(TT) end @synchronize for kk in li:WGV:w # t = inv(L11) * y - acc = zero(Float64) + acc = zero(TT) for j in 1:w acc += inv11[ip + (j - 1) * w + kk] * yb[j] end @@ -365,7 +333,7 @@ end Y[c0 + kk] = t[kk] end for i in (w + li):WGV:f # L21 update, atomic scatter - acc = zero(Float64) + acc = zero(TT) for j in 1:w acc += factor[p0 + (j - 1) * f + i] * t[j] end @@ -385,11 +353,12 @@ end @Const(snparent), done, @Const(super_ptr), @Const(rowptr), @Const(rowval), @Const(front_ptr), @Const(front_nrows), @Const(front_ncols), ::Val{WGV}) where {WGV} + @uniform TT = eltype(Y) li = @index(Local, Linear) g = @index(Group, Linear) - xb = @localmem Float64 (WGV,) - t = @localmem Float64 (MW,) - t2 = @localmem Float64 (MW,) + xb = @localmem TT (WGV,) + t = @localmem TT (MW,) + t2 = @localmem TT (MW,) @inbounds if g > length(flist) e = Int(slist[g - length(flist)]) # regime-A subtree: wait for the root's parent, walk down a0 = Int(subtree_ptr[e]) @@ -404,7 +373,7 @@ end for _ in 1:pause z += 1.0 end - z < 0 && (t[1] = z) + z < 0 && (t[1] = TT(z)) pause = min(pause << 1, Int32(128)) end end @@ -420,7 +389,7 @@ end c0 = Int(super_ptr[v]) - 1 ip = Int(inv_ptr[v]) - 1 for kk in li:WGV:w - acc = zero(Float64) + acc = zero(TT) col = p0 + (kk - 1) * f for i in (w + 1):f acc += factor[col + i] * Y[Int(rowval[rp + i])] @@ -429,7 +398,7 @@ end end @synchronize for kk in li:WGV:w - acc = zero(Float64) + acc = zero(TT) cb = ip + (kk - 1) * w for j in 1:w acc += inv11[cb + j] * t2[j] @@ -455,7 +424,7 @@ end for _ in 1:pause z += 1.0 end - z < 0 && (t[1] = z) + z < 0 && (t[1] = TT(z)) pause = min(pause << 1, Int32(128)) end end @@ -463,14 +432,14 @@ end @synchronize Threads.atomic_fence() for kk in li:WGV:w - t[kk] = zero(Float64) + t[kk] = zero(TT) end @synchronize i0 = w + 1 while i0 <= f # chunked coherent gather of ancestor solutions ii = i0 + li - 1 if ii <= f - xb[li] = Atomix.@atomic Y[Int(rowval[rp + ii])] += 0.0 + xb[li] = Atomix.@atomic Y[Int(rowval[rp + ii])] += zero(TT) end @synchronize m = min(WGV, f - i0 + 1) @@ -490,7 +459,7 @@ end end @synchronize for kk in li:WGV:w - acc = zero(Float64) + acc = zero(TT) cb = ip + (kk - 1) * w for j in 1:w acc += inv11[cb + j] * t2[j] @@ -513,10 +482,10 @@ end @kernel function chain_fwd_kernel!(Y, @Const(factor), @Const(inv11), @Const(inv_ptr), @Const(clist), @Const(super_ptr), @Const(rowptr), @Const(rowval), @Const(front_ptr), @Const(front_nrows), @Const(front_ncols), ::Val{WGV}) where {WGV} + @uniform TT = eltype(Y) li = @index(Local, Linear) - yb = @localmem Float64 (MW,) - t = @localmem Float64 (MW,) - tp = @localmem Float64 (MW * NSTRIP,) + yb = @localmem TT (MW,) + t = @localmem TT (MW,) @inbounds for q in 1:length(clist) v = Int(clist[q]) f = Int(front_nrows[v]) @@ -530,7 +499,7 @@ end end @synchronize for kk in li:WGV:w - acc = zero(Float64) + acc = zero(TT) for j in 1:w acc += inv11[ip + (j - 1) * w + kk] * yb[j] end @@ -541,7 +510,7 @@ end Y[c0 + kk] = t[kk] end for i in (w + li):WGV:f - acc = zero(Float64) + acc = zero(TT) for j in 1:w acc += factor[p0 + (j - 1) * f + i] * t[j] end @@ -554,10 +523,11 @@ end @kernel function chain_bwd_kernel!(Y, @Const(factor), @Const(inv11), @Const(inv_ptr), @Const(clist), @Const(super_ptr), @Const(rowptr), @Const(rowval), @Const(front_ptr), @Const(front_nrows), @Const(front_ncols), ::Val{WGV}) where {WGV} + @uniform TT = eltype(Y) li = @index(Local, Linear) - t = @localmem Float64 (MW,) - t2 = @localmem Float64 (MW,) - tp = @localmem Float64 (MW * NSTRIP,) + t = @localmem TT (MW,) + t2 = @localmem TT (MW,) + tp = @localmem TT (MW * NSTRIP,) @inbounds for q in length(clist):-1:1 v = Int(clist[q]) f = Int(front_nrows[v]) @@ -569,7 +539,7 @@ end kk = (li - 1) % w + 1 # strip-parallel gather: t = L21' x st = (li - 1) ÷ w + 1 if st <= NSTRIP - acc = zero(Float64) + acc = zero(TT) col = p0 + (kk - 1) * f for i in (w + st):NSTRIP:f acc += factor[col + i] * Y[Int(rowval[rp + i])] @@ -588,7 +558,7 @@ end kk2 = (li - 1) % w + 1 # strip-parallel GEMV: x = inv(L11)' t2 st2b = (li - 1) ÷ w + 1 if st2b <= NSTRIP - acc = zero(Float64) + acc = zero(TT) cb = ip + (kk2 - 1) * w for j in st2b:NSTRIP:w acc += inv11[cb + j] * t2[j] @@ -597,7 +567,7 @@ end end @synchronize for k2 in li:WGV:w - acc = zero(Float64) + acc = zero(TT) for st3 in 1:NSTRIP acc += tp[(st3 - 1) * MW + k2] end @@ -659,56 +629,66 @@ function build_fused(s; kstart::Int = 1, wcap::Int = MW, merged::Bool = (kstart arrived_base_h[v] = Int32(cnt) end done_base_h = Int32[in_range[v] ? 0 : 1 for v in 1:ns] - println("fused: entries ", i1, ":", i2, " of ", length(plan.kind), ", ", length(flist_h), " fronts") - return (; i1, i2, flist = DVEC(flist_h), arrived = KA.zeros(BACKEND[], Int32, ns), - arrived_base = DVEC(arrived_base_h), done = KA.zeros(BACKEND[], Int32, ns), - done_base = DVEC(done_base_h), nchild = DVEC(nchild_h), nfused = length(flist_h)) + # the regime-A subtrees the merged launch walks itself, longest first + sub_ids = Int32[] + if merged + for k in eachindex(plan.kind) + plan.kind[k] == SDS.SOLVE_SUBTREES || continue + append!(sub_ids, Int32.(nodes[plan.first[k]:plan.last[k]])) + end + sort!(sub_ids; by = e -> stp_h[e + 1] - stp_h[e], rev = true) + end + SDS._log(SDS.LOG_DEBUG, () -> "fused solve: plan entries $i1:$i2 of $(length(plan.kind)), " * + "$(length(flist_h)) fronts, $(length(sub_ids)) subtrees") + backend = KA.get_backend(s.numeric.factor) + return (; i1, i2, flist = _dvec(backend, flist_h), arrived = KA.zeros(backend, Int32, ns), + arrived_base = _dvec(backend, arrived_base_h), done = KA.zeros(backend, Int32, ns), + done_base = _dvec(backend, done_base_h), nchild = _dvec(backend, nchild_h), + nfused = length(flist_h), slist = _dvec(backend, sub_ids), nsub = length(sub_ids)) end -function run_fused!(fz, wg, Yv, s, backend) +# one fused segment, forward sweep: `wg > 512` runs the single-workgroup chain kernel, +# `wg == 64` the dependency-counter sweep (the workgroup size the sweep is tuned for) +function run_fused!(fz, wg, Yv, s, inv11, inv_ptr, backend) S, N = s.symbolic, s.numeric if wg > 512 - chain_fwd_kernel!(backend, CHWG)(Yv, N.factor, INV11[], INVPTR[], fz.flist, S.super_ptr, S.rowptr, + chain_fwd_kernel!(backend, CHWG)(Yv, N.factor, inv11, inv_ptr, fz.flist, S.super_ptr, S.rowptr, S.rowval, S.front_ptr, S.front_nrows, S.front_ncols, Val(CHWG); ndrange = CHWG) return nothing end + wg == 64 || throw(SDS.InvalidValueError("fused solve: workgroup size $wg; expected 64 or > 512 (chain)")) copyto!(fz.arrived, fz.arrived_base) - if wg == 64 - fwd2_kernel!(backend, 64)(Yv, N.factor, INV11[], INVPTR[], fz.flist, SSUB[], NSUB[], S.subtree_ptr, - S.subtree_nodes, S.snparent, fz.arrived, fz.nchild, - S.super_ptr, S.rowptr, S.rowval, S.front_ptr, S.front_nrows, S.front_ncols, - Val(64); ndrange = 64 * (fz.nfused + NSUB[])) - else - error("unused") - end + fwd2_kernel!(backend, 64)(Yv, N.factor, inv11, inv_ptr, fz.flist, fz.slist, fz.nsub, S.subtree_ptr, + S.subtree_nodes, S.snparent, fz.arrived, fz.nchild, + S.super_ptr, S.rowptr, S.rowval, S.front_ptr, S.front_nrows, S.front_ncols, + Val(64); ndrange = 64 * (fz.nfused + fz.nsub)) + return nothing end -function run_fused_bwd!(fz, wg, Yv, s, backend) +# the backward counterpart of `run_fused!` +function run_fused_bwd!(fz, wg, Yv, s, inv11, inv_ptr, backend) S, N = s.symbolic, s.numeric if wg > 512 - chain_bwd_kernel!(backend, CHWG)(Yv, N.factor, INV11[], INVPTR[], fz.flist, S.super_ptr, S.rowptr, + chain_bwd_kernel!(backend, CHWG)(Yv, N.factor, inv11, inv_ptr, fz.flist, S.super_ptr, S.rowptr, S.rowval, S.front_ptr, S.front_nrows, S.front_ncols, Val(CHWG); ndrange = CHWG) return nothing end + wg == 64 || throw(SDS.InvalidValueError("fused solve: workgroup size $wg; expected 64 or > 512 (chain)")) copyto!(fz.done, fz.done_base) - if wg == 64 - bwd2_kernel!(backend, 64)(Yv, N.factor, INV11[], INVPTR[], fz.flist, SSUB[], NSUB[], S.subtree_ptr, - S.subtree_nodes, S.snparent, fz.done, - S.super_ptr, S.rowptr, S.rowval, S.front_ptr, S.front_nrows, S.front_ncols, - Val(64); ndrange = 64 * (fz.nfused + NSUB[])) - else - error("unused") - end + bwd2_kernel!(backend, 64)(Yv, N.factor, inv11, inv_ptr, fz.flist, fz.slist, fz.nsub, S.subtree_ptr, + S.subtree_nodes, S.snparent, fz.done, + S.super_ptr, S.rowptr, S.rowval, S.front_ptr, S.front_nrows, S.front_ncols, + Val(64); ndrange = 64 * (fz.nfused + fz.nsub)) + return nothing end -const INV11 = Ref{Any}() -const INVPTR = Ref{Any}() -const SSUB = Ref{Any}() # subtree ids sorted by size, longest first -const NSUB = Ref{Int}(0) - -function algo2_solve!(x, s, b, inv11, inv_ptr, fz; debug::Bool = false) +# the fused solve: `fz = (segments, covered)` with `segments` a vector of +# `(build_fused(...), wg)` pairs in plan order; plan entries outside the segments +# run through the level-batched kernels (`fwd1_kernel!`/`bwd1_kernel!`, vendor +# dense calls for fronts wider than MW) +function algo2_solve!(x, s, b, inv11, inv_ptr, fz) S, N, ws = s.symbolic, s.numeric, s.workspace bm = SDS.batch_map(N; nrhs = 1) SDS.permute_rhs!(ws.Y, b, S.perm; transposed = false, bm) @@ -749,20 +729,18 @@ function algo2_solve!(x, s, b, inv11, inv_ptr, fz; debug::Bool = false) ndrange = WG * (b_ - a + 1)) end end - INV11[] = inv11; INVPTR[] = inv_ptr - segs, covered = fz + segs, _ = fz k = 1 for (fzk, wg) in segs while k < fzk.i1 fwd_entry(k); k += 1 end - run_fused!(fzk, wg, Yv, s, backend) + run_fused!(fzk, wg, Yv, s, inv11, inv_ptr, backend) k = fzk.i2 + 1 end while k <= length(plan.kind) fwd_entry(k); k += 1 end - debug && (KA.synchronize(BACKEND[]); println(" algo2: forward done"); flush(stdout)) if SDS._is_ldlt_structure(S.structure) # LDL: apply D between the unit-triangular sweeps Yv ./= view(N.d, 1:length(Yv)) end @@ -771,22 +749,16 @@ function algo2_solve!(x, s, b, inv11, inv_ptr, fz; debug::Bool = false) while k > fzk.i2 bwd_entry(k); k -= 1 end - run_fused_bwd!(fzk, wg, Yv, s, backend) + run_fused_bwd!(fzk, wg, Yv, s, inv11, inv_ptr, backend) k = fzk.i1 - 1 end while k >= 1 bwd_entry(k); k -= 1 end - debug && (KA.synchronize(BACKEND[]); println(" algo2: backward done"); flush(stdout)) SDS.unpermute_solution!(x, ws.Y, S.perm; transposed = false, bm) return x end - - - - - const SYRK_IN = 64 const TILE = 32 const NL64 = 2080 # packed 64x64 lower triangle @@ -996,7 +968,7 @@ const ROLE_WTRSM = Int8(7) # (rb = panel k | rb = chunk, rc = k | rc = const ROLE_WTILE = Int8(8) const ROLE_WSIG = Int8(9) -# spin with backoff on an Int32 cell until pred(value) holds; thread 1 only +# spin with backoff on an Int32 cell until it equals `target`; thread 1 only @inline function _spin_until_eq!(arr, idx, target, scratch, scap) pause = min(Int32(4), scap) while (Atomix.@atomic arr[idx] += Int32(0)) != target @@ -1773,21 +1745,6 @@ end end end -@kernel function waiter_kernel!(ea_left, v, fscr_unused) - li = @index(Local, Linear) - if li == 1 - pause = min(Int32(4), scap) - @inbounds while (Atomix.@atomic ea_left[v] += Int32(0)) != Int32(0) - z = 0.0 - for _ in 1:pause - z += 1.0 - end - z < 0 && (ea_left[v] = Int32(0)) - pause = min(pause << 1, scap) - end - end -end - @kernel function signal_kernel!(cb_done, arrived, @Const(snparent), v) li = @index(Local, Linear) @inbounds if li == 1 @@ -1832,7 +1789,9 @@ function build_fused_plan(s; ldl::Bool = false, inkernel_wides::Union{Nothing, B wgf::Int = WGF, scap::Integer = 128, ea_chunk::Integer = 16384) S = s.symbolic plan = s.numeric.plan - ldl && (eltype(s.numeric.factor) <: Real || error("fused LDL mode: real element types only")) + backend = KA.get_backend(s.numeric.factor) + T = eltype(s.numeric.factor) + ldl && (T <: Real || throw(SDS.NotSupportedError("fused LDL mode: real element types only"))) # LDL defaults to in-kernel wides: the host-wide LDL path (_factor_panel_c_ldl!) is # correct but needs ~5 launches per 64-column block where Cholesky's potrf is one # vendor call -- measured SLOWER on the GV100 (55.5 vs 38.6 ms tuned). Pass @@ -1886,7 +1845,7 @@ function build_fused_plan(s; ldl::Bool = false, inkernel_wides::Union{Nothing, B wt_h[v] = Int32(pv) nwt += 1; ewt += m * (m + 1) ÷ 2 end - println("write-through: ", nwt, " fronts, ", round(ewt / 1e6; digits = 2), "M CB entries"); flush(stdout) + SDS._log(SDS.LOG_DEBUG, () -> "fused plan: write-through $nwt fronts, $(round(ewt / 1e6; digits = 2))M CB entries") emit_ea(v) = for kc in chp[v]:(chp[v + 1] - 1) c = chl[kc] @@ -2023,35 +1982,35 @@ function build_fused_plan(s; ldl::Bool = false, inkernel_wides::Union{Nothing, B for k in eachindex(plan.sub_first) ids = [nodes[q] for q in plan.sub_first[k]:plan.sub_last[k]] sort!(ids; by = e -> stp2[e + 1] - stp2[e], rev = true) - push!(ssub, DVEC(ids)) + push!(ssub, _dvec(backend, ids)) end - println("segments: ", length(segments)); flush(stdout) - return (; role = DVEC(role), ra = DVEC(ra), rb = DVEC(rb), rc = DVEC(rc), - nblocks = length(role), wides, ssub, segments, - cb2 = DVEC(Int32.(cb2_h)), cb2_h, pcb = KA.zeros(BACKEND[], Float64, off - 1), - czero = DVEC(czero), wt = DVEC(wt_h), - nchild = DVEC(nchild), - arrived_base = DVEC(arrived_base), ea_base = DVEC(ea_left), - trsm_base = DVEC(trsm_left), tiles_base = DVEC(tiles_left), - cb_base = DVEC(cb_done_base), - arrived = KA.zeros(BACKEND[], Int32, ns), ea = KA.zeros(BACKEND[], Int32, ns), - trsm = KA.zeros(BACKEND[], Int32, ns), tiles = KA.zeros(BACKEND[], Int32, ns), - pdone = KA.zeros(BACKEND[], Int32, ns), cbd = KA.zeros(BACKEND[], Int32, ns), - wbase = DVEC(wbase_h), wupd_b = DVEC(wupd_b), wtl_b = DVEC(wtl_b), - wcb_b = DVEC(wcb_b), wupd = KA.zeros(BACKEND[], Int32, max(nwslots, 1)), - wtl = KA.zeros(BACKEND[], Int32, max(nwslots, 1)), wpd = KA.zeros(BACKEND[], Int32, max(nwslots, 1)), - wcb = KA.zeros(BACKEND[], Int32, ns), wgf, scap = Int32(scap), eac = Int32(ea_chunk), ldl, + SDS._log(SDS.LOG_DEBUG, () -> "fused plan: $(length(role)) roles, $(length(segments)) segments, " * + "$(length(wides)) host wide fronts") + return (; role = _dvec(backend, role), ra = _dvec(backend, ra), rb = _dvec(backend, rb), + rc = _dvec(backend, rc), wides, ssub, segments, + cb2 = _dvec(backend, Int32.(cb2_h)), cb2_h, pcb = KA.zeros(backend, T, off - 1), + czero = _dvec(backend, czero), wt = _dvec(backend, wt_h), + nchild = _dvec(backend, nchild), + arrived_base = _dvec(backend, arrived_base), ea_base = _dvec(backend, ea_left), + trsm_base = _dvec(backend, trsm_left), tiles_base = _dvec(backend, tiles_left), + cb_base = _dvec(backend, cb_done_base), + arrived = KA.zeros(backend, Int32, ns), ea = KA.zeros(backend, Int32, ns), + trsm = KA.zeros(backend, Int32, ns), tiles = KA.zeros(backend, Int32, ns), + pdone = KA.zeros(backend, Int32, ns), cbd = KA.zeros(backend, Int32, ns), + wbase = _dvec(backend, wbase_h), wupd_b = _dvec(backend, wupd_b), wtl_b = _dvec(backend, wtl_b), + wcb_b = _dvec(backend, wcb_b), wupd = KA.zeros(backend, Int32, max(nwslots, 1)), + wtl = KA.zeros(backend, Int32, max(nwslots, 1)), wpd = KA.zeros(backend, Int32, max(nwslots, 1)), + wcb = KA.zeros(backend, Int32, ns), wgf, scap = Int32(scap), eac = Int32(ea_chunk), ldl, ldl_potrf = ldl && wides_potrf, sptr_h = Array(S.super_ptr), - ldlw = KA.zeros(BACKEND[], Float64, + ldlw = KA.zeros(backend, T, ldl ? maximum(vcat([1], [max(rows_h[v] * 64, (rows_h[v] - width_h[v]) * width_h[v]) - for v in wides])) : 1), - s2 = nothing) + for v in wides])) : 1)) end -function refact_fused!(s, nzval, fp; nstreams::Int = 1) +function refact_fused!(s, nzval, fp) N, S = s.numeric, s.symbolic plan = N.plan backend = KA.get_backend(N.factor) @@ -2070,7 +2029,7 @@ function refact_fused!(s, nzval, fp; nstreams::Int = 1) launch_subtrees_sorted!(N, S, nzval, fp.ssub[k], plan.sub_local[k], backend, fp.ldl) end nodes = S.schedule.group_nodes - fill!(fp.pcb, 0.0) + fill!(fp.pcb, zero(eltype(fp.pcb))) isempty(fp.czero) || panel_zero_kernel!(backend, WGF)(N.factor, fp.czero, S.front_ptr, S.front_nrows, S.front_ncols, Val(WGF); ndrange = WGF * length(fp.czero)) @@ -2089,20 +2048,16 @@ function refact_fused!(s, nzval, fp; nstreams::Int = 1) Int32(base), fp.wbase, fp.wupd, fp.wpd, fp.wtl, fp.wcb, fp.scap, fp.eac, N.d, S.super_ptr, Val(fp.ldl), Val(fp.wgf); ndrange = fp.wgf * count) - if true - for v in gwides - if fp.ldl && fp.ldl_potrf - _factor_panel_c_ldl_potrf!(N, S, fp, v, p, backend) - elseif fp.ldl - _factor_panel_c_ldl!(N, S, fp, v, p, backend) - else - SDS._factor_panel_c!(N.factor, fp.pcb, N.work, N.info, v, S.layout.panel_ptr[v], - S.schedule.rows[v], S.schedule.width[v], fp.cb2_h[v], p) - end - signal_kernel!(backend, 32)(fp.cbd, fp.arrived, S.snparent, v; ndrange = 32) + for v in gwides # host-wide fronts at the segment boundary + if fp.ldl && fp.ldl_potrf + _factor_panel_c_ldl_potrf!(N, S, fp, v, p, backend) + elseif fp.ldl + _factor_panel_c_ldl!(N, S, fp, v, p, backend) + else + SDS._factor_panel_c!(N.factor, fp.pcb, N.work, N.info, v, S.layout.panel_ptr[v], + S.schedule.rows[v], S.schedule.width[v], fp.cb2_h[v], p) end - elseif false - nothing + signal_kernel!(backend, 32)(fp.cbd, fp.arrived, S.snparent, v; ndrange = 32) end end SDS.assemble_schur!(N, S, nzval) @@ -2110,5 +2065,4 @@ function refact_fused!(s, nzval, fp; nstreams::Int = 1) return SDS._numeric_info!(N, S, true) end - end # module diff --git a/src/symbolic/ordering.jl b/src/symbolic/ordering.jl index 4824f45d..9433e52f 100644 --- a/src/symbolic/ordering.jl +++ b/src/symbolic/ordering.jl @@ -1,33 +1,28 @@ # Symbolic step 2 (PLAN §2.3): fill-reducing ordering through CliqueTrees.jl, # user permutations, and the automatic AMD/ND choice by a cost model. # -# AMD and MMD are always available (CliqueTrees + AMD.jl). Nested dissection -# needs Metis.jl: `ext/SparseDirectSolverMetisExt.jl` stores the function that -# builds the CliqueTrees algorithm object in `ND_PROVIDER` when Metis is loaded. +# AMD and MMD come from CliqueTrees + AMD.jl, nested dissection from CliqueTrees' +# METIS extension (Metis.jl is a dependency of the package). """ - ND_PROVIDER - -`Ref` holding `nothing` or a function `(nd_nlevels, nd_ubfactor, nd_nseps, nd_seed) -> alg` that -returns the CliqueTrees nested-dissection algorithm. Set by the Metis extension. -""" -const ND_PROVIDER = Ref{Any}(nothing) - + nd_algorithm(nd_nlevels, nd_ubfactor, nd_nseps, nd_seed) -> CliqueTrees.METIS + +The CliqueTrees nested-dissection algorithm object of the `nd_*` parameters: +`METIS_NodeND` with `ufactor = nd_ubfactor`, `nseps = nd_nseps` and +`seed = nd_seed` (`-1`: METIS defaults; `nseps < 1` and `seed < 0` fall back to +the defaults). `nd_nlevels` is cuDSS' *minimum* number of dissection levels; +`METIS_NodeND` dissects recursively until the parts are small, so the minimum +holds whenever the graph is large enough to be split that often. CliqueTrees' +level-capped `ND{S}` (AMD below the cap) was 4–13× slower and gave 15–80% more +fill on Laplacians (T05 Report). """ - nd_available() -> Bool - -Whether nested dissection can be used (Metis.jl is loaded, which activates -`SparseDirectSolverMetisExt`). -""" -nd_available() = ND_PROVIDER[] !== nothing - -function _nd_algorithm(opts::Options) - nd_available() || - throw(NotSupportedError("nested dissection (reordering_alg = \"algo4\") needs Metis.jl; " * - "run `using Metis` to load SparseDirectSolverMetisExt")) - return ND_PROVIDER[](opts.nd_nlevels, opts.nd_ubfactor, opts.nd_nseps, opts.nd_seed) +function nd_algorithm(nlevels::Integer, ubfactor::Integer, nseps::Integer, seed::Integer) + return CliqueTrees.METIS(; ufactor = ubfactor, nseps = nseps >= 1 ? nseps : -1, + seed = seed >= 0 ? seed : -1) end +_nd_algorithm(opts::Options) = nd_algorithm(opts.nd_nlevels, opts.nd_ubfactor, opts.nd_nseps, opts.nd_seed) + """ OrderingCandidate @@ -51,8 +46,7 @@ Result of [`compute_ordering`](@ref): `sdepth` (supernodal schedule depth), `nsupernodes`, `cost` of the chosen ordering, `candidates::Vector{OrderingCandidate}` (every ordering evaluated, the chosen one included), `auto::Bool` (whether the automatic choice ran), - `level_flops` (the depth weight of [`ordering_cost`](@ref) used) and - `nd_available::Bool`; + `level_flops` (the depth weight of [`ordering_cost`](@ref) used); * `pairs::Vector{Tuple{Int,Int}}`: the 2×2 pivot candidate pairs `(partner, candidate)` the ordering was computed with (empty for no pair): `perm` puts each candidate right after its partner, and the symbolic factorization runs on @@ -66,7 +60,7 @@ struct Ordering alg_used::Symbol stats::@NamedTuple{nnz_L::Int, flops::Float64, nlevels::Int, sdepth::Int, nsupernodes::Int, cost::Float64, candidates::Vector{OrderingCandidate}, auto::Bool, level_flops::Float64, - nd_available::Bool, pair_rounds::Int} + pair_rounds::Int} pairs::Vector{Tuple{Int, Int}} end @@ -219,15 +213,14 @@ Fill-reducing ordering of the pattern `P` (PLAN §2.3 step 2): * `opts.user_perm` set: that permutation, validated, accepted 0- or 1-based and returned 1-based (`alg_used = :user`); * `reordering_alg = "algo5"`: natural ordering; `"algo3"`: AMD - (`CliqueTrees.AMD()`); `"algo4"`: nested dissection with Metis (needs - `using Metis`, otherwise [`NotSupportedError`](@ref)): `METIS_NodeND` with - `ufactor = nd_ubfactor`, `nseps = nd_nseps` and `seed = nd_seed` (`-1`: METIS defaults; on - fill-bound problems `nd_nseps = 4` trades a slower ordering for less fill); - `nd_nlevels` is the cuDSS - *minimum* number of dissection levels, which METIS' full recursion meets on - every graph large enough to be split that often; + (`CliqueTrees.AMD()`); `"algo4"`: nested dissection ([`nd_algorithm`](@ref)): + `METIS_NodeND` with `ufactor = nd_ubfactor`, `nseps = nd_nseps` and + `seed = nd_seed` (`-1`: METIS defaults; on fill-bound problems `nd_nseps = 4` + trades a slower ordering for less fill); `nd_nlevels` is the cuDSS *minimum* + number of dissection levels, which METIS' full recursion meets on every graph + large enough to be split that often; `"algo1"`/`"algo2"`: AMD on the symmetric pattern; -* `"default"`: AMD and, if Metis is loaded, ND are both evaluated with +* `"default"`: AMD and ND are both evaluated with [`evaluate_ordering`](@ref) and the one with the lower [`ordering_cost`](@ref) `flops + level_flops × sdepth` (supernodal schedule depth) is chosen; `stats.candidates` lists both with their schedule depths. @@ -291,8 +284,7 @@ function compute_ordering(P::SymmetricPattern, opts::Options; T::Type = Float64, opts.user_perm === nothing && throw(InvalidValueError("alg = :user needs opts.user_perm")) perms = [(:user, _validate_user_perm(opts.user_perm, P.n), fixed_pairs, 0)] elseif requested === :auto - algs = nd_available() ? (:amd, :nd) : (:amd,) - perms = [order_on(a) for a in algs] + perms = [order_on(a) for a in (:amd, :nd)] else perms = [order_on(requested)] end @@ -313,7 +305,7 @@ function compute_ordering(P::SymmetricPattern, opts::Options; T::Type = Float64, c = evaluated[best] stats = (nnz_L = c.nnz_L, flops = c.flops, nlevels = c.nlevels, sdepth = c.sdepth, nsupernodes = c.nsupernodes, cost = c.cost, candidates = evaluated, auto = requested === :auto, level_flops = Float64(level_flops), - nd_available = nd_available(), pair_rounds = rounds) + pair_rounds = rounds) return Ordering(perm, invperm(perm), alg_used, stats, used_pairs) end @@ -397,7 +389,7 @@ function compute_schur_ordering(P::SymmetricPattern, opts::Options, schur::Abstr e = evaluate_ordering(P, perm; T, level_flops, amalgamation = _amalgamation(opts)) stats = (nnz_L = e.nnz_L, flops = e.flops, nlevels = e.nlevels, sdepth = e.sdepth, nsupernodes = e.nsupernodes, cost = e.cost, candidates = candidates, auto = auto, level_flops = Float64(level_flops), - nd_available = nd_available(), pair_rounds = 0) + pair_rounds = 0) return Ordering(perm, invperm(perm), alg_used, stats, Tuple{Int, Int}[]) end diff --git a/src/types.jl b/src/types.jl index 40785e19..540de19a 100644 --- a/src/types.jl +++ b/src/types.jl @@ -99,7 +99,7 @@ end ReorderingAlg `reordering_alg` values (cuDSS `cudssReorderingAlg_t`): `"default"` automatic -AMD/ND choice (ND needs `using Metis`) by flops plus a charge per level of the +AMD/ND choice by flops plus a charge per level of the supernodal schedule (the same on every backend), `"algo1"` BTF+COLAMD and `"algo2"` COLAMD (both fall back to the symmetric-pattern path), `"algo3"` AMD, `"algo4"` nested dissection, `"algo5"` natural ordering. diff --git a/test/Project.toml b/test/Project.toml index 8fe74e30..d3ff28ce 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -5,7 +5,6 @@ DelimitedFiles = "8bb1440f-4735-579b-a4ab-409b98df4dab" KernelAbstractions = "63c18a36-062a-441e-b654-da1e3ab1ce7c" Krylov = "ba0b0d4f-ebba-5204-a429-3ac8c609bfb7" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" -Metis = "2679e427-3c69-5b7f-982b-ece356f1e94b" ParallelTestRunner = "d3525ed8-44d0-4b2c-a655-542cee43accc" Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" @@ -20,7 +19,6 @@ DelimitedFiles = "1" KernelAbstractions = "0.9" Krylov = "0.10" LinearAlgebra = "1.13" -Metis = "1" ParallelTestRunner = "2" Printf = "1.13" Random = "1.13" diff --git a/test/runtests.jl b/test/runtests.jl index 17da5af4..312bdc35 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -32,7 +32,6 @@ const init_code = quote using Aqua using KernelAbstractions using SparseDirectSolver - using Metis # activates SparseDirectSolverMetisExt (nested-dissection orderings, T05) using Krylov # activates SparseDirectSolverKrylovExt (ir_mode = "fgmres", T18) const SDS = SparseDirectSolver end diff --git a/test/test_symbolic_etree.jl b/test/test_symbolic_etree.jl index d76c46f2..23490e4c 100644 --- a/test/test_symbolic_etree.jl +++ b/test/test_symbolic_etree.jl @@ -60,7 +60,6 @@ end lap2 = laplacian2d(30, 30) lap3 = laplacian3d(10, 10, 10) kkt = kkt_matrix(300, 100, 1.0e-8) - @test SDS.nd_available() for (name, A) in (("lap2d", lap2), ("lap3d", lap3), ("kkt", kkt)) n = size(A, 1) P = SDS.SymmetricPattern(SDS.CSR(A), "S"; view = 'F') @@ -111,12 +110,12 @@ end end end end - @test SDS.ND_PROVIDER[](10, 50, -1, -1).ufactor == 50 - @test SDS.ND_PROVIDER[](0, -1, -1, -1).ufactor == -1 - @test SDS.ND_PROVIDER[](10, -1, 4, -1).nseps == 4 - @test SDS.ND_PROVIDER[](10, -1, 0, -1).nseps == -1 - @test SDS.ND_PROVIDER[](10, -1, 4, 3).seed == 3 - @test SDS.ND_PROVIDER[](10, -1, 4, -1).seed == -1 + @test SDS.nd_algorithm(10, 50, -1, -1).ufactor == 50 + @test SDS.nd_algorithm(0, -1, -1, -1).ufactor == -1 + @test SDS.nd_algorithm(10, -1, 4, -1).nseps == 4 + @test SDS.nd_algorithm(10, -1, 0, -1).nseps == -1 + @test SDS.nd_algorithm(10, -1, 4, 3).seed == 3 + @test SDS.nd_algorithm(10, -1, 4, -1).seed == -1 @testset "user_perm" begin A = laplacian2d(7, 6) @@ -137,24 +136,6 @@ end @test thrown(() -> SDS.compute_ordering(P, Options(); alg = :colamd)) isa InvalidValueError end - @testset "without Metis" begin - A = laplacian2d(12, 12) - P = SDS.SymmetricPattern(SDS.CSR(A), "SPD"; view = 'F') - saved = SDS.ND_PROVIDER[] - try - SDS.ND_PROVIDER[] = nothing - @test !SDS.nd_available() - @test thrown(() -> SDS.compute_ordering(P, Options(reordering_alg = "algo4"))) isa NotSupportedError - ord = SDS.compute_ordering(P, Options()) - @test ord.alg_used === :amd - @test [c.alg for c in ord.stats.candidates] == [:amd] - @test !ord.stats.nd_available - finally - SDS.ND_PROVIDER[] = saved - end - @test SDS.nd_available() - end - @testset "empty and tiny patterns" begin for n in (0, 1) P = SDS.SymmetricPattern(SDS.CSR(sparse(1.0I, n, n)), "SPD") @@ -210,7 +191,7 @@ end P = SDS.SymmetricPattern(SDS.CSR(A), "S"; view = 'F') G = SparseMatrixCSC(P) amd = Vector{Int}(first(SDS.CliqueTrees.permutation(G; alg = SDS.CliqueTrees.AMD()))) - nd = Vector{Int}(first(SDS.CliqueTrees.permutation(G; alg = SDS.ND_PROVIDER[](10, -1, -1, -1)))) + nd = Vector{Int}(first(SDS.CliqueTrees.permutation(G; alg = SDS.nd_algorithm(10, -1, -1, -1)))) for (s, ref) in (("algo1", amd), ("algo2", amd), ("algo3", amd), ("algo4", nd)) ord = SDS.compute_ordering(P, Options(reordering_alg = s)) @test ord.perm == ref diff --git a/test/test_symbolic_pairs.jl b/test/test_symbolic_pairs.jl index ce7267aa..bac63950 100644 --- a/test/test_symbolic_pairs.jl +++ b/test/test_symbolic_pairs.jl @@ -294,7 +294,7 @@ end opts = Options(pivot_pairs = "none", user_perm = opts.user_perm, reordering_alg = opts.reordering_alg))) end - # nested dissection (Metis is loaded by the test driver) and MMD also order the compressed graph + # nested dissection and MMD also order the compressed graph P, _, _ = pairs_ordering(A) pairs = SDS.pivot_pairs(P, C, sym_structure(T); view = 'L') for alg in (:nd, :mmd, :auto) From 7606173ad18520e770c5878db0c54102e60fa55f Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Mon, 12 Oct 2026 16:27:29 -0400 Subject: [PATCH 50/65] fused: rename the tile count in emit_split; it shadowed the element type T The cleanup (0cf1b7c) introduced T = eltype(s.numeric.factor) at the top of build_fused_plan while the emit_split closure already assigned T = cld(m, TILE) in the same function scope, so any plan with host-wide fronts (the GV100/A100/ H200 tuning profile) reached the return with T bound to an Int and crashed in KA.zeros. The merged profile (L40S, AMD) never calls emit_split, which is why the suite and small matrices pass. Co-Authored-By: Claude Fable 5 --- src/fused.jl | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/fused.jl b/src/fused.jl index 79cc34c4..6348365e 100644 --- a/src/fused.jl +++ b/src/fused.jl @@ -1868,8 +1868,9 @@ function build_fused_plan(s; ldl::Bool = false, inkernel_wides::Union{Nothing, B trsm_left[v] += 1 end if cbp[v] > 0 - T = cld(m, TILE) - for tjj in 1:T, tii in tjj:T + # ntl, not T: this closure assigns in function scope, and T is the element type + ntl = cld(m, TILE) + for tjj in 1:ntl, tii in tjj:ntl push!(role, ROLE_TILE); push!(ra, Int32(v)); push!(rb, Int32(tii)); push!(rc, Int32(tjj)) tiles_left[v] += 1 end From 2b719a3cd83461850a23ca3537e69203ba31f19b Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Mon, 12 Oct 2026 17:06:54 -0400 Subject: [PATCH 51/65] bench: the fresh-machine one-liner tests the PR branch again, not main The cleanup pointed the reproduction one-liner, fresh_machine.sh's default branch and fresh_machine.jl's pinned ref at main ahead of the merge, but PR 126 has not merged: the doc's download URL 404s (fresh_machine.jl does not exist on main) and the .sh default would benchmark the pre-campaign stock solver. Point all three at consolidated-prs, marked to flip to main when the PR merges. Co-Authored-By: Claude Fable 5 --- bench/PERFORMANCE-2026-10.md | 2 +- bench/e2e/fresh_machine.jl | 2 +- bench/e2e/fresh_machine.sh | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bench/PERFORMANCE-2026-10.md b/bench/PERFORMANCE-2026-10.md index 4867cf32..b866c35c 100644 --- a/bench/PERFORMANCE-2026-10.md +++ b/bench/PERFORMANCE-2026-10.md @@ -159,7 +159,7 @@ factor deltas, not info alone. One command on a fresh GPU machine (NVIDIA or AMD, autodetected): ```bash -julia +1.13 --startup-file=no -i -e 'include(download("https://raw.githubusercontent.com/exanauts/SparseDirectSolver.jl/main/bench/e2e/fresh_machine.jl"))' +julia +1.13 --startup-file=no -i -e 'include(download("https://raw.githubusercontent.com/exanauts/SparseDirectSolver.jl/consolidated-prs/bench/e2e/fresh_machine.jl"))' ``` Kernel-level benchmarks: the scripts listed in `bench/ARCH-TUNING.md` against diff --git a/bench/e2e/fresh_machine.jl b/bench/e2e/fresh_machine.jl index 98537d30..aefbddb1 100644 --- a/bench/e2e/fresh_machine.jl +++ b/bench/e2e/fresh_machine.jl @@ -33,7 +33,7 @@ Pkg.add(BACKEND == "cuda" ? ["CUDA", "CUDSS"] : ["AMDGPU"]) # SDS as a proper dev checkout (editable, canonical path), not a frozen Pkg.add; # an existing checkout is updated to the branch head (local edits block the pull loudly) const SDS_DEV = joinpath(homedir(), ".julia", "dev", "SparseDirectSolver") -const SDS_REF = "custom-reordering" +const SDS_REF = "consolidated-prs" # flip to main when PR 126 merges if isdir(SDS_DEV) # the checkout may be shared (NFS home) with a concurrent run: retry once on # git lock contention, then proceed on the existing tree with a warning diff --git a/bench/e2e/fresh_machine.sh b/bench/e2e/fresh_machine.sh index ae9ee124..3e75693c 100755 --- a/bench/e2e/fresh_machine.sh +++ b/bench/e2e/fresh_machine.sh @@ -9,7 +9,7 @@ # Everything is non-interactive (nothing reads the terminal), so the intended use # is detached from the login session (the suites + workflow run for an hour or more): # -# curl -fsSL https://raw.githubusercontent.com/exanauts/SparseDirectSolver.jl/main/bench/e2e/fresh_machine.sh -o fresh_machine.sh +# curl -fsSL https://raw.githubusercontent.com/exanauts/SparseDirectSolver.jl/consolidated-prs/bench/e2e/fresh_machine.sh -o fresh_machine.sh # nohup bash fresh_machine.sh > sds-fresh.log 2>&1 & # then log out; tail -f sds-fresh.log # # or under Slurm: sbatch -p --gres=gpu:1 -c 8 --mem=64G -t 120 --wrap "bash fresh_machine.sh" @@ -28,7 +28,7 @@ set -euo pipefail exec < /dev/null # never read the terminal: safe to detach -SDS_BRANCH="${SDS_BRANCH:-main}" +SDS_BRANCH="${SDS_BRANCH:-consolidated-prs}" # flip to main when PR 126 merges SDS_DIR="${SDS_DIR:-$HOME/sds-test}" if [ -z "${SDS_BACKEND:-}" ]; then From f0f14fa78cc97cb3f4e9a3d84c786ff2c4305b2f Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Mon, 12 Oct 2026 17:14:26 -0400 Subject: [PATCH 52/65] tuning: default METIS nseps 4 -> 1; fast first-touch reordering nseps = 4 (the 78k ordering champion) costs ~2x the reordering time (11.5 s versus 5 s on the 78k condensed KKT, same machine) for a 6% refactorization gain. The owner prefers the fast first touch as the default (2026-10-12); nseps = 4 stays one sds_tuning knob away, and the performance write-up records that its scoreboard numbers were measured with nseps = 4. Co-Authored-By: Claude Fable 5 --- bench/PERFORMANCE-2026-10.md | 6 +++++- src/fused.jl | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/bench/PERFORMANCE-2026-10.md b/bench/PERFORMANCE-2026-10.md index b866c35c..a1490478 100644 --- a/bench/PERFORMANCE-2026-10.md +++ b/bench/PERFORMANCE-2026-10.md @@ -80,7 +80,11 @@ times on L40S and Blackwell are nearly identical (15.2 vs 15.8). - Everywhere: division-free scale-first Cholesky column step and packed-triangle trailing-update enumeration (PR #125: 29.3 → 24.4 ms on GV100 alone); the nseps = 4 / seed = 3 METIS ordering (−6%, PR #124) with an on-disk - permutation cache so METIS runs once per matrix pattern. + permutation cache so METIS runs once per matrix pattern. Every GPU number in + this document was measured with nseps = 4. The shipped default is nseps = 1 + since 2026-10-12 (first-touch reordering is ~2x faster: 5 s versus 11.5 s on + the 78k; owner call) — reproduce the scoreboard with + `sds_tuning = SDSTuning(; nd_nseps = 4)`. Tried and rejected with measurements: inverse-based TRSM (2×), multi-stream wides, subtree/mega-kernel overlap, spin-cap tuning on NVIDIA, tile-64 on every diff --git a/src/fused.jl b/src/fused.jl index 6348365e..955f4742 100644 --- a/src/fused.jl +++ b/src/fused.jl @@ -31,7 +31,10 @@ Base.@kwdef struct SDSTuning amalgamation::NamedTuple = (max_width = 48, zero_fraction = 0.25, min_width = 8) workgroup::Int = 128 # fused-kernel workgroup size spin_cap::Int = 128 # dependency-counter backoff cap - nd_nseps::Int = 4 # METIS knobs (78k-ACOPF champions) + # METIS knobs. nseps = 1 by default: first-touch reordering latency wins (owner call, + # 2026-10-12); nseps = 4 is the 78k champion (-6% refactorization via lower fill, PR #124) + # at ~2x the reordering time -- opt in through `sds_tuning` when factor time dominates. + nd_nseps::Int = 1 nd_seed::Int = 3 end From ca58cc50056e65fe1f8b5d2db1a2ee777b5387e5 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Mon, 12 Oct 2026 17:16:34 -0400 Subject: [PATCH 53/65] bench/e2e: fresh_machine.jl reports the timing split and a warm second solve The one-liner used to end silently after the solve. It now prints status, iterations, objective and every MadNLP time counter (init = reordering + analysis + first factorization, linear_solver_time, total) for the first solve, then runs a second solve in the same process so compilation is separated from the real speed. Co-Authored-By: Claude Fable 5 --- bench/e2e/fresh_machine.jl | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/bench/e2e/fresh_machine.jl b/bench/e2e/fresh_machine.jl index aefbddb1..505227f6 100644 --- a/bench/e2e/fresh_machine.jl +++ b/bench/e2e/fresh_machine.jl @@ -75,5 +75,19 @@ m, _ = ac_opf_model("pglib_opf_case78484_epigrids.m"; # All performance knobs live in one bundle: pass sds_tuning = SparseDirectSolver.Fused.SDSTuning(; ...) # to override the measured per-architecture defaults (see bench/ARCH-TUNING.md); # sds_user_perm = perm (1-based, length n) bypasses the built-in reordering entirely. -sol = madnlp(m; linear_solver = SDSSolver, - kkt_system = MadNLP.SparseCondensedKKTSystem, tol = 1e-6) +solve() = madnlp(m; linear_solver = SDSSolver, + kkt_system = MadNLP.SparseCondensedKKTSystem, tol = 1e-6) + +t1 = @elapsed sol = solve() +println("\n== first solve (pays Julia compilation): ", round(t1; digits = 1), " s") +println(" status = ", sol.status, " iters = ", sol.counters.k, " obj = ", sol.objective) +for f in propertynames(sol.counters) + v = getfield(sol.counters, f) + v isa Real && endswith(String(f), "_time") && f !== :start_time && + println(" ", f, " = ", round(v; digits = 2), " s") +end +# the honest speed is the warm number: same process, nothing cached between the +# two solves except compiled code (and the on-disk permutation of this pattern) +t2 = @elapsed sol2 = solve() +println("== second solve (warm): ", round(t2; digits = 1), " s", + " iters = ", sol2.counters.k, " obj = ", sol2.objective) From 63534b8b88efad55647e5450a14ef3367fee0052 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Mon, 12 Oct 2026 17:36:48 -0400 Subject: [PATCH 54/65] bench/e2e: fresh_machine.sh builds the @sds project environment; workflow.jl runner The shell route now generates ~/.julia/environments/sds/Project.toml from bench/e2e/Project.toml (SparseDirectSolver's [sources] entry rewired to the local checkout) and Pkg.instantiate()s it -- no ad-hoc Pkg.add anywhere (owner request). The 78k workflow moves into bench/e2e/workflow.jl (backend autodetected through device files, first solve with the full time-counter split, warm second solve) and runs with --project=@sds; the environment is reusable afterwards from anywhere on the machine. The test suites become the opt-in RUN_TESTS=1 instead of the default. The reproduction section of the performance write-up shows the shell route first. Co-Authored-By: Claude Fable 5 --- bench/PERFORMANCE-2026-10.md | 11 ++++- bench/e2e/fresh_machine.sh | 95 +++++++++++++----------------------- bench/e2e/workflow.jl | 50 +++++++++++++++++++ 3 files changed, 95 insertions(+), 61 deletions(-) create mode 100644 bench/e2e/workflow.jl diff --git a/bench/PERFORMANCE-2026-10.md b/bench/PERFORMANCE-2026-10.md index a1490478..0d1aa0f9 100644 --- a/bench/PERFORMANCE-2026-10.md +++ b/bench/PERFORMANCE-2026-10.md @@ -160,7 +160,16 @@ factor deltas, not info alone. ## Reproduction -One command on a fresh GPU machine (NVIDIA or AMD, autodetected): +One command on a fresh GPU machine (NVIDIA or AMD, autodetected). The shell route +builds the `@sds` named project environment (everything from +`bench/e2e/Project.toml`, no ad-hoc `Pkg.add`) and is reusable afterwards with +`julia +1.13 --project=@sds bench/e2e/workflow.jl`: + +```bash +curl -fsSL https://raw.githubusercontent.com/exanauts/SparseDirectSolver.jl/consolidated-prs/bench/e2e/fresh_machine.sh | bash +``` + +No shell / no checkout wanted? The self-contained Julia variant: ```bash julia +1.13 --startup-file=no -i -e 'include(download("https://raw.githubusercontent.com/exanauts/SparseDirectSolver.jl/consolidated-prs/bench/e2e/fresh_machine.jl"))' diff --git a/bench/e2e/fresh_machine.sh b/bench/e2e/fresh_machine.sh index 3e75693c..5e90ce9e 100755 --- a/bench/e2e/fresh_machine.sh +++ b/bench/e2e/fresh_machine.sh @@ -1,50 +1,29 @@ #!/usr/bin/env bash -# Fresh-machine test of the MadNLP + SparseDirectSolver workflow on a GPU box. -# Installs Julia 1.13 (juliaup), clones the repo, runs the test suites (CPU + GPU), -# then the end-to-end MadNLP ACOPF solve (78k-bus pglib case, SparseCondensedKKTSystem). -# NVIDIA: cuDSS vs SDS on the same device. AMD: SDS stock vs SDS prototype kernels -# (no cuDSS exists there). The vendor is auto-detected (nvidia-smi / rocm-smi), or -# force it with SDS_BACKEND=cuda|amdgpu. +# Fresh-machine test of the MadNLP + SparseDirectSolver workflow, on the @sds named +# PROJECT environment — no ad-hoc Pkg.add: the environment's Project.toml is generated +# from bench/e2e/Project.toml with SparseDirectSolver wired to the local checkout, and +# Pkg.instantiate() provides everything from the project file. # -# Everything is non-interactive (nothing reads the terminal), so the intended use -# is detached from the login session (the suites + workflow run for an hour or more): +# curl -fsSL https://raw.githubusercontent.com/exanauts/SparseDirectSolver.jl/consolidated-prs/bench/e2e/fresh_machine.sh | bash # -# curl -fsSL https://raw.githubusercontent.com/exanauts/SparseDirectSolver.jl/consolidated-prs/bench/e2e/fresh_machine.sh -o fresh_machine.sh -# nohup bash fresh_machine.sh > sds-fresh.log 2>&1 & # then log out; tail -f sds-fresh.log +# After one run the environment is reusable from anywhere on the machine: # -# or under Slurm: sbatch -p --gres=gpu:1 -c 8 --mem=64G -t 120 --wrap "bash fresh_machine.sh" +# julia +1.13 --startup-file=no --project=@sds bench/e2e/workflow.jl # # Knobs (env vars): -# SDS_BRANCH=main git ref to test -# SDS_DIR=$HOME/sds-test checkout location -# SKIP_TESTS=1 skip the test suites, run only the MadNLP workflow -# CPU_ONLY_TESTS=1 run only the CPU suite (no functional GPU needed for it) -# SDS_BACKEND=cuda|amdgpu override the GPU vendor autodetection +# SDS_BRANCH=consolidated-prs git ref to test +# SDS_DIR=$HOME/sds-test checkout location +# SDS_BACKEND=cuda|amdgpu GPU vendor override (autodetected in workflow.jl) +# RUN_TESTS=1 also run the package test suite first (long) # -# Needs: git, curl, network access (pglib case data is fetched by ExaModelsPower on -# first use), and a GPU + driver. NVIDIA: the CUDA toolkit and cuDSS are downloaded -# by CUDA.jl/CUDSS.jl as artifacts, nothing to install. AMD: a system ROCm -# installation is required (AMDGPU.jl uses the system ROCm, it is not an artifact). +# Needs: git, curl, network, and a GPU + driver. NVIDIA: CUDA toolkit and cuDSS come +# as artifacts. AMD: a system ROCm installation is required. set -euo pipefail exec < /dev/null # never read the terminal: safe to detach SDS_BRANCH="${SDS_BRANCH:-consolidated-prs}" # flip to main when PR 126 merges SDS_DIR="${SDS_DIR:-$HOME/sds-test}" -if [ -z "${SDS_BACKEND:-}" ]; then - if command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1; then - SDS_BACKEND=cuda - elif command -v rocm-smi >/dev/null 2>&1; then - SDS_BACKEND=amdgpu - else - echo "no GPU detected (nvidia-smi / rocm-smi); set SDS_BACKEND=cuda|amdgpu" >&2 - SDS_BACKEND=cuda - fi -fi -echo "== backend: $SDS_BACKEND" -GPU_PKG=CUDA; RUNNER=run_gv100.jl -[ "$SDS_BACKEND" = "amdgpu" ] && GPU_PKG=AMDGPU && RUNNER=run_amd.jl - # --- 1. Julia 1.13 via juliaup ----------------------------------------------- if ! command -v juliaup >/dev/null 2>&1 && [ ! -x "$HOME/.juliaup/bin/juliaup" ]; then curl -fsSL https://install.julialang.org | sh -s -- --yes @@ -56,38 +35,34 @@ $JL --version # --- 2. Repository ------------------------------------------------------------ if [ ! -d "$SDS_DIR/.git" ]; then - git clone https://github.com/exanauts/SparseDirectSolver.jl.git "$SDS_DIR" + git clone --branch "$SDS_BRANCH" https://github.com/exanauts/SparseDirectSolver.jl.git "$SDS_DIR" fi cd "$SDS_DIR" git fetch origin "$SDS_BRANCH" git checkout "$SDS_BRANCH" -git pull --ff-only origin "$SDS_BRANCH" || true +git merge --ff-only "origin/$SDS_BRANCH" || true echo "== testing $(git log --oneline -1)" -# --- 3. Test suites ----------------------------------------------------------- -if [ "${SKIP_TESTS:-0}" != "1" ]; then - $JL --project=. -e 'using Pkg; Pkg.instantiate()' - echo "== CPU suite" - SDS_TEST_GPU=0 $JL --project=. -e 'using Pkg; Pkg.test()' - if [ "${CPU_ONLY_TESTS:-0}" != "1" ]; then - echo "== $GPU_PKG suite" - $JL --project=test -e "using Pkg; Pkg.add(\"$GPU_PKG\")" # as .github/workflows/ci.yml does - SDS_TEST_CPU=0 SDS_TEST_SKIP=test_aqua $JL --project=. -e 'using Pkg; Pkg.test()' - fi -fi +# --- 3. The @sds environment, generated from the project file (no Pkg.add) ----- +$JL -e ' +using TOML +dir = pwd() +proj = TOML.parsefile(joinpath(dir, "bench", "e2e", "Project.toml")) +get!(proj, "sources", Dict{String, Any}())["SparseDirectSolver"] = Dict("path" => dir) +env = joinpath(DEPOT_PATH[1], "environments", "sds") +mkpath(env) +open(io -> TOML.print(io, proj), joinpath(env, "Project.toml"), "w") +println("== @sds environment: SparseDirectSolver -> ", dir) +' +$JL --project=@sds -e 'using Pkg; Pkg.instantiate()' -# --- 4. MadNLP workflow: 78k-bus ACOPF, cuDSS vs SDS on the same GPU ----------- -# ext/SparseDirectSolverMadNLPExt.jl defines the MadNLP.AbstractLinearSolver wrapper -# (SparseDirectSolver.madnlp_solver(): the fused pipeline). -# NVIDIA (run_gv100.jl): MadNLPGPU.CUDSSSolver vs SparseDirectSolver. -# AMD (run_amd.jl): SparseDirectSolver. -# Each prints wall/solver/linear-solver times, iteration counts and objectives -# (convergence parity is the workflow test: same iterations, same objective). -echo "== MadNLP + SDS workflow via $RUNNER (downloads the pglib case on first use)" -$JL --project=bench/e2e -e "using Pkg; Pkg.develop(path = \"$SDS_DIR\"); Pkg.instantiate()" -if [ "$SDS_BACKEND" = "cuda" ]; then - CUDA_VISIBLE_DEVICES="${CUDA_VISIBLE_DEVICES:-0}" $JL --project=bench/e2e "bench/e2e/$RUNNER" -else - $JL --project=bench/e2e "bench/e2e/$RUNNER" +# --- 4. Optional: the package test suite --------------------------------------- +if [ "${RUN_TESTS:-0}" = "1" ]; then + echo "== package test suite" + $JL --project=. -e 'using Pkg; Pkg.instantiate(); Pkg.test()' fi + +# --- 5. The workflow: 78k-bus ACOPF, two solves, timing split ------------------ +echo "== MadNLP + SDS workflow (downloads the pglib case on first use)" +$JL --project=@sds bench/e2e/workflow.jl echo "== fresh-machine run complete" diff --git a/bench/e2e/workflow.jl b/bench/e2e/workflow.jl new file mode 100644 index 00000000..91d5b308 --- /dev/null +++ b/bench/e2e/workflow.jl @@ -0,0 +1,50 @@ +# The MadNLP + SparseDirectSolver 78k-bus ACOPF workflow, run on a project +# environment — the @sds named environment that fresh_machine.sh generates, or the +# in-repo bench/e2e one (its [sources] entry wires SparseDirectSolver either way, +# so `Pkg.instantiate()` is the only setup): +# +# julia +1.13 --startup-file=no --project=@sds bench/e2e/workflow.jl +# julia +1.13 --startup-file=no --project=bench/e2e bench/e2e/workflow.jl +# +# fresh_machine.jl inlines the same flow for a machine without a checkout. +# Backend is autodetected (SDS_BACKEND=cuda|amdgpu +# overrides); device files are consulted first, since nvidia-smi can be wedged by +# a driver updated under a loaded kernel module. +const BACKEND = get(ENV, "SDS_BACKEND") do + !isempty(filter(f -> occursin(r"^nvidia\d+$", f), readdir("/dev"))) ? "cuda" : + ispath("/dev/kfd") ? "amdgpu" : + error("no GPU found (/dev/nvidia*, /dev/kfd absent); set SDS_BACKEND=cuda|amdgpu") +end + +using SparseDirectSolver, MadNLP, MadNLPGPU, ExaModels, ExaModelsPower, Metis +if BACKEND == "cuda" + using CUDA, CUDSS # CUDSS must be LOADED: MadNLPGPU's CUDA extension (the GPU +else # condensed-KKT constructors) activates only with it present + using AMDGPU +end + +const SDSSolver = SparseDirectSolver.madnlp_solver() +println("device: ", BACKEND == "cuda" ? CUDA.name(CUDA.device()) : string(AMDGPU.device())) + +m, _ = ac_opf_model("pglib_opf_case78484_epigrids.m"; + backend = BACKEND == "cuda" ? CUDABackend() : ROCBackend()) + +# SparseCondensedKKTSystem is required (the SPD Cholesky path). All performance +# knobs live in one bundle: sds_tuning = SparseDirectSolver.Fused.SDSTuning(; ...) +# overrides the per-architecture defaults (bench/ARCH-TUNING.md). +solve() = madnlp(m; linear_solver = SDSSolver, + kkt_system = MadNLP.SparseCondensedKKTSystem, tol = 1e-6) + +t1 = @elapsed sol = solve() +println("\n== first solve (pays Julia compilation): ", round(t1; digits = 1), " s") +println(" status = ", sol.status, " iters = ", sol.counters.k, " obj = ", sol.objective) +for f in propertynames(sol.counters) + v = getfield(sol.counters, f) + v isa Real && endswith(String(f), "_time") && f !== :start_time && + println(" ", f, " = ", round(v; digits = 2), " s") +end +# the honest speed is the warm number: same process, nothing cached between the +# two solves except compiled code (and the on-disk permutation of this pattern) +t2 = @elapsed sol2 = solve() +println("== second solve (warm): ", round(t2; digits = 1), " s", + " iters = ", sol2.counters.k, " obj = ", sol2.objective) From 0eabf72a2f8dd150a9dfc48c1baafa9347310f08 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Mon, 12 Oct 2026 17:40:38 -0400 Subject: [PATCH 55/65] bench/e2e: fresh_machine.sh ends in a REPL on a terminal; fix silent curl|bash truncation Two changes. (1) exec < /dev/null is gone: under curl | bash the script itself arrives on stdin, so redirecting stdin away discards everything past bash's first read-ahead buffer -- the piped invocation executed a few header lines and exited 0 (measured with a padded script; a short script fits one buffer and masks the bug). Children that might read stdin get per-command < /dev/null. (2) With a terminal attached the workflow step execs julia -i reading from /dev/tty, so the run ends in a REPL with m, sol, sol2 and solve() defined (owner request); detached runs (nohup, sbatch) behave as before. Co-Authored-By: Claude Fable 5 --- bench/e2e/fresh_machine.sh | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/bench/e2e/fresh_machine.sh b/bench/e2e/fresh_machine.sh index 5e90ce9e..4efec8a8 100755 --- a/bench/e2e/fresh_machine.sh +++ b/bench/e2e/fresh_machine.sh @@ -6,7 +6,9 @@ # # curl -fsSL https://raw.githubusercontent.com/exanauts/SparseDirectSolver.jl/consolidated-prs/bench/e2e/fresh_machine.sh | bash # -# After one run the environment is reusable from anywhere on the machine: +# When a terminal is attached, the workflow ends in an interactive REPL (julia -i) +# with `m`, `sol`, `sol2` and `solve()` defined; detached (nohup/sbatch, no tty) it +# just runs and exits. After one run the environment is reusable from anywhere: # # julia +1.13 --startup-file=no --project=@sds bench/e2e/workflow.jl # @@ -18,15 +20,18 @@ # # Needs: git, curl, network, and a GPU + driver. NVIDIA: CUDA toolkit and cuDSS come # as artifacts. AMD: a system ROCm installation is required. +# NO `exec < /dev/null` here: under `curl | bash` the script itself arrives on stdin, +# and redirecting it away silently discards the rest of the script (measured: bash +# executes only its first read-ahead buffer and exits 0). Children that might read +# stdin get a per-command `< /dev/null` instead. set -euo pipefail -exec < /dev/null # never read the terminal: safe to detach SDS_BRANCH="${SDS_BRANCH:-consolidated-prs}" # flip to main when PR 126 merges SDS_DIR="${SDS_DIR:-$HOME/sds-test}" # --- 1. Julia 1.13 via juliaup ----------------------------------------------- if ! command -v juliaup >/dev/null 2>&1 && [ ! -x "$HOME/.juliaup/bin/juliaup" ]; then - curl -fsSL https://install.julialang.org | sh -s -- --yes + curl -fsSL https://install.julialang.org | sh -s -- --yes < /dev/null fi export PATH="$HOME/.juliaup/bin:$PATH" juliaup add 1.13 2>/dev/null || true @@ -59,10 +64,15 @@ $JL --project=@sds -e 'using Pkg; Pkg.instantiate()' # --- 4. Optional: the package test suite --------------------------------------- if [ "${RUN_TESTS:-0}" = "1" ]; then echo "== package test suite" - $JL --project=. -e 'using Pkg; Pkg.instantiate(); Pkg.test()' + $JL --project=. -e 'using Pkg; Pkg.instantiate(); Pkg.test()' < /dev/null fi # --- 5. The workflow: 78k-bus ACOPF, two solves, timing split ------------------ +# with a terminal attached, end in a REPL (m, sol, sol2, solve() are defined) echo "== MadNLP + SDS workflow (downloads the pglib case on first use)" -$JL --project=@sds bench/e2e/workflow.jl -echo "== fresh-machine run complete" +if [ -e /dev/tty ] && [ -t 1 ]; then + exec $JL -i --project=@sds bench/e2e/workflow.jl < /dev/tty +else + $JL --project=@sds bench/e2e/workflow.jl + echo "== fresh-machine run complete" +fi From 8b02065399ae31858b0f9baeb1000139ef89e62a Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Mon, 12 Oct 2026 17:14:53 -0400 Subject: [PATCH 56/65] MadNLP ext: no-pivot LDL^T mode (sds_factorization = :ldl) with true inertia :ldl builds the solver as structure "S" with pivot_type 'N' and runs the fused pipeline in LDL mode; host-wide fronts take the potrf-derived path (valid while a front's pivots share a sign; a sign flip fails potrf, info != 0, and MadNLP regularizes). MadNLP.inertia reports the true inertia from the signs of D, so SparseKKTSystem-style formulations get real curvature information instead of the found-ok/failed surrogate. Co-Authored-By: Claude Fable 5 --- ext/SparseDirectSolverMadNLPExt.jl | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/ext/SparseDirectSolverMadNLPExt.jl b/ext/SparseDirectSolverMadNLPExt.jl index 1ae4e1a4..6ca201fe 100644 --- a/ext/SparseDirectSolverMadNLPExt.jl +++ b/ext/SparseDirectSolverMadNLPExt.jl @@ -16,6 +16,9 @@ const Fused = SDS.Fused const SDSTuning = Fused.SDSTuning Base.@kwdef mutable struct SDSSolverOptions <: MadNLP.AbstractOptions + # :cholesky (SPD; the condensed-KKT default) or :ldl (no-pivot LDL^T: factors + # indefinite matrices and reports TRUE inertia from the signs of D) + sds_factorization::Symbol = :cholesky sds_reordering::String = "algo4" # native ND # ALL performance knobs live in the tuning bundle; nothing (the default) # selects the measured per-architecture profile from Fused.default_tuning @@ -100,7 +103,9 @@ function SDSSolver( n = size(csc, 1) backend = KA.get_backend(csc.nzVal) t = Fused.resolve_tuning(something(opt.sds_tuning, Fused.default_tuning(backend)), backend) - s = SDS.DirectSolver(csc.colPtr, csc.rowVal, csc.nzVal, "SPD", 'U') + ldl = opt.sds_factorization === :ldl + s = SDS.DirectSolver(csc.colPtr, csc.rowVal, csc.nzVal, ldl ? "S" : "SPD", 'U') + ldl && SDS.setparam!(s, "pivot_type", 'N') s.options.regime_c_width = t.regime_c_width s.options.regime_c_rows = t.regime_c_rows # the fused kernels own BOTH phases: no coupling conflict s.options.subtree_parallelism = t.subtree_parallelism @@ -114,8 +119,10 @@ function SDSSolver( SDS.execute!("solve", s, x, b; asynchronous = false) # allocates the solve workspace nzval = SDS._factor_values(s) merged = t.wides === :merged - fp = Fused.build_fused_plan(s; inkernel_wides = merged, merge_segments = merged, - wgf = t.workgroup, scap = t.spin_cap) + # LDL wide fronts run the verbatim Cholesky vendor path (wides_potrf): valid while a + # front's pivots share a sign; a sign flip fails potrf -> info != 0 -> MadNLP regularizes + fp = Fused.build_fused_plan(s; ldl, inkernel_wides = merged, merge_segments = merged, + wides_potrf = !merged, wgf = t.workgroup, scap = t.spin_cap) inv11, inv_ptr, invertf, _ = Fused.build_inverse(s) # the solve plan: one merged dependency-counter sweep over the whole tree fz1 = Fused.build_fused(s; kstart = 1, wcap = 256) @@ -151,6 +158,11 @@ MadNLP.is_inertia(::SDSSolver) = true # regularizes and factorizes again function MadNLP.inertia(M::SDSSolver) n = size(M.tril, 1) + if M.opt.sds_factorization === :ldl + M.last_info == 0 || return (0, 1, n) + l = count(x -> x < zero(x), view(M.inner.numeric.d, 1:n)) # true inertia from D + return (n - l, 0, l) + end return M.last_info == 0 ? (n, 0, 0) : (n - 2, 1, 1) end MadNLP.improve!(::SDSSolver) = false From 49801a16edcf530413400be3dc0a91b340a894b0 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Mon, 12 Oct 2026 17:47:50 -0400 Subject: [PATCH 57/65] bench/e2e: SDS_PERM_CACHE=0 turns the permutation cache off in workflow.jl With the cache on (the default), only the first solve on a pattern pays METIS; SDS_PERM_CACHE=0 makes every solve pay the full reordering, which is what you want when timing it. Co-Authored-By: Claude Fable 5 --- bench/e2e/workflow.jl | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bench/e2e/workflow.jl b/bench/e2e/workflow.jl index 91d5b308..710da8b5 100644 --- a/bench/e2e/workflow.jl +++ b/bench/e2e/workflow.jl @@ -32,8 +32,11 @@ m, _ = ac_opf_model("pglib_opf_case78484_epigrids.m"; # SparseCondensedKKTSystem is required (the SPD Cholesky path). All performance # knobs live in one bundle: sds_tuning = SparseDirectSolver.Fused.SDSTuning(; ...) # overrides the per-architecture defaults (bench/ARCH-TUNING.md). +# SDS_PERM_CACHE=0 disables the on-disk permutation cache, so every solve pays the +# full METIS reordering (for timing it); default: cached under ~/.julia/sds_perm_cache solve() = madnlp(m; linear_solver = SDSSolver, - kkt_system = MadNLP.SparseCondensedKKTSystem, tol = 1e-6) + kkt_system = MadNLP.SparseCondensedKKTSystem, tol = 1e-6, + (get(ENV, "SDS_PERM_CACHE", "1") == "0" ? (; sds_perm_cache = "") : (;))...) t1 = @elapsed sol = solve() println("\n== first solve (pays Julia compilation): ", round(t1; digits = 1), " s") From 5f5468f8ac2f9bc02fec057164c694e025440c47 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Mon, 12 Oct 2026 17:59:03 -0400 Subject: [PATCH 58/65] MadNLP ext: remove the permutation cache entirely Owner call (2026-10-12): no caching of any kind. The sds_perm_cache option, the on-disk store under ~/.julia/sds_perm_cache and the read/write plumbing are gone; every solver instance recomputes its ordering (nseps = 1 default: ~5 s on the 78k), and sds_user_perm remains for pinning one explicitly. workflow.jl loses its SDS_PERM_CACHE knob (nothing left to switch); the write-ups record that the campaign's numbers were produced with the cache. An existing ~/.julia/sds_perm_cache directory is now inert and can be deleted. Co-Authored-By: Claude Fable 5 --- bench/ARCH-TUNING.md | 2 +- bench/PERFORMANCE-2026-10.md | 13 +++++----- bench/e2e/workflow.jl | 10 ++++---- ext/SparseDirectSolverMadNLPExt.jl | 38 ++++-------------------------- 4 files changed, 17 insertions(+), 46 deletions(-) diff --git a/bench/ARCH-TUNING.md b/bench/ARCH-TUNING.md index 15474cc0..74896963 100644 --- a/bench/ARCH-TUNING.md +++ b/bench/ARCH-TUNING.md @@ -31,7 +31,7 @@ champions. Workload throughout: the 78k-bus ACOPF condensed KKT | wide-front strategy (`wk`/`mseg`) | plan builder | runtime | host vendor calls vs in-kernel blocked wides + single merged launch; trades ~100 host launches against in-kernel spin residency | | subtree budget (`subtree_budgets`) | solver option | runtime | regime-A local-memory class; smaller class = more subtree workgroups resident per SM/CU | | subtree parallelism / amalgamation | solver options | runtime | subtree formation and supernode merging; saturated on every tested arch except amal 32→48 (small win on big-SM parts) | -| ordering (`nd_nseps`, `nd_seed`, `user_perm` + on-disk perm cache) | solver/wrapper | runtime | fill/flops; arch-independent; pay METIS once, cache the permutation | +| ordering (`nd_nseps`, `nd_seed`, `user_perm`) | solver/wrapper | runtime | fill/flops; arch-independent; every instance recomputes (no caching); pin with `user_perm` | | panel width (`pw`) | plan + kernel `Val` | runtime per-plan | fronts ≤ pw factor in-kernel; packed panel = pw(pw+1)/2 · 8B of shared memory **for every block of the kernel** (one kernel = one shared size) | | tile size (`tl`) | plan + kernel `Val` | runtime per-plan | syrk tile; shared halves = 2·32·tl · 8B; accumulators = tl²/WGF registers per thread | | workgroup size (`SDS_WGF`) | env (const at include) | per-process | waves per workgroup; latency hiding on wave64 AMD; occupancy on 1536-thread parts | diff --git a/bench/PERFORMANCE-2026-10.md b/bench/PERFORMANCE-2026-10.md index 0d1aa0f9..dd7154e5 100644 --- a/bench/PERFORMANCE-2026-10.md +++ b/bench/PERFORMANCE-2026-10.md @@ -79,12 +79,13 @@ times on L40S and Blackwell are nearly identical (15.2 vs 15.8). per workgroup: −20%), spin cap 16 (−1 ms). - Everywhere: division-free scale-first Cholesky column step and packed-triangle trailing-update enumeration (PR #125: 29.3 → 24.4 ms on GV100 alone); the - nseps = 4 / seed = 3 METIS ordering (−6%, PR #124) with an on-disk - permutation cache so METIS runs once per matrix pattern. Every GPU number in - this document was measured with nseps = 4. The shipped default is nseps = 1 - since 2026-10-12 (first-touch reordering is ~2x faster: 5 s versus 11.5 s on - the 78k; owner call) — reproduce the scoreboard with - `sds_tuning = SDSTuning(; nd_nseps = 4)`. + nseps = 4 / seed = 3 METIS ordering (−6%, PR #124). Every GPU number in + this document was measured with nseps = 4. Two owner calls on 2026-10-12: + the shipped default is nseps = 1 (first-touch reordering is ~2x faster: 5 s + versus 11.5 s on the 78k; reproduce the scoreboard with + `sds_tuning = SDSTuning(; nd_nseps = 4)`), and the extension caches nothing — + the on-disk permutation cache the campaign used is removed; every solver + instance recomputes its ordering, and `sds_user_perm` pins one explicitly. Tried and rejected with measurements: inverse-based TRSM (2×), multi-stream wides, subtree/mega-kernel overlap, spin-cap tuning on NVIDIA, tile-64 on every diff --git a/bench/e2e/workflow.jl b/bench/e2e/workflow.jl index 710da8b5..c12c7dc0 100644 --- a/bench/e2e/workflow.jl +++ b/bench/e2e/workflow.jl @@ -32,11 +32,8 @@ m, _ = ac_opf_model("pglib_opf_case78484_epigrids.m"; # SparseCondensedKKTSystem is required (the SPD Cholesky path). All performance # knobs live in one bundle: sds_tuning = SparseDirectSolver.Fused.SDSTuning(; ...) # overrides the per-architecture defaults (bench/ARCH-TUNING.md). -# SDS_PERM_CACHE=0 disables the on-disk permutation cache, so every solve pays the -# full METIS reordering (for timing it); default: cached under ~/.julia/sds_perm_cache solve() = madnlp(m; linear_solver = SDSSolver, - kkt_system = MadNLP.SparseCondensedKKTSystem, tol = 1e-6, - (get(ENV, "SDS_PERM_CACHE", "1") == "0" ? (; sds_perm_cache = "") : (;))...) + kkt_system = MadNLP.SparseCondensedKKTSystem, tol = 1e-6) t1 = @elapsed sol = solve() println("\n== first solve (pays Julia compilation): ", round(t1; digits = 1), " s") @@ -46,8 +43,9 @@ for f in propertynames(sol.counters) v isa Real && endswith(String(f), "_time") && f !== :start_time && println(" ", f, " = ", round(v; digits = 2), " s") end -# the honest speed is the warm number: same process, nothing cached between the -# two solves except compiled code (and the on-disk permutation of this pattern) +# the honest speed is the warm number: same process, nothing carried between the +# two solves except compiled code — the reordering and symbolic analysis run in full +# both times (the extension caches nothing) t2 = @elapsed sol2 = solve() println("== second solve (warm): ", round(t2; digits = 1), " s", " iters = ", sol2.counters.k, " obj = ", sol2.objective) diff --git a/ext/SparseDirectSolverMadNLPExt.jl b/ext/SparseDirectSolverMadNLPExt.jl index 6ca201fe..7f1da4eb 100644 --- a/ext/SparseDirectSolverMadNLPExt.jl +++ b/ext/SparseDirectSolverMadNLPExt.jl @@ -25,48 +25,21 @@ Base.@kwdef mutable struct SDSSolverOptions <: MadNLP.AbstractOptions sds_tuning::Union{Nothing, SDSTuning} = nothing # a user permutation bypasses the reordering entirely (1-based, length n) sds_user_perm::Union{Nothing, Vector{Int}} = nothing - # on-disk permutation cache keyed by pattern + ordering knobs ("" disables) - sds_perm_cache::String = joinpath(homedir(), ".julia", "sds_perm_cache") -end - -function _perm_cache_file(opt::SDSSolverOptions, t::SDSTuning, csc) - (opt.sds_perm_cache == "" || opt.sds_user_perm !== nothing) && return nothing - n = size(csc, 1) - h = hash((n, length(csc.rowVal), hash(Array(csc.colPtr)), hash(Array(csc.rowVal)), - opt.sds_reordering, t.nd_nseps, t.nd_seed)) - return joinpath(opt.sds_perm_cache, "perm-" * string(h, base = 16) * ".bin") end # shared solver construction knobs: the custom reordering (nd knobs or a user -# permutation) and the amalgamation, applied before the analysis; -# returns the cache file to WRITE after the analysis (nothing: no write needed) -function _apply_reordering!(s, opt::SDSSolverOptions, t::SDSTuning, csc) +# permutation) and the amalgamation, applied before the analysis. No caching of +# any kind: every solver instance recomputes its ordering (owner call, 2026-10-12); +# pin one explicitly with sds_user_perm. +function _apply_reordering!(s, opt::SDSSolverOptions, t::SDSTuning) s.options.amalgamation = t.amalgamation if opt.sds_user_perm !== nothing SDS.setparam!(s, "user_perm", opt.sds_user_perm) return nothing end - cf = _perm_cache_file(opt, t, csc) - if cf !== nothing && isfile(cf) - n = size(csc, 1) - perm = Vector{Int32}(undef, n) - read!(cf, perm) - SDS.setparam!(s, "user_perm", Int.(perm)) - return nothing - end SDS.setparam!(s, "reordering_alg", opt.sds_reordering) SDS.setparam!(s, "nd_nseps", t.nd_nseps) SDS.setparam!(s, "nd_seed", t.nd_seed) - return cf -end - -function _save_perm(cf, s) - cf === nothing && return nothing - perm = SDS.getparam(s, "perm_reorder_row") - mkpath(dirname(cf)) - open(cf, "w") do io - write(io, Int32.(perm)) - end return nothing end @@ -110,11 +83,10 @@ function SDSSolver( s.options.regime_c_rows = t.regime_c_rows # the fused kernels own BOTH phases: no coupling conflict s.options.subtree_parallelism = t.subtree_parallelism s.options.subtree_budgets = copy(t.subtree_budgets) - cf = _apply_reordering!(s, opt, t, csc) + _apply_reordering!(s, opt, t) x = similar(csc.nzVal, n) b = similar(csc.nzVal, n) SDS.execute!("analysis", s, x, b; asynchronous = false) - _save_perm(cf, s) SDS.execute!("factorization", s, x, b; asynchronous = false) SDS.execute!("solve", s, x, b; asynchronous = false) # allocates the solve workspace nzval = SDS._factor_values(s) From 8f8d067f540b38f22f498e301d99272f491a2c91 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Mon, 12 Oct 2026 18:10:45 -0400 Subject: [PATCH 59/65] MadNLP ext: expose SDSSolver the standard way (declared global set by the extension) The madnlp_solver()/madnlp_solver_options() accessors are replaced by the idiom MadNLPGPU uses for CUDSSSolver: the parent declares and exports the SDSSolver and SDSSolverOptions globals, and the extension assigns them in its __init__ with setglobal!. With MadNLP loaded, users write madnlp(model; linear_solver = SDSSolver, sds_factorization = :ldl, ...); before that the names are unassigned, as MadNLPGPU.CUDSSSolver is without CUDA. The bench scripts and write-ups follow. MadNLP compat widens to 0.8-0.10 (the extension's interface surface is unchanged across them; load verified on 0.8 here and on 0.10.1 downstream). Load-smoke validated: SDSSolver is unassigned before MadNLP loads, and is the extension's type with working options after. Co-Authored-By: Claude Fable 5 --- Project.toml | 2 +- bench/README.md | 2 +- bench/e2e/fresh_machine.jl | 1 - bench/e2e/run_amd.jl | 1 - bench/e2e/run_gv100.jl | 1 - bench/e2e/workflow.jl | 1 - ext/SparseDirectSolverMadNLPExt.jl | 14 ++++++---- src/SparseDirectSolver.jl | 42 ++++++++++++------------------ 8 files changed, 28 insertions(+), 36 deletions(-) diff --git a/Project.toml b/Project.toml index 0064136d..f34afbe7 100644 --- a/Project.toml +++ b/Project.toml @@ -42,7 +42,7 @@ GPUArraysCore = "0.2" KernelAbstractions = "0.9" Krylov = "0.10" LinearAlgebra = "1.13" -MadNLP = "0.8" +MadNLP = "0.8, 0.9, 0.10" Metis = "1" SparseArrays = "1.13" cuBLAS = "6" diff --git a/bench/README.md b/bench/README.md index 66a49e7b..3947cf14 100644 --- a/bench/README.md +++ b/bench/README.md @@ -22,7 +22,7 @@ Baselines every later milestone is measured against (PLAN.md §5 M0, §7). The | `order_search.jl`, `get_cudss_perm.jl` | PR #107 ordering study: METIS knob sweep and schedule depth; extraction of cuDSS's reordering through the raw C API for replay under `user_perm` | | `ordering_chooser.jl` | T22 (issue #108): AMD vs ND per harness matrix and dump (schedule depth, fundamental and column-etree depth, nnz(L), flops) and the choice of the T22 and T05 cost models; host only, no GPU: `julia --project=bench bench/ordering_chooser.jl` (`SDS_BENCH_SUITESPARSE=0` skips MatrixDepot) | | `amd_proto.jl`, `amd/Project.toml` | the solve prototype on ROCm (ROCArray, raw-array constructor, `Threads.atomic_fence`); environment `bench/amd/` | -| `e2e/run_gv100.jl`, `e2e/run_amd.jl`, `e2e/fresh_machine.{jl,sh}` | full ACOPF interior-point runs through the MadNLP extension (`SparseDirectSolver.madnlp_solver()`, the fused pipeline of `src/fused.jl`) on CUDA and AMD, and the one-file fresh-machine workflow; environment `bench/e2e/` (MadNLP, MadNLPGPU, ExaModels, ExaModelsPower) | +| `e2e/run_gv100.jl`, `e2e/run_amd.jl`, `e2e/fresh_machine.{jl,sh}` | full ACOPF interior-point runs through the MadNLP extension (`SparseDirectSolver.SDSSolver`, the fused pipeline of `src/fused.jl`) on CUDA and AMD, and the one-file fresh-machine workflow; environment `bench/e2e/` (MadNLP, MadNLPGPU, ExaModels, ExaModelsPower) | | `e2e/ldl_fused_test.jl`, `e2e/ldl_fused_race.jl`, `e2e/amd_baseline.jl`, `e2e/amd_sweep.jl` | gates and timings of the fused Cholesky/LDLᵀ refactorization and the fused solve against the stock kernels on the 78k-bus condensed KKT (CUDA; the `amd_*` scripts on ROCm), see `PERFORMANCE-2026-10.md` and `ARCH-TUNING.md` | | `e2e/SDSCPU.jl`, `e2e/cpu_fast_test.jl`, `e2e/hsl_cpu_bench.jl`, `e2e/ma27_bench.jl`, `e2e/ma57_bench.jl` | the task-parallel CPU refactorization prototype over the stock schedule and its HSL (MA27/MA57/MA86 through libcoinhsl) and CHOLMOD references | | `pivot_pairs.jl` | 2×2 pivot pairs of the `S` analysis (issue #66): `pivot_pairs` = `none`/`default`/`all` on the K2 dumps and the KKT generators, with nnz(L), zero/perturbed/2×2 pivots, max abs L and factor error of the CPU reference LDLᵀ; package environment: `julia --project=. bench/pivot_pairs.jl [--only=case118,...] [--generators=false]` | diff --git a/bench/e2e/fresh_machine.jl b/bench/e2e/fresh_machine.jl index 505227f6..eaf7a77d 100644 --- a/bench/e2e/fresh_machine.jl +++ b/bench/e2e/fresh_machine.jl @@ -62,7 +62,6 @@ end # the MadNLP linear-solver wrapper (SDSSolver: the fused kernels with per-arch # tuning), shipped with the package under bench/e2e # the MadNLP interface is the SparseDirectSolverMadNLPExt package extension -const SDSSolver = SparseDirectSolver.madnlp_solver() println("device: ", BACKEND == "cuda" ? CUDA.name(CUDA.device()) : string(AMDGPU.device())) diff --git a/bench/e2e/run_amd.jl b/bench/e2e/run_amd.jl index da11a48a..c5d6e24a 100644 --- a/bench/e2e/run_amd.jl +++ b/bench/e2e/run_amd.jl @@ -10,7 +10,6 @@ using ExaModels, ExaModelsPower using SparseDirectSolver # the MadNLP interface is the SparseDirectSolverMadNLPExt package extension -const SDSSolver = SparseDirectSolver.madnlp_solver() const CASE = "pglib_opf_case78484_epigrids.m" diff --git a/bench/e2e/run_gv100.jl b/bench/e2e/run_gv100.jl index a0b307ca..45a107e8 100644 --- a/bench/e2e/run_gv100.jl +++ b/bench/e2e/run_gv100.jl @@ -10,7 +10,6 @@ using ExaModels, ExaModelsPower using SparseDirectSolver # the MadNLP interface is the SparseDirectSolverMadNLPExt package extension -const SDSSolver = SparseDirectSolver.madnlp_solver() const CASE = "pglib_opf_case78484_epigrids.m" diff --git a/bench/e2e/workflow.jl b/bench/e2e/workflow.jl index c12c7dc0..a2f12bee 100644 --- a/bench/e2e/workflow.jl +++ b/bench/e2e/workflow.jl @@ -23,7 +23,6 @@ else # condensed-KKT constructors) activates only with it using AMDGPU end -const SDSSolver = SparseDirectSolver.madnlp_solver() println("device: ", BACKEND == "cuda" ? CUDA.name(CUDA.device()) : string(AMDGPU.device())) m, _ = ac_opf_model("pglib_opf_case78484_epigrids.m"; diff --git a/ext/SparseDirectSolverMadNLPExt.jl b/ext/SparseDirectSolverMadNLPExt.jl index 7f1da4eb..00fce163 100644 --- a/ext/SparseDirectSolverMadNLPExt.jl +++ b/ext/SparseDirectSolverMadNLPExt.jl @@ -4,7 +4,8 @@ # rocSPARSE.ROCSparseMatrixCSC) whose colPtr/rowVal/nzVal are device vectors. # The CSC arrays of MadNLP's lower-triangular KKT matrix are read as the CSR # of the upper triangle, exactly as MadNLPGPU's CUDSSSolver does (view 'U'). -# Access from user code: SparseDirectSolver.madnlp_solver(). +# Access from user code: SparseDirectSolver.SDSSolver (a declared global of the +# parent package this extension assigns in its __init__, the MadNLPGPU idiom). module SparseDirectSolverMadNLPExt using LinearAlgebra, SparseArrays @@ -48,8 +49,8 @@ end The MadNLP linear solver backed by the fused SparseDirectSolver pipeline (factorization mega-kernel + partitioned-inverse solve), with per-architecture -tuning from `Fused.default_tuning`. Obtain the type with -`SparseDirectSolver.madnlp_solver()`. +tuning from `Fused.default_tuning`. Available as `SparseDirectSolver.SDSSolver` +(exported) once MadNLP is loaded. """ mutable struct SDSSolver{T} <: MadNLP.AbstractLinearSolver{T} inner::Any @@ -142,7 +143,10 @@ MadNLP.introduce(::SDSSolver) = "SparseDirectSolver.jl (fused pipeline)" MadNLP.is_supported(::Type{SDSSolver}, ::Type{Float32}) = true MadNLP.is_supported(::Type{SDSSolver}, ::Type{Float64}) = true -SparseDirectSolver.madnlp_solver() = SDSSolver -SparseDirectSolver.madnlp_solver_options() = SDSSolverOptions +function __init__() + setglobal!(SparseDirectSolver, :SDSSolver, SDSSolver) + setglobal!(SparseDirectSolver, :SDSSolverOptions, SDSSolverOptions) + return nothing +end end # module diff --git a/src/SparseDirectSolver.jl b/src/SparseDirectSolver.jl index d31da183..03a8bda0 100644 --- a/src/SparseDirectSolver.jl +++ b/src/SparseDirectSolver.jl @@ -88,31 +88,23 @@ include("generic.jl") # and its per-architecture tuning bundle; consumed by the MadNLP extension include("fused.jl") -""" - madnlp_solver() -> Type - -The MadNLP linear-solver type backed by the fused pipeline (`SDSSolver` in the -`SparseDirectSolverMadNLPExt` package extension). Available once MadNLP is -loaded in the same session: - - using MadNLP, SparseDirectSolver - madnlp(model; linear_solver = SparseDirectSolver.madnlp_solver(), - kkt_system = MadNLP.SparseCondensedKKTSystem) - -Options type: [`madnlp_solver_options`](@ref); tuning bundle: -`SparseDirectSolver.Fused.SDSTuning` (per-architecture defaults are selected -automatically). -""" -function madnlp_solver end - -""" - madnlp_solver_options() -> Type - -The options type of [`madnlp_solver`](@ref) (`SDSSolverOptions`: reordering, -tuning bundle, user permutation, on-disk permutation cache). Requires MadNLP -to be loaded. -""" -function madnlp_solver_options end +# The MadNLP linear-solver type (SDSSolver) and its options type live in the +# SparseDirectSolverMadNLPExt package extension (they subtype MadNLP types, so +# they cannot be defined here). The standard idiom — as MadNLPGPU does for +# CUDSSSolver — is a declared global the extension assigns in its __init__: +# once MadNLP is loaded in the session, +# +# using MadNLP, SparseDirectSolver +# madnlp(model; linear_solver = SDSSolver, +# kkt_system = MadNLP.SparseCondensedKKTSystem) +# +# Options go as madnlp keywords (sds_factorization = :ldl, sds_reordering, +# sds_tuning = SparseDirectSolver.Fused.SDSTuning(; ...), sds_user_perm). +# Before MadNLP is loaded the names exist but are unassigned (UndefVarError +# on use), exactly like MadNLPGPU.CUDSSSolver without CUDA. +global SDSSolver +global SDSSolverOptions +export SDSSolver, SDSSolverOptions __init__() = _init_log_level() From 95e880623dfaa306adff0c4b9e9bc6ca4f038df6 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Mon, 12 Oct 2026 18:16:22 -0400 Subject: [PATCH 60/65] CI: Aqua validates the extension-assigned exports with MadNLP loaded The exported SDSSolver/SDSSolverOptions are declared globals the MadNLP extension assigns, so Aqua's undefined-exports check fails on the bare package. The Aqua workflow now loads MadNLP first, which makes that check validate the assigned exports for real; the in-suite Aqua run excludes only undefined_exports (MadNLP is not a test dependency) and keeps everything else. Both configurations run green locally against this tree. Co-Authored-By: Claude Fable 5 --- .github/workflows/Aqua.yml | 5 ++++- test/test_aqua.jl | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/Aqua.yml b/.github/workflows/Aqua.yml index 3c3d7f0f..af731a04 100644 --- a/.github/workflows/Aqua.yml +++ b/.github/workflows/Aqua.yml @@ -14,4 +14,7 @@ jobs: with: version: '1' - name: Aqua.jl - run: julia --color=yes -e 'using Pkg; Pkg.add("Aqua"); Pkg.develop(path="."); using Aqua, SparseDirectSolver; Aqua.test_all(SparseDirectSolver, ambiguities=false)' + # MadNLP is loaded so the SparseDirectSolverMadNLPExt extension assigns the + # exported SDSSolver/SDSSolverOptions globals: the undefined-exports check + # then validates them for real instead of flagging the declared-only state + run: julia --color=yes -e 'using Pkg; Pkg.add(["Aqua", "MadNLP"]); Pkg.develop(path="."); using Aqua, MadNLP, SparseDirectSolver; Aqua.test_all(SparseDirectSolver, ambiguities=false)' diff --git a/test/test_aqua.jl b/test/test_aqua.jl index dc73de5b..57db5401 100644 --- a/test/test_aqua.jl +++ b/test/test_aqua.jl @@ -6,4 +6,8 @@ # CUDA job), so give it more headroom; a real persistent task still # fails, it just takes longer to report. -Aqua.test_all(SparseDirectSolver; ambiguities = false, persistent_tasks = (tmax = 120,)) +# undefined_exports is excluded HERE only: SDSSolver/SDSSolverOptions are declared +# globals the MadNLP extension assigns, and MadNLP is not in the test environment. +# CI's Aqua workflow loads MadNLP and runs that check with the exports assigned. +Aqua.test_all(SparseDirectSolver; ambiguities = false, undefined_exports = false, + persistent_tasks = (tmax = 120,)) From 8e6636671c2203cd99199bafcb25fe2d7c7f4a71 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Mon, 12 Oct 2026 18:45:54 -0400 Subject: [PATCH 61/65] =?UTF-8?q?fused:=20review=20fixes=20=E2=80=94=20LDL?= =?UTF-8?q?=20failure=20detection,=20completion=20race,=20NaN=20pivots,=20?= =?UTF-8?q?knob=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings of the PR-wide adversarial review, each verified at source: * LDL refactorization never reduced the per-front statuses into the slot _numeric_info! reads (cholesky_stats! was skipped in LDL mode), so after the constructor's one stock factorization every refact_fused! reported that stale success forever -- a zero pivot mid-IPM left info == 0 and MadNLP consumed 'true inertia' from a corrupt factor. The stats kernel now runs unconditionally (its positive-pivot fields are unused garbage in LDL mode; the reduction is what matters). * Per-front info is zeroed at the top of every refactorization: the host-wide LDL kernels set info[v] only on failure, so one failed refactorization stayed failed through every later (genuinely successful) one. * The last-TRSM completion check read the LIVE tiles counter, leaving a window where both the last TRSM and the last tile signal cb_done/arrived -- the consumers' != spin loops then never terminate. The check now reads the plan constant tiles_base (the static 'front has no tiles' predicate). * All four no-pivot LDL pivot checks accepted NaN/Inf pivots (!iszero), unlike the Cholesky paths' potrf semantics; they now require isfinite as well. * build_fused_plan validates wgf (>= 128, multiple of 32): the tile roles keep cld(1024, wgf) partial sums in exactly 8 registers, so smaller workgroups silently dropped/reused accumulator slots. * emit_wide rejects fronts whose tile count overflows the 10-bit WTILE rc encoding instead of silently decoding tiles into the wrong panel. * MadNLP ext: the LDL failure inertia was (0, 1, n), which sums to n + 1; it is (n - 2, 1, 1) now, like the Cholesky branch. Co-Authored-By: Claude Fable 5 --- ext/SparseDirectSolverMadNLPExt.jl | 3 ++- src/fused.jl | 40 ++++++++++++++++++++++++------ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/ext/SparseDirectSolverMadNLPExt.jl b/ext/SparseDirectSolverMadNLPExt.jl index 00fce163..99fa4a3f 100644 --- a/ext/SparseDirectSolverMadNLPExt.jl +++ b/ext/SparseDirectSolverMadNLPExt.jl @@ -132,7 +132,8 @@ MadNLP.is_inertia(::SDSSolver) = true function MadNLP.inertia(M::SDSSolver) n = size(M.tril, 1) if M.opt.sds_factorization === :ldl - M.last_info == 0 || return (0, 1, n) + # failure tuple must sum to n (the inertia contract), same as the Cholesky branch + M.last_info == 0 || return (n - 2, 1, 1) l = count(x -> x < zero(x), view(M.inner.numeric.d, 1:n)) # true inertia from D return (n - l, 0, l) end diff --git a/src/fused.jl b/src/fused.jl index 955f4742..f1c7a946 100644 --- a/src/fused.jl +++ b/src/fused.jl @@ -801,7 +801,7 @@ const _SP_CTL = _SP_C0 lf = ctl[SDS._ST_LF] - 1 if ctl[SDS._ST_STATUS] == 0 dk = real(buf[lf + SDS._packed(j, j, f)]) - if !iszero(dk) + if isfinite(dk) && !iszero(dk) # NaN/Inf must fail like potrf, not "factor" if li == 1 g = Int(ctl[_SP_C0]) + j - 1 d[g] = dk @@ -902,7 +902,7 @@ end w = Int(front_ncols[v]) if j <= w && st[1] == Int32(0) dk = real(sh[SDS._packed(j, j, 64)]) - if !iszero(dk) + if isfinite(dk) && !iszero(dk) # NaN/Inf must fail like potrf, not "factor" if li == 1 d[g0 + j] = dk pv[1] = dk @@ -988,6 +988,7 @@ end @kernel function fused_fact_kernel!(factor, stack, pcb, info, @Const(nzval), @Const(amap), @Const(amap_ptr), @Const(amap_src), @Const(cb2), @Const(wt), @Const(role), @Const(ra), @Const(rb), @Const(rc), @Const(nchild), arrived, ea_left, panel_done, trsm_left, tiles_left, + @Const(tiles_base), cb_done, @Const(snparent), @Const(front_ptr), @Const(front_nrows), @Const(front_ncols), @Const(cb_ptr), @Const(child_ptr), @Const(child_list), @Const(relind_ptr), @Const(relind), base, @@ -1204,7 +1205,12 @@ end @synchronize if li == 1 left = (Atomix.@atomic trsm_left[v] += Int32(-1)) - if left == Int32(0) && tiles_left[v] == Int32(0) # new-value semantics: 0 = last finisher + # tiles_base, not the live tiles_left: the live counter can reach 0 while + # this (stalled) last TRSM is between its decrement and this read, and the + # last tile signals too -- a double signal overshoots cb_done/arrived and + # the != spin loops of the consumers never terminate. The static "front + # has no tiles" predicate is what this check means. + if left == Int32(0) && tiles_base[v] == Int32(0) # new-value semantics: 0 = last finisher Atomix.@atomic cb_done[v] += Int32(1) pv = Int(snparent[v]) pv > 0 && (Atomix.@atomic arrived[pv] += Int32(1)) @@ -1351,7 +1357,7 @@ end if li == 1 dj = real(sh[SDS._packed(j, j, 64)]) if LDL - if st[1] == Int32(0) && !iszero(dj) + if st[1] == Int32(0) && isfinite(dj) && !iszero(dj) piv[1] = dj # d stays on the diagonal d[Int(super_ptr[v]) - 1 + c0 + j] = dj elseif st[1] == Int32(0) @@ -1597,7 +1603,7 @@ end @inbounds begin if li == 1 dk = real(sh[SDS._packed(j, j, 64)]) - if st[1] == Int32(0) && !iszero(dk) + if st[1] == Int32(0) && isfinite(dk) && !iszero(dk) pv[1] = dk d[g0 + c0b + j] = dk elseif st[1] == Int32(0) @@ -1795,6 +1801,10 @@ function build_fused_plan(s; ldl::Bool = false, inkernel_wides::Union{Nothing, B backend = KA.get_backend(s.numeric.factor) T = eltype(s.numeric.factor) ldl && (T <: Real || throw(SDS.NotSupportedError("fused LDL mode: real element types only"))) + # the tile roles keep cld(TILE*TILE, wgf) partial sums in exactly 8 registers per + # thread; a smaller workgroup needs more than 8 and would silently drop/reuse slots + (wgf >= 128 && wgf % 32 == 0) || + throw(SDS.InvalidValueError("fused plan: workgroup size $wgf; expected a multiple of 32, >= 128")) # LDL defaults to in-kernel wides: the host-wide LDL path (_factor_panel_c_ldl!) is # correct but needs ~5 launches per 64-column block where Cholesky's potrf is one # vendor call -- measured SLOWER on the GV100 (55.5 vs 38.6 ms tuned). Pass @@ -1903,6 +1913,11 @@ function build_fused_plan(s; ldl::Bool = false, inkernel_wides::Union{Nothing, B end wtl_b[nwslots + k] = Int32(nch) nt = cld(nbelow, TILE) + # the WTILE role packs rc = k * 1024 + tjj, so tjj must fit in 10 bits; + # beyond that the tile would silently decode as a different panel + nt <= 1023 || + throw(SDS.NotSupportedError("fused plan: wide front with $nbelow rows below " * + "the panel; the WTILE encoding holds cld(rows, $TILE) <= 1023")) for tjj in 1:nt, tii in tjj:nt push!(role, ROLE_WTILE); push!(ra, Int32(v)); push!(rb, Int32(tii)) push!(rc, Int32(k * 1024 + tjj)) @@ -2019,6 +2034,11 @@ function refact_fused!(s, nzval, fp) plan = N.plan backend = KA.get_backend(N.factor) p = SDS._front_impls(N, S, :auto) + # per-front statuses start clean every refactorization: the narrow-panel kernels + # overwrite info[v] unconditionally, but the host-wide LDL kernels only SET it on + # failure -- without this reset one failed refactorization stays failed forever. + # (Before the subtree launches: kernels on this stream would otherwise be clobbered.) + fill!(view(N.info, 1:SDS.nsupernodes(S)), Int32(0)) copyto!(fp.arrived, fp.arrived_base) copyto!(fp.ea, fp.ea_base) copyto!(fp.trsm, fp.trsm_base) @@ -2046,7 +2066,7 @@ function refact_fused!(s, nzval, fp) count > 0 && fused_fact_kernel!(backend, fp.wgf)(N.factor, N.stack, fp.pcb, N.info, nzval, S.amap, S.amap_ptr, S.amap_src, fp.cb2, fp.wt, fp.role, fp.ra, fp.rb, fp.rc, fp.nchild, - fp.arrived, fp.ea, fp.pdone, fp.trsm, fp.tiles, fp.cbd, + fp.arrived, fp.ea, fp.pdone, fp.trsm, fp.tiles, fp.tiles_base, fp.cbd, S.snparent, S.front_ptr, S.front_nrows, S.front_ncols, S.cb_ptr, S.child_ptr, S.child_list, S.relind_ptr, S.relind, Int32(base), fp.wbase, fp.wupd, fp.wpd, fp.wtl, fp.wcb, @@ -2065,7 +2085,13 @@ function refact_fused!(s, nzval, fp) end end SDS.assemble_schur!(N, S, nzval) - fp.ldl || SDS.cholesky_stats!(N, S) # LDL: inertia comes from the signs of d + # the stats kernel ALSO reduces the per-front info into info[ns + 1], which + # _numeric_info! reads -- LDL needs that reduction too: without it a zero/NaN + # pivot sets a front's info while the reduced slot keeps reporting the stale + # success of an earlier factorization. In LDL mode N.stats' positive-pivot + # fields are Cholesky-interpretation garbage; inertia comes from the signs of + # d (the MadNLP extension), never from these stats. + SDS.cholesky_stats!(N, S) return SDS._numeric_info!(N, S, true) end From 25fdc4bd9043beaf77542277e5a96eedad8c26be Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Mon, 12 Oct 2026 18:45:55 -0400 Subject: [PATCH 62/65] bench: the LDL gates gate (nonzero exit, stock-tied residual, matching control) * ldl_fused_race.jl exited 0 with 20/20 BAD runs; it errors on any bad run now. Its 'same-strategy' Cholesky control used in-kernel wides against the LDL plan's host-potrf wides -- the control is the host-wides Cholesky plan. * ldl_fused_test.jl gated the fused solve at relres < 1.0, which a zero vector nearly meets; the gate is now 10x the stock solve's residual on the same system. * Docs de-staled: ARCH-TUNING names the committed gate scripts (the campaign's one-off sweep drivers were never shipped) and the SDSTuning knobs that replaced the SDS_WGF/SDS_SPIN_CAP env vars; PERFORMANCE/README state the MA86 numbers' one-off-driver provenance; flip-at-merge marker on the reproduction URLs; fresh_machine.jl's stale perm-cache comment removed. Co-Authored-By: Claude Fable 5 --- bench/ARCH-TUNING.md | 24 ++++++++++++++---------- bench/PERFORMANCE-2026-10.md | 7 ++++++- bench/README.md | 2 +- bench/e2e/fresh_machine.jl | 3 ++- bench/e2e/ldl_fused_race.jl | 15 +++++++++------ bench/e2e/ldl_fused_test.jl | 6 +++++- 6 files changed, 37 insertions(+), 20 deletions(-) diff --git a/bench/ARCH-TUNING.md b/bench/ARCH-TUNING.md index 74896963..23df6957 100644 --- a/bench/ARCH-TUNING.md +++ b/bench/ARCH-TUNING.md @@ -17,7 +17,7 @@ champions. Workload throughout: the 78k-bus ACOPF condensed KKT A/B/A/B in one process is the only defensible comparison. Three pairs minimum; treat sub-2% differences as noise. 3. **Correctness before speed on a new chip.** Run 20 gated refactorizations - (`bench/race_repro.jl 20`) before believing any timing. This is what caught + (`bench/e2e/ldl_fused_race.jl`) before believing any timing. This is what caught the sm_86/89/Blackwell/gfx906 completion-signal race that sm_70/80/90 hide. 4. **Occupancy arithmetic before submitting sweeps.** Most structural knobs are decided by `blocks/SM = min(smem_per_SM ÷ smem_per_block, @@ -34,13 +34,13 @@ champions. Workload throughout: the 78k-bus ACOPF condensed KKT | ordering (`nd_nseps`, `nd_seed`, `user_perm`) | solver/wrapper | runtime | fill/flops; arch-independent; every instance recomputes (no caching); pin with `user_perm` | | panel width (`pw`) | plan + kernel `Val` | runtime per-plan | fronts ≤ pw factor in-kernel; packed panel = pw(pw+1)/2 · 8B of shared memory **for every block of the kernel** (one kernel = one shared size) | | tile size (`tl`) | plan + kernel `Val` | runtime per-plan | syrk tile; shared halves = 2·32·tl · 8B; accumulators = tl²/WGF registers per thread | -| workgroup size (`SDS_WGF`) | env (const at include) | per-process | waves per workgroup; latency hiding on wave64 AMD; occupancy on 1536-thread parts | -| spin backoff cap (`SDS_SPIN_CAP`) | env (const at include) | per-process | dependency-counter wake-up latency vs polling traffic | +| workgroup size (`SDSTuning.workgroup`) | tuning bundle | runtime (>= 128, multiple of 32: the tile roles' register accumulators) | waves per workgroup; latency hiding on wave64 AMD; occupancy on 1536-thread parts | +| spin backoff cap (`SDSTuning.spin_cap`) | tuning bundle | runtime | dependency-counter wake-up latency vs polling traffic | | CUDA graph replay (`graph`) | run_fused kwarg | runtime | whole refactorization captured once, replayed as one launch | ## Procedure for a new architecture -1. **Correctness:** `race_repro.jl 20` (kernel-level, <2 GB device memory). +1. **Correctness:** `bench/e2e/ldl_fused_race.jl` (20 gated refactorizations). Any failure: stop, bisect kernel-vs-src-vs-config before timing anything. 2. **Phase profile:** `profile_refact()` — sync-inflated but reveals the split (resets / subtrees / mega kernel / host wides / stats). The dominant phase @@ -55,8 +55,8 @@ champions. Workload throughout: the 78k-bus ACOPF condensed KKT 5. **Structural (only where shared memory allows):** `pw_ab.jl`, `tl_ab.jl`. Run the occupancy arithmetic first; on ≤100 KB/SM parts pw=96/tl=64 lose by construction, on 228 KB (Hopper) they are free. -6. **Process knobs:** `SDS_WGF` ∈ {128, 256} and `SDS_SPIN_CAP` ∈ {128, 16} - (one process per value). WGF=256 was −20% on wave64 AMD, flat-to-pending on +6. **Bundle knobs:** `SDSTuning.workgroup` ∈ {128, 256} and `SDSTuning.spin_cap` + ∈ {128, 16} (runtime, per plan; workgroup has a hard 128 floor). WGF=256 was −20% on wave64 AMD, flat-to-pending on NVIDIA; the spin cap matters only on residency-thin parts. 7. **Graphs:** `graph_ab.jl` — small (~1%) and free; keep it on everywhere. 8. **Stack and re-validate:** combine the winners, three gated interleaved @@ -96,8 +96,12 @@ compile-time or environment-dependent in the wrapper path. ## Scripts -`race_repro.jl` (gated correctness), `profile_refact` in `fact_lib.jl` (phase -split), `mseg_ab.jl` / `pw_ab.jl` / `tl_ab.jl` / `graph_ab.jl` / `combo_ab.jl` -(paired A/Bs), `tune_platform.jl` (knob sweep), `h200_matrix.jl` (structural -matrix), `e2e/amd_tune.jl` (AMD sweep). All print per-trial gates; all take the +The campaign's one-off sweep drivers (`race_repro.jl`, `fact_lib.jl`'s +`profile_refact`, `mseg_ab.jl`, `pw_ab.jl`, `tl_ab.jl`, `graph_ab.jl`, +`combo_ab.jl`, `tune_platform.jl`, `h200_matrix.jl`, `e2e/amd_tune.jl`) were +not shipped; the committed equivalents are `e2e/ldl_fused_race.jl` (gated +correctness), `e2e/ldl_fused_test.jl` (fused vs stock + same-strategy ratio) +and `e2e/amd_sweep.jl` (knob sweep). Read the procedure above with those +substitutions; CUDA-graph replay (step 7's `graph_ab.jl`) retired with the +consolidation cleanup (no graph kwarg at HEAD). All gates take the matrix from `bench/data/` and the tuned permutation from `bench/perm_cand3.bin`. diff --git a/bench/PERFORMANCE-2026-10.md b/bench/PERFORMANCE-2026-10.md index dd7154e5..ed2eac28 100644 --- a/bench/PERFORMANCE-2026-10.md +++ b/bench/PERFORMANCE-2026-10.md @@ -143,7 +143,10 @@ validated configuration each; all gated. | 2x Xeon 8160, 8 threads | 106 | - | - / MA86 151 | | 2x Xeon 8160, 1 thread | 449 | - | CHOLMOD 966 / MA86 963 | -More 1-thread references: MA57 1909 ms, MA27 2798 ms (both LDL^T, libcoinhsl; +More 1-thread references: MA57 1909 ms, MA27 2798 ms (both LDL^T, libcoinhsl, +committed drivers `e2e/ma27_bench.jl` / `e2e/ma57_bench.jl`; the MA86 numbers +came from a one-off HSL.jl raw-wrapper driver with MadNLP-matching controls, +not shipped; MA27 on the augmented k2 system of the same problem: 3709 ms at n = 2.09M). *Projected from the measured interleaved -16.8% (amalgamation 96) on the quiet-box 101.5 ms; every other entry is directly measured. Solve times where @@ -161,6 +164,8 @@ factor deltas, not info alone. ## Reproduction + + One command on a fresh GPU machine (NVIDIA or AMD, autodetected). The shell route builds the `@sds` named project environment (everything from `bench/e2e/Project.toml`, no ad-hoc `Pkg.add`) and is reusable afterwards with diff --git a/bench/README.md b/bench/README.md index 3947cf14..5db49566 100644 --- a/bench/README.md +++ b/bench/README.md @@ -24,7 +24,7 @@ Baselines every later milestone is measured against (PLAN.md §5 M0, §7). The | `amd_proto.jl`, `amd/Project.toml` | the solve prototype on ROCm (ROCArray, raw-array constructor, `Threads.atomic_fence`); environment `bench/amd/` | | `e2e/run_gv100.jl`, `e2e/run_amd.jl`, `e2e/fresh_machine.{jl,sh}` | full ACOPF interior-point runs through the MadNLP extension (`SparseDirectSolver.SDSSolver`, the fused pipeline of `src/fused.jl`) on CUDA and AMD, and the one-file fresh-machine workflow; environment `bench/e2e/` (MadNLP, MadNLPGPU, ExaModels, ExaModelsPower) | | `e2e/ldl_fused_test.jl`, `e2e/ldl_fused_race.jl`, `e2e/amd_baseline.jl`, `e2e/amd_sweep.jl` | gates and timings of the fused Cholesky/LDLᵀ refactorization and the fused solve against the stock kernels on the 78k-bus condensed KKT (CUDA; the `amd_*` scripts on ROCm), see `PERFORMANCE-2026-10.md` and `ARCH-TUNING.md` | -| `e2e/SDSCPU.jl`, `e2e/cpu_fast_test.jl`, `e2e/hsl_cpu_bench.jl`, `e2e/ma27_bench.jl`, `e2e/ma57_bench.jl` | the task-parallel CPU refactorization prototype over the stock schedule and its HSL (MA27/MA57/MA86 through libcoinhsl) and CHOLMOD references | +| `e2e/SDSCPU.jl`, `e2e/cpu_fast_test.jl`, `e2e/hsl_cpu_bench.jl`, `e2e/ma27_bench.jl`, `e2e/ma57_bench.jl` | the task-parallel CPU refactorization prototype over the stock schedule and its HSL references (MA27/MA57 through libcoinhsl; the MA86/CHOLMOD numbers in PERFORMANCE-2026-10.md came from one-off drivers, not shipped) | | `pivot_pairs.jl` | 2×2 pivot pairs of the `S` analysis (issue #66): `pivot_pairs` = `none`/`default`/`all` on the K2 dumps and the KKT generators, with nnz(L), zero/perturbed/2×2 pivots, max abs L and factor error of the CPU reference LDLᵀ; package environment: `julia --project=. bench/pivot_pairs.jl [--only=case118,...] [--generators=false]` | ## Commands diff --git a/bench/e2e/fresh_machine.jl b/bench/e2e/fresh_machine.jl index eaf7a77d..5a0f358d 100644 --- a/bench/e2e/fresh_machine.jl +++ b/bench/e2e/fresh_machine.jl @@ -86,7 +86,8 @@ for f in propertynames(sol.counters) println(" ", f, " = ", round(v; digits = 2), " s") end # the honest speed is the warm number: same process, nothing cached between the -# two solves except compiled code (and the on-disk permutation of this pattern) +# two solves except compiled code -- the reordering and symbolic analysis run in +# full both times (the extension caches nothing) t2 = @elapsed sol2 = solve() println("== second solve (warm): ", round(t2; digits = 1), " s", " iters = ", sol2.counters.k, " obj = ", sol2.objective) diff --git a/bench/e2e/ldl_fused_race.jl b/bench/e2e/ldl_fused_race.jl index 427cc986..757ffee8 100644 --- a/bench/e2e/ldl_fused_race.jl +++ b/bench/e2e/ldl_fused_race.jl @@ -45,14 +45,17 @@ for t in 1:20 flush(stdout) end println("bad: ", nbad, " of 20") +nbad == 0 || error("fused LDL race gate FAILED: $nbad of 20 runs bad") # a gate must exit nonzero -# Cholesky with the SAME wide strategy (in-kernel), isolating the LDL-math cost +# Cholesky with the SAME wide strategy as the LDL plan above (wides_potrf forces +# HOST wides, so the matching control is the host-wides Cholesky plan -- the +# in-kernel plan used before was a different strategy and confounded the ratio) s2 = setup("SPD") nz2 = SDS._factor_values(s2) -fp2i = SDSFused.build_fused_plan(s2; inkernel_wides = true, wgf = 128, scap = 128) -SDSFused.refact_fused!(s2, nz2, fp2i) -tr_chol_ik = median([CUDA.@elapsed SDSFused.refact_fused!(s2, nz2, fp2i) for _ in 1:7]) +fp2h = SDSFused.build_fused_plan(s2; wgf = 128, scap = 128) # host wides (default) +SDSFused.refact_fused!(s2, nz2, fp2h) +tr_chol_host = median([CUDA.@elapsed SDSFused.refact_fused!(s2, nz2, fp2h) for _ in 1:7]) tr_ldl = median([CUDA.@elapsed SDSFused.refact_fused!(s, nz, fp) for _ in 1:7]) -@printf "fused SPD (in-kernel wides) refact %8.2f ms\n" tr_chol_ik*1e3 -@printf "fused S/N (in-kernel wides) refact %8.2f ms ratio same-strategy %.3f\n" tr_ldl*1e3 tr_ldl/tr_chol_ik +@printf "fused SPD (host wides) refact %8.2f ms\n" tr_chol_host*1e3 +@printf "fused S/N (host wides) refact %8.2f ms ratio same-strategy %.3f\n" tr_ldl*1e3 tr_ldl/tr_chol_host println("done") diff --git a/bench/e2e/ldl_fused_test.jl b/bench/e2e/ldl_fused_test.jl index dfebec05..56139ce4 100644 --- a/bench/e2e/ldl_fused_test.jl +++ b/bench/e2e/ldl_fused_test.jl @@ -64,7 +64,11 @@ SDSFused.algo2_solve!(x2, s, bd, inv11, inv_ptr, fz) CUDA.synchronize() rel = norm(bh - A * Array(x2)) / norm(bh) @printf "fused S/N solve relres %.2e\n" rel -rel < 1.0 || error("fused LDL solve gate FAILED") +# gate against the stock solve's residual on the same system (x holds the stock +# solution at this point), not an absolute threshold a zero vector nearly meets +rel_stock = norm(bh - A * Array(xd)) / norm(bh) +rel <= 10 * rel_stock + 1e-12 || + error("fused LDL solve gate FAILED: relres $rel vs stock $rel_stock") ts_fused = median([CUDA.@elapsed SDSFused.algo2_solve!(x2, s, bd, inv11, inv_ptr, fz) for _ in 1:7]) @printf "fused S/N solve %8.2f ms\n" ts_fused*1e3 flush(stdout) From 3941d310528f8955a5f67832442165ea2d2c1c11 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Mon, 12 Oct 2026 19:12:37 -0400 Subject: [PATCH 63/65] =?UTF-8?q?symbolic/options:=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20device-route=20validation,=20Int32=20gates,=20kerne?= =?UTF-8?q?l=20Float64s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * device_symmetric_pattern validates its input with the same checks (and the same InvalidValueError messages) as the host route, on the host copies it already makes -- malformed device input previously reached the kernels (BoundsError/trap at best, in-range garbage from the unzeroed keys buffer at worst). * device_maps_supported admits only what the maps' hard-coded Int32 storage holds (n + 1, nnz + 1, factor_len + 1 <= typemax(Int32)); the 2^31..2^32 band previously died in a raw InexactError mid-analysis. * The device amap kernel writes a typemax sentinel for an entry outside the symbolic factor (the dropped host-route guard) and the wrapper raises the host route's exact error once, instead of silently producing a wrong offset. * No Float64 left in kernels (Metal has no fp64): _tri_decode is integer isqrt-based (verified equal to the float version exhaustively for r <= 300 plus big-r boundaries), and the spin-backoff scratch/accumulators are Int32. * nd_nseps = 0 is rejected loudly instead of silently coerced to the library default (-1 and >= 1 remain the documented domain). Co-Authored-By: Claude Fable 5 --- src/fused.jl | 26 +++++++++++++------------- src/options.jl | 11 ++++++++++- src/symbolic/device.jl | 27 +++++++++++++++++++++------ src/symbolic/schedule.jl | 5 +++-- 4 files changed, 47 insertions(+), 22 deletions(-) diff --git a/src/fused.jl b/src/fused.jl index f1c7a946..f692394e 100644 --- a/src/fused.jl +++ b/src/fused.jl @@ -310,11 +310,11 @@ end tgt = nchild[v] pause = Int32(4) while (Atomix.@atomic arrived[v] += Int32(0)) < tgt - z = 0.0 + z = Int32(0) for _ in 1:pause - z += 1.0 + z += Int32(1) end - z < 0 && (yb[1] = TT(z)) # keep the backoff loop alive + z < Int32(0) && (yb[1] = TT(z)) # keep the backoff loop alive pause = min(pause << 1, Int32(128)) end end @@ -372,11 +372,11 @@ end if pv > 0 pause = Int32(4) while (Atomix.@atomic done[pv] += Int32(0)) == Int32(0) - z = 0.0 + z = Int32(0) for _ in 1:pause - z += 1.0 + z += Int32(1) end - z < 0 && (t[1] = TT(z)) + z < Int32(0) && (t[1] = TT(z)) pause = min(pause << 1, Int32(128)) end end @@ -423,11 +423,11 @@ end if pv > 0 pause = Int32(4) while (Atomix.@atomic done[pv] += Int32(0)) == Int32(0) - z = 0.0 + z = Int32(0) for _ in 1:pause - z += 1.0 + z += Int32(1) end - z < 0 && (t[1] = TT(z)) + z < Int32(0) && (t[1] = TT(z)) pause = min(pause << 1, Int32(128)) end end @@ -975,11 +975,11 @@ const ROLE_WSIG = Int8(9) @inline function _spin_until_eq!(arr, idx, target, scratch, scap) pause = min(Int32(4), scap) while (Atomix.@atomic arr[idx] += Int32(0)) != target - z = 0.0 + z = Int32(0) # integer backoff: no Float64 in kernels (Metal) for _ in 1:pause - z += 1.0 + z += Int32(1) end - z < 0 && (scratch[1] = z) + z < Int32(0) && (scratch[1] = z) pause = min(pause << 1, scap) end return nothing @@ -1001,7 +1001,7 @@ end sh = @localmem TT (NL64,) st = @localmem Int32 (1,) piv = @localmem RT (1,) - fscr = @localmem Float64 (1,) + fscr = @localmem Int32 (1,) # spin-backoff scratch (integer: no Float64 in kernels) @inbounds begin r = role[G] if r == ROLE_EA diff --git a/src/options.jl b/src/options.jl index 3edb8259..f0fcdedc 100644 --- a/src/options.jl +++ b/src/options.jl @@ -421,13 +421,22 @@ for (field, lo, hi, expected) in ( (:schur_mode, 0, 1, "0 or 1"), (:deterministic_mode, 0, 1, "0 or 1"), (:nd_ubfactor, -1, typemax(Int), "-1 (library default) or an integer ≥ 0"), - (:nd_nseps, -1, typemax(Int), "-1 (library default) or an integer ≥ 1"), + # nd_nseps has a dedicated method below: 0 is in [-1, typemax] but invalid (:nd_seed, -1, typemax(Int), "-1 (library default) or an integer ≥ 0"), ) @eval _parse_option(::Val{$(QuoteNode(field))}, value, _) = _parse_int($(String(field)), value, $lo, $hi, $expected) end +# nd_nseps = 0 lies inside the plain [-1, typemax] range but is not a METIS value; +# the documented domain is -1 (library default) or >= 1, so reject it loudly +# instead of silently coercing (the ordering code maps "< 1" to the default) +function _parse_option(::Val{:nd_nseps}, value, _) + v = _parse_int("nd_nseps", value, -1, typemax(Int), "-1 (library default) or an integer ≥ 1") + v == 0 && throw(InvalidValueError("nd_nseps = 0; expected -1 (library default) or an integer ≥ 1")) + return v +end + for field in (:ir_tol, :pivot_threshold, :pivot_pair_tolerance) @eval _parse_option(::Val{$(QuoteNode(field))}, value, _) = _parse_float($(String(field)), value) end diff --git a/src/symbolic/device.jl b/src/symbolic/device.jl index 5aa77a63..f3a36b3c 100644 --- a/src/symbolic/device.jl +++ b/src/symbolic/device.jl @@ -29,7 +29,9 @@ function device_maps_supported(backend, structure, view, n::Integer, nnz::Intege (s == STRUCTURE_SPD || s == STRUCTURE_HPD || s == STRUCTURE_SYMMETRIC || s == STRUCTURE_HERMITIAN) || return false _matrix_view(view) == VIEW_FULL && return false - return n < 2^32 && nnz < 2^32 && factor_len < 2^31 + # the maps store values up to nnz + 1 / n / factor_len + 1 in Int32; admit only + # what Int32 holds (the host route serves the rest) + return n + 1 <= typemax(Int32) && nnz + 1 <= typemax(Int32) && factor_len + 1 <= typemax(Int32) end # device vector of `x` as `I` @@ -71,9 +73,12 @@ function device_symmetric_pattern(backend, rowptr, colval, n::Integer, view, ind m = length(colval) base = _index_base(index) == INDEX_ONE ? Int32(1) : Int32(0) upper = _matrix_view(view) == VIEW_UPPER + hrp = Array(rowptr) + hcv = Array(colval) + _host_pattern(hrp, hcv, n, index) # validation only: same errors as the host route I = m + 1 <= typemax(Int32) && n + 1 <= typemax(Int32) ? Int32 : Int64 - drp = _device_vec(backend, I, Array(rowptr)) - dcv = _device_vec(backend, I, Array(colval)) + drp = _device_vec(backend, I, hrp) + dcv = _device_vec(backend, I, hcv) keys = KernelAbstractions.allocate(backend, UInt64, 2m) n > 0 && _pattern_keys_kernel!(backend)(keys, drp, dcv, base, upper; ndrange = Int(n)) KernelAbstractions.synchronize(backend) @@ -132,8 +137,14 @@ end b = mid end end - off = Int(panelptr[s]) + (j - Int(superptr[s])) * f + (a - lo + 1) - 1 - amap[p] = T(flip && neg ? -off : off) + if a <= hi && Int(snrowval[a]) == i + off = Int(panelptr[s]) + (j - Int(superptr[s])) * f + (a - lo + 1) - 1 + amap[p] = T(flip && neg ? -off : off) + else + # the host route throws "outside the symbolic factor" here; a kernel + # cannot, so it writes a sentinel the host wrapper checks once + amap[p] = typemax(T) + end else amap[p] = zero(T) end @@ -167,7 +178,11 @@ function device_assembly_map(backend, sp::SupernodePartition, layout::Layout, base, v == VIEW_UPPER, _is_hermitian(s); ndrange = Int(n)) KernelAbstractions.synchronize(backend) - return Vector{Int}(Array(amap)) + h = Vector{Int}(Array(amap)) + bad = findfirst(==(Int(typemax(I))), h) + bad === nothing || + throw(InvalidValueError("entry at stored position $bad is outside the symbolic factor (pattern changed?)")) + return h end # --------------------------------------------------------------------------- diff --git a/src/symbolic/schedule.jl b/src/symbolic/schedule.jl index 2f3a9fed..558ce9e8 100644 --- a/src/symbolic/schedule.jl +++ b/src/symbolic/schedule.jl @@ -116,11 +116,12 @@ packed_length(m::Integer) = Int(m) * (Int(m) + 1) ÷ 2 # inverse of the packed lower-triangle enumeration: 0-based q in an r x r lower # triangle (col-major, n = r(r+1)/2 entries) -> 1-based (a, b), a >= b. Enumerating # only the triangle halves the work-item count of the guarded full-square loops. -# The float sqrt can land one off at the boundary; the two fixups make it exact. +# Integer isqrt (exact, and no Float64 in kernels: Metal has no fp64); the two +# boundary fixups are kept as a belt against any isqrt implementation quirk. @inline function _tri_decode(q, r) n = r * (r + 1) ÷ 2 p = n - 1 - q - t = unsafe_trunc(Int, (sqrt(8.0 * p + 1.0) - 1.0) * 0.5) + t = (isqrt(8 * p + 1) - 1) ÷ 2 t * (t + 1) ÷ 2 > p && (t -= 1) (t + 1) * (t + 2) ÷ 2 <= p && (t += 1) b = r - t From cf665d0116055e340941e5de055fbdcdbca8faf7 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Mon, 12 Oct 2026 19:12:38 -0400 Subject: [PATCH 64/65] =?UTF-8?q?MadNLP=20ext:=20no-pivot=20LDL=20disables?= =?UTF-8?q?=202x2=20pivot-pair=20detection=20=E2=80=94=20the=20production?= =?UTF-8?q?=20slowdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on the augmented 78k KKT (n = 2.09M, GV100, nseps = 1): analysis is 316 s with the default "S"-structure pair detection, 64 s with pivot_pairs = "none", 27 s for the SPD control. A saddle system's huge structurally-zero diagonal block is exactly what zero_pivot_pairs! rounds chew on, and the no-pivot factorization never consumes the pairs. This was the dominant cost of the ~6-minute per-instance MadNLP init observed on the stabilized augmented benchmarks. Co-Authored-By: Claude Fable 5 --- ext/SparseDirectSolverMadNLPExt.jl | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ext/SparseDirectSolverMadNLPExt.jl b/ext/SparseDirectSolverMadNLPExt.jl index 99fa4a3f..81e18821 100644 --- a/ext/SparseDirectSolverMadNLPExt.jl +++ b/ext/SparseDirectSolverMadNLPExt.jl @@ -80,6 +80,10 @@ function SDSSolver( ldl = opt.sds_factorization === :ldl s = SDS.DirectSolver(csc.colPtr, csc.rowVal, csc.nzVal, ldl ? "S" : "SPD", 'U') ldl && SDS.setparam!(s, "pivot_type", 'N') + # no-pivot LDL never consumes 2x2 pivot pairs, and "S" analysis pays dearly for + # detecting them on saddle systems (augmented 78k KKT, n = 2.09M: analysis 316 s + # with the default pair detection, 64 s without -- measured on a GV100) + ldl && SDS.setparam!(s, "pivot_pairs", "none") s.options.regime_c_width = t.regime_c_width s.options.regime_c_rows = t.regime_c_rows # the fused kernels own BOTH phases: no coupling conflict s.options.subtree_parallelism = t.subtree_parallelism From e86447d585ac28751f16acc03ef5e7a683d2eab4 Mon Sep 17 00:00:00 2001 From: Sungho Shin Date: Mon, 12 Oct 2026 19:33:19 -0400 Subject: [PATCH 65/65] bench: flip the fresh-machine refs to main for the merge The reproduction one-liners, fresh_machine.sh's default branch and fresh_machine.jl's pinned ref move from consolidated-prs to main, per the PR's merging checklist. Note the one-liner tests main's HEAD, so it serves the consolidated work only once the PR lands. Co-Authored-By: Claude Fable 5 --- bench/PERFORMANCE-2026-10.md | 5 ++--- bench/e2e/fresh_machine.jl | 2 +- bench/e2e/fresh_machine.sh | 6 +++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/bench/PERFORMANCE-2026-10.md b/bench/PERFORMANCE-2026-10.md index ed2eac28..66fca702 100644 --- a/bench/PERFORMANCE-2026-10.md +++ b/bench/PERFORMANCE-2026-10.md @@ -164,7 +164,6 @@ factor deltas, not info alone. ## Reproduction - One command on a fresh GPU machine (NVIDIA or AMD, autodetected). The shell route builds the `@sds` named project environment (everything from @@ -172,13 +171,13 @@ builds the `@sds` named project environment (everything from `julia +1.13 --project=@sds bench/e2e/workflow.jl`: ```bash -curl -fsSL https://raw.githubusercontent.com/exanauts/SparseDirectSolver.jl/consolidated-prs/bench/e2e/fresh_machine.sh | bash +curl -fsSL https://raw.githubusercontent.com/exanauts/SparseDirectSolver.jl/main/bench/e2e/fresh_machine.sh | bash ``` No shell / no checkout wanted? The self-contained Julia variant: ```bash -julia +1.13 --startup-file=no -i -e 'include(download("https://raw.githubusercontent.com/exanauts/SparseDirectSolver.jl/consolidated-prs/bench/e2e/fresh_machine.jl"))' +julia +1.13 --startup-file=no -i -e 'include(download("https://raw.githubusercontent.com/exanauts/SparseDirectSolver.jl/main/bench/e2e/fresh_machine.jl"))' ``` Kernel-level benchmarks: the scripts listed in `bench/ARCH-TUNING.md` against diff --git a/bench/e2e/fresh_machine.jl b/bench/e2e/fresh_machine.jl index 5a0f358d..4eb8199f 100644 --- a/bench/e2e/fresh_machine.jl +++ b/bench/e2e/fresh_machine.jl @@ -33,7 +33,7 @@ Pkg.add(BACKEND == "cuda" ? ["CUDA", "CUDSS"] : ["AMDGPU"]) # SDS as a proper dev checkout (editable, canonical path), not a frozen Pkg.add; # an existing checkout is updated to the branch head (local edits block the pull loudly) const SDS_DEV = joinpath(homedir(), ".julia", "dev", "SparseDirectSolver") -const SDS_REF = "consolidated-prs" # flip to main when PR 126 merges +const SDS_REF = "main" if isdir(SDS_DEV) # the checkout may be shared (NFS home) with a concurrent run: retry once on # git lock contention, then proceed on the existing tree with a warning diff --git a/bench/e2e/fresh_machine.sh b/bench/e2e/fresh_machine.sh index 4efec8a8..03c386ac 100755 --- a/bench/e2e/fresh_machine.sh +++ b/bench/e2e/fresh_machine.sh @@ -4,7 +4,7 @@ # from bench/e2e/Project.toml with SparseDirectSolver wired to the local checkout, and # Pkg.instantiate() provides everything from the project file. # -# curl -fsSL https://raw.githubusercontent.com/exanauts/SparseDirectSolver.jl/consolidated-prs/bench/e2e/fresh_machine.sh | bash +# curl -fsSL https://raw.githubusercontent.com/exanauts/SparseDirectSolver.jl/main/bench/e2e/fresh_machine.sh | bash # # When a terminal is attached, the workflow ends in an interactive REPL (julia -i) # with `m`, `sol`, `sol2` and `solve()` defined; detached (nohup/sbatch, no tty) it @@ -13,7 +13,7 @@ # julia +1.13 --startup-file=no --project=@sds bench/e2e/workflow.jl # # Knobs (env vars): -# SDS_BRANCH=consolidated-prs git ref to test +# SDS_BRANCH=main git ref to test # SDS_DIR=$HOME/sds-test checkout location # SDS_BACKEND=cuda|amdgpu GPU vendor override (autodetected in workflow.jl) # RUN_TESTS=1 also run the package test suite first (long) @@ -26,7 +26,7 @@ # stdin get a per-command `< /dev/null` instead. set -euo pipefail -SDS_BRANCH="${SDS_BRANCH:-consolidated-prs}" # flip to main when PR 126 merges +SDS_BRANCH="${SDS_BRANCH:-main}" SDS_DIR="${SDS_DIR:-$HOME/sds-test}" # --- 1. Julia 1.13 via juliaup -----------------------------------------------