Status: executed and accepted (2026-07-27). Phases 0–3 landed and the
Phase 4 contract surface landed with them; the decisions this plan proposed are
the accepted record
ADR-0038 — Replicated multi-device and chunked execution.
The plan stays as the phase-by-phase account; ADR-0038 is what governs the
shipped layer. The acceptance boxes below are ticked against the recorded
verifications, and what is still open is listed in the execution record and
left unticked.
Target workload (agreed 2026-07-27): extremely large ray/sample batches against scenes that fit in a single GPU's memory. This selects the scene-replication + batch-sharding regime for single-node multi-GPU and makes the cluster tier embarrassingly parallel (per-node replicas, global ray sharding, all-reduce only for small outputs: accumulation grids and vertex gradients). Geometry partitioning + ray forwarding is deferred to Appendix A.
Landed as five commits, all on 2026-07-27, all verified on this repository's
Linux verification host: 2× NVIDIA RTX A6000 (48 GB each, peer access
enabled, measured D2D copy bandwidth 49.1 GB/s one direction), Torch
2.13.0+cu130, conda env maxwell. The Windows/RTX 5080 machine that carries
this repository's historical baselines was not used for any of it.
| Commit | Phase | Landed | Verification recorded with it |
|---|---|---|---|
f643336 |
0 — device-correctness hardening | 2026-07-27 | Torch suite 198 OK (19 skipped); new 2-GPU smoke suite (10 tests); Dr.Jit device-binding test |
cc5f0f9 |
1 — Torch multi-device correctness, docs, warm-up | 2026-07-27 | Torch suite 221 OK (19 skipped); governance green; no product C++ changed |
c2c50ce |
2a–2c — replicated Scene, chunked executor, lane window |
2026-07-27 | Torch suite 282 OK (19 skipped); single-GPU parity within the D9 gate against cc5f0f9; GIL×registry deadlock fixed, 40/40 cold-JIT threaded trials clean |
869eb62 |
2d — pipelined dispatch, calibration, row floor | 2026-07-27 | Torch suite 306 OK; the measured table below; bitwise agreement 1.0 on every sharded per_ray row |
2ee6e67 |
3 — distributed recipes, Dr.Jit pure-CUDA hardening | 2026-07-27 | Torch suite 310 OK; Dr.Jit on Linux: test_geometry 63/63, test_device_binding 4/4, edge-BVH modules 23/23; governance 46/46 |
| Phase 4 (this closeout) | ADR, contracts, CI | 2026-07-27 | ADR-0038 accepted; contract/capability/PTX/compile-flag suites green (90 tests); Torch suite re-run 310 OK (19 skipped); the multi-device modules, the distributed recipe and the governance suite re-run per multi_gpu_operations.md §8 |
Headline measured numbers, from
multi_gpu_operations.md §5 (interleaved runs,
minimum over 7 rounds, ±5% on a shared machine; medians over 20 consecutive
runs in brackets):
| Configuration | Operation | 1 GPU | 2 GPUs | speedup |
|---|---|---|---|---|
| compute (2.1M-triangle cloud, incoherent, 4.19M rays) | intersect |
19.09 ms | 11.83 ms | 1.61× [1.62×] |
| compute (4 bounces, 4.19M rays) | trace_reflections |
53.33 ms | 28.38 ms | 1.88× [1.87×] |
| compute (67.1M samples) | accum_dfr_direct |
34.76 ms | 18.83 ms | 1.85× [1.85×] |
| light (192-vertex grid, 4.19M rays) | intersect |
1.27 ms | 4.63 ms | 0.27× |
light, after calibrate_devices() |
intersect |
1.22 ms | 1.22 ms | 1.00× |
Every sharded per_ray result in that table was bitwise the single-device
result (agreement 1.0 on every row); merged accumulation grids matched the
single-launch grids to 6.5e-08 (light) and 2.9e-07 (compute) relative
deviation, which is the float32 merge order D3 allows. The chunked+offload row
peaked at 2.10 GB on the master against 2.29 GB concatenated at 4.19M rows. The
row floor is 262,144 rows per device (524,288 on two).
Still open. Ticked boxes below are the ones the recorded verifications actually satisfy; these are not, and are left unticked where they appear:
- Dr.Jit 2-rank aggregate-throughput benchmark (Phase 3): never measured.
- Multi-node execution (Phase 3): documented and structurally argued, not executed — this repository has one node.
- A Dr.Jit run on the Windows/RTX 5080 baseline machine after the Phase 3
pure-CUDA hardening. The Linux modules listed above are green; the known
pre-existing
test_diffraction_paths_parityplatform divergence is recorded in ADR-0038's platform note and is not accepted by it. - The D6 batch-coupled half of 2c: sharded
trace_dfr_paths(bothSourceLaneandCompact), cross-sharddeduplicate=truesemantics, ADR-0033 failure-bit merging, andaccum_dfr_coherent_directunder sharding. The layer refuses these loudly rather than guessing; they are the Deferred list of ADR-0038. - A heterogeneous-device calibration claim (2d): both devices here are identical.
- The multi-GPU CI job actually running.
.github/workflows/multi_gpu.ymldeclares it — the multi-device modules fresh-process, the distributed recipe and the governance suite — pinned to aself-hosted, linux, x64, cuda, multi-gpurunner, and it is inert: no such runner is registered, and its only trigger isworkflow_dispatch. Every hosted job in this repository (ci.yml,stable-abi-ci.yml,pypi.yml) builds and checks packaging without a CUDA device, so "single-GPU CI" wherever it appears below means the single-device subset of the suite run on a developer machine, not a hosted job. Until a runner exists the acceptance set is run by hand:multi_gpu_operations.md§8. - A defect this closeout found rather than fixed: Phase 0 (
f643336) falsifiedtests/test_bvh4_shared_aabb.py::SharedEdgeAabbSourceTests::test_torch_adapter_uses_shared_api_on_current_stream. That test asserts the Torch edge-AABB adapter callscurrent_torch_cuda_context()and launches ontorch_ctx.stream, which is exactly the ambient-device coupling Phase 0 work item 4 removed in favour of ac10::cuda::CUDAGuardplusgetCurrentCUDAStream(out_aabbs.get_device()). The shipped code is what the plan asked for and the test states the pre-Phase-0 shape; the BVH-4 contract owner has to decide how it should read now. Nothing in this closeout touched either side, so Phase 0's first acceptance box stays unticked.
D1 — Scene replication + batch sharding. Every device holds a full scene replica (GAS/IAS, edge BVH, compact BVH, SoA, edge mask); work shards along the batch axis. Rationale: scenes fit in VRAM; NVLink-shared acceleration structures traverse 2–5x slower than replicas; Cycles/V-Ray/OWL/OptiX_Apps and Sionna's own guidance all replicate.
D2 — Backend split. Dr.Jit backend: process-per-GPU only
(CUDA_VISIBLE_DEVICES per rank; Dr.Jit is single-device per process,
drjit#359 / mitsuba3#808). Torch backend: first-class single-process
multi-device plus the process-per-GPU recipe.
D3 — Single-device numerics are untouched. Multi-GPU is a composition layer above the existing kernels: no kernel launch shape, reduction order, atomics, or compile flag changes. Cross-device/chunk merging happens in the orchestration layer with its own documented float32 summation order. The frozen guarantees of ADR-0026/0030/0032/0035 stay intact; the new ADR defines only the merge-layer semantics and the invariant that single-device, single-chunk execution stays bitwise identical.
D4 — Autograd-native gradient reduction. Replica vertices are
master.to(device_k); torch autograd reduces per-replica grad_vertices
back onto the master leaf automatically. DDP mode gets NCCL reduction free.
Chunked execution accumulates gradients additively across chunks (standard
gradient accumulation; valid because all geometry gradients land in
grad_vertices by summation).
D5 — RNG lane-offset parameter (core requirement). DfrAccumParams
gains an explicit lane_offset so that a K-device × M-chunk split is a
partition of the single-launch lane space: uniform01(lane_offset + lane, stream, seed). Contract: the multiset of drawn samples is independent of the
split (merging remains float-order-dependent). Same-seed sharding without the
offset reuses lanes 0..M/K-1 per shard and is forbidden.
D6 — Batch-coupled features get explicit per-shard semantics.
deduplicate=true dedups within a shard/chunk (optional merge-layer second
pass); the diffraction exporter prefers SourceLane layout under sharding
(row placement is (tx,rx,state)-determined), Compact falls back to
per-shard compaction + concatenation; the ADR-0033 failure transaction stays
per-shard, merge layer ORs bits and applies inertness per shard.
D7 — Chunked execution is a first-class component. At extreme N the
binding constraint is tape/output memory, not scene memory (reflection tape
≈ 40–50 B/ray/bounce ⇒ 10⁹ rays × 3 bounces ≈ 150 GB). The orchestration
layer therefore executes every large batch as a stream of chunks per device.
The budget is a per-device peak-increment bound and includes copied inputs,
outputs and tape for as many as three resident pipeline chunks plus any
complete returned output; accumulation also reserves replicated
state/material payloads and its fixed grid partials. Inference
grid_reduce reuses a fixed-size value buffer, but its autograd graph is not
O(1): each chunk retains a frozen native tape until backward. A budgeted
multi-chunk AD grid therefore fails loudly. per_ray outputs either concatenate
on the master device when the complete result fits, or stream through a
caller-supplied offload hook; bounded forward+backward uses the hook to run
backward per chunk. Chunk size is calibrated as large as the complete peak
model allows, to amortize per-launch overhead.
D8 — Multi-GPU is invisible at the top-level API. No parallel public
surface: Scene gains one optional devices= argument (default: today's
single-device behavior, derived from the mesh tensors), and every existing
op (scene.intersect, trace_reflections, accumulation, ...) transparently
shards/chunks when the scene was built with multiple devices. The
orchestration machinery (replicas, sharder, chunked executor) lives in a
private module (python/rayd/_impl/multi.py) and is not
public API. Process-per-GPU / distributed mode needs no API change at all
(rank-local scenes). Tuning knobs (shard weights, chunk memory budget,
offload hook) ride on an optional MultiDeviceOptions dataclass — additive
and defaulted.
D9 — Zero single-GPU regression. A single-device Scene executes the
identical code path as today: the multi-device layer is a Python-level
branch engaged only when len(devices) > 1 — no wrapper indirection, no
extra synchronization, no allocations on the hot path. The only
single-GPU-path change in the whole plan is Phase 0's device guards
(no-op-cheap when the device is already current). Gate: single-GPU
benchmarks (benchmark_torch_native.py set) must stay within noise (±2%)
of the pre-change baseline before each phase merges.
Already multi-device-shaped: Torch OptiX contexts keyed
(device_index, CUcontext); pipeline caches keyed by OptixDeviceContext
with per-instance device + guard; SceneCache carries device_index with
loud cross-device failures in scene-coupled ops; all shared/ kernels are
device-neutral (raw pointers + explicit stream); stable/camera.cu is the
reference for guard/stream handling; Dr.Jit has
set_device/current_device/device_count and a (device, context)-keyed
capability cache.
To fix (27 audit findings): ~30 Torch CUDA TUs launch without CUDAGuard;
scattering table/ensemble/patch (+ transmission/layer-stack/wedge) families
check is_cuda() only, not the device index; geometry_backward.cu queries
cooperative occupancy on the ambient device; native_compat.h and
compute_edge_optix_aabbs_cuda use the ambient device; Dr.Jit pure-CUDA TUs
(edge_bvh.cu, triangle_bvh.cu, cuda_multipath.cu) use raw cudaMalloc
on the runtime-current device, private per-call streams,
cudaDeviceSynchronize, a default-stream blocking copy, a racy file-scope
__constant__ params symbol, and a device/stream-less public ABI; the Dr.Jit
multipath pipeline cache object records no device.
Shardability classes:
| Class | Ops | Multi-GPU semantics |
|---|---|---|
per_ray |
intersect, nearest_edge(s), visible*, trace_reflections (no dedup), trace_refl_epc_field, sdf forward, scattering primal, camera | shard result bitwise ≡ single device |
grid_reduce |
reflection/diffraction accumulation, SDF value/box grads, scene-vertex grads | per-shard partials + merge-layer sum; float order changes (already atomic-nondeterministic on one device) |
batch_coupled |
dedup, Compact exporter count, ADR-0033 failure bit, MC RNG | explicit per-shard semantics (D5/D6) |
Work items:
c10::cuda::CUDAGuard(scene or input device) at every Torch op entry that launches kernels/CUB:scene/ops_intersect.cpp,edge/ops_edge.cppedge_topk.cu/edge_backward.cu,scene/geometry_backward.cu,diffraction/ops.cpp,sdf/ops_sdf.cpp,common/ops_stats.cpp, all 13rf/*TUs (model:stable/camera.cu).
- Same-device-index check in
common/tensor_check, applied to the scattering table/ensemble/patch, transmission, layer-stack, and wedge families (mirroringscattering_chain_checks.h). coop_launch_config_for_devicequeries occupancy under a guard for the requested device.- Replace ambient-device use in
native_compat.handcompute_edge_optix_aabbs_cudawith scene-derived device/stream. - Dr.Jit: store the device in
OptixLaunchPipelineand assert at launch; assert build-device vs current-device inScenequeries (loud failure afterset_device).
Acceptance:
- Full existing test suite passes unchanged (no numerical drift; golden
tests bitwise). Open on one test. The Torch suite passed at every
phase (198 → 310 OK) with no numerical drift and golden tests bitwise,
but work item 4 falsified
tests/test_bvh4_shared_aabb.py::SharedEdgeAabbSourceTests::test_torch_adapter_uses_shared_api_on_current_stream, which asserts thecurrent_torch_cuda_context()shape the device-explicit rewrite replaced (execution record item 7). It is a source-shape contract, not a numerical one, and it is still red. - New cross-device rejection tests for the families that lacked them
(table/ensemble/patch scattering, transmission, layer stack, wedge) —
tests/scattering/scattering_test.cpp, extended to all six families inf643336. - 2-GPU smoke suite (skipped when
torch.cuda.device_count() < 2): same mesh built on dev0 and dev1,intersectresults bitwise equal; every public op runs correctly on a non-zero device while device 0 is current —test_multi_device_smoke.py, 10 tests, green. - Dr.Jit: querying a scene after
set_deviceto another device raises instead of corrupting —backends.drjit.tests.drjit.test_device_binding, 4/4 green on Linux.
Work items:
penetration/ops.cppresolves context/pipeline from the validated scene device (drop ambient-context coupling).- Serialize OptiX context/module creation across devices (in-process mutex
exists); document per-process
OPTIX_CACHE_PATHfor process-parallel launches (Blender disk-cache race). - Optional per-device pipeline warm-up helper (worker threads) to hide N× module JIT.
Acceptance:
- Two scenes on two devices, driven concurrently from two host threads on
non-default streams, produce per-device results bitwise equal to
single-device runs (stress test in
tests/scene) —test_multi_device_stress.py::test_two_threads_on_two_devices_reproduce_single_device_results. - Per-device OptiX cold-create passes (generalization of
test_optix_pipeline_cold_createto a non-zero device) —test_multi_device_stress.py::test_cold_create_on_a_non_zero_device_in_a_fresh_process. - No cross-device serialization beyond context creation (verified by
overlapping-launch timing check, coarse threshold) —
test_multi_device_stress.py::test_two_devices_overlap_instead_of_serializing. What does serialize process-wide (device-context creation, the multipath launch-pipeline cache, and the GIL over an op body) is written up inmulti_gpu_operations.md§2.
Private module python/rayd/_impl/multi.py; public exposure
is only Scene(devices=[...]) plus the optional MultiDeviceOptions
dataclass (D8). With devices absent or singular, Scene takes the
pre-existing code path unchanged (D9).
2a — Replicated state + Sharder + per_ray dispatch (~1–1.5 weeks)
- Internal
_ReplicatedScene(meshes, devices, master_device=devices[0]): replica vertices aremaster.to(dev_k)(autograd-recorded);update_mesh_vertices/sync()/set_edge_maskbroadcast to all replicas; version counters verified in lockstep, divergence fails loudly. Sharder: static operation-local weighted splits from configuration or an explicitcalibrate_devices()micro-benchmark; no dynamic rebalancing.per_raywrappers: non-blocking scatter, launch on each device's current stream, event-ordered gather to master, concatenate. Bidirectional P2P and homogeneous model/capability are required by default; host staging and heterogeneous execution are explicit opt-ins with reduced guarantees.
Acceptance:
- Single-device
Scene(nodevices=or one device) provably takes the pre-existing code path: a unit test asserts the multi layer is never engaged, and single-GPU benchmarks stay within ±2% of baseline (D9). - 1-device
Scene(devices=[d])≡ nativeScenepath bitwise for every wrapped op (runs in single-GPU CI). - 2-device: every
per_rayop bitwise ≡ single-device result after gather, for several shard ratios including degenerate (0-length) shards. - Broadcast mutation:
update_mesh_vertices+sync()on the replica set keeps per-device results bitwise equal; injected divergence is detected and raises. - Autograd:
master_vertices.gradfrom a 2-deviceper_raybackward matches single-device grad within atomics tolerance; JVP paths covered.
2b — Chunked executor (~1–1.5 weeks)
- Per-device chunk queue with double-buffered streams (chunk k+1 compute overlaps chunk k gather/D2H).
grid_reduceops: in-place accumulation of chunk partials on each device, single cross-device merge at the end.per_rayops: concatenate on master, or stream through a caller-supplied offload hook (on_chunk(result_slice)), for outputs too large for one GPU.- Chunked forward+backward: per-chunk backward with gradient accumulation
into
master_vertices.grad. calibrate_chunk_size(): pick the largest chunk fitting a tape-memory budget; expose per-launch overhead measurement.
Acceptance:
- Chunked
per_rayexecution ≡ unchunked bitwise (same device, any chunk size). - Chunked
grid_reducematches unchunked within float tolerance and is run-to-run reproducible at fixed chunking. - Chunked backward gradient matches unchunked within atomics tolerance.
- A batch whose outputs exceed single-GPU memory completes via the offload hook (the synthetic case is a tape-memory budget that sizes the chunk plus a streamed hook, rather than a capped allocator; measured effect: 2.10 GB master peak streamed against 2.29 GB concatenated at 4M rows).
- Overlap measured: with ≥4 chunks, end-to-end time < (sum of compute) + 1.15 × (one chunk's D2H), on the benchmark scene. Measured at 8 chunks: 11.71 ms against 19.15 + 1.15 × 0.81 = 20.08 ms.
2c — RNG lane offset + batch-coupled semantics (~1 week, includes ADR text)
- Add
lane_offsettoDfrAccumParams(and the Torch op schema /autograd.pyplumbing); default 0 preserves current behavior bitwise. - Wire shard/chunk lane offsets in the executor so device × chunk splits partition the lane space.
- Per-shard dedup semantics flag;
SourceLaneselection for the exporter under sharding;Compactper-shard + concatenation; ADR-0033 failure-bit OR-merge.
Acceptance:
-
lane_offset = 0is bitwise identical to today (existing accumulation golden tests unchanged). - Lane-partition test: the multiset of
(tape_state_idx, tape_cell, tape_edge_u)rows from a K×M split equals the single-launch multiset (exact comparison after sort). Covered at the reachable boundary: the lane windows are asserted to be a contiguous, disjoint, warp-aligned partition of the caller's window, and the merged grid reproduces the single-launch grid; the tape rows themselves are internal. - Sharded exporter (
SourceLane): row-for-row identical to single-device for the successful lanes;Compactconcatenation preserves the per-shard row sets. Open —trace_dfr_pathsstill raisesNotImplementedErroron a multi-device scene. - Dedup: per-shard semantics documented and tested (shard-local dedup equals single-device dedup when shards align with dedup key groups; the general case is asserted to differ and gated behind an explicit flag). Open — the batch-coupled half of 2c is not landed.
2d — Throughput validation (~0.5 week)
The original criterion — "≥1.8× for 65k+ ray intersect" — asked for the
wrong thing, and measuring it is what showed why. A sharded row travels twice
(inputs out, outputs back), so two devices can only beat one when a row's
compute costs more than its bytes cost to move. On the verification machine
(2× RTX A6000, 49.1 GB/s measured one-direction D2D) a full Intersection row
is 100 B in and out, i.e. 2.04 ns of interconnect per ray: an intersect
cheaper than that per ray is faster on one GPU at any batch size, and 65k
rays are below the small-batch floor besides. The revised criteria therefore
separate the two regimes instead of demanding one number from both.
Acceptance:
-
grid_reduce≥1.8× on 2 GPUs (same model) vs 1 GPU for a large-sampleaccum_dfr_direct: 1.85× median at 67.1M samples (34.76 → 18.83 ms in the recorded run), 1.81–1.89× over 20 consecutive runs. No per-row data crosses the link, so the speedup isT / (T/2 + ~1.4 ms merge)and rises monotonically with the sample count (1.08× at 4.2M, 1.60× at 16.8M, 1.75× at 33.6M). -
per_ray≥1.6–1.8× on 2 GPUs vs 1 GPU on compute-bound configurations through the pipelined path: 1.62× median forintersectand 1.87× median fortrace_reflections(4 bounces) at 4.19M rays against a 2.1M-triangle cloud with incoherent rays, with a total spread of 1.60–1.63× and 1.85–1.90× over 20 consecutive runs. The residual is the pipeline's fixed ~2 ms (first scatter, last gather, the master's copy into the output, ~0.3 ms of host time per chunk), not the interconnect: both configurations are compute-bound by 2.2× and 10× respectively. Calibration reaches the same weights ([1.0, 1.0]on identical devices) when its throughput stage runs on quiet devices, matching the uncalibrated rows in 18 of 20 runs — but it is a measurement of a shared machine: two runs weighted a busycuda:1at 0.72 and 0.81 (30.1 ms against 17.0 ms forcuda:0on identical hardware) and ranintersectat 1.42× and 1.54×, and one demoted the reflection remote share to 1/2 and ran at 1.47×. All stayed above 1.4×, because the operation is compute-bound by a wide margin. The criterion is met by the dispatch, not by the calibrator. - Transfer-bound configurations are covered by the fallbacks rather than
by a speedup, with the two fallbacks carrying different strengths:
- The row floor is a guarantee. Below
min_rays_per_device × len(devices)rows (524,288 by default) the batch runs on the master bitwise as a single-deviceScenewould, verified at the 524,287/524,288 boundary. - Calibration is a measurement, not a guarantee. A light configuration (192-vertex grid, 0.31 ns/ray) shards at 0.27× andcalibrate_devices()usually answers it with a zero remote weight, which the dispatcher runs as the single-device call it is, at 1.00× — that is what the recorded light rows show. But the refinement ladder keeps the largest remote share within its 3% tolerance of the fastest rung, so on a near-crossover operation the tie-break can keep a split that then loses at run time. For light 1-bouncetrace_reflections(0.67 ns/ray transfer against 0.27 ns/ray compute) that happened in 3 of 20 consecutive full benchmark runs (0.86×, 0.85×, 0.38×), in 5 of 6 back-to-back calibrations (0.79–0.89×), and in 2 of 3 in a third study (0.71–0.84×). The claim is therefore bounded: calibration will not knowingly keep a split it measured as more than the refinement tolerance slower than the master alone, and inside that band the answer flips between runs. Ship pinned weights (MultiDeviceOptions(weights=[1.0, 0.0])) for such operations; the benchmark labels the rowsNEAR-CROSSOVER. Full evidence:multi_gpu_operations.md§5.4. - Sharded
per_rayresults stay bitwise the single-device results at the benchmark's sizes (agreement 1.0 on every row of the recorded table), and merged accumulation grids stay within float32 merge order. - Benchmark added:
benchmarks/torch/benchmark_multi_device.py, which runs both configurations, reports single vs multi times, speedups, calibrated weights, the per-chunk plan and each calibration's margin over running master-only (flagging the near-crossover decisions that do not reproduce), and runs on one GPU as a baseline collector. Recorded table, machine, cross-run spread and contention caveat:multi_gpu_operations.md§5. - Heterogeneous pair (if available): calibrated weights beat naive 50/50 by a recorded margin (informational, no hard threshold). Not available — the verification machine's two devices are identical, and calibration accordingly answers 1.00/1.00 on compute-bound probes.
- Single-GPU parity gate (D9): the full
benchmark_torch_native.pyset against the pre-Phase-2 baseline (cc5f0f9), six interleaved runs of each, min per metric: six of eight metrics within ±1.6%; the two exceptions arenearest_edgeat −3.8% (i.e. faster after the change, on a metric whose per-run spread on this contended host is ±10%) anddiffraction_directat +2.8% on a 0.19 ms measurement whose samples spanned 0.20–1.70 ms. A single-deviceScenenever imports the orchestration layer (asserted by a subprocess probe), so this is a measurement of unchanged code and the residual is the machine.
Work items:
- Single-node recipe:
torchrunone rank per GPU,CUDA_VISIBLE_DEVICESper rank, rank-local scene build, NCCL all-reduce of grids/vertex grads (automatic under DDP when vertices are module parameters; manualall_reduceexample otherwise). Works for both backends; this is the only Dr.Jit path. - Multi-node recipe: same script under multi-node
torchrun; document that traffic is only grids + gradients (scene-sized, N-independent). - Example scripts under
examples/torch/distributed/with a smoke-test harness. - Dr.Jit pure-CUDA hardening (quality items, not blocking for
process-per-GPU): device recorded in
CudaBuffer,cudaDeviceSynchronize→ stream sync, treelet H2D off the default stream,DfrAccumParamsas a kernel argument instead of file-scope__constant__, device/stream parameters in the public builder ABI (edge_bvh.h).
Acceptance:
- 2-rank single-node example produces grids/grads matching single-process
execution within float tolerance —
test_distributed_recipe.py, 4 tests, green: both examples run under a realtorchrun --nproc_per_node=2, the ranks' final parameters are bitwise equal, and the all-reduced grid matches a single-process single-device launch of the full sample count. - …and runs in the multi-GPU CI job. Open — the job is declared in
.github/workflows/multi_gpu.ymland is inert until a self-hosted 2-GPU runner exists (execution record item 6). Run by hand meanwhile:multi_gpu_operations.md§8. - Dr.Jit 2-rank example (each rank pinned via
CUDA_VISIBLE_DEVICES) runs the reflection benchmark with ≥1.8× aggregate throughput. Open — the recipe and its Dr.Jit variant are documented and the route works, but no aggregate-throughput measurement was taken. - Multi-node invocation documented and exercised at least manually
(recorded in the PR); no code path differs from single-node beyond the
rendezvous. Half done, honestly. The invocation is documented
(
multi_gpu_operations.md§6) and the "no differing code path" half is checked by reading: the examples consume onlyRANK,LOCAL_RANKandWORLD_SIZE, and per-step traffic is one[V, 3]gradient or a fixed set of grids. It has not been executed — this repository has a single node. - Hardening items each keep the Dr.Jit test suite bitwise-green.
Partial. Numerics were verified bitwise-unchanged and the committed
PTX closures were untouched (digest test green), with
test_geometry63/63,test_device_binding4/4 and the edge-BVH modules 23/23 green on the Linux verification host. Outstanding: a full Dr.Jit suite run on the Windows/RTX 5080 baseline machine, where the pre-existingtest_diffraction_paths_parityplatform divergence recorded in ADR-0038's platform note also has to be re-checked.
Work items:
- ADR "replicated multi-device and chunked execution": D1–D7, merge-layer float semantics, lane-offset contract, per-shard dedup/failure semantics, SourceLane recommendation, and the bitwise-invariance guarantee for single-device single-chunk execution.
- Contracts (template: the SDF capability commits):
multi_device_replicatedcapability key inpublic_api.json(torch=true, drjit=false),shardabilityannotation per operation inoperations.json, both_capabilities.pytwins +_SCHEMA_SHA256repin, ADR-0036 divergence-count amendment,.pyistubs forrayd.torch.multi,library.cppschema entries for any new/extended ops (lane offset). - CI: multi-GPU runner job gated on
device_count() >= 2; single-GPU CI runs the 1-device-equivalence subset; contract tests extended (test_shared_operation_contract.py,test_public_api_manifest.py). - If any
.cureachable from committed Dr.Jit PTX changes (lane offset in shared headers): regenerate PTX and refreshptx_sources.jsonper the committed-PTX policy.
As landed, two of those items read differently from the proposal:
- Item 2's typing work is on the private module (
_multi.py, annotated inline; the_multi.pyiit briefly carried went when the Torch package dropped every stub it had). There is norayd.torch.multi: the public surface isScene(devices=..., options=...),MultiDeviceOptions,Scene.calibrate_devices(),Scene.device_weights,Scene.device_weights_for(operation)and the two lane-window parameters, and ADR-0038's stop conditions forbid adding a public name under that path. - Item 4 was not needed: no
.cureachable from a committed PTX module changed, soptx_sources.jsonis untouched and its digest test stayed green throughout rather than being repaired afterwards.
Acceptance:
- ADR accepted; contract tests green in both directions (declaration ↔
build) —
ADR-0038is Accepted (2026-07-27),tests/test_adr0038_multi_device.pyguards it against the contracts, both capability copies, theMultiDeviceOptionsdefaults, the lane-window defaults in Python and in the dispatcher schema, and this plan;tests/test_shared_operation_contract.pycarries the hard-coded per-operation shardability table. - Capability visible from both backends'
_capabilities.pywith correct values; manifest/typing tests pass —multi_device_replicatedistorch: true,drjit: false, both copies repinned_SCHEMA_SHA256, andtests/test_public_api_manifest.pyis green. - Multi-GPU CI job green on the 2-device matrix. Open — the job exists
(
.github/workflows/multi_gpu.yml) but is inert: no self-hosted 2-GPU runner is registered, andworkflow_dispatchis its only trigger. The set it declares — the multi-device modules fresh-process, the distributed recipe, the governance suite — was run by hand on the verification host instead (multi_gpu_operations.md§8). - Single-GPU CI unchanged —
ci.yml,stable-abi-ci.ymlandpypi.ymlare untouched by this work. None of them has a CUDA device, so they were never running the GPU subset in the first place. - PTX digest test green (regenerated if touched) —
tests/test_ptx_source_digest.pygreen withdrjit/ptx_sources.jsonuntouched: the lane window lives in the Torch backend and the Dr.Jit Phase 3 hardening changed host and object translation units only.
Non-goals (this plan): geometry partitioning & ray forwarding, NVLink-shared
GAS, dynamic load rebalancing, Dr.Jit single-process multi-device, wavefront
restructuring of trace_reflections.
Risks: merge-layer float-order drift in training loops (documented + tolerance-tested); replica divergence (broadcast-only mutation + version lockstep checks); N× first-build module JIT (warm-up + disk cache); per-launch overhead at large chunk counts (mitigated by chunk-size calibration; measure before considering CUDA Graphs); heterogeneous-GPU static weights (explicitly re-calibratable).
Revisit trigger: a target scene whose replicated footprint (after IAS instancing and selective replication of small structures) exceeds single-GPU memory. Cheap capacity levers to exhaust first: per-mesh IAS instancing (already built), a per-structure memory profiler, selective replication (edge BVH / material tables are far smaller than triangle geometry and can stay replicated even when triangles shard).
The partitioned route then requires, in dependency order: (P1) wavefront
restructuring of trace_reflections (bounce-level launches with materialized
ray state; conflicts with the OptiX pipeline guardrail and needs its own
ADR; the Dr.Jit symbolic path is a limited prototype); (P2) partitioned
GAS/IAS with proxy routing and partition-local → global ID mapping; (P3) a
ray-forwarding engine (NVLink ray-queue cycling intra-node; NCCL
alltoallv / MPI + GPUDirect inter-node) — closest-hit merges are a
deterministic min-reduce with (t, global_prim_id) tie-break, visibility is
boolean OR with early-out; (P4) nearest_edge under partitioning — prefer
full edge-structure replication; else bound-pruned multi-round top-k merge;
(P5) distributed differentiation — RayD's fixed-winner tape AD is unusually
distribution-friendly (backward needs tape rows + per-hit vertex gathers,
never BVH re-traversal): route tape rows to owning partitions, accumulate
locally, reduce by owner; reflection-chain VJPs need per-ray tape gathering;
no published prior art; (P6) distributed dedup/exporter semantics (extends
D6); (P7) cluster runtime + partition loader boundary (RayD provides the
partitioner and partition-scene build API; file formats stay caller-owned);
(P8) Torch-first, shared/-neutral engine — the Dr.Jit backend does not
participate in-process.