Repository navigation
ordering: GPU-hybrid METIS NodeND bench with packed-sort contraction and task-parallel recursion - #130
Conversation
…and task-parallel recursion Adds bench/ordering (the exact METIS 5.1.0 NodeND port, the device matching and contraction kernels, and the hybrid ordering developed alongside #126) with two performance changes: the contraction groups edges with one radix sort! of (crow<<44 | ccol<<24 | edge index) instead of sortperm + gathers (element-identical output, 1.8x on the GV100 coarsening chain, guarded with the sortperm path as fallback for larger graphs), and the sub-threshold recursion spawns tasks above SPAWN_MIN instead of running one serial walk per top subtree. Opt-in PARALLEL_TOP overlaps the top sibling bisections' host work; on multi-GPU hosts TASK_SETUP must pin each task's device (documented). Case78484 (n = 674,562, GV100, 16 Julia threads): MadNLP init_time 8.62s -> 7.42s (packed contraction) -> 5.64s (+ parallel top), identical convergence (101 iterations, same objective) and fill (1.48e7 nnz_L vs METIS 1.476e7). Gates: lap2d fill 1.017x vs the exact port; 78k fill 1.003x/1.012x, valid permutations. The recursion gains require a multi-threaded Julia session; single-threaded sessions should keep algo4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| 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 |
There was a problem hiding this comment.
nit (non-blocking): this flips the module-global MetisExact.RNG_TASK[] and never restores it, and MetisExact.NodeND does not force it back to false. In a session with JULIA_NUM_THREADS > 1, any nodend_exact call made after a nodend_hybrid call silently runs on the Julia task RNG, so the "bitwise-identical to libmetis" oracle is no longer exact (the smoke script only avoids this because it calls the exact port first). Fix: either save/restore the previous value in a try … finally here, or have NodeND set RNG_TASK[] = false for its own run (it has no spawned tasks).
| 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) |
There was a problem hiding this comment.
nit (non-blocking): Main.GpuRefine (also lines 125 and 127) ties this module to being included at top level of Main; the sibling modules are reached with using ..MetisExact / using ..GpuND, and gpu_nodend.jl already includes GpuRefine.jl before Hybrid.jl, so using ..GpuRefine at the top of the module would work the same way and keep the loader order the only requirement.
| Int(SDS.evaluate_ordering(P, perm).nnz_L) | ||
| end | ||
|
|
||
| g = isempty(ARGS) ? 60 : parse(Int, ARGS[1]) |
There was a problem hiding this comment.
nit (non-blocking): with the default grid = 60 the graph has n = 3600 < Hybrid.GPU_MIN_NVTXS[] = 50_000, so node_bisection! takes the MlevelNodeBisectionL2! host branch everywhere and the default smoke run never executes GpuND.gpu_match / gpu_contract (nor the packed-sort path this PR adds); it only gates the task-parallel host recursion. Line 49 already prints "below threshold" for the refine path, but the coarsening path is silently skipped too. Suggest a default grid ≥ 224 (n ≥ 50_176), or temporarily lowering Hybrid.GPU_MIN_NVTXS[] (and REFINE_HOST_BELOW[]) inside the smoke so the device kernels are exercised on KA.CPU() at any grid size.
| boundary_packed_kernel!(backend)(isnew, packed; ndrange = m) | ||
| KA.synchronize(backend) | ||
| segid = cumsum(isnew) # segment index per element (1-based) | ||
| nseg = Int(Array(view(segid, m:m))[1]) |
There was a problem hiding this comment.
nit (non-blocking): view(segid, m:m) (and line 321 in the fallback) is a BoundsError when m == 0. The coarsening loop in Hybrid.coarsen_gpu! cannot reach this on later levels (the nedges > nvtxs ÷ 2 test breaks first), but the root call can: a graph with ≥ GPU_MIN_NVTXS vertices and no off-diagonal entries (nedges == 0) goes straight to gpu_contract, where METIS would take the Match_RM/RandomBisection route. A m == 0 && return (cn, …, empty arrays, cmap) guard (or routing nedges == 0 to the host port in node_bisection!) closes it.
There was a problem hiding this comment.
VERDICT: APPROVE (head cb21b8d)
Scope. This is not a TNN task PR: it adds the GPU-hybrid NodeND prototype under bench/ordering/ only (7 new files, 3675 lines). src/, ext/, test/, PLAN.md, TASKS.md, Project.toml and Manifest.toml are untouched, so the task-section/Report checks do not apply and no public name, parameter string or phase string is affected. bench/Project.toml already carries Metis; the new code reaches KernelAbstractions and Atomix through SparseDirectSolver.*, and Printf/Libdl/Random are stdlibs, so no new dependency is introduced.
What I checked.
GpuND.gpu_contractpacked path: the(crow << 44) | (ccol << 24) | edgelayout fits under thecn < 2^20,m < 2^24guard; thetypemax(UInt64)self-edge sentinel cannot collide with a real key (a non-self edge never has both ids equal to2^20-1); segment boundaries compare>> 24, the weight is recovered through the embedded edge index, and therowcount[crow + 2]/cumsumoffsetting yields acn+1row pointer withcxadj[1] = 0. Representative scan,cmap,cvwgtare consistent with the hostCreateCoarseGraphcontract; the only atomics are integer adds (deterministic) in a bench prototype, not in library assembly/extend-add.gpu_match: mutual-proposal resolve is symmetric and atomic-free; themaxvwgtcap matches METIS SHEM semantics (heavy vertices never propose and end as singletons); the 2-hop stage mirrorsMatch_2HopAnygating (>10% unmatched, degree classes 2/3/any) and, like METIS, does not apply the weight cap.Hybrid.coarsen_gpu!/node_bisection!followCoarsenGraph/MlevelNodeBisectionL1control flow (maxvwgt = 1.5·tvwgt/CoarsenTo, theCOARSEN_FRACTION/nedges > nvtxs ÷ 2break,niparts ÷ 2on early stop);finer/coarser/cmaplinks are set so the hostRefine2WayNode!walks the device-built chain. Replacing L2 by an L1-shaped bisection above the threshold is a documented quality trade (1.003–1.015 fill).- Task-parallel recursion (
mlnd!,dissect!): spawned tasks get their ownCtrlTcopy (all 15 fields,pijbmcopied), a reseeded task-local RNG keyed on(nvtxs, lastvtx), and disjointorderranges;MetisExactmodule state touched from tasks is read-only (RNG_TASK,DBG,GENMMD_PTR) or locked (prof!), andgenmmdis reentrant. The spawn sequence is a deterministic function of the graph, so the ordering is reproducible for a fixed thread count. The multi-GPU pinning hazard ofPARALLEL_TOPis documented at the knob. nodend_hybridmirrorsMETIS_NodeND(compression,nsepsbump, uncompress, 0→1-based return);nodend_smoke.jluses the existingSymmetricPattern(rowptr, colval, n, structure; view, index)andevaluate_orderingAPI correctly.- Int32 device casts go through
Int32.(…), which throwsInexactErroron overflow rather than wrapping.
Not verified here. The runner lets me neither execute Julia nor read the workflow logs, so I could not run nodend_smoke.jl or confirm the perm-identity and timing claims of the description; CI is pending on this head (fix shows cancelled, which is the pipeline job, not a test). The claims are consistent with the code.
Non-blocking findings (inline).
Hybrid.jl:298leavesMetisExact.RNG_TASK[]set, sonodend_exactafter a hybrid call in a threaded session is no longer bitwise libmetis.Hybrid.jl:123usesMain.GpuRefineinstead ofusing ..GpuRefine.nodend_smoke.jldefault grid (n = 3600) is belowGPU_MIN_NVTXS, so the default run never executes the GpuND kernels.GpuND.gpu_contractview(segid, m:m)errors form == 0(a ≥ 50k-vertex graph without off-diagonal entries).bench/README.mdfile table does not listbench/ordering/.
No blocking findings.
|
CI-FIX round 1 of 3: https://github.com/exanauts/SparseDirectSolver.jl/actions/runs/38343843966 failed on cb21b8d; Claude is diagnosing it. |
|
CI-FIX round 1 — no code change. Root cause: the self-hosted CUDA runner, not this PR. In run 38343843966, only This PR (cb21b8d) adds only files under Fix: none in the code. Please free the GPU on the 🤖 Generated with Claude Code |
Adds
bench/ordering/— the GPU-hybrid nested-dissection ordering prototype developed alongside #126 (not part of the #126 squash) — with two performance changes on top of the version used in that work, and the end-to-end MadNLP measurements that motivated them.Contents
MetisExact.jl— bitwise-faithful Julia translation of METIS 5.1.0'sMETIS_NodeNDpath (all-default options); 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 coarsening (hashed-handshake HEM matching incl. 2-hop, device-resident contraction), stage-validated against the exact port.Hybrid.jl—nodend_hybrid(backend, A): device coarsening for blocks ≥GPU_MIN_NVTXS, exact host initial separator / FM refinement / MMD below, task-parallel recursion.GpuRefine.jl,GpuBatch.jl— device refinement rounds (off by default: fill cost) and level-batched coarsening, kept as measured evidence: batched device coarsening of the sub-50k blocks is 4.2x slower than 16 host threads on the case78484 population, which is why those blocks deliberately stay on the CPU.gpu_nodend.jlloader and thenodend_smoke.jlgate.Performance changes in this PR
GpuND.gpu_contract): thesortpermover(crow<<32 | ccol)keys was ~90% of the device coarsening cost (72–77 ms of 85 ms at the 7.3M-edge top level). When the coarse ids fit 20 bits and the edge count 24 bits (guarded; the originalsortpermpath remains as fallback for larger graphs), one radixsort!of(crow<<44 | ccol<<24 | edge index)replacessortperm+ two gathers, recovering the weight through the embedded edge index. Output is element-identical; the coarsening chain over the 674k graph is 1.8x faster on a GV100.Hybrid.mlnd!): previously each top subtree ran one serialMlevelNestedDissection!task, so the largest subtree set the wall. The recursion now spawns aboveSPAWN_MIN(ownCtrlTcopy, task-local RNG, disjoint order ranges — the same invariants the existing top-level spawns rely on). Ordering 6.3 s → 4.3 s at fill 1.002x of exact METIS.Hybrid.PARALLEL_TOP+TASK_SETUP): overlaps the host work (initial separator, FM, split, downloads) of the ≥ threshold sibling bisections while the device coarsening serializes per device. Off by default. On a multi-GPU host theTASK_SETUPhook must pin every task to one device — unpinned tasks default to device 0 while the caller may be on another device, and that cross-device mix was observed to corrupt the ordering (caught by the Int32 overflow guard in the symbolic maps). The module header documents the recipe.Measurements (78k ACOPF condensed KKT, n = 674,562, Quadro GV100, 16 Julia threads)
init_timealgo4(host METIS, wrapper default)All three runs: 101 IPM iterations, identical objective,
linsolveflat (fill identical: 1.48e7 nnz_L vs METIS 1.476e7). Interleaved same-load comparison: the hybrid ordering runs at 0.63x of host METIS per repetition. No runtime data is cached; a fresh analysis runs every time.Requirement: the recursion gains need a multi-threaded Julia session (the measurements used
JULIA_NUM_THREADS=16). Single-threaded, only the contraction gain remains and the Julia host recursion is slower than libmetis C — single-threaded sessions should keepalgo4.🤖 Generated with Claude Code