Repository navigation
Conversation
…ctivation_offload
Xdydy
marked this pull request as ready for review
September 18, 2026 06:52
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Enable local whole-layer CUDA Graph together with fine-grained CPU activation offload. Activations live at a fixed GPU virtual address (VA); physical backing can be released or swapped outside Graph replay, so the captured Graph never sees a new pointer.
Why Graph and CPU offload currently conflict
Upstream already has CUDA Graph and activation offload as separate features. Combining them fails on pointer semantics, not on a missing flag.
Naive offload/reload:
Capture recorded
src_tensor.data_ptr(). After reload,gpu_tensor != src_tensor, so replay reads the old address. That can be silent corruption if the allocator reuses the captured VA, or a miss if the data landed elsewhere.Related: NVIDIA/Megatron-LM#3697. Community therefore either used partial Graph (e.g. Graph + MoE-only offload) or made Graph and activation offload mutually exclusive.
Core idea: keep the VA, replace the physical pages
VMM (CUDA/MUSA Virtual Memory Management) splits “address the Graph captured” from “pages currently mapped there”:
The Graph column never changes. Graph does not know about P1/P2; it only reads the VA, and we swap what lives behind that VA outside replay.
Group lifecycle
LocalCudaGraphOffloadGrouphas five life-cycle phases and three explicitstatevalues.statediscover_tensor()discoveringallocate()discovering → mappedbegin_capture()/save_tensor()/finish_capture()mappedoffload_and_release()mapped → unmapped(or resident pending)remap_and_reload()unmapped → mappedDiscovery and capture run once at graph build. Training replay then cycles
mapped → unmapped → mapped(or the resident equivalent) every iteration. Empty groups no-op.Build-time flow: discovery, allocate, capture
Slot reserve/map is not done inside Graph capture.
Capture checks: group count, descriptor count, shape/stride/dtype/device, and
data_ptr() == reserved VA.Resident ping-pong is not an extra env var.
_link_backward_runners()assigns banks when_uses_local_graph_activation_offload()is true. Runners without local Graph offload keeplocal_graph_slot_bank = None. Eager offload never enters this chain.Forward replay: Graph on compute stream, D2H on a copy stream
Principle: replay stays on the compute/replay stream; D2H is queued on
d2h_streamand only waits for the forward-done event. No host sync on the hot path.sequenceDiagram autonumber participant S as training schedule participant R as _CudagraphReplayNode.forward participant G as forward CUDA Graph participant C as compute/replay stream participant M as PipelineOffloadManager participant D as d2h_stream participant H as pinned CPU buffer S->>R: replay runner.fwd_graph R->>M: local_graph_forward_wait_ready(runner, replay_stream) M->>C: wait previous D2H event (resident bank reuse) R->>G: fwd_graph.replay() G->>C: execute forward kernels R->>M: local_graph_forward_replay(runner) M->>D: wait forward completion event M->>D: group.enqueue_d2h() D->>H: asynchronous device-to-host copy D-->>M: record bank D2H completion eventResident path does not
MemUnmap/MemReleaseon the forward hot path. Banks stay mapped after capture. Forward only saves the current activation into that runner’s pinned host buffer and records a D2H event so the bank can be reused later.Backward: look-ahead so reload overlaps the current Graph
Autograd consumption order can be the reverse of forward save order, so runner links follow the recorded backward order. Current runner waits for its own reload, then immediately prefetches
next_bwd_runner. Next-layer H2D can then run on a copy stream while the current backward Graph runs.The first serial restore waited until the current backward Graph finished before starting the next layer’s H2D. That left a host/driver bubble between consecutive Graphs:
Look-ahead moves next-layer H2D (and, on the fallback path, VMM remap) under the current Graph:
Resient Threads offload
This is one method to implement the vmm slot.
VMM driver APIs cannot run inside a CUDA host callback (deadlock with the driver). First look-ahead version sent unmap/release/remap to resident worker threads:
Ping-pong slots: two mapped banks, no hot-path remap
While there exists some latency when using
MemUnmap/MemSetAccesset all, we provideping-pong slots, which reduces the calls of these driver apis. In some scene, this will reduce some latency.Capture-time allocate two banks and keep them mapped for the whole run. Replay no longer calls
MemUnmap/MemRelease/MemCreate/MemMap/MemSetAccess.Two banks are required because the Graph captured a VA, not an abstract buffer. One shared slot:
Next-layer H2D would overwrite the activation still being read (silent numerical error), or a page fault if unmapped at the wrong time.
Host buffers stay per-runner. Ping-pong only solves GPU concurrent read/write:
Resident state:
Here is an example to show the steps how ping-pong slots work.
Forward ping-pong (three layers)
Forward order
F0 → F1 → F2.H0/H1/H2are three independent pinned host buffers. Slot A and slot B are two GPU banks (fixed VA + resident backing). Graph writes the layer into its bank, then D2H saves it to that layer’s host buffer. Slot A is reused by F2 only after F0’s D2H has finished, so H0 is not overwritten.Backward ping-pong (three layers)
Backward order
B2 → B1 → B0. Restore G2 from H2 into slot A; B2 reads slot A. While B2 runs, H2D restores G1 from H1 into slot B. B2 reads A, reload writes B — different physical backing, safe overlap. After B2, B1 reads slot B; slot A can then restore G0 from H0. B0 reads slot A.Two safety conditions:
Ping-pong does not give each layer a permanent GPU slot. It only guarantees that “the bank the current backward Graph is reading” and “the bank the next layer is reloading” are different. Three host buffers stay independent; two GPU banks rotate under D2H-complete and consumed events.
Results
Memory (MB, after init / capture / warmup)
Vs no-Graph/no-offload:
Same Graph mode, offload vs not: no-Graph reserved −354 MB (~1.0%); local Graph reserved −726 MB (~2.0%).
allocated/reservedare PyTorch caching-allocator stats. VMM physical pages fromMemCreateare not proven returned to the driver by these two numbers alone. Driver-levelMemGetInfo(or vendor tools) is required for that claim.Performance
Vs no-Graph/no-offload: local Graph alone +~2.2% throughput; CPU offload −~2.7–3.1%. Graph+offload reserved is still +538 MB vs baseline (Graph static memory + offload storage), even though it is −726 MB vs Graph-only.
These are short windows (14 / 13 / 20 / 763 memory-report iterations). Treat as trend, not a ship gate. Formal numbers need matched
train_iters, warmup, and an explicit resident-ping-pong vs fallback A/B with driver-level memory.Issue tracking
For PRs from open-source community contributors:
Linked issue: Related to NVIDIA/Megatron-LM#3697 (local Graph vs activation offload pointer conflict). No dedicated public issue yet.
Contribution process
Pre-checks
jit_fuser/torch.compile, CUDA extension, TE or external-library dispatch, or a scatter/index accumulation), I have added or updated its bit-exact determinism test and registered it intests/unit_tests/determinism/kernels/manifest.py(guide)Suggested coverage before Ready for Review:
empty()reload either silently reads an interloper (allocator reuse of the captured VA) or restores bytes the Graph never reads. VMM unmap turns the same bug into a hard fault.Code review
Feel free to message or comment @NVIDIA/mcore-oncall to help accelerate your merge into main. The less complex your PR is, the faster it will be approved and merged!
All PRs start as draft. If you open a non-draft PR, it will be automatically converted to draft.
Step 1: Mark PR as "Ready for Review"
.github/CODEOWNERS.Final Review might get declined if these requirements are not fulfilled.
Step 2: Final Review
For PRs that change
megatron/core, once all expert reviewers have approved, theFinal Reviewlabel is applied automatically and final reviewers are assigned.For PRs outside
megatron/core, this step is skipped.Step 3: Approved
Once all required reviewers have approved, the
Approvedlabel is applied automatically.Merge
Any member of mcore-engineers will be able to merge your PR.