- Status: engineering preview
- Execution model: one Python process, homogeneous NVIDIA GPUs, CUDA peer-to-peer
- Validated hardware scope: two NVIDIA RTX A6000 GPUs only
This document is the maintained user and acceptance guide for one FDTD solve that spans multiple GPUs. It implements the joint-solve direction from the detailed implementation plan and the multi-GPU execution roadmap. It is not the independent-simulation ensemble scheduler described in the roadmap.
The public entrypoint remains Scene -> Simulation -> Result. Add an
FDTDParallelConfig to an ordinary FDTD simulation:
import witwin.maxwell as mw
# Construct the scene on a participating CUDA device. PointDipole sources must use
# profile="ideal" in the current multi-GPU runtime.
scene = mw.Scene(
domain=mw.Domain(bounds=((-1.0, 1.0), (-0.5, 0.5), (-0.5, 0.5))),
grid=mw.GridSpec.uniform(0.02),
boundary=mw.BoundarySpec.pml(num_layers=8),
device="cuda:0",
)
scene.add_source(
mw.PointDipole(
position=(0.0, 0.0, 0.0),
polarization="Ez",
profile="ideal",
source_time=mw.CW(frequency=1.0e9),
name="source",
)
)
scene.add_monitor(
mw.PointMonitor("probe", (0.2, 0.0, 0.0), fields=("Ez",))
)
parallel = mw.FDTDParallelConfig(
devices=("cuda:0", "cuda:1"),
decomposition_axis="x",
transport="auto", # "auto" and "cuda_p2p" use the same-process P2P path
overlap=True,
gather_fields=False, # monitor-first; do not rebuild a global field by default
result_device="cuda:0",
)
simulation = mw.Simulation.fdtd(
scene,
frequencies=(1.0e9,),
run_time=mw.TimeConfig(time_steps=1000),
parallel=parallel,
cuda_graph=False,
)
# prepare() performs topology, homogeneity, and partition checks. run() performs the
# DFT/output-capacity preflight before advancing the first time step.
prepared = simulation.prepare()
result = prepared.run()
probe = result.monitor("probe")
parallel_stats = result.solver_stats["parallel_stats"]
print(parallel_stats["partitions"])
print(parallel_stats["peak_memory_bytes"])devices is ordered and must contain at least two unique, explicitly indexed CUDA
devices. result_device defaults to the first device and must be one of the
participants. Only decomposition_axis="x" is accepted. Passing parallel=None
continues to use the unchanged single-GPU solver.
transport="auto" currently selects CUDA P2P. transport="nccl" requires a
one-process-per-GPU torchrun launch (torch 2.13 binds one device per rank);
without the RANK/WORLD_SIZE/LOCAL_RANK launcher environment it raises an
explicit RuntimeError from DistributedFDTD and never silently switches to P2P
or stages through host memory. Under torchrun the coordinator drives the NCCL
transport directly: the runtime is split into a rank-local ShardEngine
(fdtd/distributed/shard_engine.py) that each rank builds deterministically from
the shared partition plan, and a coordinator that expresses every cross-rank
operation through transport primitives, so the same coordinator per-step time
loop runs over both the in-process CudaP2PHaloTransport and the
one-process-per-GPU NcclHaloTransport branch-free over the transport
primitives. The claim is scoped to the per-step field/halo loop, not to all of
DistributedFDTD: construction, validation, and result-gather scope still branch
on transport kind (self._nccl at solver.py around lines 227, 272, 575, 657,
697, 762, and 1024 -- transport selection, rank-local vs. all-shard engine build,
NCCL-specific hardware preflight, the narrower NCCL capability envelope, the
overlap gate, and the field-shutoff gate). The NCCL transport
(fdtd/distributed/nccl_transport.py) performs the forward electric/magnetic Yee
x-plane halos via torch.distributed.batch_isend_irecv on the engine's compute
stream, the transposed reverse-halo accumulations for the adjoint, a scalar
all-reduce (reduce_owned_energy), a sized point-to-point field gather to rank 0
(gather_component_slabs, using the deterministic partition layouts so rank 0
needs no shape exchange), a cross-rank homogeneity check, and deterministic
destroy_process_group teardown.
An end-to-end two-rank NCCL forward solve is qualified: each rank builds its
own ShardEngine, the coordinator runs the serialized time loop over the NCCL
transport, and rank 0 gathers the global full-field DFT (Ex/Ey/Ez) and matches
an independent single-GPU reference at the same tolerances as the in-process P2P
leg (rtol 5e-5 / atol 5e-6). This is verified by a two-rank torchrun
conformance worker (tests/fdtd/multi_gpu/_nccl_forward_worker.py), and a
rank-death failure-matrix worker confirms a dead peer surfaces as a bounded
nonzero exit rather than a hang. The NCCL forward path fails closed on monitors,
coupled circuit/network/wire/port scenes, trainable density (adjoint), and field
shutoff (each supported today only on transport="cuda_p2p"); NCCL monitor
gather, NCCL adjoint, and multi-node execution remain deferred.
The pre-existing single-process transport="nccl" guard (the torchrun
RuntimeError raised from DistributedFDTD) is now pinned by explicit
no-silent-fallback tests rather than being introduced here: the raise is asserted
both from DistributedFDTD directly and through the public Simulation.prepare()
path, and the in-process CudaP2PHaloTransport is verified to never be
constructed as a fallback (monkeypatched to fail if reached). The code
contribution alongside those tests is adopted-group validation: preflight()
validates an adopted process group so that when torch.distributed is already
initialised, the transport verifies the live group's world size, rank, and
backend match this rank's expectations before adopting it, rather than binding to
a mismatched group. Backend validation accepts both the plain nccl token and a
composite device->backend spec (for example cpu:gloo,cuda:nccl) whose CUDA
collectives are NCCL, and fails closed otherwise. The matching-group accept path
is exercised inside the two-rank worker; the mismatch legs (world size, rank,
non-NCCL backend, and the composite accept/reject parse) are covered by host
tests. The two-rank worker also asserts the shared-group teardown contract: after
one adopted handle destroys the default group, a sibling transport that still
reads connected fails closed via a vanished-group guard instead of driving a
collective on a destroyed group.
The one-process-per-GPU launch is torchrun --standalone --nnodes=1 --nproc-per-node=<gpus> <entrypoint>, which populates the RANK, WORLD_SIZE,
and LOCAL_RANK variables the transport reads via
NcclHaloTransport.from_env(...); a missing variable raises an actionable error
naming the required launch. Set TORCH_NCCL_ASYNC_ERROR_HANDLING=1 so a stalled
or dead peer surfaces as a ProcessGroupNCCL watchdog abort within the collective
timeout (timeout_s, default 1800 s) instead of an indefinite hang; the two-rank
conformance harness sets it by default. Teardown always calls
destroy_process_group() so the group is released deterministically even on the
error path.
The default is monitor-first because gathering a large global field can erase the memory-capacity benefit of domain decomposition.
| Setting | Public result behavior |
|---|---|
gather_fields=False |
Supported point/time and assembled plane/flux/mode monitor payloads and metadata are moved to result_device; result.fields is empty. Rank-local mutable fields remain solver internals and are not a public distributed-field object. |
gather_fields=True, one frequency, full_field_dft=False |
The last-step global Ex, Ey, and Ez fields are assembled on result_device. |
gather_fields=True, full-field DFT or multiple frequencies |
Global electric DFT fields are assembled on result_device; multi-frequency tensors retain the leading frequency axis. |
Magnetic full fields are not emitted by the public Result field contract in this
preview. Request magnetic components through supported point monitors when they are
needed.
Before a gathered run starts, the solver checks the destination's free memory for the
global electric output plus pending local DFT work. It raises MemoryError rather than
starting a solve that cannot complete the requested gather. The check is reported in
parallel_stats["gather_preflight"].
Result.save(path) and Result.load(path, scene=...) keep their ordinary gathered
snapshot semantics: fields, monitors, metadata, and solver statistics are serialized,
but live solvers, CUDA streams, peer transport state, and prepared-scene runtime state
are not.
For a distributed result, Result.save_sharded(directory) asks the coordinator for
owned electric field shards and writes a manifest plus one detached CPU tensor payload
per rank. It does not assemble a global field first. The manifest records component
global x intervals, tensor shapes/dtypes, frequency metadata, and rank files; monitor
payloads, result metadata, and solver statistics remain in the result-level metadata.
Result.load_sharded(directory, scene=..., gather_fields=False) is lazy with respect
to rank tensor deserialization. It validates the manifest and required rank paths,
returns fields == {}, and exposes the sharded manifest and rank paths. Passing
gather_fields=True loads and validates the rank payloads, stitches each component's
contiguous owned intervals in global x order, and returns conventional gathered
fields on map_location. These APIs restore detached inference results, not a
resumable distributed solve.
This is spatial domain decomposition, not replicated data parallelism:
FDTDPartitionPlanbalances the global physical x cells into contiguous half-open slabs. PML cells at the global x faces stay with the first and last slabs.FDTDShardLayoutgives every staggered component explicit owned, allocated, halo, and global-coordinate slices.Ex,Hy, andHzfollow cell ownership;Ey,Ez, andHxfollow low-node ownership, with the terminal node assigned to the final shard. An interface value therefore has exactly one owner.- Python is the control plane in both modes, but the time-loop ownership differs:
the single-GPU solver owns its ordinary loop, while
DistributedFDTDis an independent distributed coordinator with its own shard orchestration, halo exchange, event ordering, and distributed time loop. This is not a claim that the Python loops are the same. - The numerical core is shared. Every shard calls the same native CUDA Yee, boundary,
CPML, conductivity, and ADE kernels as the single-GPU solver. The standard real-field
path exposes six bounded native operations for
Hx/Hy/HzandEx/Ey/Ez; the legacy full-domain standard wrappers invoke those same implementations with the full x range. There is no second Python or Torch field-update implementation. - One-plane asymmetric halos carry only the tangential components required by the
second-order stencil.
Ey/Ezmove from the right shard to the left shard before the magnetic update.Hy/Hzmove from the left shard to the right shard before the electric update. - A dedicated communication stream and CUDA events order each peer copy against both
producer and destination use. With
overlap=True, eligible standard real-field runs compute interior regions while halos are in flight and wait only for the boundary strip. CPML and modulation currently use the serialized-safe schedule; inspectoverlap_activeinstead of assuming a request was activated. - Only the first and last slabs apply physical x-face boundary behavior. Internal interfaces never apply PML, PEC, PMC, Mur, periodic, or symmetry boundary rules. Every shard still owns the global y/z faces.
- For a circuit-coupled run, each bound lumped/terminal port has one Yee-edge
owner. The circuit owner is the shard owning the lexicographically minimum
bound voltage edge, with
Ex,Ey,Ezas the deterministic component tie-break. A remote port sends one voltage scalar to that owner and receives one solved current scalar per step; ports already on the circuit owner take a zero-P2P fast path. MNA state, circuit time series, port spectra, and live circuit checkpoint tensors remain on the circuit owner until final results are moved once toresult_device. Each remote stream acknowledges completion of its current-scalar peer read, and the next owner solve waits for that event before reusing the source buffer; the acknowledgement does not wait for the subsequent shard-local field correction. - An embedded-network-coupled run uses the identical ownership rule and scalar
contract as the circuit path, applied to the network's connected terminals: the
network owner is the shard owning the lexicographically minimum connected
voltage edge with the same
Ex,Ey,Ezcomponent tie-break, so a scene carrying both a circuit and a network would place them consistently. Each shard samples one voltage scalar per local connected port; the owner gathers the remote scalars, runs the sole authoritative state-space recurrence and the prepare-time pivoted-LU same-step feedthrough solve, accumulates every port's V/I DFT, and scatters one solved branch-current scalar back per remote port under the same copy-acknowledgement discipline. Network state and the ownerPortData/EmbeddedNetworkDataremain on the owner until moved once toresult_device. The distributed apply is multi-stream and event-driven and is never CUDA-Graph captured.
The steady-state fields and field-sized CPML/ADE/DFT state are shard local. The coordinator prepares global grid metadata and a common time step, but does not initialize a complete duplicate six-field simulation on every GPU.
“Accepted” means the current public runtime has an implementation path. “A6000 parity” means a two-GPU case is in the hardware acceptance suite and is compared with the single-GPU native solver. Features accepted by the runtime but not named in that suite must not be described as independently hardware-qualified.
| Area | Current joint-solve status | Evidence / notes |
|---|---|---|
| Devices and topology | Accepted for homogeneous, explicitly indexed NVIDIA CUDA devices with bidirectional direct peer access | Two RTX A6000 GPUs validated. No CPU/host-staged fallback. |
| Decomposition | x slabs, one-cell halo, balanced or uneven physical-cell counts | A6000 parity covers uniform and uneven nonuniform x grids, including an interface source. |
| Grid | Uniform, anisotropic-spacing, custom/nonuniform, and prepared auto grids through shard-local custom coordinates | Uniform and uneven custom-grid parity are explicit acceptance cases. |
| Linear materials | Vacuum, scalar dielectric, diagonal electric/magnetic tensors, scalar/diagonal conductivity | Scalar dielectric plus CPML and sigma_e are in A6000 parity. Full off-diagonal electric tensors fail fast. |
| Dispersion | Electric and magnetic Debye/Drude/Lorentz ADE state is shard local | Conductive electric Lorentz and an interface-crossing magnetic Lorentz case have A6000 parity. Other pole combinations retain runtime support but are not separately hardware-qualified. |
| Modulation | Forward serialized-safe path | Communication/compute overlap is disabled for this path; no final A6000 acceptance claim yet. |
| Nonlinearity | Rejected | Additional collocation halos and bounded nonlinear kernels are required. |
| Absorbers and boundaries | Global-face CPML/PML/StablePML/absorber behavior and mixed none/PEC/PMC/Mur; y/z periodic and y/z symmetry are accepted |
XYZ CPML plus dielectric, mixed PEC/PMC/Mur, y periodic, and y symmetry have two-GPU parity coverage. x periodic, all Bloch, and x symmetry fail fast. |
| Sources | PointDipole(profile="ideal") and UniformCurrentSource |
Point sources on/near interfaces and a current volume crossing the interface have A6000 parity coverage. Plane-wave/TFSF, beam, mode, and other surface sources fail fast. |
| Circuit co-simulation | One linear circuit with one or more bound LumpedPort/TerminalPort objects; GPU-native owner MNA and P2P scalar exchange |
Each individual port must be wholly owned by one x slab. Multiple ports may reside on different shards. Communication is two scalars per remote bound port per step, and no external circuit process is used. The physical two-GPU parity gate passes on 2x RTX A6000 with exact (bitwise, 0.000e+00) single-versus-two-GPU parity on all six fields, port V/I, and circuit node/branch data. |
| Embedded-network co-simulation | One NetworkBlock/TouchstoneNetwork connecting one or more LumpedPort/resolved TerminalPort terminals; GPU-native owner state-space + same-step LU feedthrough solve and P2P scalar exchange |
Each connected terminal must be wholly owned by one x slab. Terminals may reside on different shards. Communication is two scalars per remote connected port per step; the owner alone advances the authoritative network state recurrence and accumulates every port V/I DFT. The physical two-GPU parity gate passes on 2x RTX A6000 with exact (bitwise, 0.000e+00) single-versus-two-GPU parity on all six fields, port V/I, network V/I, and network state. A scene mixing an embedded network with a bound circuit, more than one embedded network, and delayed (delay_seconds=) network cores are rejected on the distributed path. |
| Thin-wire co-simulation (forward) | One PEC ThinWire network on the shared real standard path with a non-absorbing boundary; distributed fragment ownership with owner-resident I/q and monitors |
Every cell-local sampling/deposition Yee edge is applied by the shard that owns it; a physical segment may span any number of shards and its fragments split at each partition boundary. Per-segment EMF partials are reduced to the state owner (the shard owning the minimum global sampling edge, declaration-order independent) with a deterministic rank-ordered sum; I/q advance owner-only; the advanced current is broadcast back so each owned edge deposits exactly once. The shared joint Maxwell/wire dt is taken from the full-domain single-GPU wire runtime. Two-GPU A6000 gate: a straight wire crossing the x split is bitwise (0.000e+00) single-versus-two-GPU on all six fields and wire current/charge at a short horizon while carrying ~36 A across the split; a closed lossless loop crossing the split preserves the discrete energy invariant to ~2e-9 relative even where individual field phases drift by chaotic float32 halo-curl accumulation over long runs. A trainable wire, a distributed CPML boundary, and a wire mixed with an embedded network or lumped circuit fail closed at prepare. Finite-conductor loss and distributed wire reverse/gradient communication are deferred. |
| Monitors | Point spectral monitor, point FieldTimeMonitor, a valid DipoleEmissionMonitor, and spectral PlaneMonitor/FinitePlaneMonitor/FluxMonitor/ModeMonitor |
y/z-normal planes are tiled across x and stitched from owned component intervals on result_device; x-normal planes have exactly one shard owner. Five Plane/Flux/Mode A6000 numerical cases observed exact single/two-GPU parity. Closed-surface, diffraction, flux-time, non-point field-time, and material monitors remain rejected. |
| Spectral output | One or many requested frequencies; supported point/plane monitor assembly; optional gathered electric fields | Multiple frequencies preserve frequency metadata and leading frequency dimension. |
| Persistence | Gathered save/load and distributed save_sharded/load_sharded |
Lazy sharded load leaves fields empty and rank tensors unopened; explicit gather validates and stitches owned intervals. Persistence does not restore live solver/transport state. |
| Source normalization | Accepted only when exactly one logical source is present | Multiple logical sources fail fast when normalization is requested. |
| Auto shutoff | Global owned-electric-energy reduction on result_device |
steps_run, halo totals, and normalization reflect early termination. |
| CUDA Graph | Rejected | Peer communication is not graph captured. |
| Plotting | Rejected during solve | Run with gather_fields=True, then consume the gathered result explicitly. |
| Trainable scenes / adjoint (Box density, standard path) | Accepted with A6000 parity | A trainable Box MaterialRegion density on the pure real standard (open/PEC boundary) path routes through the distributed joint-solve adjoint bridge: per-shard forward checkpoints, a transposed reverse step per forward step (Phase 1 → transposed magnetic halo → Phase 2 → transposed electric halo → Phase 3 → per-shard source-term eps grad), and a global grad_eps gather feeding the existing single-GPU material pullback once. Two-GPU acceptance: 1-vs-2-GPU objective parity (bit-identical forward) and gradient parity (rtol 1e-4, atol tied to grad magnitude); central finite differences (~1e-5 relative) on density texels on the x-split and interior to each shard, with the source and point-monitor objective on the interface node; 1-vs-2-GPU full-field-DFT objective gradient parity (right-half EZ power loss isolating the non-result shard's cotangent scatter leg); a checkpoint-capture ordering contract (every mid-loop capture preceded by a stream sync); and bitwise-reproducible gathered grad_eps. Point-monitor spectra and full-field DFT objectives only. |
| Trainable scenes / adjoint (other channels) | Rejected before distributed allocation | Trainable geometry, material perturbation, circuit, RF/port, and embedded-network (residue/direct-term) parameters have no verified distributed reverse core; any absorbing (PML) boundary (CPML/stable-PML and the legacy graded-sigma pml/absorber absorbers), dispersive/conductive/nonlinear/anisotropic/modulated media, field shutoff, and tiled plane/flux/mode-seeded objectives are rejected at prepare before any distributed allocation. Absorbing-boundary-trainable and tiled-monitor seed scatter are the remaining follow-ups (S4/S5). |
| NCCL transport primitive | Rank-local primitive implemented and conformance-tested | fdtd/distributed/nccl_transport.py provides the one-process-per-GPU (torchrun) transport surface: forward electric/magnetic Yee x-plane halos via batch_isend_irecv, transposed reverse-halo adjoint accumulations, a scalar all-reduce, an env/world-size/non-Linux failure matrix, an adopted-process-group world-size/rank/backend validation (accepting a composite cuda:nccl backend spec, failing closed otherwise), a cross-rank homogeneity check, and deterministic teardown with a vanished-group guard. A two-rank torchrun worker (tests/fdtd/multi_gpu/test_nccl_transport.py) asserts bitwise halo round-trips (including endpoint-ghost negative invariants, the matching-group adopt path, and the shared-group teardown contract) and adjoint accumulation. |
| NCCL no-silent-fallback guard | Pre-existing guard, now pinned | The single-process transport="nccl" torchrun RuntimeError pre-dates this slice; it is now pinned by tests that assert it from both DistributedFDTD and the public Simulation.prepare() path, and that the in-process CudaP2PHaloTransport is never constructed as a fallback (P2P monkeypatched to fail if reached). |
| End-to-end NCCL forward solve (2 ranks) | Qualified for the standard forward field solve | Under torchrun --nproc-per-node=2, each rank builds its own ShardEngine and the coordinator runs the serialized time loop over NcclHaloTransport; rank 0 gathers the global full-field DFT via sized point-to-point and matches an independent single-GPU reference at rtol 5e-5 / atol 5e-6 (tests/fdtd/multi_gpu/_nccl_forward_worker.py). A rank-death test confirms bounded failure propagation. |
| NCCL monitors / adjoint / coupled runtimes / shutoff / multi-node | Not landed | The NCCL forward path fails closed on per-monitor payload gather, trainable-density adjoint, coupled circuit/network/wire/port scenes, and field shutoff (each supported on transport="cuda_p2p"). Three/four-GPU and cross-node NCCL are out of scope. |
| Three or four GPUs | Structurally representable by the partition and neighbor transport | Not qualified in the current hardware acceptance record; no validation claim is made here. |
Configuration, preparation, or the solve preflight raises a specific error for the following cases rather than silently changing the physics or execution model:
- fewer than two devices, duplicate/unindexed/non-CUDA devices, a non-x axis, or a
result_deviceoutside the participant list; - unavailable devices, heterogeneous device model/compute capability, any missing
bidirectional neighbor P2P link, or missing bidirectional P2P between
result_deviceand a shard; - too few physical x cells for the shard count/halo, or insufficient device memory for local DFT state or an explicitly requested gather;
- trainable geometry, material-perturbation, circuit, or RF/port parameters (a
trainable
BoxMaterialRegiondensity on the standard path is accepted and routed to the distributed adjoint bridge; a non-Box density region still fails); - for a trainable distributed run: any absorbing (PML) boundary (CPML/stable-PML or
the legacy graded-sigma
pml/absorberabsorbers), dispersive/conductive/nonlinear/ anisotropic/modulated media, field shutoff, or a tiled plane/flux/mode-seeded objective (point-monitor and full-field-DFT objectives only); - nonlinear media, full off-diagonal electric anisotropy, or lossy-metal/SIBC ownership;
- Bloch boundaries, periodic x, or x-axis symmetry;
- mode ports, unbound lumped/terminal ports, one bound port whose voltage edges cross an x-slab split, unsupported source classes, non-ideal point-dipole profiles, or an invalid dipole-emission source reference;
ClosedSurfaceMonitor,DiffractionMonitor,FluxTimeMonitor, non-pointFieldTimeMonitor,PermittivityMonitor, andMediumMonitor;- an ordinary non-flux x-normal
PlaneMonitororFinitePlaneMonitorthat requestsExexactly on an internal x partition node. The owner lacks a current monitor-only low-Exhalo; move the plane off the split, removeEx, or use a tangential-fieldFluxMonitor/ModeMonitor; cuda_graph=True, solve-time plotting, NCCL transport, or source normalization with more than one logical source.
This list is intentionally narrower than the single-GPU FDTD feature list. Removing a guard requires interface-aware numerical parity and hardware evidence; accepting a scene locally is not enough.
Use either result.solver_stats["parallel_stats"] or
result.stats()["parallel_stats"]. The nested record includes:
devices,decomposition_axis,transport, and the peertopologysnapshot;overlap_requestedand the truthfuloverlap_activeresult;gather_fields,result_device,gather_preflight, and per-devicedft_preflight;- per-rank physical/global cell and node extents in
partitions; halo_bytes_per_stepandhalo_bytes_total;- per-device
peak_memory_bytesbefore gathering andpeak_memory_bytes_including_gatherafter output collection; wall_time_s.
Circuit-coupled runs additionally expose parallel_stats["circuit"], including
the circuit and per-port owner ranks, same-shard and remote-port counts, scalar
transfers, owner copy acknowledgements, bytes per step/total, and the
circuit-checkpoint owner. Embedded-network-coupled runs expose the same shape
under parallel_stats["network"] (network and per-port owner ranks, connected
and remote-port counts, scalar transfers, copy acknowledgements, bytes per
step/total, and communication_order == "O(connected_ports)"). These
statistics count only circuit/network scalar traffic; halo bytes remain in the
top-level halo counters.
compute_time_s, communication_time_s, and
exposed_communication_time_s are currently None. Per-phase timing would require
synchronization that is deliberately absent from the production loop; use a CUDA
profiler and retain the accompanying timing_note instead of interpreting these keys
as zero communication cost.
Run the tests and benchmark from the repository root with both A6000 devices visible.
The examples below use the repository's maxwell environment; an equivalent Python
environment with the current native extension is acceptable.
nvidia-smi topo -m
/home/xingyu/miniconda3/envs/maxwell/bin/python -m pytest \
tests/fdtd/multi_gpu -q --basetemp=/tmp/witwin-mgpu-pytest
/home/xingyu/miniconda3/envs/maxwell/bin/python \
scripts/dev/fdtd/multi_gpu/bench_joint.py \
--devices cuda:0 cuda:1 \
--nodes-x 257 --nodes-y 257 --nodes-z 257 \
--steps 100 --warmups 1 --repeats 5 \
--weak-scaling --p2p-size-mib 256 \
--assert-gates \
--json /tmp/fdtd-multi-gpu-a6000.jsonRun --assert-gates only on an otherwise idle, thermally stable host. The harness
checks a six-component one-versus-two-GPU diagnostic, both P2P directions, strong and
weak scaling, and peak memory. Its default gates are:
- diagnostic maximum absolute error at most
2e-6, maximum relative error at most2e-5, finite fields, and nonzero reference signal; - median P2P bandwidth at least 40 GB/s;
- strong-scaling speedup at least 1.0;
- weak-scaling efficiency at least 0.70;
- maximum per-GPU peak at most 1.15 times the ideal half-single-GPU allocation, plus any explicitly supplied fixed-overhead allowance.
The benchmark's default gather_fields=False measures the scalable monitor-first
solve. Run an additional --gather-fields pass when validating result assembly, but
do not mix gather allocation/time into the core scaling number.
The circuit-owner acceptance case is a real single-versus-two-GPU solve, not a mock transport test. It places two bound ports on different x slabs, exercises the same-shard fast path and one bidirectional remote scalar exchange, and compares gathered fields, port phasors, circuit time series, result placement, communication accounting, and checkpoint ownership:
python -m pytest \
tests/fdtd/multi_gpu/test_circuit_owner.py::test_physical_two_gpu_circuit_matches_single_gpu_and_reports_scalar_contract \
-qThe gate requires two homogeneous GPUs with bidirectional CUDA peer access and uses
rtol=2e-5 for field/circuit parity. It skips whenever the run sees fewer than two
peer-accessible devices, in which case the file reports 6 passed, 1 skipped and the
skipped item is exactly this gate. On 2x RTX A6000 with both devices visible the file
reports 7 passed, and this gate passes by an exact (bitwise, 0.000e+00) margin on all
six fields, both bound-port voltages/currents, and the circuit node/branch series. The
full deviation table and the scalar-transfer contract are recorded in
docs/assessments/spice-mna-phase-4-acceptance.md.
The network-owner acceptance case mirrors the circuit gate for a two-port embedded
network split across x slabs. A PointDipole(profile="ideal") excites a scene whose
two connected LumpedPort terminals sit on different shards; the owner runs the sole
state-space recurrence and same-step LU solve, and the run compares gathered fields,
port phasors, network V/I, network state, result placement, the scalar-communication
contract, and checkpoint ownership:
python -m pytest \
tests/fdtd/multi_gpu/test_network_owner.py::test_physical_two_gpu_network_matches_single_gpu_and_reports_scalar_contract \
-qThe gate requires two homogeneous GPUs with bidirectional CUDA peer access and uses
rtol=2e-5 for field/port/network parity. It skips whenever fewer than two
peer-accessible devices are visible. On 2x RTX A6000 with both devices visible this
gate passes by an exact (bitwise, 0.000e+00) margin on all six fields, both connected
port voltages/currents, the network V/I, and the network state, and asserts the
parallel_stats["network"] scalar contract (owner rank, per-port owner ranks, two
scalar transfers per step, one owner copy-acknowledgement, O(connected_ports)). The
file's ownership-unit and fail-closed cases (deterministic owner independent of
connection order, one terminal spanning a split, overlapping edges, network+circuit
rejection, and trainable+parallel rejection) run without GPUs.
The final bounded-operator source was rebuilt cleanly with the CUDA 13 toolchain before this acceptance record. Scoped checkpoints from that build are listed separately because their membership can evolve; no aggregate total is implied:
- the multi-GPU suite passed 109 tests, then the five dual-A6000 numerical Plane/Flux/Mode cases raised that suite checkpoint to 114 passed;
- the CUDA FDTD suite passed 65 tests;
- the public API suite passed 31 tests;
- the focused numerical/persistence matrix passed 7 tests.
These are final measurements for this engineering-preview implementation on the named two-A6000 host, not estimates from the pre-bounded-kernel prototype. The memory factor is the reported maximum per-GPU peak divided by ideal half of the one-GPU peak.
| Workload | One GPU | Two GPUs | Strong speedup | Weak efficiency | Memory factor | Numerical observation |
|---|---|---|---|---|---|---|
Vacuum 257 x 257 x 257, 100 steps |
3.4659 ms/step | 1.7559 ms/step | 1.97385 | 0.99274 | 1.01058 | Six-field diagnostic observed exact single/two-GPU agreement |
CPML dielectric 257 x 129 x 129, slab CPML |
1.0040 ms/step | 0.8116 ms/step | 1.23701 | — | 1.03698 | Diagnostic observed exact agreement |
Two-frequency full-field DFT 257 x 129 x 129 |
1.7001 ms/step | 1.1110 ms/step | 1.53026 | — | 0.76273 | Multi-frequency DFT numerical acceptance passed |
Two-frequency full-field DFT 129 x 65 x 65 |
0.3012 ms/step | 1.1284 ms/step | 0.26692 | — | passed | Numerically exact and memory gate passed; below the two-GPU break-even size |
The same host measured 52.65-52.69 GB/s direct P2P bandwidth across the two directions. The small case is intentionally retained: it shows that correct domain decomposition does not imply a speedup when launch and halo overhead dominate.
The acceptance host contained only two RTX A6000 GPUs connected through NV4. No three- or four-GPU system and no PCIe-only peer topology were available, so this record does not qualify either case. Nsight Systems and Nsight Compute were not available on the host; the benchmark therefore supplies no profiler trace or per-kernel roofline claim.
This remains an engineering preview. The distributed joint-solve adjoint is qualified
only for a trainable Box MaterialRegion density on the pure real standard path with
point-monitor/full-field-DFT objectives (1-vs-2-GPU objective/gradient parity and
interface finite differences; timing/speedup of the reverse pass is not measured here).
The measurements and tests above do not qualify
multi-node execution, the NCCL adjoint or NCCL monitor-payload gather, distributed
CPML-trainable adjoint or tiled-monitor adjoint seed scatter, peer-aware CUDA Graph
capture, advanced plane/beam/mode/TFSF sources, ports on the NCCL path, x-periodic
or Bloch decomposition, x symmetry, nonlinear media, full off-diagonal media, SIBC, or
any other guarded feature. The end-to-end one-process-per-GPU NCCL FORWARD solve IS
qualified on this host at the P2P tolerances (two-rank torchrun, seam-crossing
precondition, rank-death failure matrix; see the transport section above); every
unimplemented NCCL adjacency fails closed with a pinned test.