Route ZeRO/SuperOffload pin sites through accelerator pin_memory - #8256
Route ZeRO/SuperOffload pin sites through accelerator pin_memory#8256sfc-gh-truwase wants to merge 7 commits into
Conversation
Honor DS_PIN_MEMORY_BACKEND for core CPU offload buffers and avoid an extra host copy on empty scratch pins via make_copy=False. Signed-off-by: Olatunji Ruwase <tunji.ruwase@snowflake.com> Co-authored-by: Cursor <cursoragent@cursor.com>
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
Skip GPU->pinned offload helper coverage on CPUAccelerator and add a native pin-pattern smoke that cpu-torch-latest can run. Signed-off-by: Olatunji Ruwase <tunji.ruwase@snowflake.com> Co-authored-by: Cursor <cursoragent@cursor.com>
|
Hi @sfc-gh-truwase , this PR looks good to me. There is only one thoughts, the phrase " get_accelerator().pin_memory(torch.empty(...), make_copy=False)" keeps repeating and it looks like an idiom. Maybe it worth a place in utility. |
|
@delock great point. I will address in a separate PR. |
The native pin backend has no CUDA stream tracking, so dropping a host buffer while its non-blocking copy is in flight is a use-after-free. Signed-off-by: Olatunji Ruwase <tunji.ruwase@snowflake.com> Co-authored-by: Cursor <cursoragent@cursor.com>
The torch path pinned a copy even for empty scratch destinations, so every make_copy=False site briefly needed two full-size host buffers plus a copy. Signed-off-by: tunji-ruwase_snow <tunji.ruwase@snowflake.com> Co-authored-by: Cursor <cursoragent@cursor.com>
tohtana
left a comment
There was a problem hiding this comment.
Hi @sfc-gh-truwase, I left a few comments.
These issues are not very critical, but it would be great if we could fix the memory leak one in this PR (though it is a preexisting issue).
|
|
||
| # Pre-allocate reusable pinned memory buffer for gradients | ||
| pinned_grad_buffer = torch.empty(max_grad_numel, dtype=torch.float32, device='cpu', pin_memory=True) | ||
| pinned_grad_buffer = get_accelerator().pin_memory(torch.empty(max_grad_numel, dtype=torch.float32, device='cpu'), |
There was a problem hiding this comment.
Do we want to disable pinning based on offload_optimizer.pin_memory? We might not, but it would be good to raise an error if pin_memory is False.
There was a problem hiding this comment.
Good question.
@xylian86 what do you think? Should we condition memory pinning on offload_optimizer.pin_memory or not?
There was a problem hiding this comment.
@sfc-gh-truwase Since pinning appears to be a performance optimization rather than a correctness requirement. My preference is to honor offload_optimizer.pin_memory. When it is true, we should keep the current pinned allocation; when it is false, the worker should allocate a regular CPU buffer.
There was a problem hiding this comment.
Done in 658dc247. SuperOffload now honors offload_optimizer.pin_memory:
true(default): same pinned scratch buffer as before, viaget_accelerator().pin_memory(..., make_copy=False)false: regulartorch.empty(..., device="cpu")
The flag is passed from self.offload_optimizer_pin_memory into SuperOffloadCPUOptimizer and the worker. On worker shutdown we unpin_memory only when we actually pinned.
Tests: test_superoffload_grad_buffer_unpinned_when_disabled and test_superoffload_grad_buffer_pinned_when_enabled (native) both passed locally.
| if not hasattr(self, "hp_params_pin_buffers"): | ||
| self.hp_params_pin_buffers = [ | ||
| torch.empty_like(t, device=device).pin_memory() for t in self.single_partition_of_fp32_groups | ||
| get_accelerator().pin_memory(torch.empty_like(t, device=device), make_copy=False) |
There was a problem hiding this comment.
When we use engine.offload_states(pin_memory=True) and reach engine.destroy() without calling reload_states(), engine.destroy() doesn't release the pinned buffers (hp_params_pin_buffers, lp_params_pin_buffers, and self.optimizer.state[*][key]).
This is because we have the condition in destroy:
if not (self.cpu_offload and self.cpu_offload_pin_memory):
returnI think this is a preexisting issue but it is now related to NativePinnedMemory manager as well. Can we resolve this issue in this PR?
There was a problem hiding this comment.
Fixed in 05ddc1a41. You were right: _unpin_offload_buffers returned immediately unless cpu_offload and cpu_offload_pin_memory, so offload_states(pin_memory=True) then destroy() without reload_states() leaked the host buffers.
destroy() now unpins hp_params_pin_buffers, lp_params_pin_buffers, and any CPU tensors in optimizer.state that is_pinned before that config check. ZeRO-3 already released its contiguous pin buffers unconditionally; it now uses the same helper for the leftover offload_adam_states aliases.
test_native_destroy_frees_offload_states_pins covers stages 2 and 3 with no ZeRO CPU offload configured. H200 job-20260824T224008Z:
| Run | Result |
|---|---|
test_destroy_unpin.py with the fix |
7 passed (both new stage-2 and stage-3 cases) |
same test, sources reverted to 598ad17a |
fails offloaded=4 after=4 on both stages |
| accelerator + pin_memory + offload-route UTs | 36 passed |
offload_states(pin_memory=True) page-locks host buffers with no regard for the ZeRO CPU-offload config, so destroy() leaked them whenever reload_states() never ran. Signed-off-by: tunji-ruwase_snow <tunji.ruwase@snowflake.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Pinning is a DMA optimization, not a correctness requirement, so a False config should allocate a regular CPU scratch buffer instead of always page-locking. Signed-off-by: tunji-ruwase_snow <tunji.ruwase@snowflake.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
offload_stateshp/lp pins,offload_optimizer_states, ZeRO++ secondary shards, and SuperOffload grad buffers throughget_accelerator().pin_memory()soDS_PIN_MEMORY_BACKEND=nativeapplies.make_copy=Falsefor empty scratch destinations to avoid an extra host alloc/copy under the native backend.memory.rstthat ZeROoffload_*.pin_memory(whether) is orthogonal toDS_PIN_MEMORY_BACKEND(how); env-only, no ds_config backend field.tests/unit/v1/pin_memory/test_offload_route.pycoveringoffload_optimizer_statesunder native.Test plan
pre-commit run --fileson touched paths (already run locally)DS_PIN_MEMORY_BACKEND=native+pytest tests/unit/v1/pin_memory/test_offload_route.py(+ pin_memory/accelerator UTs) on H200 host with pin_memory op — 23 passed (incl. both offload_route tests)test_offload_states.py+test_destroy_unpin.py— 133 passedget_accelerator().pin_memory(..., make_copy=False); native empty-buffer pin/unpin smoke OKEvidence: autorun
job-20260818T143819Zone6dc5f9d/tjruwase/pin-memory-route-zero(EXIT 0). GitHub CI also green on the tip commit.