Article

LLM CPU–GPU Data Path in Production: Reducing Inference Jitter with NUMA Affinity, Pinned Memory, and Async Copy

A systematic guide to optimizing the CPU-to-GPU data path for LLM inference, covering NUMA affinity, pinned memory, async copy, topology scheduling, and container alignment to reduce host-side transfer jitter, tail latency, and cross-node resource misconfiguration.

Why Inference Jitters Even When GPU Is Busy

LLM inference optimization typically focuses on GPU kernels, KV cache, batching, and parallelism strategies. However, before a request enters the model, it still goes through tokenization, batching, host memory writes, Host-to-Device (H2D) copy, and CUDA scheduling. The output stage may also move small results back to the CPU.

On a single-socket server, these overheads are often hidden by GPU computation. On dual-socket or multi-socket NUMA servers, the problem becomes significantly more complex: the inference process may run on Socket 0, but the batch buffer is backed by memory pages from Socket 1, while the target GPU is attached to the PCIe Root Complex of Socket 0. Data must traverse the NUMA interconnect before entering PCIe, creating a longer path that is more prone to contention with other workloads.

This leads to a common misdiagnosis in production: GPU utilization looks decent, but time-to-first-token (TTFT) and P99 latency periodically spike. The real bottleneck may not be model computation, but host-side data preparation, remote NUMA access, small copies, synchronous waits, or incorrect resource placement.

Core Principles: Treat the Host-to-Device Path as a Complete Pipeline

1. CPU, Memory, and GPU Have Physical Proximity

On NUMA servers, each CPU has its local memory node. GPUs connect to a CPU socket via a PCIe Switch or Host Bridge. The closer the inference process’s CPU cores, memory pages, and GPU are, the more direct the data path.

nvidia-smi topo -m shows the affinity between GPUs, NICs, CPUs, and memory nodes, distinguishing paths like PIX, PXB, PHB, NODE, SYS, and NVLink. In production, don’t just record “how many GPUs a node has.” Also save:

  • GPU-to-CPU core affinity
  • Nearest NUMA CPU and memory node for each GPU
  • Whether GPU, NIC, and NVMe share a PCIe path
  • The process’s actual CPUSet and memory allocation nodes
  • Whether topology alignment is preserved after container scheduling

2. Pinned Memory Provides DMA-Capable Host Buffers

When pageable memory participates in H2D copy, CUDA often needs to stage the data in a pinned region first. Pinned memory cannot be swapped out by the OS, making it more suitable for DMA and generally providing more stable host-to-device transfers.

But it’s not free. Pinning pages has a cost, and excessive pinning can starve the system of pageable memory, impacting the entire node. The correct approach is not to call pin_memory() temporarily for every object, but to maintain a bounded, reusable staging buffer pool with shape-based buckets.

3. non_blocking Is an Async Request, Not Automatic Overlap

PyTorch’s tensor.to(device, non_blocking=True) requests an asynchronous copy, but achieving true overlap between computation and transfer typically requires:

  1. The source tensor is already in pinned memory
  2. The H2D operation is enqueued on a dedicated or appropriate CUDA stream
  3. The next computation stage waits via a CUDA event, not a global sync
  4. The staging buffer is not reused or overwritten before the copy completes
  5. The hardware and current workload allow the copy engine to run concurrently with compute

In other words, the optimization target is not a single parameter, but the buffer lifecycle, stream dependencies, and batch pipeline.

Engineering Implementation: Six Steps to a Verifiable Data Path

Step 1: Build a Topology Inventory for Each Node

Collect and persist topology during node initialization:

nvidia-smi topo -m
nvidia-smi topo -C -i 0
nvidia-smi topo -M -i 0
numactl --hardware
lscpu -e=CPU,NODE,SOCKET,CORE

Don’t assume all nodes of the same model are identical. PCIe slots, BIOS settings, MIG, virtualization, and device replacements can change the actual path. Generate a topology fingerprint and include it, along with node labels, GPU UUIDs, and driver versions, in the asset inventory.

Step 2: Bind Inference Process CPU and Host Memory

After identifying the nearest NUMA node for the target GPU, run controlled experiments with numactl:

numactl \
  --cpunodebind=0 \
  --membind=0 \
  python -m vllm.entrypoints.openai.api_server \
  --model /models/example

--cpunodebind restricts the process to CPUs on specific NUMA nodes; --membind restricts memory page allocation to those nodes. Strict binding can fail if local memory is insufficient, so validate capacity before production and prepare a fallback to preferred or a wider node set.

Also beware of AutoNUMA. NVIDIA’s CUDA best practices documentation notes that automatic NUMA balancing can degrade performance on certain GPU workloads. Whether to disable or tune it must be decided based on the target kernel, distribution, and real stress tests, not by blindly applying a uniform configuration.

Step 3: Establish a Bounded Pinned Buffer Pool

Create a small number of buckets based on input shapes or token counts (e.g., short, medium, long) rather than pinning memory per request. The pool should track:

  • Total pinned bytes
  • Number of free, in-flight, and reclaimable buffers
  • Per-wait time
  • Pageable fallback count when the pool is exhausted
  • Whether the associated CUDA event for each buffer has completed

A simplified PyTorch example:

import torch

h2d_stream = torch.cuda.Stream()
ready_event = torch.cuda.Event()

# In production, manage this via a bounded pool, not per-request creation.
host_ids = torch.empty(
    (32, 4096), dtype=torch.int32, pin_memory=True,
)

def copy_batch_to_gpu(batch_ids: torch.Tensor) -> torch.Tensor:
    rows, cols = batch_ids.shape
    # Write batched results into a reusable pinned staging buffer.
    host_view = host_ids[:rows, :cols]
    host_view.copy_(batch_ids)
    # Initiate async H2D on a dedicated stream.
    with torch.cuda.stream(h2d_stream):
        device_ids = host_view.to("cuda", non_blocking=True)
    ready_event.record(h2d_stream)
    # The current compute stream waits only for this batch's data, no device-wide sync.
    torch.cuda.current_stream().wait_event(ready_event)
    return device_ids

A real implementation must ensure that host_view is not reassigned to the next batch before ready_event completes. Otherwise, hard-to-reproduce data corruption can occur.

Step 4: Coalesce Small Copies to Avoid Per-Request Fragmentation

CUDA best practices recommend aggregating multiple small transfers into larger contiguous ones. For LLM serving, prioritize completing the following on the CPU side:

  • Token ID concatenation
  • Packing metadata like position, slot, and sequence length
  • Shape bucket alignment
  • One or a few H2D copies, rather than per-request, per-field copies

Aggregation doesn’t mean blindly increasing batch size. The goal is to reduce fixed transfer overhead without breaking TTFT or scheduling fairness.

Step 5: Ensure Container Scheduling Maintains Topology Alignment

In Kubernetes, requesting nvidia.com/gpu: 1 alone does not guarantee that CPU, memory, and GPU reside on the same NUMA node. For latency-sensitive, exclusive inference pods, evaluate:

  • CPU Manager’s static policy
  • Topology Manager’s restricted or single-numa-node policy
  • Pod scope to keep sidecar and main containers on a common NUMA set
  • Explicit CPU request/limit and Guaranteed QoS
  • Compatibility with the NVIDIA Device Plugin and node resource manager

single-numa-node will reject pods that cannot be aligned to a single node, making it suitable for dedicated nodes prioritizing stability, not for global enablement without capacity planning.

Step 6: Prove Optimization Effectiveness with Timelines

Don’t just compare aggregate throughput. Nsight Systems’ CUDA Trace can show H2D/D2H memory operations, CUDA APIs, kernels, and streams on a timeline, answering:

  • Is H2D happening on the expected stream?
  • Do copy and kernel truly overlap?
  • Are there many small memcpy operations?
  • Is the CPU thread blocked on synchronous APIs for a long time?
  • Do pinned, pageable, or unified memory introduce extra paths?
  • After the copy completes, is the GPU still waiting due to scheduling gaps?

Performance replay should cover at least short inputs, long inputs, burst concurrency, mixed shapes, different GPU slots, and different NUMA binding configurations.

Establish at least the following metrics, grouped by node, GPU, NUMA, model, and input bucket:

MetricDescription
h2d_copy_duration_secondsH2D latency distribution
h2d_bytes_totalActual bytes transferred
h2d_copy_countCopies per batch
pinned_pool_bytes / pinned_pool_wait_secondsPool capacity and wait time
pageable_fallback_totalNumber of fallbacks to pageable memory
remote_numa_accessNode-level NUMA miss metrics
Tokenize/Batch/Copy/Prefill segment durationsPer-stage time breakdown
GPU idle gap, TTFT, TPOT, and P99End-to-end latency metrics
Topology fingerprint, CPUSet, GPU UUIDDeployment version correlation

When tail latency anomalies occur, you should be able to trace a single request back to: which CPU batched it, which NUMA node provided the memory, which GPU received the copy, how many memcpy operations occurred, and where the wait happened.

Applicable Scenarios

This governance is most valuable for:

  • Multi-GPU inference servers with dual or multi-socket CPUs
  • Online serving with high input shape variance and abundant batching metadata
  • Smaller models with shorter GPU compute times, where host-side overhead becomes a larger fraction
  • VLM, speech, or embedding services requiring frequent transfer of larger inputs
  • CPU Offload, KV Offload, or tiered caching schemes
  • Nodes with complex PCIe topologies shared by GPUs, NICs, and NVMe
  • Inference pods in Kubernetes requiring dedicated CPU cores and low tail latency

For services where computation is overwhelmingly dominant, with single-socket CPUs, single GPUs, and very small inputs, the benefits may be limited. Profile first before investing in these changes.

Common Misconceptions

Misconception 1: Enabling pin_memory Always Makes Things Faster

Temporarily converting a pageable tensor to a pinned tensor is itself a blocking operation. If you pin memory per request, the added cost can negate the transfer benefit. Prioritize pre-allocation and reuse, and limit pool size.

Misconception 2: non_blocking=True Means It’s Already Asynchronous

If the source memory isn’t pinned, the stream relationship is incorrect, or the code subsequently performs a global sync, the expected overlap won’t occur. Confirm with timelines, not just API parameters.

Misconception 3: High GPU Utilization Means the Data Path Is Fine

Utilization is an aggregate metric over a time window and cannot expose small but frequent gaps. A brief H2D stall can significantly raise TTFT and P99 without necessarily lowering average GPU utilization.

Misconception 4: CPU Offload Is a Free Way to Extend GPU Memory

The vLLM documentation explicitly notes that CPU Offload relies on fast CPU–GPU interconnect because parts of the model are accessed or moved from the host during the forward pass. Cross-NUMA or congested PCIe can turn a memory problem into a persistent latency problem.

Misconception 5: Tighter NUMA Binding Is Always Better

Strict membind can fail outright when local memory is insufficient; an overly narrow CPUSet can cause contention among tokenizer, network, and scheduler threads. Split CPU roles by function, reserve capacity, and keep a rollback configuration.

Production Readiness Checklist

  • Topology and version fingerprints for GPU, CPU, NUMA, NIC, and NVMe have been collected.
  • The inference process’s CPUSet and memory policy are close to the target GPU.
  • Pinned memory uses a bounded pool with metrics for capacity, wait time, and fallback.
  • Staging buffers are not reused before their associated CUDA event completes.
  • Small H2D copies have been reasonably coalesced without introducing excessive batch wait time.
  • Nsight Systems has demonstrated the expected overlap between H2D and computation.
  • TTFT, P99, and throughput have been compared across default, bound, and fallback configurations.
  • Kubernetes topology policies have been validated under capacity shortage and pod rescheduling scenarios.
  • CPU Offload or Unified Memory has been evaluated independently, not conflated with the normal path.
  • Production gates include alerts for topology changes, remote NUMA access, and pageable fallback.
  • Fallback switches are ready to disable binding, shrink the pinned pool, or revert to synchronous copy.

References

  1. NVIDIA, CUDA C++ Best Practices Guide 13.3https://docs.nvidia.com/cuda/cuda-c-best-practices-guide/index.html
  2. PyTorch, A guide on good usage of non_blocking and pin_memory()https://docs.pytorch.org/tutorials/intermediate/pinmem_nonblock.html
  3. NVIDIA, nvidia-smi Documentation — Topology Commandshttps://docs.nvidia.com/deploy/nvidia-smi/index.html
  4. NVIDIA, Nsight Systems User Guide — CUDA Tracehttps://docs.nvidia.com/nsight-systems/UserGuide/index.html
  5. Kubernetes, Control Topology Management Policies on a nodehttps://kubernetes.io/docs/tasks/administer-cluster/topology-manager/
  6. Linux man-pages, numactl(8)https://man7.org/linux/man-pages/man8/numactl.8.html
  7. vLLM, Engine Arguments — CPU Offloadhttps://docs.vllm.ai/en/latest/configuration/engine_args/

FAQ

Does enabling pin_memory and non_blocking guarantee H2D copy overlaps with computation?
Not necessarily. True overlap requires the source data to be in pinned memory, the copy to run on an appropriate CUDA stream, the GPU to support concurrent copy engines, and subsequent computation to wait on a CUDA event for data readiness.
Should NUMA binding be enforced on all GPU inference nodes?
Not globally. First verify the actual topology of GPU, CPU, and memory nodes, then compare H2D latency, CPU overhead, and tail latency under load with and without binding. Strict membind can cause allocation failures when local memory is insufficient.
Can CPU Offload equivalently extend GPU memory without performance impact?
No. CPU Offload places weights or states in host memory, accessed or moved during inference. Performance heavily depends on CPU–GPU interconnect, NUMA locality, and access patterns, requiring separate throughput and tail latency validation.