Skip to content

cudax: __sharded — placed containers and algorithms for the in-process rung - #4

Open
caugonnet wants to merge 27 commits into
sharded/place-groupfrom
sharded/dev
Open

cudax: __sharded — placed containers and algorithms for the in-process rung#4
caugonnet wants to merge 27 commits into
sharded/place-groupfrom
sharded/dev

Conversation

@caugonnet

@caugonnet caugonnet commented Aug 20, 2026

Copy link
Copy Markdown
Owner

The framing: CUB's cooperation-scope ladder, extended

CUB's structural invariant: the primitive at scope N = scope N−1's primitive run locally + a combine using what scope N shares.

scope what is shared combine mechanism primitives
warp registers shuffles WarpReduce, WarpScan, ...
block shared memory smem staging BlockReduce, BlockScan, ...
device global memory global staging DeviceReduce, DeviceScan, ...
places (one process) one VA, placed pages in-place fold over the shared address space sharded algorithms — this PR (__sharded)
ranks (multi-process / multi-node) nothing message passing communicator algorithms (__multi_gpu MGMN)

Each rung owns its combine. Sharded containers exist exactly at the highest rung that still shares an address space; where nothing is shared, the MGMN communicator algorithms take over. The two surfaces are scoped by rung and compose: hierarchical multi-process + placed-per-process is ordinary rung nesting, the same way DeviceReduce runs BlockReduce runs WarpReduce.

What this PR adds

A new sibling capability cudax/include/cuda/experimental/__sharded/ (public umbrella cuda/experimental/sharded.cuh, namespace cuda::experimental::sharded), peer to __multi_gpu, matching the ladder 1:1. It builds only on the places layer (and place_group from the previous PR in this stack) — no other dependencies.

Container tier

  • shard<T>: one placed piece — data/size/capacity/global_offset + data_place/exec_place/reference stream + global↔local index math
  • sharded_array<T>: allocation from explicit (size, data_place, exec_place, stream) specs or from a place_group (even split or explicit sizes; reference streams from the group's pools); adoption of existing shards (non-owning views); allocate_like; host transfer (from_host, copy_from_host, copy_to_host); slice with place correspondence preserved (empty shards keep their position); each_shard visitation with exec-place activation; sync; validation; copy_between for arbitrary re-sharding
  • Contiguous backing contract (allocate_contiguous): shards become views into ONE contiguous VA range whose physical pages are owned per place (VMM via places::localized_array). Logical shard boundaries are exact (shard(i).data == contiguous_data() + global_offset); physical ownership snaps to the allocation granularity (typically 2 MiB). contiguous_data() hands the whole array to unmodified single-pointer consumers. The range is mapped once, so shard sizes are fixed: size-mutating operations must refuse such arrays (the contract is documented on the method and enforced with a throw by the size-mutating algorithms copy_if/filter/remove_if and unique). One VA also means one physical home per byte — the container holds partitioned data only; replicated operands belong to the binding tier (STF logical_data).

Algorithm tier (native, per-place CUB + combine)

  • Elementwise (no cross-place stage): fill, sequence, iota, tabulate, generate, for_each, transform (in-place / unary / binary, with cross-array stream ordering and layout checks)
  • reduce / sum / min / max: per-place cub::DeviceReduce through the group's stream + memory-resource environments, then a combine of the P partials
  • inclusive_scan / exclusive_scan (+ sum aliases): per-place cub::DeviceScan, host prefix of the P shard totals, in-place prefix fold into each shard over the shared address space
  • adjacent_difference: local differences + exactly one boundary element per shard
  • count / count_if: per-place cub::DeviceReduce::TransformReduce (predicate mapped to 0/1), per-place counts summed — read-only, so available on every array including contiguous ones
  • histogram_even: per-place cub::DeviceHistogram::HistogramEven, per-bin sum of the per-place histograms — read-only; invalid bins/levels refused with std::invalid_argument
  • copy_if / filter / remove_if: per-place in-place cub::DeviceSelect::If (stable compaction to the front of each shard), then shard sizes and global offsets are updated; capacities are unchanged, so reset_sizes_to_capacity() reuses the buffers
  • unique (std::unique semantics across shard boundaries): per-place in-place cub::DeviceSelect::Unique, then duplicates straddling shard boundaries are trimmed with an O(1) size decrement per boundary (compared against the previous non-empty shard, so runs spanning empty shards are handled)

The size-mutating algorithms (copy_if/filter/remove_if, unique) enforce the contiguous-backing contract: they throw std::invalid_argument on allocate_contiguous arrays, leaving them untouched — shrinking shard sizes would leave gaps between shards' valid elements, falsifying the read-as-one-array contract of contiguous_data(), and compacting across the gaps would migrate elements across the placement the caller asked for.

Every algorithm takes (place_group&, containers...) — the mapping-tier signature; algorithm temporaries are drawn from each shard's own place.

Tests (cudax/test/sharded/)

  • containers/sharded_array.cu: allocation (specs / group / uniform), index math, host roundtrips, copy_between incl. misaligned resharding, adoption + slicing, allocate_like layout+placement preservation, contract throws (size-count mismatch, check_compatible)
  • containers/contiguous.cu: exact-boundary + density checks, per-shard placement correspondence, per-shard writes read by one whole-range kernel through contiguous_data() (and the reverse), backing release on clear()
  • algorithms/{elementwise,reduce_scan}.cu: correctness vs host references over multiple places (locality domains), custom operators, layout-mismatch throws, empty-input behavior
  • algorithms/count_histogram.cu: count/count_if and histogram_even vs host references, including a contiguous-array case showing read-only algorithms stay available there; invalid-argument throws
  • algorithms/compaction.cu: copy_if vs host reference incl. empty-result shards and a keep-nothing case, remove_if/filter, unique vs std::unique with runs straddling shard boundaries plus the one-value-everywhere degenerate chain, and the refuse-on-contiguous negative tests (copy_if, remove_if, unique) verifying the array is left untouched
  • include_only.cu + header-test targets for the new headers

Example

cudax/examples/places/sharded_reduce.cu: place_group::by_locality_domains()sharded_array::allocateiotasum, 256M elements.

Verification

GB300 node (sm_103a, CUDA 13.4, 2 locality domains): 26/26 places+sharded tests and both examples pass.

cmake --preset=cudax -DCMAKE_CUDA_ARCHITECTURES=103a -Dcudax_ENABLE_NCCL=OFF
ninja -C build/cudax cudax.test.sharded cudax.test.places cudax.example.places
ctest --test-dir build/cudax -R "cudax\.(test\.(places|sharded)|example\.places)"

Sequenced next

  • a places-scoped communicator conformant to the __multi_gpu communicator concept — already validated out of tree: cudax::reduce and the MGMN HSS sort run unmodified over it — as the bridge between the two rungs
  • MGMN constructs as engines behind sharded names where they are the right tool (sort via HSS first)
  • a CI bridge test keeping the cross-rung interop honest

Opened on the fork for design review with Andrei. Main review points: the __sharded-as-sibling placement, the container/algorithm split, and the contiguous-backing contract.

🤖 Generated with Claude Code

Update: sharded_array<T> gains fork_from(cudaStream_t) / join_into(cudaStream_t) — ordering declarations (not synchronizations; the host returns immediately) bridging a caller stream and the per-shard streams via a small container-owned event pool (lazily created disableTiming events, reused across calls; correct for adopted arrays with foreign streams). Both are capture-safe and documented as the composition idiom with a caller stream or graph. Tests cover the eager no-host-sync chain, an adopted-array variant, and a capture variant.

caugonnet and others added 6 commits August 20, 2026 08:27
…es layer

Sharded arrays partition one logical 1D array across in-process places
(devices or sub-device locality domains) while keeping a common address
space. Algorithms follow the cooperation-scope recipe CUDA already uses
from warps to blocks to devices: run the previous scope's primitive
locally on each place, combine through what the scope shares.

Containers:
- shard<T>: one placed piece (data/size/capacity/global_offset +
  data_place/exec_place/reference stream + index math)
- sharded_array<T>: allocation from explicit specs or a place_group,
  adoption of existing shards, allocate_like, host transfer helpers,
  slicing with place correspondence, shard visitation, validation,
  copy_between/resharding
- allocate_contiguous: shards as views into ONE contiguous VA range with
  per-place physical backing (VMM via places::localized_array); logical
  boundaries exact, physical ownership granule-approximate; fixed-size
  contract (size-mutating operations must refuse such arrays)

Algorithms (cuda::experimental::sharded):
- elementwise: fill, sequence, iota, tabulate, generate, for_each,
  transform (in-place/unary/binary) — no cross-place stage
- reduce/sum/min/max: per-place CUB DeviceReduce + combine
- inclusive_scan/exclusive_scan: per-place CUB DeviceScan + in-place
  prefix fold over the shared address space
- adjacent_difference: local differences + one boundary element/shard
Temporaries come from each shard's place via place_group resources.

Also: public umbrella cuda/experimental/sharded.cuh, tests
(containers + contiguous contract + algorithm correctness), a
sharded_reduce example over locality domains, docs stub, header-test
wiring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ous; silent tests

The contiguous backing places each spec's physical blocks at its exec
place's affine data place; a non-affine data_place in a spec was
silently ignored. Throw std::invalid_argument up front instead, with a
negative test. Also strip success-path printf chatter from the sharded
tests (CCCL practice: tests are silent unless they fail or skip).
…/ view)

A contiguous array owns its memory -- through the VMM backing rather
than per-shard allocations -- yet reported is_owning()==false because
the flag conflated shard-level deallocation policy with ownership.
Split the enum: owning_shards frees per shard, owning_backing releases
via the localized_array backing, view owns nothing. is_owning() is now
true for both owning kinds; clear() semantics unchanged.
…ilent-tests pass)

The printf removal in the silent-tests commit left its argument list behind,
breaking compilation of containers/sharded_array.cu; the loud path is the
EXPECT itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…if, unique -- size-mutating contract enforced

Completes the initial algorithm family with the read-only pair and the
size-mutating pair, per-place CUB + combine like reduce/scan:

- count/count_if: per-place DeviceReduce::TransformReduce (0/1 transform),
  per-place counts summed; read-only, available on contiguous arrays.
- histogram_even: per-place DeviceHistogram::HistogramEven, per-bin sum of
  the per-place histograms; read-only. Invalid num_bins/levels refused with
  std::invalid_argument.
- copy_if/filter/remove_if: per-place in-place DeviceSelect::If (stable
  compaction to the front of each shard), then shard sizes and offsets are
  updated; capacities unchanged (reset_sizes_to_capacity reuses buffers).
- unique: per-place in-place DeviceSelect::Unique, then duplicates straddling
  shard boundaries are trimmed with an O(1) size decrement per boundary
  (compared against the previous NON-empty shard, so runs spanning empty
  shards are handled).

THE CONTRACT, now enforced rather than documented-only: the size-mutating
algorithms throw std::invalid_argument on contiguous (allocate_contiguous)
arrays -- shrinking shard sizes would leave gaps between shards' valid
elements, falsifying the read-as-one-array contract of contiguous_data(),
and compacting across the gaps would migrate elements across the placement
the caller asked for. Read-only algorithms remain available on every array.

Tests (silent on success): count/histogram vs host references incl. a
contiguous-array read-only case; copy_if with empty-result shards and a
keep-nothing case; remove_if/filter; unique vs std::unique with runs
straddling shard boundaries plus the one-value-everywhere degenerate chain;
the three refuse-on-contiguous negative tests verifying the array is left
untouched. VMM-gated cases skip loudly when the capability is absent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
caugonnet and others added 21 commits August 22, 2026 06:48
sharded_array<T>::adopt(shards) is the named form of the adopting
constructor (which stays): a zero-copy wrap of caller-owned memory. The
name states the contract -- the container becomes ownership::view and the
caller owes the memory's lifetime; from_* factories, by contrast, build
owned storage by copying or transforming their inputs. The two-word rule
is documented in sharded.rst's container section, and the container test
asserts adopt() matches the constructor (is_view, per-shard data
identity).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…_array

Bridge a caller stream and the per-shard streams without host round-trips:
fork_from(stream) records one event on the caller stream and makes every
shard stream wait on it; join_into(stream) records an event on each shard
stream and makes the caller stream wait on all. Both are ordering
declarations, not synchronizations (the host returns immediately), and both
are capture-safe: inside an active CUDA graph capture the record/wait pairs
become graph dependencies, making them the composition idiom between a
captured caller stream (or graph) and the per-shard work.

Events come from a small pool owned by the container (disableTiming,
lazily created, reused across calls): join events are per shard, created in
the shard's execution context; fork events are keyed by the caller stream's
device. Container ownership (rather than a place_group resource cache) is
deliberate: shards do not reference the group that created their streams,
and adopted arrays never had one, so a group cache would leave the adopted
path on transient events. Lazy creation is mutex-guarded; recording reuses
the pooled events, so concurrent fork/join calls on one container must be
ordered externally (documented).

Tests (containers/fork_join.cu): eager producer -> fork -> per-shard
consumers -> join -> reader chain with no host sync between stages, the
same chain on an adopted array over foreign caller-owned streams, the
members inside a graph capture (instantiate + relaunch x3), and degenerate
no-ops (empty container, shard stream == caller stream). sharded.rst gains
the composition section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…in event pool

cudaStreamGetDevice requires CUDA 12.8+; cuda::stream_ref::device()
provides the same answer (green-context streams report their underlying
device) on every toolkit cudax supports, via the versioned driver-API
loader below CTK 13.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ring

places::make_stream_wait_for is retired in favor of the equivalent
cuda::stream_ref::wait(stream_ref) (RAII event, driver-API wait).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rved namespace, factory cut, stream helper retired) into sharded/dev
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… docs style

- New sharded::contiguous_backing_supported(device): the public answer
  to 'can this machine back allocate_contiguous' (the VMM attribute the
  localized_array backing requires; allocate_contiguous throws where it
  reports false). The three tests drop their hand-rolled driver probes
  and ask the library.
- rst: emphasis instead of capitals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fork_from is the capture-composition idiom, but querying a capturing
stream's device (cuStreamGetDevice) returns
CUDA_ERROR_STREAM_CAPTURE_UNSUPPORTED on some drivers (caught by the
T4 CI arms; newer drivers permit it, which is why local runs passed).
cudaStreamIsCapturing is the one stream query legal everywhere: under
capture the recorded event only becomes a graph dependency node, so the
current device is the right home for it; outside capture the stream's
device is queried as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, per-shard sync spelling

- namespace detail -> reserved across the __sharded headers (the STF
  convention, kept deliberately).
- allocate() keeps zero-size shards instead of dropping them: shard
  positions correspond to spec positions — and to group places in the
  place_group overloads — even when a size is zero, the same way
  compaction leaves emptied shards in place. Host-copy paths skip
  empties; validate() already tolerated them. Tests: allocation shape +
  round-trip across an empty shard, and reduce over an array with an
  allocation-empty shard.
- sync() documents the per-shard spelling
  (cuda::stream_ref{arr.shard(i).stream}.sync()).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… correctness, API polish

- scan (CRITICAL fix): init and identity are now distinct. Inclusive
  custom-op scans need no identity at all — the cross-shard prefixes
  are folds of the preceding non-empty shards' totals (has-prefix
  chain), so non-additive operators (multiplies, max) fold correctly.
  Custom-op exclusive scans take init AND identity explicitly: locals
  run with the identity, the init seeds the global sequence exactly
  once through the prefix chain (it used to be folded per shard).
  Empty shards contribute nothing (presence flags, not identity
  pre-fill). Tests pin the multiplies cross-shard product, the
  exclusive nonzero-init sequence, a custom-op exclusive scan with an
  explicit identity, and a scan across allocation-empty shards.
- adjacent_difference: rejects input/output aliasing (one thread's
  write races another's read); the predecessor boundary walks back to
  the previous NON-EMPTY shard. Regression test: {nonzero, 0, ...,
  nonzero} shards + the aliasing refusal.
- sharded_array::sync(shard_idx): the per-shard synchronization is now
  a member (exec scope applied, same as every shard operation) and
  sync() iterates it; the docstring recommends the member rather than
  a raw stream_ref spelling that would bypass the exec scope.
- release() refuses ownership::owning_backing (a VMM mapping cannot be
  handed over through raw pointers; it used to leave the caller with
  pointers into a range the destructor unmaps).
- allocate_contiguous keeps zero-size shards (zero-length views at the
  running offset) — the same place-correspondence invariant allocate()
  documents.
- fork_join: the current device is restored on the event-creation
  error path; cuda::std::size_t + <cuda/std/cstddef>.
- unique: host staging laid out _Tp-first with max alignment (an
  over-aligned _Tp used to land on an 8-byte boundary).
- _CCCL_HOST_API on the public algorithm surface ([[nodiscard]] on the
  value-returning ones); shard<T> members _CCCL_HOST_DEVICE_API +
  [[nodiscard]] noexcept (device code calls contains/to_local/
  to_global); missing std includes; the rst usage example is
  self-contained.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…alue type; example includes

- The exclusive scan's identity-prefix shortcut imposed an undeclared
  operator== requirement on _Tp; removed — the fold is a cheap
  elementwise pass the value-type contract should not pay for.
- sharded_reduce example: direct <cstddef>, std::size_t.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant