CPU Offloading Overhead
CPU offloading trades GPU memory for PCIe bandwidth. Parameters, gradients or optimizer state cross the bus every step, and when the transfer cannot hide behind compute the step becomes bound by the link rather than the GPU.
Offloading moves parameters, gradients or optimizer state to host RAM or NVMe, so every step pays a PCIe round trip that the GPU cannot overlap away. If step time rises far more than memory falls, the job is transfer-bound, not compute-bound. Confirm it by profiling H2D and D2H copy time against total step time before changing any configuration.
What this failure is
CPU offloading is a family of memory-reduction techniques that relocate part of a training step's state out of HBM. Three distinct mechanisms are commonly meant by the phrase, and they have different costs, so diagnosing one as though it were another is the usual reason a fix does not work. PyTorch FSDP CPUOffload keeps sharded parameters and gradients in pinned host memory between uses. The optimizer step then runs on the CPU. DeepSpeed ZeRO-Offload moves optimizer state, and optionally parameters under ZeRO-3, to host RAM. The optimizer update is executed by a CPU implementation of Adam. DeepSpeed NVMe offload extends the same idea to a local SSD, adding a filesystem and block-device hop underneath the PCIe transfer. In all three the GPU stalls whenever the tensor it needs is not resident. That stall is the failure being diagnosed. It presents as low GPU utilization with healthy memory headroom, which is the opposite of the pattern engineers expect from a memory-reduction feature.
Is this what broke your run? Paste your log.
You're reading about CPU Offloading Overhead. Paste your own crash log or traceback below and get the real root cause for YOUR run, not this generic entry. No account, no card. Logs are masked at ingress and never saved to account history.
Want 14 days on the Scale plan?
Request a work-email trial for up to 50 diagnoses a day, alerts, history, and follow-up questions. No credit card or automatic subscription.
Why it happens (the mechanism)
A training step has a fixed amount of arithmetic and, once offloading is enabled, a fixed amount of data movement. The movement can only be free if it overlaps with compute, and there are structural reasons it often cannot. The first is ordering. Parameters must arrive before the layer that consumes them executes, and gradients must leave before the buffer is reused. Prefetch depth is bounded by how much memory is left to stage into, which is small precisely because memory pressure is why offloading was enabled. The second is the link. Host transfers traverse PCIe. A Gen4 x16 link runs at 16 GT/s per lane with 128b/130b encoding, about 31.5 GB/s per direction in theory and materially less in practice; Gen5 x16 doubles the signalling rate. Compare that with HBM bandwidth measured in terabytes per second and the asymmetry is several orders of magnitude, so a tensor that is cheap to read on device is expensive to fetch across the bus. The third is the optimizer itself. With offloaded optimizer state the Adam update executes on the CPU, so its cost scales with host core count and memory bandwidth rather than with the GPU. A host that is already saturated by data loading has nothing left for it. The fourth is placement. If host memory is allocated on a NUMA node that is not local to the GPU's PCIe root complex, every transfer additionally crosses the inter-socket interconnect. Nothing in the training configuration reveals this; it is a property of process and memory affinity.
What you'll observe
- Step time increases sharply after enabling offloading, by far more than the memory saving would suggest is a reasonable trade
- GPU utilization sits low and uneven while GPU memory shows plenty of free headroom, which reads as an underloaded GPU rather than a memory feature working
- Host CPU is pinned near saturation and the data loader begins to fall behind, because the offloaded optimizer step competes with it for the same cores
- Throughput scales worse than linearly when adding ranks per node, since every rank on the node contends for the same PCIe root complex and host memory bandwidth
- NVMe offload shows periodic multi-second stalls that do not correlate with any step boundary in the training loop
Common symptoms and what they mean
| Symptom | Why it happens |
|---|---|
| nvidia-smi shows GPU utilization oscillating well below saturation with several gigabytes of free memory | Per-step data movement exceeds what the step's compute can hide. The GPU idles waiting for parameters to arrive or gradients to drain, so the step is bound by PCIe rather than by arithmetic. |
| A PyTorch profiler trace is dominated by aten::copy_ and Memcpy DtoH or HtoD rows rather than by matmul kernels | Prefetch depth is limited by the free HBM available to stage into. Offloading is usually enabled because that headroom is small, so the mechanism most able to hide the transfer is the one least able to operate. |
| Nsight Systems shows long gaps on the CUDA compute row with concurrent activity on the memory-transfer rows | With offloaded optimizer state the parameter update runs on the CPU, so its duration is governed by host core count and memory bandwidth and it serializes against data loading on the same cores. |
| DeepSpeed prints an offload_optimizer or offload_param block naming device cpu or nvme in its startup configuration dump | Host buffers allocated on a non-local NUMA node force every transfer across the inter-socket link in addition to PCIe, which is invisible in the training configuration and is a property of process affinity. |
| Host memory usage climbs to a plateau roughly proportional to parameter count rather than to batch size | Unpinned host memory prevents true asynchronous DMA, so transfers are staged through an intermediate copy and cannot overlap with compute even when ordering would allow it. |
| top or htop shows the training process consuming close to all available cores during the optimizer phase of each step | NVMe offload adds filesystem and block-layer latency beneath the PCIe transfer, and without a correctly configured asynchronous I/O backend the queue depth is too shallow to reach the device's rated throughput. |
Which systems are affected
- PyTorch FSDP with CPUOffload(offload_params=True), and FSDP2 with CPUOffloadPolicy
- DeepSpeed ZeRO-2 and ZeRO-3 with offload_optimizer.device set to cpu
- DeepSpeed ZeRO-3 with offload_param.device set to cpu or nvme
- Hugging Face Accelerate and Trainer wrapping either of the above through a DeepSpeed or FSDP plugin
- Any multi-GPU node where several ranks share one PCIe root complex or one NUMA domain
How to confirm this is the problem
Use this checklist to test the hypothesis against a small reproduction. No single line proves the root cause, so preserve the preceding events and compare one variable at a time.
- ✓Run the same configuration with offloading disabled for as many steps as memory allows, even a handful, and record median step time for both. If offloading is not the dominant cost the two medians will be close.
- ✓Discard the first ten to twenty steps of every measurement. Compilation, autotuning, allocator growth and cache warming make early steps unrepresentative, and comparing a cold run against a warm one is the most common way this diagnosis is reached incorrectly.
- ✓Profile one warm step and compute the fraction of wall time spent in host-to-device and device-to-host copies. Transfer-bound means that fraction is large, not merely non-zero.
- ✓Check GPU utilization and memory headroom together. Low utilization with substantial free memory is the signature; low utilization with no free memory is a different problem.
- ✓Inspect NUMA and CPU affinity with nvidia-smi topo -m and confirm the process is bound to the node local to its GPU. A mismatch here changes the measurement before any configuration is touched.
- ✓Confirm pinned memory is actually in use. An offload path running on pageable host memory cannot overlap transfers with compute regardless of prefetch settings.
- ✓For NVMe offload, measure the device directly rather than inferring from step time, so a slow disk is not misread as a slow model.
Searchable error signature
[INFO] [config.py:1006:print] offload_optimizer ............ {'device': 'cpu', 'nvme_path': None, 'buffer_count': 4, 'pin_memory': True, 'pipeline': False, 'ratio': 1.0, 'fast_init': False}
[INFO] [config.py:1006:print] offload_param ................ {'device': 'cpu', 'nvme_path': None, 'buffer_count': 5, 'buffer_size': 100000000, 'max_in_cpu': 1000000000, 'pin_memory': True}
[INFO] [utils.py:781:see_memory_usage] MA 12.44 GB Max_MA 21.07 GB CA 24.00 GB Max_CA 24 GB
[INFO] [stage3.py:128:__init__] Reduce bucket size 500,000,000
Name Self CUDA Self CUDA % CUDA total CUDA time avg # of Calls
Memcpy HtoD (Pageable -> Device) --- --- --- --- ---
aten::copy_ --- --- --- --- ---
void at::native::vectorized_elementwise_kernel<...> --- --- --- --- ---Use this text as a lookup key in logs and upstream issue trackers. It is not presented as a captured customer log. Confirm the cause from your own preceding events, versions, configuration and the cited references.
The fix and the prevention pattern
The root cause is on this page and stays free. A free account adds the exact remediation steps, keeps your diagnoses instead of discarding them, and unlocks the fix on every entry in the encyclopedia.
Sign up free. Unlock the full analysisNo credit card · 3 free diagnoses · Instant access
Why the recommended fix works
Each step here removes one specific reason the transfer cannot be hidden, which is why the order matters more than the individual settings. Measuring warm steps on both configurations turns a subjective slowdown into a ratio, and it is the only step that can tell you offloading is not the cause at all. Correcting NUMA affinity removes a hop that the training configuration cannot express and that no amount of tuning inside the framework will compensate for. Enabling pinned memory is what makes the DMA asynchronous in the first place, so prefetch settings only begin to matter once it is on. Preferring activation checkpointing works because it spends the resource that is not scarce. Recomputation consumes GPU arithmetic, which is idle in exactly the situation being diagnosed, whereas offloading consumes bus bandwidth, which is already the bottleneck. Offloading optimizer state before parameters follows from access frequency. In mixed precision an Adam optimizer keeps two moment estimates plus an fp32 master copy, roughly twelve bytes per parameter against two for a bf16 weight, and that state is read and written once per step. Parameters are needed on every forward and again on every backward, so moving them across the bus costs several times more traffic for a smaller memory saving.
Code examples
# ---------------------------------------------------------------------------
# 1. Establish the paired baseline. This is the measurement everything else
# depends on, so run it first and keep both numbers.
#
# Discard warmup steps. Compilation, autotuning and allocator growth make the
# first steps unrepresentative, and comparing a cold run to a warm one is the
# most common way this diagnosis is reached incorrectly.
# ---------------------------------------------------------------------------
import time, statistics, torch
def timed_steps(step_fn, warmup=20, measure=30):
"""Median step time over `measure` warm steps. Returns seconds."""
for _ in range(warmup):
step_fn()
torch.cuda.synchronize()
samples = []
for _ in range(measure):
torch.cuda.reset_peak_memory_stats()
t0 = time.perf_counter()
step_fn()
torch.cuda.synchronize() # do not time an async launch queue
samples.append(time.perf_counter() - t0)
return statistics.median(samples), torch.cuda.max_memory_allocated()
# Run this twice, once with offloading enabled and once without, at identical
# batch size, sequence length and precision. Report both.
# median_off, peak_off = timed_steps(step)
# median_on, peak_on = timed_steps(step)
# Offloading is worth it only if the memory you bought is worth the time you paid.
# ---------------------------------------------------------------------------
# 2. Attribute the time. Transfer-bound means the copy rows DOMINATE, not merely
# that they appear. Profile ONE warm step; a trace of a cold step is noise.
# ---------------------------------------------------------------------------
from torch.profiler import profile, ProfilerActivity
for _ in range(20): # warm up first
step()
torch.cuda.synchronize()
with profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
record_shapes=True,
profile_memory=True,
) as prof:
step()
torch.cuda.synchronize()
# Sort by device time and read the top rows. Memcpy HtoD / DtoH and aten::copy_
# sitting at the top is the confirmation; matmul kernels at the top is not.
print(prof.key_averages().table(sort_by="self_cuda_time_total", row_limit=25))
# ---------------------------------------------------------------------------
# 3. Same question at the system level, including the gaps a Python profiler
# cannot see. Run for a bounded window, not the whole job.
# ---------------------------------------------------------------------------
#
# nsys profile \
# --trace=cuda,nvtx,osrt \
# --sample=cpu \
# --duration=60 \
# --output=offload_trace \
# python train.py
#
# In the timeline, look for gaps on the CUDA compute row that line up with
# activity on the memory row. That alignment is the stall being diagnosed.
# ---------------------------------------------------------------------------
# 4. Placement. A non-local NUMA node adds an inter-socket hop to every
# transfer, and nothing in the training config reveals it.
# ---------------------------------------------------------------------------
#
# nvidia-smi topo -m # read the CPU Affinity and NUMA Affinity columns
# numactl --hardware # confirm the node layout
# nvidia-smi --query-gpu=index,pcie.link.gen.current,pcie.link.width.current \
# --format=csv # confirm the link is running at full width
#
# Then launch each rank bound to the node local to its GPU, for example:
# numactl --cpunodebind=0 --membind=0 python train.py
#
# Host saturation check, run WHILE training:
# mpstat -P ALL 1 10 # per-core utilization during the optimizer step
# numastat -p $(pgrep -f train.py) # is memory actually on the local node
Adapt the snippet to your framework. The same pattern holds for PyTorch Lightning, Hugging Face Trainer, DeepSpeed, Megatron-LM, and vLLM training wrappers. Where the wrapper exposes a config flag (for examplelr_scheduler_type in Trainer), prefer the flag over the imperative API to keep the schedule declarative and reproducible.
Best practices by model family
| Model / Stack | Recommendation | Notes |
|---|---|---|
| Model and optimizer fit in HBM with headroom | Do not offload | Every step would pay bus time to buy memory that is not needed. Raise micro-batch or sequence length before considering a memory feature. |
| Fits only just, activations dominate | Activation checkpointing first | Trades recomputation for memory entirely on device, so it spends GPU arithmetic rather than PCIe bandwidth. Usually the cheaper trade when it is sufficient. |
| Optimizer state is the largest consumer | Offload optimizer state only | In mixed precision Adam holds roughly twelve bytes per parameter against two for a bf16 weight, and it is touched once per step rather than twice per layer. |
| Parameters alone exceed HBM | ZeRO-3 or FSDP sharding before parameter offload | Sharding across ranks moves state over the interconnect between GPUs rather than to the host, which is typically far faster than PCIe to system memory. |
| Model cannot run any other way | CPU parameter offload, accepting the throughput cost | A slow run that completes beats a fast run that raises out-of-memory. Measure the cost so the trade is deliberate and revisited when hardware changes. |
| Host RAM is also insufficient | NVMe offload, with the I/O path verified first | Benchmark the device in isolation and confirm the asynchronous I/O backend is configured. An NVMe path limited by queue depth is indistinguishable from a slow model from inside the training loop. |
With the fix vs without the fix
| Dimension | With the fix | Without the fix |
|---|---|---|
| Where the diagnosis starts | A paired warm-step measurement, offload on and off, at identical settings | Adjusting bucket sizes and prefetch depth against an unmeasured baseline |
| What confirms the cause | Copy rows dominating a profile of one warm step, and compute gaps aligned to transfer in an Nsight timeline | GPU utilization looking low, which has many unrelated causes |
| NUMA and affinity | Read from nvidia-smi topo -m and pinned in the launcher | Left to the scheduler, so the same job is fast on one node and slow on another |
| Order of memory levers | Checkpointing, then sharding, then optimizer offload, then parameter offload | Offloading enabled first because it is a single configuration flag |
| Outcome when offloading is not the cause | Ruled out in one measurement and attention moves to the real bottleneck | Hours spent tuning a subsystem that was never dominant |
Diagnostic note
“The reason this is mis-diagnosed so often is that the symptom looks like the opposite of what it is. A memory-reduction feature is working correctly, memory headroom is visibly healthy, and the GPU is visibly idle, so the natural reading is that the job is underloaded and the batch size should go up. Raising it makes the problem worse, because it adds transfer volume to a step that was already bound by transfer. The second trap is measurement. Offloading changes the cost of the first steps disproportionately, since buffers are being staged and pinned for the first time, so a comparison that includes warmup exaggerates the penalty. Comparing medians over warm steps and reporting peak memory alongside is what makes the trade legible, and it is the part most often skipped.”
Visual fingerprint
Step time rose after enabling offload
|
v
Measure 20-50 WARM steps, offload ON and OFF
(discard warmup; identical batch/seqlen/precision)
|
+--------+--------+
| |
medians close median much higher with offload
| |
v v
Not the cause Profile ONE warm step
Look elsewhere |
+----------+----------+
| |
copy rows dominate matmul rows dominate
| |
v v
TRANSFER-BOUND Compute-bound:
| offload is not the
v limiter, look at the
Check NUMA affinity model or the loader
(nvidia-smi topo -m)
Check pin_memory is on
|
v
Still bound? Reorder the levers:
checkpointing -> sharding ->
optimizer offload -> param offloadDiagnose this failure in VS Code
Select the traceback or open the failed terminal, then run Denpex locally to see the initiating rank, collateral failures, exact fix, and verification command without uploading the log.
Install the free VS Code extensionDeepSpeed errors in context
DeepSpeed changes when parameters, gradients and optimizer state are created, partitioned, gathered and offloaded. The hub separates ZeRO, memory, checkpoint and pipeline failures by lifecycle phase.
Compare every deepspeed error side by sideRelated failures to investigate next
Root cause
- Per-step data movement exceeds what the step's compute can hide. The GPU idles waiting for parameters to arrive or gradients to drain, so the step is bound by PCIe rather than by arithmetic.
- Prefetch depth is limited by the free HBM available to stage into. Offloading is usually enabled because that headroom is small, so the mechanism most able to hide the transfer is the one least able to operate.
- With offloaded optimizer state the parameter update runs on the CPU, so its duration is governed by host core count and memory bandwidth and it serializes against data loading on the same cores.
- Host buffers allocated on a non-local NUMA node force every transfer across the inter-socket link in addition to PCIe, which is invisible in the training configuration and is a property of process affinity.
- Unpinned host memory prevents true asynchronous DMA, so transfers are staged through an intermediate copy and cannot overlap with compute even when ordering would allow it.
- NVMe offload adds filesystem and block-layer latency beneath the PCIe transfer, and without a correctly configured asynchronous I/O backend the queue depth is too shallow to reach the device's rated throughput.
The fix and how to prevent it
Unlock the full remediation runbook
14 days on the Scale plan, up to 50 diagnoses a day. Step-by-step remediation, the RMA evidence payload, and multi-node correlation on your own logs. No card, and it does not roll into a subscription.
Frequently asked questions
Questions engineers and on-call staff commonly ask about this failure.
Why is my GPU utilization low when I have plenty of free GPU memory?
How do I tell whether offloading is actually the bottleneck?
Should I use activation checkpointing or CPU offloading?
Is it better to offload optimizer state or parameters?
Does NUMA placement really affect offloading performance?
Why did enabling pin_memory not speed anything up?
When is NVMe offload worth using instead of CPU offload?
Can gradient accumulation replace offloading?
References
- ↗PyTorch FullyShardedDataParallel API, including CPUOffload
- ↗PyTorch Getting Started with FSDP tutorial
- ↗DeepSpeed ZeRO-Offload tutorial
- ↗DeepSpeed configuration reference, offload_optimizer and offload_param
- ↗DeepSpeed ZeRO-Infinity, offloading to NVMe
- ↗NVIDIA Nsight Systems user guide
- ↗PyTorch Profiler recipe
- ↗NVIDIA System Management Interface, topology and link query
Don't just read the fix, diagnose your run
The encyclopedia tells you what went wrong. Denpex tells you what went wrong in YOUR training run. With your logs, your config, and your stack.