Repository navigation
feat(multi-gpu): GIL-safe P2P copies across the quantization lifecycle - #3489
Merged
Merged
Conversation
Build on the P2P module merged in #3479 rather than replacing it. P2P copies - Add `peer_copy_batch`: enqueue `cudaMemcpyPeerAsync`/`hipMemcpyPeerAsync` through `ctypes` (GIL released), with an event join from the source stream to the destination stream. It is gated on `enable_peer_access(...).can_copy` and drains the sticky CUDA/HIP last error on failure. - Add `move_module_to_device` / `move_nested_to_device` and route module moves, calibration input/mask/position_ids staging, and per-layer moves through them. The module fast path refuses non-contiguous and tied leaves, and waits for the destination stream before returning. - Reuse one Hessian staging buffer only when every ordered pair is enabled and free VRAM exceeds 1.1x the buffer; adopt shared Hessians with non-blocking CUDA-to-CUDA copies. - Skip `torch.nn.parallel.replicate` when the GIL is disabled, since it funnels into the broken `broadcast_coalesced` entry point. Warning - Log exactly one actionable warning when a multi-GPU run cannot enable every ordered pair: motherboard/BIOS/hardware compatibility and sub-optimal speed. GIL=0 ledger - Add `pytorch_gil_0_compat.md` recording the measured GIL=0 status of every PyTorch API we use, plus workarounds. `broadcast_coalesced` is documented as broken on both ROCm (6/6: 4 hangs, 2 torn copies) and CUDA (6/6 torn copies). - Add `scripts/gil0_api_probe.py` (per-API subprocess timeout) and the `gptqmodel-pytorch-gil0` skill, and require the ledger in AGENTS.md. Tests - Add `tests/test_p2p_copy_utils.py`: batch roundtrip, heterogeneous and non-contiguous rejection, module/nested moves, cross-stream visibility, CPU fallback, an 8-thread concurrency test, a no-`torch.cuda.comm` regression guard, and the once-only warning.
Replace the GPTQMODEL_P2P* and GPTQMODEL_USE_TORCH_REPLICATE environment variables with a P2PConfig dataclass exposed as BaseQuantizeConfig.p2p and as the p2p= argument of GPTQModel.load/from_pretrained/from_quantized. - P2PConfig: mode (auto/on/off), probe, probe_mib, min_gbps, min_ratio, use_torch_replicate; dicts are accepted anywhere a config is. - configure_p2p()/get_p2p_config() install the process-wide policy; the loopers install BaseQuantizeConfig.p2p before enabling peer access and the inference loader installs load(..., p2p=...). - clone_module_for_devices reads use_torch_replicate at call time. - Update tests, benchmark script, README, multi-GPU guide, AGENTS rule, skill and the GIL=0 ledger; record the measured payload dependence of broadcast_coalesced and the NCCL hang in reduce_add.
Peer access is an accelerator concept. Document that P2PConfig does not affect CPU-only runs, and add a regression test that a forced probing policy still leaves CPU transfers on their regular path: no pairs, no warning, and the peer helpers return not-applicable.
HIP associates a stream with the calling thread's current device context, not with the device that owns the stream. peer_copy_batch enqueued the copy while the source device was current, so the work landed on the source device's queue and the destination stream -- and Tensor.cpu() -- never waited for it. Measured on 4x R9700 with PYTHON_GIL=0: p2p_move_nested of a dict whose first leaf is 32 KiB read back uninitialised memory in 24/24 single-threaded trials, and a concurrent 8-thread run tore 3/3 processes. torch.cuda.synchronize(destination) did not fix it; synchronising the source did, which pinned the copy to the wrong queue. Wrap the stream lookup, the event join and the copy loop in `with torch.cuda.device(target_device)`. After the fix: 24/24 concurrent tasks byte-exact in 3/3 processes, no synchronize needed before a read on the destination device. CUDA ordered the copy correctly either way, so the defect is HIP-specific, but the explicit scope is correct on both. Add test_p2p_move_nested_is_ordered_on_destination_stream (fails on the pre-fix tree) and record the finding in pytorch_gil_0_compat.md and the multi-GPU docs. Also correct the copy-path docs: they claimed coalescing and a speedup, but peer_copy_batch issues one peer copy per leaf. Interleaved A/B on 2x RTX 4090 shows module moves at parity across 12 shapes and calibration staging ~11% slower; end-to-end quantization is within run-to-run noise.
The peer-copy path allocates a destination and enqueues an event join per leaf, so it only wins when dispatch overhead dominates the transfer. Measured within one process against Tensor.to (2x RTX 4090, median of 3 runs x 10 roundtrips, 12 module shapes): 128-256 KiB leaves are 3-15% faster (256x256 -10.9%, 64x256 -15.2%), while 2 MiB and larger leaves are 0-6% slower (2x2048 +6.3%). Gate both the module and the nested path on average leaf size (256 KiB, _PEER_COPY_MAX_AVG_LEAF_BYTES). Large leaves fall back to the regular move, which brings every large-leaf shape back within 1.6% of the pre-change tree while keeping the small-leaf win. Real coalescing (pack into one buffer, one peer copy, scatter) was prototyped and measured 7-17% slower on every shape: it replaces N peer transactions with 2N local ones plus one peer transaction. Not used; the docs record the result so it is not re-attempted blind. Tests: add test_p2p_move_nested_skips_large_leaves and move the peer-path tests onto leaves inside the win region.
…-init Kernels and the P2P peer-copy path both assume contiguous parameters and buffers. That already holds, so this adds a check rather than a rewrite. Measured before adding anything: a tiny Qwen3 MoE quantized on CPU is 0/26 non-contiguous after load, 0/74 after quantize, 0/74 after save and 0/106 after reload. On CUDA, TORCH, GPTQ_TORCH and GPTQ_TRITON are all contiguous through load+quantize. The remaining backends (Marlin, ExLlamaV2, Swordfish, BitBLAS, W4AFP8) could not be instantiated in this environment, so they stay unverified rather than assumed good. Contiguity is also not the reason coalescing needs a pack step, so forcing tensors contiguous would not remove it: leaves are contiguous but live in separate allocations (4x Linear(256,256) gives 8 distinct storages with allocator gaps), which no single memcpy can span. - add gptqmodel/utils/tensor_layout.py with iter_model_tensors, find_non_contiguous_tensors and assert_model_tensors_contiguous; tied weights are reported once and meta tensors are skipped - assert the invariant across load, quantize and reload in the tiny MoE smoke test - log the non-contiguous fallback in broadcast_module_to_device instead of silently skipping the peer path, since it usually means a load path or kernel dropped its .contiguous() - document the contiguity/adjacency distinction next to the coalescing result so it is not mistaken for the blocker again
This was referenced Nov 5, 2026
Resolve the P2P conflicts by keeping main's asymmetric/unverified host-staging safety fixes and porting this branch's P2PConfig, GIL-safe runtime peer copies, tensor-layout invariant checks, and looper wiring on top. Default use_torch_replicate to False to preserve main's replicate safety posture. Update the P2P tests for the config API and the move_to -> move_module_to_device call-site change.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Builds on the P2P module merged in #3479 rather than replacing it: it keeps
enable_peer_access/enable_for_devices/PeerStatus/peer_matrixandtests/test_p2p.py, and adds the pieces #3479 does not cover.What this adds
Configuration, not environment variables
P2P behavior is a config object. The former
GPTQMODEL_P2P*/GPTQMODEL_USE_TORCH_REPLICATEenv switches are gone:P2PConfig(mode=..., probe=..., probe_mib=..., min_gbps=..., min_ratio=..., use_torch_replicate=...); a plain dict is accepted anywhere a config is.p2p=is also onfrom_pretrained/from_quantized. The loopers installBaseQuantizeConfig.p2pbefore enabling peer access, and the inference loader installsload(..., p2p=...).Peer access is an accelerator concept:
P2PConfigis a no-op for CPU-only runs and for CPU<->CPU transfers. CPU devices are skipped, no warning is emitted, and the CPU move helpers keep their regular copy path.GIL-safe runtime peer copies
peer_copy_batch(sources, targets)drivescudaMemcpyPeerAsync/hipMemcpyPeerAsyncthroughctypeswith the GIL released. It joins the source stream to the destination stream with an event, enqueues on the destination's current stream, gates onenable_peer_access(...).can_copy(src, dst), and drains the sticky CUDA/HIP last error on failure. No custom kernel is needed: the runtime peer copy is already the native DMA path, and a hand-written kernel would be slower for the large contiguous copies we move.broadcast_coalescedis confirmed broken on both backends underPYTHON_GIL=0:2.16.0.dev20261025+rocm10.1, Python 3.14.10t): 6/6 realistic trials failed (4 hangs, 2 torn copies); a 4-trial rerun gave 1 pass, 2 torn copies and 1 SIGSEGV.2.16.0.dev+cu130, Python 3.14.10t): 6/6 trials torn; a 10-trial rerun gave 8 torn copies + 2 hangs.torch.cuda.comm.reduce_addis also unsafe: SIGSEGV on ROCm, and hangs 2/3 trials on NVIDIA insidenccl.reduce.The fast path is guarded by a regression test that makes every
torch.cuda.comm.*entry point raise.The enqueue runs with the destination device current. HIP binds a stream to the calling thread's current device context, not to the device that owns the stream: issuing the copy while the source device was current put the work on the source queue, where the destination stream and
Tensor.cpu()never waited for it. Measured on 4x R9700 withPYTHON_GIL=0, ap2p_move_nestedwhose first leaf was 32 KiB read back uninitialised memory in 24/24 single-threaded trials and tore 3/3 concurrent processes;synchronize(destination)did not fix it,synchronize(source)did. After wrapping the enqueue inwith torch.cuda.device(target_device): 24/24 tasks byte-exact in 3/3 processes, with no synchronize needed before a read on the destination device. CUDA ordered the copy correctly either way, so the defect is HIP-specific, but the explicit scope is correct on both. Regression test:test_p2p_move_nested_is_ordered_on_destination_stream.Contiguity invariant
Kernels and the peer-copy path both assume contiguous parameters and buffers. That already holds today, so this adds a check instead of a rewrite:
gptqmodel/utils/tensor_layout.pyprovidesiter_model_tensors,find_non_contiguous_tensorsandassert_model_tensors_contiguous, and the tiny MoE smoke test asserts the invariant across load, quantize and reload.broadcast_module_to_devicenow logs the non-contiguous fallback rather than silently skipping the peer path.Measured: 0/26 non-contiguous after load, 0/74 after quantize, 0/74 after save, 0/106 after reload (CPU TORCH); CUDA TORCH, GPTQ_TORCH and GPTQ_TRITON are clean through load+quantize. Marlin, ExLlamaV2, Swordfish, BitBLAS and W4AFP8 could not be instantiated in this environment and remain unverified.
This also settles why coalescing needs a pack step: it is not a strided-layout problem. Contiguous leaves still live in separate allocations (4x
Linear(256,256)yields 8 distinct storages with allocator gaps), which no single memcpy can span. Forcing contiguity would not remove the pack.Lifecycle wiring
move_module_to_device/move_nested_to_deviceinlooper_helpers.pyusepeer_copy_batch; the module path refuses non-contiguous and tied leaves and waits for the destination stream before returning.module_looper.py/weight_only_looper.pyuse the new helper while keeping upstream'senable_for_devices(...),_cross_device_pool(), andquant_devicestate.position_ids/kwarg staging inforward_executor.pyandforward_batch_workerusesmove_nested_to_device.adopt_hessian_fromuses non-blocking CUDA-to-CUDA copies.clone_module_for_devicesskipstorch.nn.parallel.replicatewhen the GIL is disabled, because it funnels intobroadcast_coalesced.One-time actionable warning
When a multi-GPU run cannot enable/validate every ordered pair,
enable_peer_accesslogs exactly one warning explaining that the GPUs do not have usable P2P because of motherboard compatibility (PCIe wiring, ACS/IOMMU), BIOS compatibility (Above 4G decoding, Resizable BAR, PCIe generation), or hardware compatibility, and that speed may be sub-optimal. The existing per-run info summary is unchanged.GIL=0 ledger, skill, probe
pytorch_gil_0_compat.mdrecords the measured GIL=0 status of every PyTorch API we use, including the both-backendbroadcast_coalescedandreduce_addresults, with workarounds.scripts/gil0_api_probe.pyregenerates the table with a per-API subprocess timeout so hangs are recorded instead of wedging a run..agents/skills/gptqmodel-pytorch-gil0/SKILL.mdplus anAGENTS.mdrule require reading and updating the ledger for threaded PyTorch work.Performance
Measured against the pre-change tree (
upstream/mainatca525580), 2x RTX 4090, within one process so both paths see identical clocks, median of 3 runs x 10 roundtrips over 12 module shapes:Tensor.towith a runtime peer copy pays off._PEER_COPY_MAX_AVG_LEAF_BYTES(256 KiB) now sends those modules back throughmove_to, so every large-leaf shape lands within 1.6% of the pre-change tree.dense_vram_strategy=BALANCED: medians 22.56 s (main) vs 24.58 s (this branch) with ±20% run-to-run variance. Compute dominates.For completeness:
mainis not observably broken underPYTHON_GIL=0today, becauseclone_module_for_devicesserializestorch.nn.parallel.replicatebehind a device lock, sobroadcast_coalescednever runs concurrently there (8 threads x 16 clones, 3/3 processes passed). This branch removes the reliance on that lock instead of relying on it.Verification
On 2x RTX 4090, torch 2.15.0+cu132 (GIL build):
pytest tests/test_p2p.py tests/test_p2p_copy_utils.py tests/test_looper_helpers.py tests/test_hessian_device_reduction_order.py tests/test_looper_concurrency.py tests/test_gptq_hessian_pipeline.py tests/test_tiny_moe_quant_smoke.py -q-> 97 passedruff checkandpy_compilepass on every changed Python file.On 4x R9700, torch 2.16.0.dev20261025+rocm10.1, Python 3.14.10t,
PYTHON_GIL=0(sys._is_gil_enabled() is False):pytest tests/test_p2p.py tests/test_p2p_copy_utils.py tests/test_looper_helpers.py tests/test_tensor_layout.py -q-> 49 passedNo test was skipped or disabled to obtain these results; P2P-dependent cases skip only when the host genuinely lacks a usable directed pair.