cudax sharded: sharded_csr + opt-in cuSPARSE sparse products (spmv/spmm) with measured rebalance - #7
cudax sharded: sharded_csr + opt-in cuSPARSE sparse products (spmv/spmm) with measured rebalance#7caugonnet wants to merge 32 commits into
Conversation
…sed library as the engine The container tier is unconditional: sharded_csr<T> (row-partitioned CSR, one self-contained shard per place_group place: nnz slice + rebased offsets, stored in the shard's place) ships vendor-free in the sharded umbrella, with type-erased lib_state() slots so vendor layers can own per-(shard, op) state without the container ever including their headers. The engine tier is opt-in: <cuda/experimental/sharded_sparse.cuh> #errors without the cuSPARSE headers (the cufile precedent) and is NOT pulled in by sharded.cuh. sharded::spmv / sharded::spmm issue one confined cuSPARSE call per shard on the shard's place stream; handles/descriptors/ workspaces/preprocessed plans are built lazily into the container's lib_state and reused across calls (pointer rebinds only). Row partition => disjoint output row blocks, no combine; outputs compose with the contiguous (VMM-backed) sharded_array backing. Measured rebalance: spmv/spmm_shard_times time each shard solo through the exact call path, and sharded_csr::time_balanced_boundaries turns one measurement into a time-equalizing split (piecewise-rate model) -- one calibration round amortized over repeated calls on a reused operator. CMake: cudax_ENABLE_CUSPARSE (default OFF, the STF mathlibs precedent) gates the cuSPARSE-linked tests and the vendor header's header test; the container test is ungated. Tests: container construction/rebasing/boundaries vs a host CSR reference + the host-side rate-model checks (ungated); FP64 spmv/spmm vs host reference on mixed row-length (incl. empty rows) and skewed matrices, whole-matrix single-call comparison (bitwise for SpMM ALG3; tight-tolerance for SpMV ALG2, whose reduction shape varies with the call's row count), alpha/beta accumulation, plan-reuse determinism, contiguous outputs read through one base pointer, shape refusal; rebalance direction/equalized-prediction/decreasing-measured-skew on a deliberately time-skewed matrix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lifecycle Two workload-shaped additions: - sparse/spmv_spmm.cu: test_value_mutation -- a transform kernel rescales the sharded_csr's values in place between calls (the pattern solvers use for problem scaling). Structure is untouched, so the per-shard plans stay valid with no rebuild: results track the scaled values against the host reference and one whole-matrix call, and an exact x2.0/x0.5 round trip restores byte-identical outputs (no plan or engine state drifts across mutations). - containers/stream_ordered_lifecycle.cu: a full sharded_array cycle (allocate -> write -> copy out -> host-side destroy with work in flight -> reallocate -> write) carried by ONE external stream -- a plain cudaStreamCreate stream outside any place_group pool -- with a single final synchronization. Asserts ordering/correctness only; host-side completion timing of pool operations is implementation- and driver-defined. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Added two workload-shaped tests in 0612022:
Suite state on GB300 (sm_103a, CUDA 13.4, 2 locality domains, |
…s/colinds backing Two integration seams for applications whose matrices already live on the GPU and whose existing kernels rewrite the values in place: - from_device(group, rows, cols, d_offsets, d_colinds, d_values, ...): adopt a device CSR. The offsets make one small host round trip (the split and the per-shard rebasing are host-side either way); colinds/values are sliced device-to-device into the shards' places. The host and device constructors share one builder (the slice copies use cudaMemcpyDefault). - contiguous = true: values/colinds are exact nnz-slices of the parent arrays, so they admit the one-VA-range backing (sharded_array::allocate_contiguous). contiguous_values()/_colinds() hand the whole array to unmodified writers (e.g. an existing problem-scaling kernel) as one normal pointer while each slice keeps per-place physical placement. In-place value mutation never invalidates the per-shard plans, whichever backing is used. Tests: construction equivalence vs the host path, contiguous read-back and exact shard-view offsets (sparse/sharded_csr.cu, ungated); SpMM through a device-adopted contiguous matrix with a whole-array mutation through the base pointer -- scaled-reference + whole-matrix agreement and byte-identical x2/x0.5 round trip (sparse/spmv_spmm.cu, gated). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
ba948bc adds the two integration seams for applications whose matrices already live on the GPU and whose existing kernels rewrite values in place:
Together with the earlier mutation test this closes the loop: an application can own the matrix values as ordinary mutable device data (single whole-array writer, no knowledge of shards) while the container owns placement and plans. Tests cover construction equivalence vs the host path, contiguous read-back + exact shard-view offsets, and SpMM through a device-adopted contiguous matrix with a whole-array ×2/×0.5 mutation round trip restoring byte-identical results. Suite: 32/32 sharded+places ctest green on GB300. |
Hardens first-use behavior of a freshly built plan (handle created inside an exec-place scope): one throwaway product launch and a stream drain at the end of build(), so the first visible result always goes through a fully warmed handle (same idiom as the warm-up run in consumers' SpMM benchmark contexts; beta=0 makes the warm-up write the same values the first run() recomputes). Precautionary: the first-call anomaly that motivated it was ultimately attributed to the consumer's CUDA-graph capture pass (in-solver verify evidence), but deterministic first-launch ordering is worth one extra launch per plan set regardless. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…roup Split library state by natural scope (value-vs-resource-scope; raft::handle_t precedent): place_group gains a type-erased, vendor-free per-place library-state cache (keyed by place index and state type, lazily constructed, destroyed with the group), and the sparse products fetch ONE cusparseHandle_t per place from it instead of creating one handle per (matrix, shard). Descriptors, plans and workspaces stay container-owned (matrix-bound, retire with the storage); the handle's stream is rebound per call since the handle now serves every matrix built over the group. N matrices x P places goes from N x P handles to P, removing the ~ms per-container handle-creation cost and first-call hiccup. Lifetime rule documented: the group must outlive containers built over it (the existing group contract); the cache destroys handles at group teardown through deleters captured at creation, so __places stays vendor-free. Tests: place_group/lib_state.cu exercises the generic cache vendor-free (per-place identity and laziness, type and group isolation, teardown destroys every cached object exactly once, survival across move); sharded/sparse/handle_lifecycle.cu checks the vendor wiring (two matrices on one group share the same per-place handles, two groups do not, container destruction leaves the handle alive and usable, spmv / spmm / shard-times all draw from the same cache). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… user's y/C The plan-build warm-up launch (a22f130) ran with alpha=1, beta=0 into the caller's output vector/matrix, clobbering its contents before run()'s real launch could read them -- wrong result whenever the FIRST call on a plan carries beta != 0 (caught by the existing alpha/beta accumulation arm of sparse/spmv_spmm, relative error ~0.5). The warm-up now writes into a scratch buffer drawn from the shard's place and released immediately after the drain; the user's output is only ever touched by the visible call. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
from_device builds owned storage, per the from_* naming rule: per-shard offsets are rebased into container-owned shards and the colinds/values slices are copied device-to-device into the shards' places. Nothing in the returned matrix aliases the caller's arrays -- they may be freed as soon as the call returns -- and the copy is a snapshot: later writes to the caller's arrays do not propagate to the shards (mutate the shard views or contiguous_values() instead, which never invalidates the per-shard plans). Doxygen on from_device plus one sharded.rst line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts: # cudax/test/sharded/CMakeLists.txt
Same ordering-declaration members as sharded_array, over the matrix's per-shard reference streams (which the three backing arrays share, so ordering the shard streams orders every consumer of the matrix): fork_from records one event on the caller stream and every shard stream waits; join_into records an event per shard stream and the caller stream waits on all. Host returns immediately in both; events come from the same container-owned lazy pool (detail::fork_join_event_pool), capture-safe. Test (sparse/sharded_csr.cu): producer on the caller stream scales values in place -> fork_from -> per-shard sums on the shard streams -> join_into -> caller-stream readback, one host sync total, checked against a host reference. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…csr fork/join path Same change as the sharded_array event pool: cudaStreamGetDevice requires CUDA 12.8+, cuda::stream_ref::device() is version-portable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Conflict resolution: the lib_state cache block keeps its position; the move-ctor comment takes the new exclusive-access wording. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Conflict resolution: place_group.cu is gone (its tests moved into the header's UNITTEST blocks); lib_state.cu stays as this level's test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Conflict resolutions: include union (tuple + typeindex/unordered_map); the lib_state block keeps its position with the new copy/move wording; lib_state.cu uses the ctor vocabulary (exec_place::device(0)) now that the free helpers left the public surface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…join path Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same treatment as the place_group test conversion: the four lib_state tests (identity/laziness, group isolation, teardown-exactly-once, move survival) move into UNITTEST blocks next to the cache they test, run by the already-registered unittested-header target — one test TU + link less. The probe types use C++17 inline statics and exist only in the unittest TU. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lklore, review) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stacked on #6 (base
sharded/sort); review only the last commit.This PR adds the sparse tier of the sharded rung: a row-partitioned CSR container, and cuSPARSE-backed sparse products where a closed vendor library is the engine — the container carries the placement, the library never changes.
The gating split: container unconditional, vendor calls opt-in
sharded_csr<T>(in thesharded.cuhumbrella, vendor-free): one self-contained CSR shard perplace_groupplace — the shard's nnz slice of values/colinds plus its rows+1 offsets rebased to zero, stored in the shard's place. Because each shard is a complete CSR operator over its row range, any library that understands pointers and a stream can consume it with one ordinary per-place call. Split rows are caller-suppliable (nnz-balanced default), and the container owns type-erasedlib_state()slots so vendor layers can attach per-(shard, op) state without this header ever including a vendor header.<cuda/experimental/sharded_sparse.cuh>(opt-in, NOT included by the umbrella):sharded::spmv(group, A, x, y, alpha, beta)andsharded::spmm(group, A, B, C, n_cols, ...)(FP64-first, dtype-trait templated). The header#errors when the cuSPARSE headers are absent — the exact model of the in-tree<cuda/experimental/cufile.cuh>precedent. On the CMake side, a newcudax_ENABLE_CUSPARSEoption (default OFF, following thecudax_ENABLE_CUDASTF_MATHLIBSprecedent) gates the cuSPARSE-linked tests and the vendor header's header-test;CUDA::cusparseis linked for those targets only.The call and plan model
Each product issues one confined cuSPARSE call per shard, on the shard's place stream (
cusparseSetStream), with the shard's exec place active. The row partition makes the output row blocks disjoint, so there is never a combine step. Per-(shard, operation) cuSPARSE state — handle, descriptors, workspace (drawn from the shard's place), preprocessed plan — is created lazily on first call into the container'slib_state()and reused for the matrix's lifetime; later calls only rebind the dense pointers when they change. Plans are built once against the shard's fixed addresses and die with the matrix they describe.Outputs are row-partitioned
sharded_arrays fromA.make_row_partitioned(n_cols[, contiguous]), including the contiguous (VMM-backed) backing: the products write exact row ranges, andcontiguous_data()hands the result to unmodified single-pointer consumers (positively tested).Measured rebalance
An nnz-balanced split is not a TIME-balanced split: with per-place SM confinement a call finishes at max(shard time), so a skewed row-length distribution makes the default split pay the full skew.
spmv_shard_times/spmm_shard_timesmeasure each shard solo through the exact call path the products use, andsharded_csr::time_balanced_boundariesconverts one measurement into a time-equalizing split via a piecewise-rate model. One calibration round is amortized over every subsequent call on the rebuilt matrix — the natural fit for iterative consumers that reuse one operator across many products (repeat the round, keeping the best measured split, when the skew is extreme).What composes later
Dense operands are plain device pointers in this PR (e.g. one whole-device allocation readable from every place). Which per-place copies of a re-read operand should exist — and when a write makes them stale — is a coherence question that belongs to the binding tier: an STF
logical_datacan materialize and cache a per-place instance and hand its pointer to these calls unchanged. The container deliberately does not absorb that role (one home per byte is the container invariant; replicated operands are the runtime's); wiring the STF-replicated operand path is the natural follow-up tier.Tests (all silent)
sparse/sharded_csr.cu(ungated, no cuSPARSE): construction/rebasing/split boundaries vs a host CSR reference on a mixed row-length matrix (empty rows included), default-split nnz balance, explicit/invalid boundaries, row-partitioned outputs (separate + contiguous, exact logical offsets),lib_statecreate-once caching, and deterministic host-side checks of the piecewise-rate model (exact boundary math, direction, monotonicity in the skew).sparse/spmv_spmm.cu(gated): FP64 correctness vs a host reference on mixed (empty rows) and skewed matrices sized to span both locality domains; comparison against ONE whole-matrix single call — bitwise for SpMM (CSR_ALG3); tight-tolerance for SpMV (CSR_ALG2's reduction shape varies with the call's row count, so a row split can differ in the last bits — noted in the test); alpha/beta accumulation; plan-reuse determinism across repeated calls; contiguous outputs read back through the single base pointer; mis-shaped output refusal.sparse/rebalance.cu(gated): on a deliberately time-skewed two-region matrix, asserts the boundary moves in the measured direction (the slower shard shrinks), that the model's predicted times at the new boundaries are equalized, and that the re-measured imbalance decreases — direction/monotonicity, not exact values.Verified on a 2-locality-domain device (sm_103a, CUDA 13.4): 33/33 ctest green across the places+sharded suites including the three new tests; the
cudax_ENABLE_CUSPARSE=OFF(default) variant configures and builds with the sparse targets absent and the ungated container test green; header tests green for both the umbrella (now includingsharded_csr.cuh) and the gated vendor header.Error handling follows the house safe-call's optional-vendor-status precedent, scoped to the vendor header (
cusparse_safe_call), leaving__stfuntouched.🤖 Generated with Claude Code
Update (0612022): two workload-shaped tests added —
test_value_mutation(in-place transform-rescale of the values between calls: plans stay valid with no rebuild, scaled-reference + whole-matrix agreement, exact ×2/×0.5 round trip restores byte-identical outputs) andcontainers/stream_ordered_lifecycle.cu(full allocate/write/destroy-with-work-in-flight/reallocate cycle on one external non-pool stream, single final sync; ordering/correctness asserts only).Update (f8ec211, 67a182b) — handle lifecycle: library state is now split by natural scope.
place_groupgains a type-erased per-place library-state cache (keyed by place index and state type, lazily constructed, torn down with the group;__placesstays vendor-free — objects are destroyed through deleters captured at creation), and the sparse products draw ONEcusparseHandle_tper place from it instead of creating a handle per (matrix, shard) — theraft::handle_tprecedent. Descriptors, plans and workspaces remain container-owned (matrix-bound, retire with the storage); the handle's stream is rebound per call since one handle now serves every matrix built over the group. The group must outlive containers built over it (the existing group contract, now documented on the cache). Tests:places/place_group/lib_state.cucovers the generic cache vendor-free (identity, laziness, type/group isolation, teardown-destroys-exactly-once, move);sharded/sparse/handle_lifecycle.cucovers the vendor wiring (two matrices on one group share per-place handles, two groups do not, container destruction leaves the handle alive and usable). The second commit also fixes the plan-build warm-up launch to write into a scratch buffer instead of the caller's output, whose contents must survive for a first call carryingbeta != 0(caught by the existing alpha/beta accumulation arm).Update:
sharded_csrgains the samefork_from/join_intoordering members assharded_array(the backing arrays share the matrix's per-place streams, so ordering the shard streams orders every consumer), with an ordering test in the ungated container suite.