Repository navigation
[megatron] 11/n towards Kimi K2.6: file-backed offload of frozen masters so Tinker API LoRA training fits host RAM - #2062
Conversation
…ers resident On TB-scale colocated models (Kimi K2.7: ~1.19TB BF16 masters) the CPU offload held ~1.34TB/node of pinned/anonymous RAM next to the vLLM engines' own ~1.3TB level-1 sleep backup — two full CPU copies of the model per 2.9TB node, leaving <100GB of margin. Every bring-up was a coin flip that ended in NUMA-local OOM kills (CONSTRAINT_MEMORY_POLICY, one socket full with TBs free on the other) or, with swap present, compress/disk thrash until Ray's keepalive declared the workers dead. Two changes to the offload layer: - Frozen (requires_grad=False, non-adapter) params now offload to file-backed mmap tensors, written once per rank on first offload (SKYRL_FROZEN_OFFLOAD_DIR, default /data/skyrl/frozen-offload, "0" disables and restores pinned RAM). The masters are immutable for the whole run, so their pages become clean, evictable page cache instead of unreclaimable RAM; re-offloads are free and backloads read hot cache. - LoRA models skip offloading the fused DDP buffers entirely: Megatron's param/grad buffers only hold grad-requiring params, which for LoRA is the adapters (a few GB). Keeping them GPU-resident lets the adapter-only weight sync export without ever backloading the masters. Measured on ghost002+ghost013 (2x8xB300): peak-phase MemAvailable went from 80-380GB (repeated OOM kills across 8 consecutive bring-up attempts) to ~1.5TB, with bring-up reliably passing. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Code Review
This pull request introduces file-backed mmap CPU offloading for frozen weights in Megatron-based distributed training to reduce RAM usage and prevent OOM issues. It also skips offloading fused buffers when training LoRA adapters. The review feedback highlights three key areas for improvement: a high-severity correctness issue where the hash key for the offload file only uses the parameter name, shape, and dtype (which can lead to silent weight corruption across different runs or checkpoints on the same node); an efficiency issue where calling .tobytes() on the entire tensor duplicates CPU memory allocation; and a usability issue where failures during offloading flood the logs with warnings for every parameter.
| def _frozen_offload_file(name: str, tensor) -> str: | ||
| import hashlib | ||
|
|
||
| rank = torch.distributed.get_rank() if torch.distributed.is_initialized() else 0 | ||
| key = hashlib.sha1(f"{name}|{tuple(tensor.shape)}|{tensor.dtype}".encode()).hexdigest()[:20] | ||
| rank_dir = os.path.join(_FROZEN_OFFLOAD_DIR, f"rank{rank}") | ||
| os.makedirs(rank_dir, exist_ok=True) | ||
| return os.path.join(rank_dir, f"{key}.bin") | ||
|
|
||
|
|
||
| def _offload_frozen_param_to_file(name: str, param) -> bool: | ||
| """Move a frozen param's data to a file-backed mmap CPU tensor. | ||
|
|
||
| Returns True on success; False to let the caller fall back to pinned RAM. | ||
| """ | ||
| try: | ||
| data = param.data.detach() | ||
| nbytes = data.numel() * data.element_size() | ||
| path = _frozen_offload_file(name, data) | ||
| if not (os.path.exists(path) and os.path.getsize(path) == nbytes): | ||
| tmp = f"{path}.tmp{os.getpid()}" | ||
| with open(tmp, "wb") as f: | ||
| f.write(data.contiguous().view(torch.uint8).flatten().cpu().numpy().tobytes()) | ||
| os.replace(tmp, path) | ||
| mapped = ( | ||
| torch.from_file(path, shared=False, size=nbytes, dtype=torch.uint8) | ||
| .view(data.dtype) | ||
| .view(data.shape) | ||
| ) | ||
| param._offload_cpu_data = mapped | ||
| return True | ||
| except (OSError, RuntimeError) as exc: | ||
| logging.getLogger(__name__).warning( | ||
| "file-backed frozen offload failed for %s (%s); falling back to pinned RAM", name, exc | ||
| ) | ||
| return False |
There was a problem hiding this comment.
Critical/High Issues Identified:
-
Correctness (High Severity) - Silent Weight Corruption across Runs/Checkpoints:
The current key generation in_frozen_offload_fileonly hashes the parameter name, shape, and dtype:
hashlib.sha1(f"{name}|{tuple(tensor.shape)}|{tensor.dtype}".encode())
If you run different training jobs (e.g., with different base model checkpoints of the same architecture, or different initializations) on the same node, they will map to the exact same file paths. Since the code checksos.path.exists(path), the second run will silently reuse the frozen weights of the first run, leading to silent correctness corruption.
Solution: Hash a small prefix of the actual tensor values (e.g., the first 1024 elements). This is extremely fast (microseconds) and guarantees uniqueness across different checkpoints/weights. -
Efficiency (Medium Severity) - Double CPU Memory Allocation:
Calling.tobytes()on the entire tensor in_offload_frozen_param_to_filecreates a Pythonbytesobject copy of the entire tensor. For multi-gigabyte parameters, this duplicates the CPU memory consumption (one for the numpy array, one for the bytes object), which can trigger OOM.
Solution: Write the contiguous numpy array directly to the file, as Python'swrite()accepts any object supporting the buffer protocol. -
Usability (Medium Severity) - Log Flooding on Failure:
If the offload directory is not writable or offloading fails, a warning is logged for every single parameter, completely flooding the console/logs.
Solution: Use a global flag to log the warning only once.
_warned_offload_failed = False
def _frozen_offload_file(name: str, tensor) -> str:
import hashlib
rank = torch.distributed.get_rank() if torch.distributed.is_initialized() else 0
# Hash a small prefix of the tensor to distinguish different checkpoints/weights
# of the same shape and dtype, preventing silent corruption or collisions
# across different runs or models.
flat = tensor.view(-1)
prefix_len = min(1024, flat.numel())
prefix = flat[:prefix_len].cpu().numpy().tobytes() if prefix_len > 0 else b""
h = hashlib.sha1(f"{name}|{tuple(tensor.shape)}|{tensor.dtype}".encode())
h.update(prefix)
key = h.hexdigest()[:20]
rank_dir = os.path.join(_FROZEN_OFFLOAD_DIR, f"rank{rank}")
os.makedirs(rank_dir, exist_ok=True)
return os.path.join(rank_dir, f"{key}.bin")
def _offload_frozen_param_to_file(name: str, param) -> bool:
"""Move a frozen param's data to a file-backed mmap CPU tensor.
Returns True on success; False to let the caller fall back to pinned RAM.
"""
try:
data = param.data.detach()
nbytes = data.numel() * data.element_size()
path = _frozen_offload_file(name, data)
if not (os.path.exists(path) and os.path.getsize(path) == nbytes):
tmp = f"{path}.tmp{os.getpid()}"
with open(tmp, "wb") as f:
# Write the contiguous numpy array directly to avoid creating a
# temporary Python bytes object copy of the entire tensor.
f.write(data.contiguous().view(torch.uint8).flatten().cpu().numpy())
os.replace(tmp, path)
mapped = (
torch.from_file(path, shared=False, size=nbytes, dtype=torch.uint8)
.view(data.dtype)
.view(data.shape)
)
param._offload_cpu_data = mapped
return True
except (OSError, RuntimeError) as exc:
global _warned_offload_failed
if not _warned_offload_failed:
logging.getLogger(__name__).warning(
"file-backed frozen offload failed for %s (%s); falling back to pinned RAM (future failures will be silent)", name, exc
)
_warned_offload_failed = True
return FalseThere was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Reviewed by Cursor Bugbot for commit c3a70d3. Configure here.
avigyabb
left a comment
There was a problem hiding this comment.
Hey Casper, thanks again for all your contributions! Left a comment regarding some of the naming logic. If you're strapped on time, just lmk and I can take this over the line if you'd like!
| path = _frozen_offload_file(name, data) | ||
| if not (os.path.exists(path) and os.path.getsize(path) == nbytes): |
There was a problem hiding this comment.
If my understanding is correct, I believe there might be a bug in how we do file naming here. If we launch two jobs using models of the same architecture (lets say a fresh model and the SFT’d version of it), there is a chance that the second job may use the weights of the first job’s model since we skip writing to the file if it already exists.
|
Also, if my understanding is correct, when we first |
Replace the per-chunk _chunk_has_lora_adapters name heuristic (a third string-matching variant of LoRA detection) with the authoritative is_lora flag the strategy already receives from the worker. Also avoids walking named_parameters() twice per sleep/wake cycle on non-LoRA runs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
|
Overall the PR is a good addition for memory constrained colocated training case. I think there are some good follow-ups here:
|
SumanthRH
left a comment
There was a problem hiding this comment.
Let's address the follow-ups in separate PRs
|
Non-Megatron GPU CI tests passing: https://github.com/NovaSky-AI/SkyRL/actions/runs/34811362082 |
…sync for Tinker API LoRA (merge_lora=false) (#2064) Part of the Kimi K2.6/K2.7 series. **Stacked on #2026 (4/n), #2031 (9/n), #2062 (11/n), and #2063 (12/n)** — their commits appear in this diff; review the last commit ("Adapter-only colocated weight sync for megatron LoRA") for this PR's own changes. ## What `save_weights_for_sampler` routes megatron + LoRA + `merge_lora=false` colocated runs through a new adapter-only path instead of the full-weight NCCL broadcast: 1. **Collective adapter export + per-node write** via a new worker method `save_lora_adapters` (the engine-load half is split out of the worker's fused save+load with `send_load_request=False`). The bridge export reads only the LoRA adapter tensors, which stay GPU-resident through offload (#2062 exempts the LoRA DDP buffers), so the TB-scale frozen masters remain offloaded the whole time. 2. **Offload masters/optimizer only if a preceding forward/optim phase left them resident**, so the engines fit on the GPUs; a cold sync skips this entirely. 3. **Wake the engines only if they were asleep** (`engines_asleep` is threaded from the backend). The eager post-create sleep is also skipped on this path — it was a pure sleep→wake round-trip. 4. **Reset the prefix cache and issue the adapter load from the engine process** once the engines are awake. Supporting changes: `_prepare_for_weight_sync` releases cached CUDA allocator blocks before engine wake (also benefits the classic path); `persist=True` sampler saves on this path tar the just-written PEFT adapter files instead of running a merged multi-TB `save_hf_model` export (the classic persist path keeps the HF export but sleeps the engines first); `SKYRL_LORA_SLEEP_LEVEL` lets TB-scale LoRA runs opt into level-2 (discard-and-reload) sleeps. The classic full-broadcast path is unchanged for FFT / merged-LoRA. Unit tests cover the cold/hot/wake orderings (`test_worker_dispatch.py`). ## Why With `merge_lora=false`, vLLM serves the released base checkpoint and hot-loads the adapter — yet the colocated sync still moved terabytes to deliver gigabytes: backload ~1.19TB of frozen masters to GPU, wake engine weights, broadcast, offload again, wake KV cache. Measured on our Kimi K2.7-Code runs (2x8xB300, INT4 QAT + LoRA rank 32 through the Tinker API): a sync took 13–80 minutes when it survived at all. Run-start syncs re-faulted TBs of mmap'd frozen weights through a page cache the engine build had just evicted (554GB checkpoint read + ~830GB/node sleep backup), stalling `save_weights_for_sampler` 15–25 minutes — past the client's deadline, which abandoned and retried, rebuilding the engines and repeating the eviction in a loop that never converged. The eager post-create sleep alone cost 5–6 minutes of ~830GB/node CPU backup (sequential ~50s/worker) for engines that the first sync would immediately wake. And the trainer's freed-but-cached allocator blocks (tens of GB after a forward/optim step) pushed `wake_up(tags=["weights"])` over GPU capacity. With this path, a cold sampler sync exports + loads in seconds with the engines awake the whole time, and the first end-to-end Kimi K2.7 LoRA training run through the Tinker API completed with stable step times. Made with [Cursor](https://cursor.com) --------- Signed-off-by: SumanthRH <sumanthrh@anyscale.com> Signed-off-by: SumanthRH <sumanthrh99@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: SumanthRH <sumanthrh@anyscale.com> Co-authored-by: SumanthRH <sumanthrh99@gmail.com>
…end example (#2032) ## What 2-node (2x8 B300) DAPO example training Kimi-K2.7-Code at 262144-token context with INT4 fake-QAT + capacity-normalized expert LoRA and merge_lora=false adapter hot-loading, plus the docs page covering the full setup: dequantizing the INT4 release into BF16 masters (#2024) and the training/serving configuration (#2025, #2026, #2027) on the CUDA-13-native root lock (#2040; the separate deploy stack from #2028 is no longer needed). Notable pins the example carries: `mla_prefill_backend=FLASHINFER` (FA4 CuTe-DSL prefill is incompatible with the locked nvidia-cutlass-dsl), `trainer.flash_attn=false` (MLA + sample packing needs TE cuDNN fused attention), `UV_PROJECT_ENVIRONMENT` + `--no-sync` for Ray's uv hook (workers execute from Ray's copied working dir where no .venv exists, and concurrent implicit syncs race), and a raised `SKYRL_WAIT_UNTIL_INFERENCE_SERVER_HEALTHY_TIMEOUT_S` (the 595 GB INT4 load + FULL_DECODE_ONLY capture exceeds the 600 s default on a cold page cache). The docs page also spells out the multi-node Ray bring-up (which variables must be in the raylet environment on every node vs. what SkyRL re-exports through the runtime env), the file-backed frozen-master offload knob (`SKYRL_FROZEN_OFFLOAD_DIR`, #2062), and two host-level failure modes hit while verifying: a system cuDNN 9 of another 9.x than the pip one breaking TE fused attention with `CUDNN_STATUS_SUBLIBRARY_VERSION_MISMATCH`, and an NVSwitch fabric-state desync making NCCL's NVLS multicast bind fatal (`NCCL_NVLS_ENABLE=0` until the GPUs are reset). ## Why Single runnable reference for the whole Kimi K2.x INT4+LoRA flow the series enables. The diff is self-contained (example script + docs only). Follow-up to #1862. ## Verification Run end-to-end on 2 x 8 B300 against this branch (main @ 53b1155 merged in; torch 2.13 cu130 / vLLM 0.28 / TE 2.16 / megatron-bridge 8e5f0e1): - 2 steps at `MAX_RESPONSE_LENGTH=2048 TRAIN_BATCH_SIZE=8 MINI_BATCH_SIZE=8 N_SAMPLES_PER_PROMPT=4` (the smoke invocation now in the docs): engine build, BF16 master load + fake-INT4 QAT forward, LoRA train, adapter-only hot-load sync (~38 s, ~25 s transfer), second generation with the updated adapter; `is_ratio_mean` 0.9994 / 1.0015. - 1 step at the script defaults (262144-token context, batch 16 x 8 = 128 samples, longest sample ~31.6k tokens): generate 686 s, logprob forward 495 s, policy train 570 s, sync 36 s; `is_ratio_mean` 1.0003, pass@8 1.0. Made with [Cursor](https://cursor.com) --------- Signed-off-by: SumanthRH <sumanthrh@anyscale.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: SumanthRH <sumanthrh@anyscale.com>

Part of the Kimi K2.6/K2.7 series (previous: #2024, #2025, #2026, #2027, #2029, #2031, #2032). Standalone; no dependency on the other open PRs, but #2026 (merge_lora=false adapter sync) and the upcoming adapter-only weight sync build on it.
What
Two changes to the Megatron CPU-offload layer in
megatron_utils.py, for LoRA training through the Tinker API on models whose frozen base does not fit host RAM twice:requires_grad=False, non-adapter) params offload to file-backed mmap tensors instead of pinned/anonymous RAM. Written once per rank on first offload underSKYRL_FROZEN_OFFLOAD_DIR(default/data/skyrl/frozen-offload; set to0to disable and restore the previous pinned-RAM behavior). The masters are immutable for the whole run, so after the one-time write their pages are clean, evictable page cache instead of unreclaimable RAM; re-offloads are free and backloads read from hot cache.Why
Measured on our 2x8xB300 (sm103) cluster training Kimi K2.7-Code (INT4 QAT + LoRA, colocated vLLM engines) through the Tinker API: the trainer's CPU offload held ~1.34TB/node of pinned/anonymous RAM (~1.19TB BF16 masters + optimizer/misc) next to the vLLM engines' own ~1.3TB/node level-1 sleep backup — two full CPU copies of the model on a 2.9TB node, leaving <100GB of margin. Bring-up was a coin flip that ended in NUMA-local OOM kills (
CONSTRAINT_MEMORY_POLICY: one socket full while the other had TBs free), or with swap enabled, compress/disk thrash until Ray's keepalive declared the workers dead.With this change, peak-phase
MemAvailableon the same nodes went from 80–380GB (repeated OOM kills across 8 consecutive bring-up attempts) to ~1.5TB, and bring-up passes reliably.Made with Cursor