Skip to content

feat(multi-gpu): GIL-safe P2P copies across the quantization lifecycle - #3489

Merged
Qubitium merged 7 commits into
mainfrom
feat/multi-gpu-p2p
Nov 9, 2026
Merged

Qubitium merged 7 commits into
mainfrom
feat/multi-gpu-p2p

Conversation

@Qubitium

@Qubitium Qubitium commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Builds on the P2P module merged in #3479 rather than replacing it: it keeps enable_peer_access / enable_for_devices / PeerStatus / peer_matrix and tests/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_REPLICATE env switches are gone:

from gptqmodel import GPTQModel
from gptqmodel.quantization.config import QuantizeConfig
from gptqmodel.utils.p2p import P2PConfig

config = QuantizeConfig(bits=4, p2p=P2PConfig(mode="auto", probe=False))
model = GPTQModel.load("/path/to/quantized", p2p=P2PConfig(probe=True))

P2PConfig(mode=..., probe=..., probe_mib=..., min_gbps=..., min_ratio=..., use_torch_replicate=...); a plain dict is accepted anywhere a config is. p2p= is also on from_pretrained / from_quantized. The loopers install BaseQuantizeConfig.p2p before enabling peer access, and the inference loader installs load(..., p2p=...).

Peer access is an accelerator concept: P2PConfig is 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) drives cudaMemcpyPeerAsync / hipMemcpyPeerAsync through ctypes with the GIL released. It joins the source stream to the destination stream with an event, enqueues on the destination's current stream, gates on enable_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_coalesced is confirmed broken on both backends under PYTHON_GIL=0:

  • ROCm 4x R9700 (torch 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.
  • NVIDIA 2x RTX 4090 (torch 2.16.0.dev+cu130, Python 3.14.10t): 6/6 trials torn; a 10-trial rerun gave 8 torn copies + 2 hangs.
  • Payload dependence: two 8192-element tensors passed 8/8, but 1024x1024 (4 MiB) payloads tore 7/8 with one hang. Treat any size as unsafe.

torch.cuda.comm.reduce_add is also unsafe: SIGSEGV on ROCm, and hangs 2/3 trials on NVIDIA inside nccl.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 with PYTHON_GIL=0, a p2p_move_nested whose 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 in with 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.py provides iter_model_tensors, find_non_contiguous_tensors and assert_model_tensors_contiguous, and the tiny MoE smoke test asserts the invariant across load, quantize and reload. broadcast_module_to_device now 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_device in looper_helpers.py use peer_copy_batch; the module path refuses non-contiguous and tied leaves and waits for the destination stream before returning.
  • Module replication and per-layer moves in module_looper.py / weight_only_looper.py use the new helper while keeping upstream's enable_for_devices(...), _cross_device_pool(), and quant_device state.
  • Calibration input/mask/position_ids/kwarg staging in forward_executor.py and forward_batch_worker uses move_nested_to_device.
  • Hessian partial reduction reuses one staging buffer only when P2P is fully enabled and free VRAM is > 1.1x the buffer; adopt_hessian_from uses non-blocking CUDA-to-CUDA copies.
  • clone_module_for_devices skips torch.nn.parallel.replicate when the GIL is disabled, because it funnels into broadcast_coalesced.

One-time actionable warning

When a multi-GPU run cannot enable/validate every ordered pair, enable_peer_access logs 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.md records the measured GIL=0 status of every PyTorch API we use, including the both-backend broadcast_coalesced and reduce_add results, with workarounds.
  • scripts/gil0_api_probe.py regenerates the table with a per-API subprocess timeout so hangs are recorded instead of wedging a run.
  • .agents/skills/gptqmodel-pytorch-gil0/SKILL.md plus an AGENTS.md rule require reading and updating the ledger for threaded PyTorch work.

Performance

Measured against the pre-change tree (upstream/main at ca525580), 2x RTX 4090, within one process so both paths see identical clocks, median of 3 runs x 10 roundtrips over 12 module shapes:

  • Small leaves: 3-15% faster. 256x256 modules (256 KiB per leaf) move in 11.64 ms vs 13.07 ms (-10.9%), and 64x256 in 2.80 ms vs 3.30 ms (-15.2%). At this size the per-copy dispatch cost dominates the transfer, so replacing Tensor.to with a runtime peer copy pays off.
  • Large leaves: gated to the regular path. The peer copy needs an extra destination allocation and an event join per leaf, which costs 0-6% at 2 MiB and above (2x2048 was +6.3%). _PEER_COPY_MAX_AVG_LEAF_BYTES (256 KiB) now sends those modules back through move_to, so every large-leaf shape lands within 1.6% of the pre-change tree.
  • Coalescing does not work here. Packing 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. Not used.
  • End-to-end: no meaningful difference. 438M-parameter synthetic Llama, 12 layers, 8x256-token calibration, 2 GPUs, 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: main is not observably broken under PYTHON_GIL=0 today, because clone_module_for_devices serializes torch.nn.parallel.replicate behind a device lock, so broadcast_coalesced never 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 passed
  • ruff check and py_compile pass 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 passed
  • Concurrent module-move + nested-staging harness (8 threads x 24 tasks, byte-exact assertions) -> 24/24 in 3/3 processes, both implementations.

No test was skipped or disabled to obtain these results; P2P-dependent cases skip only when the host genuinely lacks a usable directed pair.

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.
Comment thread scripts/gil0_api_probe.py Resolved
Comment thread gptqmodel/utils/p2p.py Resolved
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
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.
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