Repository navigation
perf: regime-C LDLᵀ through blocked pivot steps and GEMMs (#75) - #89
Conversation
| the device, never synchronizes with the host, and is deterministic. | ||
| """ | ||
| function factorize_ldlt!(N::Numeric{T}, S::Symbolic, nzval::AbstractVector; opts::Options = Options()) where {T} | ||
| function factorize_ldlt!(N::Numeric{T}, S::Symbolic, nzval::AbstractVector; impl::Symbol = :auto, |
There was a problem hiding this comment.
nit (non-blocking): the docstring above is now stale. Its signature line still reads factorize_ldlt!(numeric, symbolic, nzval; opts = Options()) -> 0 (no impl, no nb), and the text says every regime-B/C launch group is one front_ldlt_kernel! launch with "no vendor calls, see the T15 report". After this PR the regime-C fronts and the wide tall regime-B bins run panel_ldlt_kernel! blocks plus GEMMs through src/dense/interface.jl (:vendor on CUDA). Suggest: add impl = :auto (the GEMM implementation) and nb = LDLT_C_NB (block size, 1:LDLT_C_NB, tests only) to the signature, and replace the "no vendor calls" sentence with a pointer to ldlt_blocked_path / src/numeric/ldlt_c.jl.
| function factorize!(N::Numeric{T}, S::Symbolic, nzval::AbstractVector; impl::Symbol = :auto, | ||
| opts::Options = Options()) where {T} | ||
| _is_ldlt_structure(S.structure) && return factorize_ldlt!(N, S, nzval; opts) | ||
| _is_ldlt_structure(S.structure) && return factorize_ldlt!(N, S, nzval; impl, opts) |
There was a problem hiding this comment.
nit (non-blocking): impl is now forwarded to factorize_ldlt!, but the docstring of factorize! (line 197) still says "Structures "S"/"H": factorize_ldlt! with the pivoting options opts (impl is not used)". Update it to say impl selects the GEMM implementation of the blocked regime-C LDLᵀ path.
| """ | ||
| Smallest width class and row class of a regime-B bin that the LDLᵀ/LDLᴴ factorization runs on the blocked | ||
| regime-C path ([`ldlt_blocked_path`](@ref)) instead of the fused front kernel. | ||
| """ | ||
| build_layout(sp::SupernodePartition, schedule::Schedule) -> Layout | ||
| const LDLT_BLOCKED_MIN_WCLASS = 32 | ||
| const LDLT_BLOCKED_MIN_FCLASS = 256 |
There was a problem hiding this comment.
nit (non-blocking): a docstring attaches only to the next binding, so this one documents LDLT_BLOCKED_MIN_WCLASS and LDLT_BLOCKED_MIN_FCLASS ends up undocumented. Either give each constant its own one-liner or move the shared text into a comment and keep short docstrings per constant.
| lo, wo, so = _ltc_offsets(f, f - w, nb, cb_ptr[s] > 0) .+ _ltc_slice(nodes, qa, q, front_nrows, front_ncols, | ||
| cb_ptr) | ||
| ctl[_LTC_LO] = (lo + 1) % IT | ||
| ctl[_LTC_WO] = (wo + 1) % IT | ||
| ctl[_LTC_SO] = (so + 1) % IT |
There was a problem hiding this comment.
nit (non-blocking): the workspace offsets lo/wo/so (up to Layout.work_len, 2× the largest slice) are stored in ctl with % IT, i.e. truncated to INT. The symbolic maps get an overflow check in _index_vector, but work_len is not a map, so with INT = Int32 and a workspace above 2³¹ entries this would wrap silently instead of raising. Unreachable at today's sizes (apache2: 28 M entries), so just a suggestion: check L.work_len <= typemax(INT) once in factorize_ldlt! (or at adapt time) with an InvalidValueError, like the maps.
There was a problem hiding this comment.
VERDICT: APPROVE (head 452b273)
Reviewed the step-3 commits (cd3776b..452b273, the four commits stacked on #88/#87, which carry their own reviews) against the T25 owner note for #75, AGENTS.md and PLAN.md §2.4/§3.3.
Checked
- Scope: perf PR under T25/#75 (owner-sanctioned, labelled
performance, milestone M11). PLAN.md untouched; TASKS.md changed by one line under the T25 owner note only; no Manifest.toml; no public name, parameter or phase string changed (factorize_ldlt!gainsimpl/nbkeywords,factorize!forwardsimpl). - Mathematics of
src/numeric/ldlt_c.jl: the lazy panel (_LazyFront: stored value minus the block's pending pivots in pivot order, 2×2 as one term) reproduces the reference's right-looking update exactly up to rounding, Hermitian vs complex-symmetric via_cj/'C'/'T'consistently; the interchange swaps rows ofLb/Wbtogether with the stored panel; the straddling 2×2 pivot (slotsnb,nb+1, column saved before the trailing GEMM and restored by the late-starting next block, slot 1 left zero) is consistent in both GEMMs and the next block's pending set; the fallback scan's per-entry threshold form is equivalent to_lt_threshold_ok(monotoneu·max, NaN rejected) and its duplicate flag writes are benign;_LT_KENDkeeps the fused kernels' behaviour (_lt_reset!sets it after_ST_Win all three kernels). - Layout/chunking:
ldlt_c_work_lenmatches_ltc_offsetsand the device/host slice accumulation (cb_ptr > 0⇔cb_len > 0); the runtime greedy withcap = work_lenyields the same chunks the layout sized (work_len ≤ 2·single), so every slice fits;group_width == 0⇒takes_c_path⇒ldlt_blocked_path, so the layout's and the numeric phase's choice of groups agree; batched members reuse the first slice sequentially (stream-ordered). - Conventions: generic
T/INT, noFloat64/Intin kernels (only pre-existingInt32forpiv/info), 1-D workgroups,@localmemfromVal, no atomics, no host sync in the phase, dense ops only through_gemm_impl!(:vendoron CUDA,:ka/:genericelsewhere,β = 0on the first block is safe in all three),InvalidValueErrorfornb, docstrings on the new public names. - Tests: new testset loops over
BACKENDS×ELTYPES,Random.seed!(666),tol/panel_tol, sharedldlt_setup/kkt_matrix/kkt_interleaved_perm; checkspiv,pivot_kind, D, panels, stats, totals and relres for every dense impl, at a straddling block size and atLDLT_C_NB; range check onnb. The"C only"regime of the existing suites (incl. complex symmetric,'D'/'N', uniform batches intest_ubatch) exercises the new path too. The one adaptation (Float32kkt(300,100,1e-8)gets the refinement step ComplexF32 already had) is documented in place with the measured reference bound;panel_error ≤ panel_tolfor that case is still asserted, so the factor itself is unchanged to rounding. No@test_brokenadded. - Report: matches the diff (files, workspace sizes, unmet criteria stated, #86 cited, deviations listed). Test counts plausible; "CUDA: local RTX 4080" — CI (
test-github-cpuonly,test-gpu cuda) is still pending at review time and is handled by the pipeline. I could not run the CPU suite in this review session (sandbox permission), so test verification rests on CI.
Blocking findings: none.
Non-blocking nits (inline): stale factorize_ldlt! and factorize! docstrings (signature/impl, "no vendor calls"); the shared docstring before LDLT_BLOCKED_MIN_WCLASS/_FCLASS attaches to the first constant only; workspace offsets are truncated to INT without the overflow check the maps get.
|
CI is green and the reviewer approved, but the merge failed (conflict with |
452b273 to
5fbca4d
Compare
|
Draft until #88 merges: its squash would conflict with the step-2 commits still at the bottom of this branch. I will rebase onto main then and mark it ready (tree unchanged). |
| """ | ||
| function ldlt_blocked_path(sc, s::Integer) | ||
| takes_c_path(sc, s) && return true | ||
| sc.regime[s] == REGIME_B || return false |
There was a problem hiding this comment.
nit (non-blocking): the regime-B branch of ldlt_blocked_path (width class ≥ 32, row class ≥ 256, so bins (32..64) × (256..512) under the default regime_c_width = 64 / regime_c_rows = 512) is the one place where the blocked path is reached with the group's assembly done by zero_fronts!/scatter_A!/extend_add! instead of the fused kernel. The new testset forces "C only", so it does not cover this branch; whether any default-analysis LDLᵀ test matrix (all have n ≤ 400) produces such a bin is not asserted anywhere. Suggest either an @test in the new testset that at least one front of some default-analysis case satisfies ldlt_blocked_path(sc, s) && !takes_c_path(sc, s) (and then compare piv/pivot_kind/D/panels with the reference as the "C only" case does), or a case with regime_c_width/regime_c_rows raised so that a 32-wide, 256-row front stays in regime B. The owner's bcsstk38/lap2d runs exercise it on CUDA, but the suite should too.
There was a problem hiding this comment.
VERDICT: APPROVE (head 5fbca4d)
Re-review after the owner's rebase onto the rebased #88. The tree of this head is bitwise identical to the previously approved head 452b273 (git rev-parse <sha>^{tree} = 7c542d7b… for both), so the earlier findings stand; this pass re-read the step-3 commits (diff ef5c6f5…5fbca4d, i.e. on top of #88's head) independently rather than relying on the earlier verdict.
Checked
- Scope: owner-sanctioned perf PR under T25/#75 (label
performance, milestone M11,Refs #75, notCloses, with the unmet criteria stated). PLAN.md untouched; TASKS.md changed by one line under the T25 owner note; no Manifest.toml; no public name, parameter or phase string changed (factorize_ldlt!gainsimpl/nbkeywords,factorize!forwardsimpl,build_layoutgainsldlt). - Mathematics of
src/numeric/ldlt_c.jl, re-derived: the lazy readF[i,j] − Σₜ Lb[i,t]·cj(Wb[j,t])(2×2 as one term, pivot order) is exactly the fused kernel's_lt_update!arithmetic (l = x/d,l1 = x1 e11 + x2 e21,l2 = x1 e12 + x2 e22); the commit materializes withcur = false(pending set = pivots before the current one), pass 1 keeps the column only when no interchange happened (C == k, 1×1); the interchange moves the stored panel (_lt_raw_front) and the rows ofLb/Wbtogether, which keeps the lazy value permutation-consistent (and Hermitian-consistent, sinceEis Hermitian anddreal); the straddling 2×2 at the block's last column occupies slotsnb,nb+1, is saved (rowskend+1:f) before the trailing GEMM corrupts columnk1, restored by the next block (late⇔ the save condition,t0 = 2, slot 1 zero), and the next block'sK/KENDand slot numbering agree;g0 + tindexespivot_kindof columnk0 + t − 1. The fallback scan's per-entry form equals_lt_threshold_ok(u·max = max(u·|x|)exactly foru ≥ 0, NaN rejected),_ltc_pick!reproduces_lt_pass3!'s best/tie/'D'rules; duplicate flag writes are benign.finish_ldlt_kernel!:_lt_finalize!touches strictly-lower entries only, the clear loop the diagonal and upper triangle (the GEMMs write it), so no barrier is needed._LT_KENDis set in_lt_reset!after_ST_Win all three setup paths. - Layout/launch consistency:
_ltc_offsetsmatchesldlt_c_work_len;cb_ptr > 0 ⇔ cb_len > 0(build_layout), so the device, host and layout agree on each slice; the runtime greedy withcap = work_lenyields exactly the chunks the layout sized with2·single(every chunk sum ≤work_len, and the layout's stop condition implies the runtime's);W == 0⇒ regime C ⇒ldlt_blocked_path; bothbuild_layoutcallers (solver.jl,maps.jl) passldlt, the only plain call is an SPD test.pack_add!adds the lower triangle exactly as the Cholesky regime-C path. - Conventions: generic
T/INT, noFloat64/Intin kernels, 1-D workgroups,@localmemfromVal, no atomics, no host sync in the phase, dense ops through_gemm_impl!only,InvalidValueErrorfornb, docstrings on new public names. - Tests: new testset over
BACKENDS×ELTYPES,Random.seed!(666),tol/panel_tol, shared helpers;piv,pivot_kind, D, panels, stats, totals, relres for every dense impl at a straddlingnband atLDLT_C_NB; range check onnb. The Float32kkt(300,100,1e-8)adaptation is documented with the measured reference bound and keeps thepanel_errorassertion. No@test_brokenadded. - Report (PR body): matches the diff; #86 exists (
found-by-agent); numbers are from the owner's machine. CI:buildgreen,test-github-cpuonlyandtest-gpu (cuda)still pending and handled by the pipeline. I could not run Julia in this review sandbox (commands denied), so test verification rests on CI.
Blocking findings: none.
Non-blocking: the four nits from the previous round (stale factorize_ldlt!/factorize! docstrings, shared docstring on LDLT_BLOCKED_MIN_WCLASS, work_len offsets truncated to INT without a check) still apply to this identical tree; one new inline nit on test coverage of the blocked regime-B branch of ldlt_blocked_path.
Every regime-C front runs its pivot steps in static blocks of nb = 32 columns: panel_ldlt_kernel! (one workgroup) applies the reference's pivot search to a lazily updated panel (stored values minus the block's pending pivots, kept as Lb/Wb in the workspace), then one GEMM updates the trailing fully-summed columns and one accumulates the contribution block, through src/dense/interface.jl (:vendor on CUDA, :generic/:ka elsewhere). A 2×2 pivot crossing a block boundary takes the next block's first column, which is saved across the GEMM. finish_ldlt_kernel! scales the pivot columns into L; pack_add! adds the block to the update stack. Launch shapes depend on the analysis only. The LDLᵀ workspace holds Lb, Wb and a saved column (ldlt_c_work_len). factorize! passes impl on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cqp7TUSyCEdjPtLDEftJf5
…ake it (#75) The fronts of a blocked launch group are factored together, one workgroup each, in chunks whose workspace slices (ldlt_c_chunk_end, at most twice the largest front's) are laid out one after another; the slice offsets are recomputed in the kernels. Pass 1 keeps the materialized next column for the commit when the pivot needs no interchange. Regime-B bins of width class ≥ 32 and row class ≥ 256 take the blocked path (ldlt_blocked_path). bench/profile_phases.jl profiles lap3d_40 LDLᵀ again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cqp7TUSyCEdjPtLDEftJf5
…up (#75) Pass 3 in panel_ldlt_kernel! splits the candidate columns into tasks of 64 rows over all work items, flags a column on the first violation of the per-entry threshold test (exact, as _lt_threshold_ok), then reduces the unflagged columns. One column per work item read every entry with the block's pending pivots serially: kkt(3000,1000,1e-8) 5.0 → 2.5 s. The workspace slice holds the flags (3f entries after Lb/Wb). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cqp7TUSyCEdjPtLDEftJf5
PERFORMANCE.md: step-3 results and the criteria of experiments 1-2; tracked issues (#75, #86). bench/comparison regenerated with the step-3 LDLᵀ rows. TASKS.md: one line under the T25 owner note on what #75's PRs delivered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cqp7TUSyCEdjPtLDEftJf5
5fbca4d to
72f5f79
Compare
#90) * CI: parallel test runner, CPU job on kkt, LDLᵀ compiled only when used The CI jobs went from ~21 to ~52 min with the regime-B/C LDLᵀ kernels (#87-#89). Almost all of it is compilation: on the CPU backend the KA kernels compile with their caller, and the solver's numeric phase called factorize!, which branches on the structure at run time and so compiled the LDLᵀ path (all its kernels) for every Cholesky solver as well. test_api (SPD/HPD only, 4 T x 2 INT) went from 658 s to 2416 s. * src/solver.jl: the numeric phase dispatches on the structure behind an inference barrier that takes the mutable solver handle only (no boxing, so the numeric-phase allocation budgets hold); factorize_cholesky! is the Cholesky path of factorize!, which keeps its static branch. First solver factorization per (T, INT) on the CPU backend: 42-84 s -> 20-36 s. * test/runtests.jl: ParallelTestRunner (as CUDA.jl and oneAPI.jl); every test_*.jl runs in its own module on a worker pool with the shared helpers as init_code. The seed 666 goes with each test file since the runner reseeds with 1 after init_code. SDS_TEST_ONLY/SDS_TEST_SKIP still work. * ci.yml: the CPU-only job runs on the self-hosted kkt machine (same check name, it is required by the ruleset); PTR_NUM_JOBS caps the workers (8 CPU, 4 GPU) since the four kkt runners share the machine. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QSF5MG6xxpF2D1TCFkb3UZ * Tests: run the long test files once per element type A worker's cold compilation sets the wall time, and it grows with the element types a test file covers. The long files (SPLIT_FILES in test/runtests.jl) now run as four parts, test_api[Float32] etc., each in a worker of its own: ELTYPES/REAL_ELTYPES/COMPLEX_ELTYPES are the part's subset (test/utils.jl, set through Main.SDS_TEST_PART before the helpers are included), and the testsets that do not loop over the element types run in the Float64 part only (RUN_SHARED). Test counts are unchanged (CPU 62938, CUDA 69499, 1 broken). Owner's machine: CPU suite 13m52s -> 9m30s (16 workers), CUDA 16m23s -> 10m31s (8 workers); test_numeric_ldlt's slowest part 226 s against 814 s unsplit. ci.yml: 32 CPU workers and 12 CUDA workers on kkt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QSF5MG6xxpF2D1TCFkb3UZ --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Refs #75
Stacked on #88 (and #87); merge those first. The step-3 commits are the top four of this branch. Not "Closes #75": two of the criteria are not met (below), #75 stays open for them.
Task: #75, experiment 2 of PERFORMANCE.md, step 3 of 3 — regime-C LDLᵀ through vendor BLAS with the reference pivot sequence (T25 owner note, item 2).
What was built:
src/numeric/ldlt_c.jl(new): every front on the regime-C path, and every regime-B bin of width class ≥ 32 and row class ≥ 256 (ldlt_blocked_path, measured), runs its pivot steps in static blocks ofLDLT_C_NB = 32columns.panel_ldlt_kernel!(one workgroup per front; the fronts of a launch group concurrently, in chunks whose workspace fits twice the largest front's,ldlt_c_chunk_end) runs the pivot search, interchanges and D offront_ldlt_kernel!on a lazily updated panel: the block's pivots are kept asLb(multipliers) andWb(unscaled pivot columns) in the workspace, and an entry of a non-pivot column is read as its stored value minus the pending pivots in pivot order (2×2 pivots as one term: the reference's right-looking arithmetic). After each block one GEMM updates the trailing fully-summed columns and one accumulates the contribution block, throughsrc/dense/interface.jlonly (_gemm_impl!::vendoron CUDA,:generic/:kaelsewhere;implis passed fromfactorize!);finish_ldlt_kernel!scales into L and clears F₁₁'s upper triangle;pack_add!puts the block on the update stack (packed lower triangle, as the Cholesky regime-C path). A 2×2 pivot at a block's last column takes the next block's first column, saved across the GEMM and restored by the next block. Pass 1 keeps the materialized column for the commit; the fallback scan runs as (candidate column, 64 rows) tasks over the workgroup. Static launch shapes, no host synchronization, no atomics (one benign duplicate write of the same flag value).src/symbolic/layout.jl:ldlt_c_work_len,ldlt_c_chunk_end,ldlt_blocked_path,build_layout(…; ldlt)sizes the LDLᵀ workspace (lap3d_40 101 MB vs 62 MB Cholesky, apache2 223 vs 109 MB).factorize_ldlt!(…; impl, nb)(nb ≤ LDLT_C_NBfor the tests,InvalidValueErrorotherwise).test/test_numeric_ldlt.jl): new testset "regime C: blocked pivot steps and GEMMs" for every backend andT ∈ ELTYPES: "C only" analysis, a root front wider than the block, a block size chosen from the reference's pivots so that 2×2 pivots straddle blocks, andLDLT_C_NB, every dense implementation of the GEMMs:piv,pivot_kind, stats, totals equal toref_ldlt!, D and panels withinpanel_tol(T), relres ≤tol(T);nbout of range raises.PERFORMANCE.md(step-3 results, criteria, tracked issues),bench/comparison/comparison.{md,png}andsds.csvregenerated,TASKS.mdone line under the T25 owner note,bench/profile_phases.jlprofiles lap3d_40 LDLᵀ again.Tests:
julia --project=. -e 'using Pkg; Pkg.test()'with CUDA in the local test environment (RTX 4080): 105738 pass / 0 fail / 2 broken (the T16@test_broken, once per backend); after the rebase on T18,test_numeric_ldlt,test_symbolic_schedule,test_refinementon CPU and CUDA: 16871 pass, 0 fail, 2 broken.factorize!LDLᵀ allocates 64 B on the CPU backend (unchanged).Test adaptation, please review: in "device factor equals the reference", the existing T14/#66 exception for
kkt(300,100,1e-8)(one refinement step before the relres check, ComplexF32 only) now covers Float32 too: the GEMM rounding moved one right-hand side to relres 4.04e-4 > tol 3.45e-4 in "C only". Measured: on 20 draws the reference factor itself reaches 5.3e-4 (median 1.3e-4) andmain's device factor 7.6e-4, so the bound depends on the draw for this matrix; the comment in the test says so. No other assertion changed.Measurements (RTX 4080; refactorization best of 5 warm runs,
bench/profile_phases.jl;*:bench/compare.jlmedian):f > 512(lap3d_40 and apache2 calls replayed through_gemm_impl!(:vendor, …)): 0.41 TFLOP/s = 56–57% of cuBLAS DGEMM (0.73 TFLOP/s on 4096²).nperturbedidentical tomainon all nine.kkt_matrix(Float64, 3000, 1000, 1e-8), default and "C only": CUDA 33.9 s (main) → 2.5 s; KA CPU backend 7.0 → 3.3–3.4 s;ref_ldlt!2.5–2.7 s.comparison.mdoverview, LDLᵀ static pivoting vs cuDSS: factorization 6.37× → 4.41×, refactorization 9.90× → 6.27×. The K2 + refinement row (5.45× → 7.19×) is dominated by the case1354 K2 dumps, whose harness medians vary 2–3× between reruns of the same code (8.8–27.6 ms formain; profile 7.2–9.1 → 6.3–7.0 ms).Criteria: experiment 1 (LDLᵀ within 1.5× of SDS Cholesky): met on lap2d_300 (1.31×) and case14 condensed_1, not on bcsstk17/38 (1.73×), lap3d_40 (1.89×), apache2 (2.06×), other KKT dumps (1.5–2.6×); inertia unchanged on all K2 dumps: met. Experiment 2: ≥ 50% of DGEMM peak on fronts > 512: met (56%); "C only" faster than the reference on the CPU backend (0.8×) and ≥ 5× on the GPU (1.1×): not met on the T15 matrix, whose pivot steps end in a fallback scan half the time with no acceptable column (every candidate must be rejected exactly; each lazily read entry subtracts up to 32 pending pivots;
nb = 8gives 0.86 s; a local-memory tiled scan was slower, 4.6 s). Details and what remains in PERFORMANCE.md.Deviations from PLAN.md / TASKS.md: regime C does not use
sytrf(owner decision on #75); regime-B bins of width class ≥ 32 and row class ≥ 256 also take the blocked path (measured: bcsstk38 47 → 39 ms, lap2d 53 → 41 ms); the LDLᵀ workspace is larger (above). Suggested plan note (PLAN §2.4, not edited): regime C of"S"/"H"= KA pivot blocks + GEMMs for the trailing columns and the contribution block.Follow-up issues opened: #86 (with step 1). The unmet criteria stay with #75.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Cqp7TUSyCEdjPtLDEftJf5