Skip to content

[WIP]: [Fix] Per-rank SHM for EP offload + streaming 1× host peak - #64

Open
cennn wants to merge 44 commits into
mainfrom
fix/ep-offload-weight-corruption
Open

[WIP]: [Fix] Per-rank SHM for EP offload + streaming 1× host peak#64
cennn wants to merge 44 commits into
mainfrom
fix/ep-offload-weight-corruption

Conversation

@cennn

@cennn cennn commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Motivation

model_cpu_offload=True materializes weights into /dev/shm so ranks on one node can share pages. On main, rank 0 writes one file and every rank maps it. That is correct when all ranks hold the same weights. With EP>1 each rank holds a different expert shard, so the shared file silently replaces every shard with rank 0's — MoE routing still runs, but the experts are numerically wrong (garbled video).

The same main path also builds a full flat_buffer = torch.zeros(total_numel) before tofile(), so host anonymous memory peaks at ~2× model size. That is the reason concurrent per-rank materialize used to look unsafe.

Separately, offload tracing leaves .to(device("cpu")) literals and CPU example_values in the FX graph. main only rewrites factory device= kwargs, so Inductor still hits mixed-device FakeTensor errors (aten.index_select on cuda + cpu).

What this PR does

  1. Keep EP shards. ep_size comes from MAGI_COMPILE_TOPOLOGY_KEY (get_topology_dim("ep")). EP>1 writes _rank{N}.bin per rank; EP≤1 still shares one file (rank 0 writes, others map). Same mmap + pin_memory_in_place H2D path as main.

  2. Stream instead of batch-copy. _stream_copy_and_replace copies one parameter into the mmap and immediately swaps it into the module. Peak extra Anonymous memory is ~1/N of the model, not a second full copy. Dense / EP=1 is not slower.

  3. Rewrite offload-traced CPU devices. fix_graph_device_placement remaps .to(cpu) / device=cpu and recursively moves CPU example_values to the current CUDA device. Metadata-only is not enough — the .to(cpu) node itself must move.

Evidence

Minimal EP repro (test_ep_shared_memory.py, 2 ranks, gloo, no GPU): per_rank=False → both ranks end with identical weights (bug); per_rank=True → each rank keeps its own shard.

Host peak (test_shm_memory_peak.py, 256 MB dummy, smaps_rollup Anonymous at the known peak site):

Path Anonymous growth vs model Wall time
main-style flat_buffer 260 MB 1.02× 0.331 s
streaming (this PR) 4 MB 0.02× 0.220 s (0.67×)

Device mismatch (test_fix_to_cpu_in_graph.py): graph x.index_select(0, mapping.to(cpu)) — metadata-only rewrite raises FakeTensor device error; full rewrite runs on CUDA and leaves no residual .to(cpu).

Cluster: 8×5090 base compile+offload warmup completes on the SHM path; all-5090 E2E (textenc CPU + base/sr/vae 5090) produced valid videos.

[attachment: garbled vs fixed video, and base warmup [offload] after SHM materialize logs — to be pasted]

Other

  • _force_cpu skips the GPU round-trip when the tensor is already on CPU and fn does not change dtype (common model.cuda() offload apply).
  • fmt_host_mem() one-liner after _force_cpu / SHM / gc.collect.

cennn added 4 commits August 27, 2026 18:59
_patch_cpu_offload_apply created a single shared-memory file from
local_rank=0 and had all ranks read it. With expert parallelism (EP>1),
each rank holds a different expert shard; reading rank-0 data on every
rank destroyed expert weight diversity and produced garbled video output.

Fix: when EP_SIZE>1, fall back to per-rank pin_memory instead of
cross-rank shared-memory dedup.

Also: move model weights to CUDA before Dynamo tracing (_deep_cuda) so
Dynamo captures the fused Triton kernel path instead of the decomposed
Python fallback, and extend _fix_graph_device_placement to fix ALL FX
nodes with CPU example_values (not just get_attr/placeholder).
…tion

Two tests using torch.multiprocessing.spawn with gloo backend:

1. test_shared_memory_overwrites_ep_shards:
   Reproduces the bug — local_rank=0 writes expert weights to a shared
   file, all other ranks read it, silently overwriting their own expert
   shards with rank 0's data.

2. test_ep_fix_preserves_per_rank_shards:
   Verifies the fix — when EP_SIZE > 1, the shared-memory path is
   skipped and each rank retains its own expert weights.
ENGINE_CONFIG__EP_SIZE may not be set if the host framework
(e.g. disagg_compute_runner) only sets EP_SIZE or configures
ep_size programmatically. Fall back to EP_SIZE env var before
defaulting to 1.
When EP_SIZE > 1, each rank holds a unique expert shard. The previous
fix skipped shared memory entirely and used pin_memory, which was
extremely slow for large models (~46GB per rank).

Now each rank writes its own shared-memory file to /dev/shm and
mmap-reads it back, preserving per-rank expert weights while keeping
the speed benefit of shared memory + pin_memory_in_place on
already-resident pages.

For EP_SIZE <= 1, the original rank-0-writes-all-read scheme is
retained (all ranks have identical weights).

Also updates the regression test to verify the per-rank shm path.
@cennn cennn changed the title fix(offload): skip shared-memory weight dedup when EP>1; move model to CUDA before tracing fix(offload): per-rank shared memory for EP>1 to prevent expert weight corruption Aug 27, 2026
cennn added 12 commits August 28, 2026 00:59
…_apply

With EP>1, all 8 ranks simultaneously created ~43GB flat_buffer + wrote ~43GB
to /dev/shm = ~87GB per rank x 8 = ~700GB, exceeding 512Gi container limit.

Fix: serialize writes across ranks (one at a time) and write directly into
mmap file (no flat_buffer). Peak memory drops from ~700GB to ~392GB.
1. _force_cpu: skip GPU roundtrip for CPU tensors when fn only changes
   device (not dtype). Reduces peak host memory during model.cuda() by
   avoiding temporary CUDA host allocations for every parameter.

2. MAGI_OFFLOAD_SKIP_SHM: when set to "1", skip shared memory creation
   and pin_memory_in_place entirely. Params remain as regular CPU tensors.
   This allows OffloadExecutor to work on memory-constrained nodes (e.g.
   5090 with 512Gi container limit for 8x EP ranks) where the shm+pin
   overhead causes OOM.
When converting CPU example_value metadata to CUDA for Inductor, the
.to(device) call strips torch.nn.Parameter wrapping. This caused
OffloadExecutor to misidentify all model weights as regular input
tensors, loading all 43.6GB onto GPU simultaneously instead of
offloading per-submodule — OOM on 5090 (31GB VRAM).

Re-wrap the converted FakeTensor in nn.Parameter to preserve type info.
- OffloadExecutor: log per-step H2D vs compute breakdown when MAGI_OFFLOAD_DEBUG=1
  (cuda.synchronize between prefetch and compute for accurate wall-clock split)
- _patch_cpu_offload_apply: support MAGI_OFFLOAD_PIN_BUDGET_GB env var
  Pin up to N GB of weights per rank via cudaHostRegister (no SHM copy)
  for faster async H2D while staying within host memory budget
Log start/end time for each rank during staggered pin, plus OS memlock
limit. Helps debug slow NFS page faults during cudaHostRegister.
Replace sequential 1-rank-at-a-time pinning with parallel waves.
Auto-detects max concurrent ranks: total_ram/2 / per_rank_param_size.
Override via MAGI_OFFLOAD_PIN_CONCURRENCY env var.

512GB node, 43.36GB/rank → concurrency=5, 2 waves instead of 8.
Expected pin time: ~6min vs ~22min sequential.
The per-submodule cuda.synchronize() barriers prevented H2D/compute
pipeline overlap, reducing production throughput. Profiling data has
been collected; this debug scaffolding is no longer needed.
- Revert offload_warpper.py (all changes were unused imports after debug removal)
- Remove dead offload() __dict__ branch (call sites only pass tuple/dict)
- Extract 80-line inline pin logic into _staggered_pin_memory() helper
- magi_backend.py: use module-level os/magi_logger instead of inline imports
- Use %-formatting instead of f-string for logger calls
…fload_apply

- _shm_path(): centralize /dev/shm path construction
- _pack_params_flat(): copy named tensors into contiguous buffer
- _split_flat_to_params(): split flat buffer back to named param views
- _create_shm_tensor(): create mmap file + pack in one call
- Unify EP>1 and EP<=1 serialization (both use mmap now, remove numpy bf16/fp8 workaround)
- _patch_cpu_offload_apply SHM logic: ~135 lines -> ~45 lines
- Merge per_rank_shm and shared branches into single helper
- Collapse 3-way dispatch to 2-way (skip_shm vs materialize)
- Single cleanup point for del/gc.collect()
- Eliminate duplicated pin/append/split/remove/load_state_dict
@cennn cennn added the ci:run Trigger CI integration tests label Aug 29, 2026
@github-actions github-actions Bot removed the ci:run Trigger CI integration tests label Aug 29, 2026
@cennn cennn added the ci:run Trigger CI integration tests label Aug 29, 2026
@github-actions github-actions Bot removed the ci:run Trigger CI integration tests label Aug 29, 2026
…tracing

After _deep_cuda removal (e0c7277), Dynamo traces with CPU tensors and
specialises .to(x.device) as .to(device('cpu')) — a hardcoded literal in the
FX graph.  _fix_graph_device_placement already moves example_values to CUDA,
but these baked .to(cpu) nodes remained, causing index_select(CUDA, CPU) →
BackendCompilerFailed during PiecewiseCompileInterpreter.run().

Extend _fix_graph_device_placement to also rewrite:
  - call_method('to', device('cpu')) → call_method('to', device('cuda'))
  - call_function(..., device='cpu') → call_function(..., device='cuda')

Add regression test (test_fix_to_cpu_in_graph.py) that:
  1. Confirms metadata-only fix still produces the device mismatch
  2. Verifies the full rewrite resolves the error
  3. Ensures .to(dtype) calls are not affected
@cennn cennn added the ci:run Trigger CI integration tests label Aug 29, 2026
@github-actions github-actions Bot removed the ci:run Trigger CI integration tests label Aug 29, 2026
@cennn cennn added the ci:run Trigger CI integration tests label Aug 29, 2026
@github-actions github-actions Bot removed the ci:run Trigger CI integration tests label Aug 29, 2026
@cennn cennn added the ci:run Trigger CI integration tests label Aug 29, 2026
- Add gc.collect() to _batch_materialize for symmetry with streaming
- Move _split_flat_to_params to top-level import, remove inline import
- Remove unused _assign_param import
@cennn cennn added the ci:run Trigger CI integration tests label Aug 31, 2026
@github-actions github-actions Bot removed the ci:run Trigger CI integration tests label Aug 31, 2026
@cennn cennn added the ci:run Trigger CI integration tests label Aug 31, 2026
@github-actions github-actions Bot removed the ci:run Trigger CI integration tests label Aug 31, 2026
@cennn cennn added the ci:run Trigger CI integration tests label Aug 31, 2026
@github-actions github-actions Bot removed the ci:run Trigger CI integration tests label Aug 31, 2026
@cennn cennn added the ci:run Trigger CI integration tests label Aug 31, 2026
@github-actions github-actions Bot removed the ci:run Trigger CI integration tests label Aug 31, 2026
@cennn cennn added the ci:run Trigger CI integration tests label Aug 31, 2026
@github-actions github-actions Bot removed the ci:run Trigger CI integration tests label Aug 31, 2026
512MB model caused subprocess timeout in CI Docker containers.
256MB still clearly distinguishes batch (0.6x growth) from streaming
(0.08x growth). Threshold 0.4x accommodates smaller model sizes where
VmHWM overhead is proportionally lower.
@cennn cennn added the ci:run Trigger CI integration tests label Aug 31, 2026
@github-actions github-actions Bot removed the ci:run Trigger CI integration tests label Aug 31, 2026
@cennn cennn added the ci:run Trigger CI integration tests label Aug 31, 2026
@github-actions github-actions Bot removed the ci:run Trigger CI integration tests label Aug 31, 2026
…ss isolation

Major changes:
- _batch_materialize faithfully replicates the ORIGINAL production code
  (flat_buffer + tofile + from_file) instead of writing directly to mmap
- Switch metric from VmHWM to peak RssAnon via background polling thread.
  VmHWM could not distinguish batch from streaming (both create mmap).
  RssAnon cleanly isolates the flat_buffer overhead: batch=0.59x vs
  streaming=0.00x
- Replace mp.fork+Manager with subprocess.run for CI Docker compatibility
  (fork in multi-threaded environments caused 120s deadlocks in CI)
- Fix copyright year in test_ep_shared_memory.py (2025 -> 2026)
@cennn cennn added the ci:run Trigger CI integration tests label Aug 31, 2026
@github-actions github-actions Bot removed the ci:run Trigger CI integration tests label Aug 31, 2026
Replace RssAnon (per-CPU batched counter, ~40% under-reporting) with
smaps_rollup Anonymous (page-table walk, exact). Remove polling thread
in favor of deterministic reads at known peak points. Tighten thresholds
to 0.8x batch / 0.1x streaming.
@cennn cennn added the ci:run Trigger CI integration tests label Sep 1, 2026
@github-actions github-actions Bot removed the ci:run Trigger CI integration tests label Sep 1, 2026
@cennn cennn changed the title fix(offload): per-rank shared memory for EP>1 to prevent expert weight corruption [WIP]: [Fix] Per-rank SHM for EP offload + streaming 1× host peak Sep 1, 2026
@cennn cennn added the ci:run Trigger CI integration tests label Sep 1, 2026
@github-actions github-actions Bot removed the ci:run Trigger CI integration tests label Sep 1, 2026
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