You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Opened by a coding agent at @gdalle's request — please review before acting on it. Everything below is measured, not inferred; the harness, raw result.json files, CUPTI traces, Julia profiles and nvidia-smi samples are on master01-hpc under ~/pdlp-profiling/ (see README.md there for the run order).
What was measured
Hardware / software. One NVIDIA L40S (sm_89, 44.99 GiB, 142 SMs, 864 GB/s theoretical peak, 350 W cap, SM clock pinned at 2520 MHz throughout), driver 590.48.01 / CUDA 13.1, Julia 1.12.7, CUDA.jl CUDACore 6.3.0, cuSPARSE 12.7.3.
Configuration.PDLP(Float32, Int32, cuSPARSE.CuSparseMatrixCSR; backend = CUDABackend(), time_limit = 120.0), all other parameters at their defaults (ruiz_iter = 10, check_every = 100, termination_reltol = 1e-4, record_error_history = true). One fresh Julia process per instance, exclusive access to the GPU.
Instances. The 10 largest by nnz(A) in the MIPLIB2017 benchmark set, found by reading all 240 instances through MathOptBenchmarkInstances.jl. (8 of the 240 could not be read at all — read_miplib2017_instance lowercases the name, so mixed-case files like eilA101-2.mps.gz are not found; all 8 are far too small to belong in the top 10. Worth an issue on that repo.)
Three instruments, as requested.
CUDA.@profile (CUPTI): full host-API + device-activity trace over a ~6 s window, with NVTX.@range markers around steps / termination_check! / restart_check! / restart!. Device operations are attributed to a phase through their correlation ID → launching API call → enclosing NVTX range. Note CUPTI inflates absolute host times, so only shares are quoted from it.
Julia's sampling profiler over the same window, restricted to samples whose backtrace contains the solver loop.
nvidia-smi sampled at 250 ms for the whole 120 s of each clean (unprofiled) run.
Plus per-call microbenchmarks with CUDA.@elapsed (median of 20), which are the numbers quoted in µs.
nsys/ncu are not installed on the cluster, so the CUPTI-based integrated profiler is the timeline source.
Headline
One number dominates everything else in this report: the step size is wrong, and it is wrong because spectral_norm does not return a spectral norm. Kernel-level work is in reasonable shape — cuSPARSE SpMV runs at 63–110 % of the L40S's DRAM bandwidth (above 100 % where the matrix fits in the 48 MB L2) and is 47–97 % of all GPU time — so the throughput wins available from micro-optimisation are worth maybe 10–35 %, whereas the step size is worth up to 45× on the same instance. The rest of the findings are real and worth fixing, but they should be read in that order of magnitude.
Also note these are the ten largest instances, i.e. the most favourable case for the current design. The fixed per-iteration overheads below get proportionally worse on the other 230.
1. spectral_norm returns a provably wrong value, and the step size follows it — refines #95
fixed_stepsize sets η = 0.9 / spectral_norm(A, At) and never changes it again (reset_stepsize! only zeroes η_sum). So this single scalar fixes the step size for the whole solve.
spectral_norm calls powm!(KᵀK, x0) with no tol/maxiter, and x0 is a raw randn! vector (norm ≈ √n) rather than the normalised guess powm! documents. IterativeSolvers' default tol = eps(T)·size(B,2)^3 is astronomically loose in Float32 (≈ 1.0e8 for n = 10⁵), so the iteration stops before the first normalisation ever takes effect. powm! then returns θ = x₀ᵀKᵀKx₀ = ‖Kx₀‖², and spectral_norm hands back √θ = ‖Kx₀‖ — a quantity that scales with ‖x₀‖ ≈ √n, rather than the Rayleigh quotient ‖Kx₀‖/‖x₀‖.
That claim is not a guess. ‖A‖₂ ≤ √(‖A‖₁·‖A‖∞) is a rigorous upper bound, and on 3 of the 4 audited instances the returned value exceeds it:
instance
spectral_norm returns
rigorous upper bound √(‖A‖₁‖A‖∞)
300-iteration normalised power method
η used
η from converged ‖A‖
square47
29.659
5.826 ❌
1.0000
0.0303
0.900
roi5alpha10n8
23.170
6.292 ❌
1.0000
0.0388
0.900
supportcase7
29.709
13.901 ❌
0.9999
0.0303
0.900
neos-4647030-tutaki
0.9436
1.916 ✅
0.9968
0.9538
0.903
Across all ten instances the ratio (converged ‖A‖) / (returned value) ranges from 0.0027 (neos-5114902-kasavu, i.e. a 370× overestimate) to 1.53 (neos-3402454-bohle, an underestimate that puts η‖A‖ ≈ 1.38 > 1, violating the PDHG stability condition). The error is unpredictable in both magnitude and sign — see Table 8.
Because Ruiz + Chambolle–Pock equilibration leaves ‖A‖₂ ≈ 1, the practical effect is usually η about 25–30× too small.
What that costs — A/B at identical settings, 120 s budget
instance
η default
result
η corrected
result
roi5alpha10n8
0.0388
OPTIMAL, 382,100 iters, 33.4 s
0.900
OPTIMAL, 9,500 iters, 0.75 s
square47
0.0303
TIME_LIMIT, rel. err 9.19e-01
0.900
TIME_LIMIT, rel. err 1.38e-01
neos-4647030-tutaki
0.9538
OPTIMAL, 21,800 iters, 3.0 s
0.903
OPTIMAL, 32,600 iters, 4.2 s
supportcase7
0.0303
TIME_LIMIT, rel. err 4.68e-03
0.900
TIME_LIMIT, rel. err 1.11e+04 💥
A 45× speedup on roi5alpha10n8 and a 6.7× better residual on square47, from one scalar.
But the fix is not just "compute ‖A‖ correctly"
supportcase7diverges with the mathematically-correct η = 0.9/‖A‖, while the accidental 30×-too-small η converges nicely. In other words the broken estimator is currently acting as an erratic safety factor that happens to paper over the absence of the adaptive step size that PDLP actually specifies. Fixing spectral_norm on its own would be a regression on at least one of these ten instances.
Suggested fix, in order:
Fix spectral_norm: normalise x0, pass an explicit tol and maxiter, check powm!'s convergence flag, guard sqrt against a negative λ, and cross-check against √(‖A‖₁‖A‖∞) (both are cheap SpMVs against abs.(nzval) and give a free correctness assertion).
Then add the adaptive/linesearch step size from Applegate et al. §3.1 (what cuPDLP/cuPDLPx use), or at minimum a divergence guard that halves η and restarts when the KKT residual grows by orders of magnitude. Step 1 without step 2 is not safe.
Consider making the safety factor invnorm_scaling do real work in Float32 (0.9 leaves little margin once ‖A‖ itself is a Float32 estimate).
2. Five KKT evaluations per check where two would do — extends #100
Per outer check (check_every = 100), kkt_errors! runs five times:
Verified on the GPU (redundancy_check.jl): termination_check! and restart_check! return bit-identical errors for the same iterate, e.g. on neos-4647030-tutaki both give (19.947240829467773, 0.0008696960867382586, 53675.42578125).
The err_last / err_avg_last pair exists only to produce abs_candidate_last for the no_local_progress = candidate > candidate_last test. But restart_stats.abs_candidate from the previous call already holds a candidate error — caching it would remove two full evaluations. It does change the semantics from "one iteration ago" to "one check ago", which is what the reference implementations actually compare, and is arguably more correct given the criterion is only evaluated every 100 iterations anyway.
Each kkt_errors! is 2 SpMVs + ~9 elementwise passes + 7 reductions. Removing three of the five is worth, per check:
square47
square41
neos-2075418-temuka
neos-4647030-tutaki
roi5alpha10n8
3 × kkt_errors!
3725 µs
2688 µs
1886 µs
1011 µs
899 µs
share of a 100-iteration check
5.6 %
8.0 %
8.1 %
9.4 %
12.0 %
(Full table in the appendix; the check machinery as a whole is 8.5 %–19.7 % of wall clock, mean 13.0 %.)
2b. primal_scale and dual_scale are problem constants, recomputed every time
These depend only on D1, lc, uc, D2, c — none of which move during a solve. Confirmed bit-identical over 1000 iterations on two instances:
after 200 iterations: primal_scale = 32085.00195 dual_scale = 38.41656876
after 1000 iterations: primal_scale = 32085.00195 dual_scale = 38.41656876
They cost 2 of the ~9 elementwise passes and 2 of the 7 reductions (with their 2 blocking syncs) in every one of the 5 calls — 10 wasted reductions, 10 wasted vector passes and 10 wasted syncs per check. Hoist them into initialize and store them next to the preconditioner.
3. 14.8 GPU operations and ~290 CUDA API calls per PDHG iteration
The CUPTI trace gives exactly the same op count per iteration on all ten instances:
per PDHG iteration
count
source
cusparse::csrmv_v3_kernel
2.10
the two SpMVs (the 0.10 is the checks' share)
cusparse::csr_partition_kernel
2.10
cuSPARSE recomputing its row partitioning on every call
For comparison, a fused PDHG step needs ~4: SpMV, fused primal+extrapolation+average, SpMV, fused dual+average.
Where that shows up in GPU time (share of GPU-busy in the profiled window):
square47 (27.3M nnz)
neos-5114902-kasavu (4.2M)
roi5alpha10n8 (2.4M)
supportcase7 (2.8M)
cuSPARSE SpMV
96.7 %
70.2 %
58.5 %
47.2 %
cuSPARSE row partitioning
1.0 %
5.6 %
12.0 %
15.0 %
elementwise broadcasts
1.3 %
14.1 %
16.1 %
21.0 %
D2D copies (sol_avg_last)
0.4 %
5.5 %
4.4 %
5.6 %
cuSPARSE workspace memset
0.3 %
2.9 %
4.2 %
5.2 %
reductions
0.3 %
1.6 %
4.0 %
5.1 %
And GPU-busy / wall for the whole profiled region drops from 93 % (square47) to 32 % (supportcase7) — the smaller instances are launch-bound, not compute-bound.
3a. cuSPARSE mv! rebuilds everything on every call
cuSPARSE.jl's mv! constructs a fresh CuSparseMatrixDescriptor and two dense-vector descriptors, queries cusparseSpMV_bufferSize, and calls with_workspace — which allocates and frees a CuVector{UInt8}per call (cuMemAllocFromPoolAsync 2.6/iter, cuMemFreeAsync 2.1/iter in the trace). It never calls cusparseSpMV_preprocess, even though mm! already calls cusparseSpMM_preprocess.
Since A and Aᵀ are fixed for the whole solve, CoolPDLP could build the descriptors once, size and keep the workspace once, call cusparseSpMV_preprocess once per matrix, and then issue only cusparseSpMV per iteration. That removes 2.10 partition kernels + 2.11 memsets + ~2 device alloc/free pairs per iteration — worth ~20 % of GPU time on supportcase7/roi5alpha10n8 and ~1 % on square47.
3b. copy!(sol_avg_last, sol_avg) every iteration — confirms #111
Exactly 2.01 device-to-device copies per iteration, 0.4 %–5.6 % of GPU time, for a value read once every 100 iterations. Since update_average! already writes sol_avg in place, the cheapest fix is to write the new average into sol_avg_last and swap the two references (as sol/sol_last already do) — that also turns a copy + in-place broadcast into a single fused broadcast. If §2's caching lands, sol_avg_last may disappear entirely.
update_average! alone is 3 %–29 % of step! (Table 2), rising as instances get smaller.
3c. Fuse the elementwise passes
At_y =mul!(scratch.x, At, y)
@. sol.x =clamp(x - τ * (c - At_y), lv, uv) # pass 1 over n
xdiff =@. scratch.x =2sol.x - x # pass 2 over n — reads what pass 1 just wrote
sol.x and xdiff can be produced by one kernel writing two outputs, and the primal half of colaxpby! folds into the same pass; likewise for the dual side. That takes 5.46 broadcasts/iteration down to 2.
4. Every colnorm!! / colsum!! drains the pipeline — confirms #89
In the non-batched path colnorm!!(::Number, v) = norm(v) and colsum!!(::Number, v) = sum(v) return CPU scalars, so each one is a blocking device→host transfer. The trace shows 0.36 [copy device to pageable memory] events per iteration — matching 7 per kkt_errors! call × 5 calls per 100 iterations, plus the two in primal_weight_update!! at each restart. That is ~35 pipeline drains per check.
Julia's profiler makes this vivid. On square47, of the 830 samples inside the solver loop:
frame
share of solver samples
kkt_errors!
77 %
↳ colnorm!! → cuBLAS.nrm2 → norm
74 %
↳ CUDACore.nonblocking_synchronize
71 %
__futex_abstimed_wait_common (self)
71 %
The CPU is blocked rather than busy, so on a GPU-bound instance the first drain simply absorbs the queued backlog. But the cost is real when the GPU is not saturated: an isolated norm on a device vector measures 26–30 µs against a 7.2 µs kernel-launch floor and ~1.5 µs for the reduction kernel itself. This is part of why the smallest instances sit at 32–38 % GPU-busy.
Contrast with the tiny reference instance I used to validate the harness (neos5, 63×63): there 83 % of solver samples are inside step! issuing kernels, and GPU-busy/wall is 14 %.
Suggested fix (this is #89): keep the reductions in 0-dimensional device arrays, do the absolute!!/relative!! arithmetic on-device, and read back exactly one scalar per check — the termination decision. That collapses 35 syncs per check into 1. Better still, the 7 reductions inside kkt_errors! are over just two vectors (x-shaped and y-shaped) and could be fused into two multi-output reduction kernels.
Related, the host-side chatter is significant on small instances: cuCtxGetCurrent is called 128.7 times per iteration and cuStreamIsCapturing 87.6 times (9.0 % and 6.5 % of host CUDA-API time on supportcase7). That's CUDA.jl/GPUArrays per-operation bookkeeping, not CoolPDLP's code, but it scales with the op count from §3 — halving GPU ops halves this too.
5. GPUSparseMatrixCSR's own SpMV kernel is one-thread-per-row, which is a lottery
The package's spmv_csr! assigns one thread per row. Measured against cuSPARSE on the same preconditioned matrices:
instance
rows
nnz/row
cuSPARSE A·x
GPUSparseMatrixCSRA·x
ratio
neos-3402454-bohle
2,897,380
3.1
130 µs
32 µs
0.24× (4× faster)
neos-5114902-kasavu
961,170
4.4
67 µs
164 µs
2.5×
square47
61,591
444
389 µs
18,412 µs
47×
roi5alpha10n8
4,665
508
24 µs
4,284 µs
182×
supportcase7
6,532
436
25 µs
6,120 µs
249×
The kernel is excellent when rows are short and numerous (a coalesced, perfectly balanced case) and catastrophic when rows are long — one thread serially walking 400+ non-zeros with a strided gather. Since GPUSparseMatrixCSR is the portable path (AMD/Intel/Apple, where there is no cuSPARSE), this is worth fixing: a warp-per-row (CSR-Vector) variant, or a row-block/adaptive partitioning chosen from nnz/row at construction time, would recover most of it. Selecting between scalar and vector kernels on nnz/nrows alone would already cover both ends of this table.
6. Two defaults that quietly cap a 120 s run
max_kkt_passes = 10^5. On square47 this fires at 72 s of a 120 s budget (ITERATION_LIMIT), and the faster instances reach it in single-digit seconds — supportcase7 does 1.29 M iterations in 120 s, i.e. 13× the default cap. Every measurement in this report was taken with max_kkt_passes = 10^9 so that time_limit is the only stopping rule. Consider scaling the default with the time limit, or documenting that the two limits interact. (kkt_passes also doesn't count the 5 evaluations per check — that's kkt_passes undercounts the actual number of KKT evaluations performed #99.)
Preprocessing counts against time_limit.solve sets starting_time = time()beforepreprocess, and the MOI wrapper goes through solve. Ruiz + Chambolle–Pock runs entirely on the CPU and costs 4.3 s–9.5 s here, i.e. 3.6 %–7.9 % of a 120 s budget gone before the first PDHG step. Combined with GPU-friendly preconditioning #54 (GPU-friendly preconditioning) that is worth reclaiming. (initialize measures 12.8–13.3 s, but that is almost entirely first-call Julia compilation — the spectral-norm computation it wraps takes 26–780 ms — so it is a one-off per session, not per solve.)
7. GPU memory is not a constraint, which is an opportunity
nvidia-smi sampled at 250 ms over each full 120 s run:
smallest (roi5alpha10n8)
largest (square47)
peak memory.used
587 MiB
1003 MiB
of 46,068 MiB available
1.3 %
2.2 %
mean GPU utilisation
68 %
94 %
mean power
171 W
255 W (350 W cap)
SM clock
2520 MHz (pinned, no throttling anywhere)
2520 MHz
Roughly 250–300 MiB of that is the CUDA context; A and Aᵀ in CSR/Float32/Int32 account for 2·(8·nnz + 4·(m+1)) ≈ 437 MiB on square47, and the solver state (5 PrimalDualSolutions + scratch ≈ 12 vectors) is only ~7.5 MiB.
So even the largest instance in MIPLIB2017 uses 2 % of an L40S. Every "trade memory for speed" fix in this report — cached cuSPARSE descriptors and workspaces, precomputed constant scales, a second matrix layout for the portable kernel — is essentially free. Power never approaches the cap and clocks never throttle, which also confirms the runs are latency/bandwidth-bound rather than thermally limited.
Suggested order of work
#
change
measured payoff
existing issue
1
Fix spectral_norm (normalise x0, explicit tol/maxiter, convergence check, √(‖A‖₁‖A‖∞) sanity assert) together with an adaptive step size or divergence guard
up to 45×; but see the supportcase7 divergence — do not ship the first half alone
What was measured
Hardware / software. One NVIDIA L40S (sm_89, 44.99 GiB, 142 SMs, 864 GB/s theoretical peak, 350 W cap, SM clock pinned at 2520 MHz throughout), driver 590.48.01 / CUDA 13.1, Julia 1.12.7, CUDA.jl
CUDACore6.3.0, cuSPARSE 12.7.3.Configuration.
PDLP(Float32, Int32, cuSPARSE.CuSparseMatrixCSR; backend = CUDABackend(), time_limit = 120.0), all other parameters at their defaults (ruiz_iter = 10,check_every = 100,termination_reltol = 1e-4,record_error_history = true). One fresh Julia process per instance, exclusive access to the GPU.Instances. The 10 largest by
nnz(A)in the MIPLIB2017 benchmark set, found by reading all 240 instances throughMathOptBenchmarkInstances.jl. (8 of the 240 could not be read at all —read_miplib2017_instancelowercases the name, so mixed-case files likeeilA101-2.mps.gzare not found; all 8 are far too small to belong in the top 10. Worth an issue on that repo.)Three instruments, as requested.
CUDA.@profile(CUPTI): full host-API + device-activity trace over a ~6 s window, withNVTX.@rangemarkers aroundsteps/termination_check!/restart_check!/restart!. Device operations are attributed to a phase through their correlation ID → launching API call → enclosing NVTX range. Note CUPTI inflates absolute host times, so only shares are quoted from it.nvidia-smisampled at 250 ms for the whole 120 s of each clean (unprofiled) run.Plus per-call microbenchmarks with
CUDA.@elapsed(median of 20), which are the numbers quoted in µs.nsys/ncuare not installed on the cluster, so the CUPTI-based integrated profiler is the timeline source.Headline
One number dominates everything else in this report: the step size is wrong, and it is wrong because
spectral_normdoes not return a spectral norm. Kernel-level work is in reasonable shape — cuSPARSE SpMV runs at 63–110 % of the L40S's DRAM bandwidth (above 100 % where the matrix fits in the 48 MB L2) and is 47–97 % of all GPU time — so the throughput wins available from micro-optimisation are worth maybe 10–35 %, whereas the step size is worth up to 45× on the same instance. The rest of the findings are real and worth fixing, but they should be read in that order of magnitude.Also note these are the ten largest instances, i.e. the most favourable case for the current design. The fixed per-iteration overheads below get proportionally worse on the other 230.
1.
spectral_normreturns a provably wrong value, and the step size follows it — refines #95fixed_stepsizesetsη = 0.9 / spectral_norm(A, At)and never changes it again (reset_stepsize!only zeroesη_sum). So this single scalar fixes the step size for the whole solve.spectral_normcallspowm!(KᵀK, x0)with notol/maxiter, andx0is a rawrandn!vector (norm ≈ √n) rather than the normalised guesspowm!documents. IterativeSolvers' defaulttol = eps(T)·size(B,2)^3is astronomically loose inFloat32(≈ 1.0e8 for n = 10⁵), so the iteration stops before the first normalisation ever takes effect.powm!then returnsθ = x₀ᵀKᵀKx₀ = ‖Kx₀‖², andspectral_normhands back√θ = ‖Kx₀‖— a quantity that scales with‖x₀‖ ≈ √n, rather than the Rayleigh quotient‖Kx₀‖/‖x₀‖.That claim is not a guess.
‖A‖₂ ≤ √(‖A‖₁·‖A‖∞)is a rigorous upper bound, and on 3 of the 4 audited instances the returned value exceeds it:spectral_normreturnsηusedηfrom converged ‖A‖square47roi5alpha10n8supportcase7neos-4647030-tutakiAcross all ten instances the ratio (converged ‖A‖) / (returned value) ranges from 0.0027 (
neos-5114902-kasavu, i.e. a 370× overestimate) to 1.53 (neos-3402454-bohle, an underestimate that putsη‖A‖ ≈ 1.38 > 1, violating the PDHG stability condition). The error is unpredictable in both magnitude and sign — see Table 8.Because Ruiz + Chambolle–Pock equilibration leaves ‖A‖₂ ≈ 1, the practical effect is usually
ηabout 25–30× too small.What that costs — A/B at identical settings, 120 s budget
ηdefaultηcorrectedroi5alpha10n8square47neos-4647030-tutakisupportcase7A 45× speedup on
roi5alpha10n8and a 6.7× better residual onsquare47, from one scalar.But the fix is not just "compute ‖A‖ correctly"
supportcase7diverges with the mathematically-correctη = 0.9/‖A‖, while the accidental 30×-too-smallηconverges nicely. In other words the broken estimator is currently acting as an erratic safety factor that happens to paper over the absence of the adaptive step size that PDLP actually specifies. Fixingspectral_normon its own would be a regression on at least one of these ten instances.Suggested fix, in order:
spectral_norm: normalisex0, pass an explicittolandmaxiter, checkpowm!'s convergence flag, guardsqrtagainst a negativeλ, and cross-check against√(‖A‖₁‖A‖∞)(both are cheap SpMVs againstabs.(nzval)and give a free correctness assertion).cuPDLP/cuPDLPxuse), or at minimum a divergence guard that halvesηand restarts when the KKT residual grows by orders of magnitude. Step 1 without step 2 is not safe.invnorm_scalingdo real work inFloat32(0.9 leaves little margin once ‖A‖ itself is a Float32 estimate).2. Five KKT evaluations per check where two would do — extends #100
Per outer check (
check_every = 100),kkt_errors!runs five times:termination_check!sol→stats.errrestart_check!→best_error!!sol→err_currentrestart_check!→best_error!!sol_avg→err_avgrestart_check!→best_error!!sol_last→err_lastrestart_check!→best_error!!sol_avg_last→err_avg_lastVerified on the GPU (
redundancy_check.jl):termination_check!andrestart_check!return bit-identical errors for the same iterate, e.g. onneos-4647030-tutakiboth give(19.947240829467773, 0.0008696960867382586, 53675.42578125).The
err_last/err_avg_lastpair exists only to produceabs_candidate_lastfor theno_local_progress = candidate > candidate_lasttest. Butrestart_stats.abs_candidatefrom the previous call already holds a candidate error — caching it would remove two full evaluations. It does change the semantics from "one iteration ago" to "one check ago", which is what the reference implementations actually compare, and is arguably more correct given the criterion is only evaluated every 100 iterations anyway.Each
kkt_errors!is 2 SpMVs + ~9 elementwise passes + 7 reductions. Removing three of the five is worth, per check:square47square41neos-2075418-temukaneos-4647030-tutakiroi5alpha10n8kkt_errors!(Full table in the appendix; the check machinery as a whole is 8.5 %–19.7 % of wall clock, mean 13.0 %.)
2b.
primal_scaleanddual_scaleare problem constants, recomputed every timeThese depend only on
D1, lc, uc, D2, c— none of which move during a solve. Confirmed bit-identical over 1000 iterations on two instances:They cost 2 of the ~9 elementwise passes and 2 of the 7 reductions (with their 2 blocking syncs) in every one of the 5 calls — 10 wasted reductions, 10 wasted vector passes and 10 wasted syncs per check. Hoist them into
initializeand store them next to the preconditioner.3. 14.8 GPU operations and ~290 CUDA API calls per PDHG iteration
The CUPTI trace gives exactly the same op count per iteration on all ten instances:
cusparse::csrmv_v3_kernelcusparse::csr_partition_kernel[set device memory]gpu_broadcast_kernel_linearclamp×2,2x-xₖ,colaxpby!×2[copy device to device]copy!(sol_avg_last, sol_avg)(#111)nrm2/mapreducecolnorm!!/colsum!![copy device to pageable]For comparison, a fused PDHG step needs ~4: SpMV, fused primal+extrapolation+average, SpMV, fused dual+average.
Where that shows up in GPU time (share of GPU-busy in the profiled window):
square47(27.3M nnz)neos-5114902-kasavu(4.2M)roi5alpha10n8(2.4M)supportcase7(2.8M)sol_avg_last)And GPU-busy / wall for the whole profiled region drops from 93 % (
square47) to 32 % (supportcase7) — the smaller instances are launch-bound, not compute-bound.3a. cuSPARSE
mv!rebuilds everything on every callcuSPARSE.jl'smv!constructs a freshCuSparseMatrixDescriptorand two dense-vector descriptors, queriescusparseSpMV_bufferSize, and callswith_workspace— which allocates and frees aCuVector{UInt8}per call (cuMemAllocFromPoolAsync2.6/iter,cuMemFreeAsync2.1/iter in the trace). It never callscusparseSpMV_preprocess, even thoughmm!already callscusparseSpMM_preprocess.Since
AandAᵀare fixed for the whole solve, CoolPDLP could build the descriptors once, size and keep the workspace once, callcusparseSpMV_preprocessonce per matrix, and then issue onlycusparseSpMVper iteration. That removes 2.10 partition kernels + 2.11 memsets + ~2 device alloc/free pairs per iteration — worth ~20 % of GPU time onsupportcase7/roi5alpha10n8and ~1 % onsquare47.3b.
copy!(sol_avg_last, sol_avg)every iteration — confirms #111Exactly 2.01 device-to-device copies per iteration, 0.4 %–5.6 % of GPU time, for a value read once every 100 iterations. Since
update_average!already writessol_avgin place, the cheapest fix is to write the new average intosol_avg_lastand swap the two references (assol/sol_lastalready do) — that also turns a copy + in-place broadcast into a single fused broadcast. If §2's caching lands,sol_avg_lastmay disappear entirely.update_average!alone is 3 %–29 % ofstep!(Table 2), rising as instances get smaller.3c. Fuse the elementwise passes
sol.xandxdiffcan be produced by one kernel writing two outputs, and the primal half ofcolaxpby!folds into the same pass; likewise for the dual side. That takes 5.46 broadcasts/iteration down to 2.4. Every
colnorm!!/colsum!!drains the pipeline — confirms #89In the non-batched path
colnorm!!(::Number, v) = norm(v)andcolsum!!(::Number, v) = sum(v)return CPU scalars, so each one is a blocking device→host transfer. The trace shows 0.36[copy device to pageable memory]events per iteration — matching 7 perkkt_errors!call × 5 calls per 100 iterations, plus the two inprimal_weight_update!!at each restart. That is ~35 pipeline drains per check.Julia's profiler makes this vivid. On
square47, of the 830 samples inside the solver loop:kkt_errors!colnorm!!→cuBLAS.nrm2→normCUDACore.nonblocking_synchronize__futex_abstimed_wait_common(self)The CPU is blocked rather than busy, so on a GPU-bound instance the first drain simply absorbs the queued backlog. But the cost is real when the GPU is not saturated: an isolated
normon a device vector measures 26–30 µs against a 7.2 µs kernel-launch floor and ~1.5 µs for the reduction kernel itself. This is part of why the smallest instances sit at 32–38 % GPU-busy.Contrast with the tiny reference instance I used to validate the harness (
neos5, 63×63): there 83 % of solver samples are insidestep!issuing kernels, and GPU-busy/wall is 14 %.Suggested fix (this is #89): keep the reductions in 0-dimensional device arrays, do the
absolute!!/relative!!arithmetic on-device, and read back exactly one scalar per check — the termination decision. That collapses 35 syncs per check into 1. Better still, the 7 reductions insidekkt_errors!are over just two vectors (x-shaped andy-shaped) and could be fused into two multi-output reduction kernels.Related, the host-side chatter is significant on small instances:
cuCtxGetCurrentis called 128.7 times per iteration andcuStreamIsCapturing87.6 times (9.0 % and 6.5 % of host CUDA-API time onsupportcase7). That's CUDA.jl/GPUArrays per-operation bookkeeping, not CoolPDLP's code, but it scales with the op count from §3 — halving GPU ops halves this too.5.
GPUSparseMatrixCSR's own SpMV kernel is one-thread-per-row, which is a lotteryThe package's
spmv_csr!assigns one thread per row. Measured against cuSPARSE on the same preconditioned matrices:A·xGPUSparseMatrixCSRA·xneos-3402454-bohleneos-5114902-kasavusquare47roi5alpha10n8supportcase7The kernel is excellent when rows are short and numerous (a coalesced, perfectly balanced case) and catastrophic when rows are long — one thread serially walking 400+ non-zeros with a strided gather. Since
GPUSparseMatrixCSRis the portable path (AMD/Intel/Apple, where there is no cuSPARSE), this is worth fixing: a warp-per-row (CSR-Vector) variant, or a row-block/adaptive partitioning chosen fromnnz/rowat construction time, would recover most of it. Selecting between scalar and vector kernels onnnz/nrowsalone would already cover both ends of this table.6. Two defaults that quietly cap a 120 s run
max_kkt_passes = 10^5. Onsquare47this fires at 72 s of a 120 s budget (ITERATION_LIMIT), and the faster instances reach it in single-digit seconds —supportcase7does 1.29 M iterations in 120 s, i.e. 13× the default cap. Every measurement in this report was taken withmax_kkt_passes = 10^9so thattime_limitis the only stopping rule. Consider scaling the default with the time limit, or documenting that the two limits interact. (kkt_passesalso doesn't count the 5 evaluations per check — that's kkt_passes undercounts the actual number of KKT evaluations performed #99.)time_limit.solvesetsstarting_time = time()beforepreprocess, and the MOI wrapper goes throughsolve. Ruiz + Chambolle–Pock runs entirely on the CPU and costs 4.3 s–9.5 s here, i.e. 3.6 %–7.9 % of a 120 s budget gone before the first PDHG step. Combined with GPU-friendly preconditioning #54 (GPU-friendly preconditioning) that is worth reclaiming. (initializemeasures 12.8–13.3 s, but that is almost entirely first-call Julia compilation — the spectral-norm computation it wraps takes 26–780 ms — so it is a one-off per session, not per solve.)7. GPU memory is not a constraint, which is an opportunity
nvidia-smisampled at 250 ms over each full 120 s run:roi5alpha10n8)square47)memory.usedRoughly 250–300 MiB of that is the CUDA context;
AandAᵀin CSR/Float32/Int32 account for2·(8·nnz + 4·(m+1))≈ 437 MiB onsquare47, and the solver state (5PrimalDualSolutions + scratch ≈ 12 vectors) is only ~7.5 MiB.So even the largest instance in MIPLIB2017 uses 2 % of an L40S. Every "trade memory for speed" fix in this report — cached cuSPARSE descriptors and workspaces, precomputed constant scales, a second matrix layout for the portable kernel — is essentially free. Power never approaches the cap and clocks never throttle, which also confirms the runs are latency/bandwidth-bound rather than thermally limited.
Suggested order of work
spectral_norm(normalisex0, explicittol/maxiter, convergence check,√(‖A‖₁‖A‖∞)sanity assert) together with an adaptive step size or divergence guardsupportcase7divergence — do not ship the first half alonestats.errinrestart_check!; cache the previous candidate error instead of recomputingerr_last/err_avg_lastprimal_scale/dual_scaleintoinitializecusparseSpMV_preprocessonce per matrixcopy!(sol_avg_last, sol_avg); fuse the average update into the PDHG kernelsspmv_csr!forGPUSparseMatrixCSRmax_kkt_passesdefault; move preconditioning to the GPU / start the clock after itReproducing
Appendix A — full measurement tables (all 10 instances)
Table 1 — instances and end-to-end behaviour (120 s budget)
square47square41neos-3402454-bohleneos-2075418-temukaneos-5052403-cygnetsupportcase19neos-5114902-kasavuneos-4647030-tutakisupportcase7roi5alpha10n8Table 2 — where the wall clock goes, per outer check of 100 iterations (microbenchmarks)
square47square41neos-3402454-bohleneos-2075418-temukaneos-5052403-cygnetsupportcase19neos-5114902-kasavuneos-4647030-tutakisupportcase7roi5alpha10n8Table 3 — GPU utilisation during the profiled window
square47square41neos-3402454-bohleneos-2075418-temukaneos-5052403-cygnetsupportcase19neos-5114902-kasavuneos-4647030-tutakisupportcase7roi5alpha10n8Table 4 — SpMV efficiency
square47square41neos-3402454-bohleneos-2075418-temukaneos-5052403-cygnetsupportcase19neos-5114902-kasavuneos-4647030-tutakisupportcase7roi5alpha10n8Table 5 — GPU time by kernel category (share of total GPU-busy time in the profiled window)
square47square41neos-3402454-bohleneos-2075418-temukaneos-5052403-cygnetsupportcase19neos-5114902-kasavuneos-4647030-tutakisupportcase7roi5alpha10n8Table 6 — calls per PDHG iteration, by kernel category
square47square41neos-3402454-bohleneos-2075418-temukaneos-5052403-cygnetsupportcase19neos-5114902-kasavuneos-4647030-tutakisupportcase7roi5alpha10n8Table 7 — NVTX phase breakdown (share of GPU-busy time / of host time)
square47square41neos-3402454-bohleneos-2075418-temukaneos-5052403-cygnetsupportcase19neos-5114902-kasavuneos-4647030-tutakisupportcase7roi5alpha10n8Table 8 — setup cost and spectral-norm estimate
square47square41neos-3402454-bohleneos-2075418-temukaneos-5052403-cygnetsupportcase19neos-5114902-kasavuneos-4647030-tutakisupportcase7roi5alpha10n8Appendix B — kernel and CUDA-API breakdowns
Top device operations (3 representative instances)
square47 (2000 iterations profiled, 1339.9 ms GPU-busy)
neos-5114902-kasavu (2000 iterations profiled, 302.1 ms GPU-busy)
supportcase7 (2000 iterations profiled, 85.4 ms GPU-busy)
Top host-side CUDA API calls (largest and smallest)
square47
cuStreamSynchronizecudaLaunchKernel_v7000cuMemcpyDtoDAsync_v2cuLaunchKernelExcuCtxGetCurrentcudaMemsetAsync_v3020cuStreamIsCapturingcuMemcpyDtoHAsync_v2cuMemAllocFromPoolAsynccuStreamQuerysupportcase7
cuLaunchKernelExcudaLaunchKernel_v7000cuMemcpyDtoDAsync_v2cudaMemsetAsync_v3020cuCtxGetCurrentcuStreamIsCapturingcuMemAllocFromPoolAsynccuMemcpyDtoHAsync_v2cuMemFreeAsynccuOccupancyMaxPotentialBlockSize