[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>

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