<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Gpudirect on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/gpudirect/</link><description>Recent content in Gpudirect on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Mon, 22 Jun 2026 11:00:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/gpudirect/index.xml" rel="self" type="application/rss+xml"/><item><title>Storage in the AI era (2/4): performance</title><link>https://blog.lo0.es/en/posts/ai-era-storage-performance/</link><pubDate>Mon, 22 Jun 2026 11:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/ai-era-storage-performance/</guid><description>&lt;p>In the &lt;a href="https://blog.lo0.es/en/posts/ai-era-storage-state-of-the-art/">first article&lt;/a> we drew the map: the memory-storage hierarchy, the media technologies and the software stack that dominates GPU clusters. This second article goes into the property that justifies all that complexity: performance. The question an architect has to answer is not &amp;ldquo;how many GB/s does my array deliver?&amp;rdquo;, but &amp;ldquo;can I get my GPUs working at 95 % instead of 40 %?&amp;rdquo;. That difference is, literally, half the compute bill.&lt;/p>
&lt;h2 id="the-metric-that-really-matters-accelerator-utilisation">The metric that really matters: accelerator utilisation&lt;/h2>
&lt;p>It is tempting to measure AI storage with the classic metrics, sequential &lt;em>throughput&lt;/em>, random IOPS, latency, and we do need them. But none of them captures what the business pays for. The integrating metric is &lt;strong>accelerator utilisation&lt;/strong>: what fraction of the time the GPU spends computing rather than waiting for data.&lt;/p>
&lt;p>Most real ML &lt;em>pipelines&lt;/em> run at between 30 % and 60 % GPU utilisation. Industry surveys are damning: only around 7 % of AI/ML teams manage to exceed 85 % at peak, and poor forecasting or weak autoscaling can leave GPUs idle 70-85 % of the time. A &lt;em>job&lt;/em> running at 30 % utilisation is paying for 70 % of its compute capacity and getting nothing for it.&lt;/p>
&lt;p>We can formalise this. If \( t_{\text{cmp}} \) is the compute time per &lt;em>batch&lt;/em> and \( t_{\text{io}} \) the time the GPU spends blocked waiting for I/O that fails to overlap with compute, effective utilisation is:&lt;/p>
$$U = \frac{t_{\text{cmp}}}{t_{\text{cmp}} + t_{\text{io}}}$$
&lt;p>The goal of all AI storage engineering is to drive \( t_{\text{io}} \) towards zero, whether by increasing bandwidth, reducing latency or overlapping I/O with compute through &lt;em>prefetching&lt;/em> and &lt;em>pipelining&lt;/em>. And there is an aggravating factor: the faster the GPU, the harder it is to feed. Moving from V100 to H100 cut the per-&lt;em>batch&lt;/em> compute time of the 3D U-Net model by 76 %, turning a bandwidth-sensitive workload into a latency-sensitive one. Every accelerator generation raises the bar for storage.&lt;/p>
&lt;p>This change of regime, from bandwidth-limited to latency-limited, has a design consequence worth internalising. When a workload is bandwidth-limited, the fix is to add transfer capacity: more NVMe, more &lt;em>lanes&lt;/em>, more nodes. When it becomes latency-limited, that recipe stops working; what matters then is response time per operation, which is attacked with cache, with intelligent &lt;em>prefetching&lt;/em> and with data paths that remove hops (GPUDirect, for instance). Diagnosing correctly which of the two regimes each workload sits in is the first task of any optimisation: throwing bandwidth at a latency-limited workload is spending money without moving utilisation.&lt;/p>
&lt;h2 id="mlperf-storage-the-benchmark-that-does-measure-the-right-thing">MLPerf Storage: the benchmark that does measure the right thing&lt;/h2>
&lt;p>For years there was no neutral way to compare AI storage platforms. MLPerf Storage, from MLCommons, has provided one. Its design insight is not to measure GB/s in the abstract but to simulate the accelerators&amp;rsquo; &lt;em>think time&lt;/em> so as to generate a realistic I/O pattern, and to require those simulated accelerators to sustain a minimum utilisation level. In other words, it measures exactly what matters: how many accelerators a platform can keep fed without their utilisation dropping below the threshold.&lt;/p>
&lt;p>&lt;strong>v1.0&lt;/strong> (September 2024) used the 3D U-Net, ResNet-50 and CosmoFlow models, simulating A100 and H100, with utilisation thresholds of 90 % for the first two and 70 % for CosmoFlow. &lt;strong>v2.0&lt;/strong> (August 2025) marked a leap: more than 200 results from 26 organisations, including Alluxio, DDN, Hammerspace, HPE, IBM, KIOXIA, Micron, Oracle, Samsung and WDC, systems able to sustain roughly twice as many accelerators as in v1.0, and, above all, a new &lt;em>checkpointing&lt;/em> workload (which we will come back to).&lt;/p>
&lt;p>Some v2.0 results help calibrate the state of the art:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>System&lt;/th>
&lt;th>Throughput&lt;/th>
&lt;th>Accelerators&lt;/th>
&lt;th>GPU utilisation&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Volumez&lt;/td>
&lt;td>1.079 TB/s&lt;/td>
&lt;td>—&lt;/td>
&lt;td>92.21 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Hammerspace (5 nodes)&lt;/td>
&lt;td>420.8 GB/s&lt;/td>
&lt;td>140&lt;/td>
&lt;td>&amp;gt;94.7 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Hammerspace (3 nodes)&lt;/td>
&lt;td>253.1 GB/s&lt;/td>
&lt;td>84&lt;/td>
&lt;td>&amp;gt;94.7 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Hammerspace (1 node)&lt;/td>
&lt;td>85.6 GB/s&lt;/td>
&lt;td>28&lt;/td>
&lt;td>&amp;gt;94.7 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Alluxio&lt;/td>
&lt;td>24.14 GiB/s&lt;/td>
&lt;td>128&lt;/td>
&lt;td>99.57 %&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The Hammerspace figure illustrates a property an architect should demand: &lt;strong>linear scaling&lt;/strong>. From 1 to 5 nodes &lt;em>throughput&lt;/em> grows from 85.6 to 420.8 GB/s while keeping utilisation above 94.7 % with a minute coefficient of variation (0.08-0.14 %). Linear scaling is what separates an AI platform from a fast NAS.&lt;/p>
&lt;h2 id="the-numbers-from-commercial-platforms">The numbers from commercial platforms&lt;/h2>
&lt;p>Outside the benchmark, platforms certified for DGX SuperPOD publish aggregate figures worth keeping as an order-of-magnitude reference:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>WEKA WEKApod Nitro&lt;/strong>: 720 GB/s read and 186 GB/s write per configuration, 18 million IOPS, with ConnectX-8 NICs at 800 Gb/s. Per node, 70 GB/s read and 40 GB/s write; the minimum 8-node configuration delivers 560 GB/s read. A single entry-level system covers the I/O demand of a 1,152-GPU GB200 &lt;em>scalable unit&lt;/em>.&lt;/li>
&lt;li>&lt;strong>DDN AI400X2&lt;/strong>: more than 90 GB/s and 3 million IOPS per &lt;em>appliance&lt;/em>; the Turbo variant saturates close to 100 GB/s (800 Gbps) per DGX B200. DDN&amp;rsquo;s new platform claims more than 1 TB/s read per &lt;em>appliance&lt;/em> with per-rack scaling, and has fed NVIDIA&amp;rsquo;s Eos supercomputer at 4 TB/s.&lt;/li>
&lt;li>&lt;strong>VAST Data&lt;/strong>: multiple TB/s from a single &lt;em>mount point&lt;/em> via NFS &lt;em>multipathing&lt;/em>, RDMA over NFS and GPUDirect, managing exabytes in a single multiprotocol namespace.&lt;/li>
&lt;li>&lt;strong>Pure Storage&lt;/strong>: certified for DGX SuperPOD with GB200 and GB300.&lt;/li>
&lt;/ul>
&lt;p>These figures only mean something in relation to the cluster they feed. A GB200 NVL72 moves 130 TB/s of internal GPU-to-GPU communication; storage does not compete with that, but it does have to sustain &lt;em>data loading&lt;/em> and &lt;em>checkpointing&lt;/em> without strangling 72 GPUs that behave as one.&lt;/p>
&lt;h2 id="the-io-pattern-of-training-and-gpudirect-storage">The I/O pattern of training and GPUDirect Storage&lt;/h2>
&lt;p>Understanding the I/O pattern is the key to neither over- nor under-sizing. Training combines several patterns: large sequential reads of the dataset, small random reads during &lt;em>shuffling&lt;/em> between epochs, and massive periodic writes during &lt;em>checkpointing&lt;/em>. Each stresses storage differently.&lt;/p>
&lt;p>The traditional data path goes through the CPU: from storage to a &lt;em>bounce buffer&lt;/em> in host memory and from there to GPU memory. Every byte of every &lt;em>checkpoint&lt;/em> and every weight load pays the latency and CPU &lt;em>overhead&lt;/em> of that double hop. &lt;strong>GPUDirect Storage (GDS)&lt;/strong> cuts it out: it enables direct DMA between GPU memory and NVMe through the cuFile user-space library and the nvidia-fs kernel module, which intercepts POSIX calls and redirects them through the DMA engine. The result is &lt;em>zero-copy&lt;/em>, with direct transfers exceeding 40 GB/s and, above all, without stealing cycles from the CPU.&lt;/p>
&lt;p>There is a nuance that naive deployments miss: &lt;strong>GDS is optimised for large, aligned, sequential transfers&lt;/strong>. For workloads dominated by small files or heavily random access, the traditional path with CPU caching can be more efficient. GDS is not a magic &amp;ldquo;more performance&amp;rdquo; switch; it is a tool for a specific pattern. Designing the &lt;em>data pipeline&lt;/em>, meaning dataset format, &lt;em>shard&lt;/em> size and number of &lt;em>DataLoader&lt;/em> &lt;em>workers&lt;/em>, matters as much as picking the array.&lt;/p>
&lt;p>The &lt;em>data pipeline&lt;/em> deserves a pause, because it is where performance leaks most quietly. Training does not read the dataset just any way: it walks it in &lt;em>batches&lt;/em>, shuffles it between epochs to avoid ordering bias, and often applies transformations (image decoding, tokenisation, &lt;em>augmentation&lt;/em>) on the CPU before handing the tensor to the GPU. Each of those steps can become the bottleneck. Too few &lt;em>DataLoader&lt;/em> &lt;em>workers&lt;/em> leaves the GPU waiting; a dataset format that forces millions of small files open sinks metadata performance; a badly designed &lt;em>shuffle&lt;/em> turns efficient sequential reads into costly random accesses. Common practice in serious workloads is to pack the dataset into large &lt;em>shards&lt;/em> (formats such as WebDataset, TFRecord or Parquet) precisely so that the read pattern is large and sequential, the terrain where GDS and parallel file systems perform best. The mental rule: the fastest storage in the world will not save a badly designed data &lt;em>pipeline&lt;/em>.&lt;/p>
&lt;h2 id="the-real-cost-of-checkpointing-when-there-is-training">The real cost of checkpointing (when there is training)&lt;/h2>
&lt;p>&lt;em>Checkpointing&lt;/em> is the most demanding write workload in the AI world, but it needs to be placed properly: it is a &lt;strong>training&lt;/strong> problem. A pure inference factory barely suffers from it, since its heavy writes are different ones, KV-cache, vector indexes, telemetry. We cover it here because many installations combine some &lt;em>fine-tuning&lt;/em> or occasional training with serving, and because it illustrates better than any other case how asynchrony beats brute force. If your platform is for inference, you can read this section as context and jump to the next one, which is the one that really concerns you.&lt;/p>
&lt;p>That said, most people&amp;rsquo;s intuition about &lt;em>checkpointing&lt;/em> is wrong on two counts: size and frequency.&lt;/p>
&lt;p>&lt;strong>Size.&lt;/strong> A &lt;em>checkpoint&lt;/em> is not just the weights. The rule of thumb is around 16 bytes per parameter, because the bulk is optimiser state. The figures from the MLPerf Storage v2.0 &lt;em>checkpointing&lt;/em> workload, based on Meta&amp;rsquo;s &lt;em>Llama 3 Herd&lt;/em>, make it plain:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Model&lt;/th>
&lt;th>Processes&lt;/th>
&lt;th>Checkpoint size&lt;/th>
&lt;th>Of which, optimiser state&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>8B&lt;/td>
&lt;td>8&lt;/td>
&lt;td>105 GB&lt;/td>
&lt;td>90 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>70B&lt;/td>
&lt;td>64&lt;/td>
&lt;td>912 GB&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>405B&lt;/td>
&lt;td>512&lt;/td>
&lt;td>5.29 TB&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1T&lt;/td>
&lt;td>1024&lt;/td>
&lt;td>15 TB&lt;/td>
&lt;td>13.2 TB&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Frequency.&lt;/strong> At scale, hardware failures are constant (we develop this in the &lt;a href="https://blog.lo0.es/en/posts/storage-ai-era-availability/">article on availability&lt;/a>). Meta&amp;rsquo;s model for a 16,000-accelerator cluster predicts a failure every few hours. To lose less than 5 % of progress between failures, you need on the order of 20 &lt;em>checkpoints&lt;/em> in that interval. That translates into aggressive cadences:&lt;/p>
&lt;ul>
&lt;li>16,000 accelerators: around 155 &lt;em>checkpoints&lt;/em> a day, one every 9.3 minutes.&lt;/li>
&lt;li>100,000 accelerators: around 967 &lt;em>checkpoints&lt;/em> a day, one every 1.5 minutes.&lt;/li>
&lt;/ul>
&lt;p>And here is where the bandwidth problem appears. A synchronous &lt;em>checkpoint&lt;/em> halts training while it writes. If \( S_{\text{ckpt}} \) is the &lt;em>checkpoint&lt;/em> size and \( t_{\text{win}} \) the acceptable time window for writing it while keeping &lt;em>overhead&lt;/em> below 5 %, the required bandwidth is:&lt;/p>
$$B_{\text{req}} = \frac{S_{\text{ckpt}}}{t_{\text{win}}}$$
&lt;p>For a 1T model on 100,000 accelerators, the window falls to around 4.4 seconds. Writing 15 TB in that time demands on the order of 3.6 TB/s across the cluster, or some 200 GB/s sustained over the whole &lt;em>job&lt;/em>. On &lt;em>checkpoints&lt;/em> alone, that scenario generates more than 14 PB of writes a day. This is not a marginal workload: it is one of the most demanding write workloads in existence.&lt;/p>
&lt;p>It is worth seeing the calculation laid out, because it reveals where the lever is. Take the 405B model, whose &lt;em>checkpoint&lt;/em> weighs 5.29 TB. If policy requires writing it while keeping &lt;em>overhead&lt;/em> below 5 % at a cadence where the acceptable window is, say, 30 seconds, the synchronous bandwidth needed is around 176 GB/s sustained, within reach of a high-end array, but only one dedicated to that task. If instead of 30 seconds the window contracts to 5 (a larger cluster, more frequent failures), the requirement jumps above 1 TB/s. Sensitivity to the window is brutal: the difference between a viable design and an impossible one lies in how time is managed, not in how many drives are bought. That is where asynchrony changes the rules.&lt;/p>
&lt;p>&lt;strong>The solution is not just more bandwidth; it is asynchrony.&lt;/strong> &lt;em>Asynchronous checkpointing&lt;/em> decouples the &lt;em>snapshot&lt;/em> (a fast GPU→CPU copy) from persistence (a background write, overlapped with compute). PyTorch&amp;rsquo;s &lt;em>Distributed Async Checkpointing&lt;/em> cuts effective &lt;em>checkpoint&lt;/em> time by 10 to 20 times; in one IBM example a 7B model brought its &lt;em>down time&lt;/em> down from 148.8 to 6.3 seconds, a 23.6-fold improvement. Techniques such as CheckFreq pipeline the &lt;em>snapshot&lt;/em> and persistence with training, allowing a &lt;em>checkpoint&lt;/em> every 14-19 iterations. This is why vendors like VAST argue the relevant metric is not GB/s but &lt;em>checkpoint overlap&lt;/em>: what fraction of the &lt;em>checkpoint&lt;/em> overlaps with compute. Around 10 % overlap is usually enough.&lt;/p>
&lt;p>The design lesson is clear: sizing training storage for the peak write rate of synchronous &lt;em>checkpointing&lt;/em> is expensive and often unnecessary if the software stack gets asynchrony right. But trusting asynchrony without measuring &lt;em>overlap&lt;/em> is playing roulette with weeks of compute.&lt;/p>
&lt;h2 id="performance-in-an-inference-factory">Performance in an inference factory&lt;/h2>
&lt;p>Here is the heart of the matter for anyone running an inference factory. In an inference factory storage is not measured in epochs or GPU-hours but in the experience the client perceives, and that experience condenses into two metrics: &lt;strong>TTFT&lt;/strong> (&lt;em>time to first token&lt;/em>, the time until the first token of the answer) and &lt;strong>inter-token latency&lt;/strong> (the rate at which the following tokens arrive). At fleet scale, the metric that pays the bills is &lt;strong>throughput in tokens per second&lt;/strong> per GPU, because it determines how many requests each accelerator serves. Storage touches all three, in ways a training-centric design does not anticipate.&lt;/p>
&lt;h3 id="prefill-decode-and-their-disaggregation">Prefill, decode and their disaggregation&lt;/h3>
&lt;p>Inference has two phases with opposite profiles. &lt;strong>Prefill&lt;/strong> processes the entire input &lt;em>prompt&lt;/em> at once, generates the key-value pairs of the KV-cache and is a compute-intensive phase; it dominates TTFT. &lt;strong>Decode&lt;/strong> generates tokens one by one, reusing that cache, is memory-bandwidth-intensive and dominates inter-token latency. Because their bottlenecks differ, the 2025-2026 trend is to &lt;strong>disaggregate them&lt;/strong>: run &lt;em>prefill&lt;/em> and &lt;em>decode&lt;/em> on separate GPU &lt;em>pools&lt;/em> and transfer the KV-cache between them. That transfer, which may travel over NVLink, over the network or through a shared storage layer, makes KV-cache movement a performance-critical operation. A factory that disaggregates prefill and decode needs a very high bandwidth, low latency KV-cache path between both &lt;em>pools&lt;/em>; storage (or the DPU that governs it) stops being a passive actor and enters the critical path of every request.&lt;/p>
&lt;h3 id="the-kv-cache-as-a-performance-layer">The KV-cache as a performance layer&lt;/h3>
&lt;p>The central problem is the size of the KV-cache with long contexts: it overflows HBM, which is scarce and expensive. The options are to recompute it on every request, ruinously expensive in compute and lethal for TTFT, or to offload it to a slower layer and reuse it. Two approaches compete here. &lt;em>Offload&lt;/em> to &lt;strong>CXL&lt;/strong> memory offers gains of more than 5 times against SSD or RDMA-based &lt;em>caching&lt;/em>, according to CXL supply chain vendors, and allows &lt;em>batches&lt;/em> up to 30 % larger while holding latency targets, which raises &lt;em>throughput&lt;/em> and GPU utilisation. &lt;em>Offload&lt;/em> to &lt;strong>NVMe&lt;/strong>, standardised by NVIDIA in 2026 with its ICMS platform on BlueField-4, is slower but much cheaper and more capacious, ideal for &lt;em>pod&lt;/em>-scale KV-cache.&lt;/p>
&lt;p>The big performance lever here is &lt;strong>KV-cache reuse&lt;/strong> (&lt;em>prefix caching&lt;/em>). Many requests share prefixes: the same &lt;em>system prompt&lt;/em>, the same context document, the same prior conversation. If the KV-cache of those prefixes is kept in a fast storage layer and reused instead of recomputed, &lt;em>prefill&lt;/em> shortens drastically and TTFT collapses. This turns a low-latency storage layer into a direct multiplier of the factory&amp;rsquo;s throughput: the more useful cache fits and the faster it is served, the less compute is wasted recalculating what has already been calculated. Sizing this layer well, in capacity, bandwidth, latency and eviction policy, is one of the most profitable performance decisions in the whole factory.&lt;/p>
&lt;h3 id="model-load-time-and-cold-start">Model load time and cold start&lt;/h3>
&lt;p>There is a storage cost specific to inference that training ignores: &lt;strong>starting a replica&lt;/strong>. When autoscaling decides to bring up a new instance of a model, because demand is rising, because a replica has failed or because a version is being rotated, the weights have to be read from the registry and loaded into HBM. For a model of tens or hundreds of GB, that &lt;em>cold start&lt;/em> can run from seconds to minutes depending on registry bandwidth and the load path. And during that time the new GPU serves no requests while the system is, perhaps, saturated. A slow model registry translates directly into missed SLAs at peak. The mitigations, keeping weights on hot local flash, using fast-loading formats, &lt;em>streaming&lt;/em> weights as they are needed, or GPUDirect Storage to load straight into HBM, are storage decisions that determine the factory&amp;rsquo;s real elasticity.&lt;/p>
&lt;h3 id="rag-performance-and-vector-search">RAG performance and vector search&lt;/h3>
&lt;p>If the factory serves RAG, there is one more link in the critical path of every request: retrieval. Before the model generates anything, the system searches a vector database for the relevant fragments, and that similarity search, over indexes that can run to TB, adds to TTFT. Vector search performance depends on keeping the indexes on low-latency flash and on an efficient random read pattern; an index that does not fit in memory and is served from slow storage adds latency to every request. Beyond that, precomputed KV-caches of the retrieved documents speed up &lt;em>prefill&lt;/em>, because the model reuses keys and values already calculated and only encodes the user&amp;rsquo;s query. An inference factory with RAG therefore has two latency-sensitive read workloads coexisting, the vector index and the KV-cache, competing for the same fast flash and needing to be sized together.&lt;/p>
&lt;p>For an inference architect, all this introduces a storage plane with a profile of its own: latency-sensitive random reads (indexes, reusable KV-cache prefixes), high-turnover ephemeral writes (new KV-cache), large, sporadic but urgent reads (model loading) and an append-only telemetry flow. It is not sized in the least like the storage of a training cluster.&lt;/p>
&lt;h2 id="how-to-measure-your-own-beyond-the-spec-sheet">How to measure your own: beyond the spec sheet&lt;/h2>
&lt;p>Vendor and MLPerf figures are useful as a reference, but real performance depends on the specific workload, and an architect must measure rather than assume. The methodology that works starts from three principles.&lt;/p>
&lt;p>The first is to &lt;strong>measure accelerator utilisation, not storage in the abstract&lt;/strong>. An array can deliver 500 GB/s in a synthetic test and still leave the GPUs at 50 % because the real pattern is small random files that the sequential test does not reproduce. The right question is always: what fraction of the time is my GPU computing, with this workload, this dataset format and this &lt;em>pipeline&lt;/em>?&lt;/p>
&lt;p>The second is to &lt;strong>reproduce the real I/O pattern&lt;/strong>, not a convenient one. That means testing with the same dataset format, the same &lt;em>shard&lt;/em> size, the same number of &lt;em>workers&lt;/em> and the same &lt;em>checkpointing&lt;/em> cadence that will be used in production. MLPerf Storage&amp;rsquo;s insight was precisely to simulate accelerator &lt;em>think time&lt;/em> in order to generate a realistic pattern; replicating that rigour at your own scale is what separates a useful measurement from a &lt;em>marketing&lt;/em> number.&lt;/p>
&lt;p>The third is to &lt;strong>look for scaling, not peak&lt;/strong>. A platform that delivers a very high peak in a small configuration but flattens out as nodes are added is no use for growth. Tests should be run at several scales to verify that &lt;em>throughput&lt;/em> grows roughly linearly and that accelerator utilisation holds, as the Hammerspace example in MLPerf v2.0 showed. A low coefficient of variation across configurations matters as much as the peak.&lt;/p>
&lt;h2 id="the-cost-of-getting-it-wrong">The cost of getting it wrong&lt;/h2>
&lt;p>It is worth putting a number on the problem. The price of an H100 in the cloud in mid-2026 moves across a wide range by provider, with a market median around 2.3-3.1 USD per GPU-hour. Multiply that by thousands of GPUs and by the weeks a training run lasts, and 40 % utilisation instead of 90 % translates into millions of euros of wasted compute. That is why more than 50 % of organisations report data or storage bottlenecks limiting their AI in 2026, and why storage bandwidth has emerged as a hard ceiling on scaling, comparable to power and cooling: when there is &lt;em>data starvation&lt;/em>, adding compute gives diminishing returns.&lt;/p>
&lt;p>The operational recommendation that keeps coming up is to size the I/O &lt;em>pipeline&lt;/em> for 300 Gbps or more in serious workloads, and to measure, not assume, accelerator utilisation as a health indicator. Optimised storage can deliver up to 5 times the &lt;em>throughput&lt;/em> of standard S3 access over HTTP: more than 100 GB/s aggregate against some 20 GB/s.&lt;/p>
&lt;h2 id="the-physical-layer-networks-and-pcie-gen6">The physical layer: networks and PCIe Gen6&lt;/h2>
&lt;p>All of the above rests on a physical layer that has also advanced. NVIDIA&amp;rsquo;s ConnectX-8 NIC combines PCIe Gen6 with 800 GbE or 800 Gb InfiniBand XDR, designed for Blackwell systems. InfiniBand XDR offers 800 Gbps per direction (double NDR, which remains the usual &lt;em>storage fabric&lt;/em>), and the Quantum-X800 &lt;em>switch&lt;/em> adds in-network compute that accelerates collective operations. The transition to XDR is accelerating with the Blackwell deployments of 2025-2026. The mental rule is simple: storage must match the network, and the network must match the GPU. One slow link defines the performance of the whole chain.&lt;/p>
&lt;p>It is worth translating this into concrete numbers. An 800 Gb/s NIC delivers, at best, around 100 GB/s to a node. If the array can serve more than the NIC absorbs, the network is the bottleneck; if the array serves less, storage is. Balance is found by sizing each layer so none strangles the next, and by verifying it with end-to-end measurements rather than the isolated specifications of each component. It is common to find installations with an excellent array and an undersized &lt;em>fabric&lt;/em>, or the reverse, performing far below the sum of their parts. The physical layer also imposes its geography: network topology (how many hops there are between the compute node and the array, whether they share a switch or cross the cluster spine) affects latency as much as the array itself, and an AI storage design that ignores network topology is incomplete.&lt;/p>
&lt;h2 id="three-questions-before-sizing">Three questions before sizing&lt;/h2>
&lt;p>All this complexity can be organised into three questions an architect should answer before buying anything. What is the dominant I/O pattern of my workload, large sequential, small random, bursty writes, and therefore which metric limits me? Is my workload bandwidth-limited or latency-limited, and consequently where do I invest? And what is my &lt;em>checkpointing&lt;/em> budget, in size, cadence and, above all, what fraction can I overlap with compute? Whoever answers these three with measured data, rather than intuitions, sizes well; whoever skips them ends up with idle GPUs or with an oversized, very expensive array that is never put to use. AI storage performance is, at bottom, an exercise in fit: putting the right capacity where the workload needs it, no more and no less.&lt;/p>
&lt;h2 id="takeaways">Takeaways&lt;/h2>
&lt;p>AI storage performance is not measured in GB/s but in accelerator utilisation, and that utilisation depends on three things: enough bandwidth, low latency and, above all, overlapping I/O with compute. MLPerf Storage v2.0 has finally given a common language for comparing platforms through that lens. &lt;em>Checkpointing&lt;/em> is the most demanding write workload and is mastered with asynchrony, not hardware alone. Long-context inference adds a new storage layer around the KV-cache. And the cost of getting it wrong is measured in idle GPUs, which is the most expensive line in the budget.&lt;/p>
&lt;p>Performance, however, is worth nothing if the data is neither protected nor available. The &lt;a href="https://blog.lo0.es/en/posts/storage-ai-era-security/">third article&lt;/a> takes on the security of this new storage layer.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/ai-era-storage-state-of-the-art/">Storage in the AI era (1/4): the state of the art&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/storage-ai-era-security/">Storage in the AI era (3/4): security&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/storage-ai-era-availability/">Storage in the AI era (4/4): availability&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>MLCommons, &lt;em>MLPerf Storage v2.0 Results&lt;/em> — &lt;a href="https://mlcommons.org/2025/08/mlperf-storage-v2-0-results/">https://mlcommons.org/2025/08/mlperf-storage-v2-0-results/&lt;/a>&lt;/li>
&lt;li>MLCommons, &lt;em>Announcing the MLPerf Storage v2.0 Checkpointing Workload&lt;/em> — &lt;a href="https://mlcommons.org/2025/08/storage-2-checkpointing/">https://mlcommons.org/2025/08/storage-2-checkpointing/&lt;/a>&lt;/li>
&lt;li>MLCommons, &lt;em>MLPerf Storage v1.0 Benchmark Results&lt;/em> — &lt;a href="https://mlcommons.org/2024/09/mlperf-storage-v1-0-benchmark-results/">https://mlcommons.org/2024/09/mlperf-storage-v1-0-benchmark-results/&lt;/a>&lt;/li>
&lt;li>Hammerspace, &lt;em>MLPerf Storage v2.0 Benchmark Results&lt;/em> — &lt;a href="https://hammerspace.com/hammerspace-mlperf-storage-v2-0-benchmark-results/">https://hammerspace.com/hammerspace-mlperf-storage-v2-0-benchmark-results/&lt;/a>&lt;/li>
&lt;li>Alluxio, &lt;em>Strong Performance in MLPerf Storage v2.0&lt;/em> — &lt;a href="https://www.alluxio.io/blog/alluxio-demonstrates-strong-performance-in-mlperf-storage-v2-0-benchmarks">https://www.alluxio.io/blog/alluxio-demonstrates-strong-performance-in-mlperf-storage-v2-0-benchmarks&lt;/a>&lt;/li>
&lt;li>NVIDIA, &lt;em>GPUDirect Storage Overview Guide&lt;/em> — &lt;a href="https://docs.nvidia.com/gpudirect-storage/overview-guide/index.html">https://docs.nvidia.com/gpudirect-storage/overview-guide/index.html&lt;/a>&lt;/li>
&lt;li>Spheron, &lt;em>GPU Direct Storage Guide 2026&lt;/em> — &lt;a href="https://www.spheron.network/blog/gpu-direct-storage-nvme-ai-training-inference-guide/">https://www.spheron.network/blog/gpu-direct-storage-nvme-ai-training-inference-guide/&lt;/a>&lt;/li>
&lt;li>PyTorch, &lt;em>Reducing Checkpointing Times with Async Checkpointing&lt;/em> — &lt;a href="https://pytorch.org/blog/reducing-checkpointing-times/">https://pytorch.org/blog/reducing-checkpointing-times/&lt;/a>&lt;/li>
&lt;li>VAST Data, &lt;em>Optimizing Checkpoint Bandwidth for LLM Training&lt;/em> — &lt;a href="https://www.vastdata.com/blog/optimizing-checkpoint-bandwidth-for-llm-training">https://www.vastdata.com/blog/optimizing-checkpoint-bandwidth-for-llm-training&lt;/a>&lt;/li>
&lt;li>Astera Labs, &lt;em>How CXL Transforms RAG and KV Cache Performance&lt;/em> — &lt;a href="https://www.asteralabs.com/breaking-through-the-memory-wall-how-cxl-transforms-rag-and-kv-cache-performance/">https://www.asteralabs.com/breaking-through-the-memory-wall-how-cxl-transforms-rag-and-kv-cache-performance/&lt;/a>&lt;/li>
&lt;li>MinIO, &lt;em>AI Storage Architecture: Overcoming the Bottleneck in 2026&lt;/em> — &lt;a href="https://www.min.io/blog/ai-storage-architecture-bottleneck-2026">https://www.min.io/blog/ai-storage-architecture-bottleneck-2026&lt;/a>&lt;/li>
&lt;li>WEKA, &lt;em>WEKApod Nitro reference architecture&lt;/em> — &lt;a href="https://www.weka.io/resources/reference-architecture/nvidia-gb200-nvl72-systems-reference-architecture-for-cloud-partners/">https://www.weka.io/resources/reference-architecture/nvidia-gb200-nvl72-systems-reference-architecture-for-cloud-partners/&lt;/a>&lt;/li>
&lt;li>Blocks &amp;amp; Files, &lt;em>Storage vendors rally behind Nvidia at GTC 2025&lt;/em> — &lt;a href="https://blocksandfiles.com/2025/03/18/nvidia-storage-announcements/">https://blocksandfiles.com/2025/03/18/nvidia-storage-announcements/&lt;/a>&lt;/li>
&lt;li>ServeTheHome, &lt;em>NVIDIA ConnectX-8 SuperNIC PCIe Gen6 800G&lt;/em> — &lt;a href="https://www.servethehome.com/nvidia-connectx-8-supernic-pcie-gen6-800g-nic-detailed/">https://www.servethehome.com/nvidia-connectx-8-supernic-pcie-gen6-800g-nic-detailed/&lt;/a>&lt;/li>
&lt;li>SpendArk, &lt;em>GPU Cloud Pricing 2026&lt;/em> — &lt;a href="https://spendark.com/blog/machine-learning-cloud-cost/">https://spendark.com/blog/machine-learning-cloud-cost/&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>The corridors and the security guard: PCIe topology, GPUDirect P2P and ACS</title><link>https://blog.lo0.es/en/posts/pcie-topology-gpudirect-p2p-acs-security-guard/</link><pubDate>Mon, 08 Jun 2026 06:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/pcie-topology-gpudirect-p2p-acs-security-guard/</guid><description>&lt;blockquote>
&lt;p>This continues the &lt;em>below the engine&lt;/em> series. The &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">NVLink and NCCL post&lt;/a> explained the &lt;em>shared bench&lt;/em> over which the GPUs pass data to each other at 450 GB/s. But that bench only connects GPUs to each other. Everything else, disk, network, the host, travels over &lt;strong>another bus&lt;/strong>, PCIe, and through its corridors. The &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">cold start&lt;/a> already brushed against this with GPUDirect Storage; this post opens up the complete floor plan of the corridors and the guard who watches them.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>On a 4×H100 SXM node, the GPUs talk over NVLink (450 GB/s per direction, around 7× PCIe), and for the all-reduce of &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">tensor parallel&lt;/a> that is the path. But PCIe does not go away: it is where everything else comes in. The weights come up from the NVMe over PCIe (the &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">cold start&lt;/a>), the data from another node arrives via the NIC over PCIe (RDMA), and a KV that moves between nodes travels over PCIe. GPUDirect is the family that lets those bytes go &lt;strong>straight from the device into the HBM&lt;/strong> without bouncing through host RAM: P2P (GPU↔GPU), RDMA (GPU↔NIC) and Storage (GPU↔NVMe). The obstacle is a guard called ACS (Access Control Services): a PCIe security feature that by default forces &lt;em>peer-to-peer&lt;/em> traffic &lt;strong>up to the root complex&lt;/strong> for inspection, which destroys the direct path and inserts a detour through the CPU. The IOMMU (VT-d) does something similar if it is not in &lt;em>passthrough&lt;/em> mode. Disabling them gives performance; keeping them gives isolation and virtualisation, and that is a real decision in an ENS environment. This post explains the topology (&lt;code>nvidia-smi topo -m&lt;/code>), GPUDirect, why ACS and IOMMU break P2P with numbers, the 10 knobs and the trap of removing the guard without knowing what it was watching. On the generic 4×H100 SXM cluster.&lt;/p>
&lt;h2 id="where-you-are-the-corridors-not-the-bench">Where you are: the corridors, not the bench&lt;/h2>
&lt;p>Picture the kitchen as a building. The elite cooking stations, the GPUs, are in a room with a &lt;strong>huge central bench&lt;/strong> (NVLink/NVSwitch) over which they pass ingredients at full speed without standing up. That bench is for them and them alone.&lt;/p>
&lt;p>But the building has more to it: the pantry (the NVMe storage), the loading door (the network, the NIC) and reception (the CPU and its RAM). To reach any of those, the stations do not use the central bench: they use the &lt;strong>corridors of the building&lt;/strong>, the PCIe bus. And here the character of the post appears: at the entrance of each corridor there is a &lt;strong>security guard&lt;/strong> (ACS) who, by default, does not let two stations hand each other something directly along the corridor: it forces them to &lt;strong>take the parcel up to reception&lt;/strong> to be checked, and only then does it come down to its destination. It is safe, but it is an absurd detour when the two stations are right next to each other. GPUDirect is the permission to skip that detour; ACS and IOMMU are the reasons why, often, you cannot.&lt;/p>
&lt;h2 id="the-topology-of-a-node-two-buses-not-one">The topology of a node: two buses, not one&lt;/h2>
&lt;p>The most common mistake is to think that a node has &amp;ldquo;a bus&amp;rdquo;. There are (at least) two, and they do different things:&lt;/p>
&lt;ul>
&lt;li>NVLink / NVSwitch — the high-speed GPU↔GPU mesh. On H100 SXM, &lt;strong>18 links × 50 GB/s = 900 GB/s bidirectional&lt;/strong> between any two GPUs, with NVSwitch giving an &lt;em>all-to-all&lt;/em> with no contention (&lt;a href="https://www.nvidia.com/en-us/data-center/h100/">NVLink, NVIDIA&lt;/a>). It is the shared bench.&lt;/li>
&lt;li>PCIe Gen5 — the general I/O bus. An &lt;strong>x16 link gives 128 GB/s bidirectional&lt;/strong> (around 64 per direction) (&lt;a href="https://www.nvidia.com/content/dam/en-zz/Solutions/Data-Center/h100/PB-11773-001_v01.pdf">H100 product brief&lt;/a>). It connects each GPU with the CPU, the RAM, the NICs and the NVMes. It is the corridor.&lt;/li>
&lt;/ul>
&lt;p>The difference is 7×: NVLink moves in one second what PCIe takes seven to move. That is why intra-node tensor parallel goes over NVLink and nobody argues about it. PCIe matters for the &lt;em>other&lt;/em> things: pulling weights off disk, receiving from the network, moving KV between nodes.&lt;/p>
&lt;p>The tool for seeing this is &lt;code>nvidia-smi topo -m&lt;/code>, which prints a matrix of how each pair is connected (&lt;a href="https://forums.developer.nvidia.com/t/nvidia-smi-topo-m-revisited/216584">NVIDIA forum&lt;/a>):&lt;/p>
&lt;pre tabindex="0">&lt;code> GPU0 GPU1 GPU2 GPU3 NIC0 CPU Affinity NUMA
GPU0 X NV18 NV18 NV18 PXB 0-47 0
GPU1 NV18 X NV18 NV18 PXB 0-47 0
GPU2 NV18 NV18 X NV18 SYS 48-95 1
GPU3 NV18 NV18 NV18 X SYS 48-95 1
&lt;/code>&lt;/pre>&lt;p>The legend is what matters: NV18 = 18 NVLink links (the bench); PXB = crosses PCIe switches but not the host; PHB = goes through the host bridge; NODE = same NUMA, crossing PCIe; SYS = crosses the inter-socket interconnect (the worst case, it traverses NUMA). The fact that &lt;code>GPU0↔NIC0&lt;/code> is PXB and &lt;code>GPU2↔NIC0&lt;/code> is SYS tells you exactly which GPU should handle that NIC&amp;rsquo;s traffic, GPU 0, without crossing NUMA. This links directly with the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">NUMA post&lt;/a> and the &lt;a href="https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/">network NUMA&lt;/a> one: PCIe affinity &lt;strong>is&lt;/strong> NUMA affinity.&lt;/p>
&lt;svg viewBox="0 0 720 280" xmlns="http://www.w3.org/2000/svg" font-family="sans-serif" font-size="12" role="img" aria-label="Topology of a node: NVLink on top, PCIe below, ACS as the gate">
&lt;rect x="120" y="20" width="480" height="40" rx="6" fill="none" stroke="#7c3aed" stroke-width="2"/>
&lt;text x="360" y="45" text-anchor="middle" fill="#7c3aed">NVSwitch — NVLink mesh 900 GB/s (GPU↔GPU bench)&lt;/text>
&lt;rect x="120" y="80" width="90" height="36" rx="4" fill="none" stroke="currentColor" stroke-width="1.5"/>&lt;text x="165" y="103" text-anchor="middle" fill="currentColor">GPU0&lt;/text>
&lt;rect x="250" y="80" width="90" height="36" rx="4" fill="none" stroke="currentColor" stroke-width="1.5"/>&lt;text x="295" y="103" text-anchor="middle" fill="currentColor">GPU1&lt;/text>
&lt;rect x="380" y="80" width="90" height="36" rx="4" fill="none" stroke="currentColor" stroke-width="1.5"/>&lt;text x="425" y="103" text-anchor="middle" fill="currentColor">GPU2&lt;/text>
&lt;rect x="510" y="80" width="90" height="36" rx="4" fill="none" stroke="currentColor" stroke-width="1.5"/>&lt;text x="555" y="103" text-anchor="middle" fill="currentColor">GPU3&lt;/text>
&lt;line x1="165" y1="80" x2="165" y2="60" stroke="#7c3aed" stroke-width="1.5"/>&lt;line x1="295" y1="80" x2="295" y2="60" stroke="#7c3aed" stroke-width="1.5"/>&lt;line x1="425" y1="80" x2="425" y2="60" stroke="#7c3aed" stroke-width="1.5"/>&lt;line x1="555" y1="80" x2="555" y2="60" stroke="#7c3aed" stroke-width="1.5"/>
&lt;rect x="250" y="160" width="220" height="34" rx="4" fill="none" stroke="#dc2626" stroke-width="2" stroke-dasharray="5 3"/>&lt;text x="360" y="182" text-anchor="middle" fill="#dc2626">PCIe switch + ACS (the guard)&lt;/text>
&lt;line x1="165" y1="116" x2="300" y2="160" stroke="#2563eb" stroke-width="1.2"/>&lt;line x1="295" y1="116" x2="330" y2="160" stroke="#2563eb" stroke-width="1.2"/>&lt;line x1="425" y1="116" x2="390" y2="160" stroke="#2563eb" stroke-width="1.2"/>&lt;line x1="555" y1="116" x2="420" y2="160" stroke="#2563eb" stroke-width="1.2"/>
&lt;rect x="120" y="230" width="120" height="34" rx="4" fill="none" stroke="currentColor"/>&lt;text x="180" y="252" text-anchor="middle" fill="currentColor" font-size="11">CPU + RAM (root)&lt;/text>
&lt;rect x="300" y="230" width="120" height="34" rx="4" fill="none" stroke="currentColor"/>&lt;text x="360" y="252" text-anchor="middle" fill="currentColor" font-size="11">NIC (network)&lt;/text>
&lt;rect x="480" y="230" width="120" height="34" rx="4" fill="none" stroke="currentColor"/>&lt;text x="540" y="252" text-anchor="middle" fill="currentColor" font-size="11">NVMe (disk)&lt;/text>
&lt;line x1="180" y1="194" x2="180" y2="230" stroke="#2563eb" stroke-width="1.2"/>&lt;line x1="360" y1="194" x2="360" y2="230" stroke="#2563eb" stroke-width="1.2"/>&lt;line x1="430" y1="194" x2="540" y2="230" stroke="#2563eb" stroke-width="1.2"/>
&lt;text x="610" y="180" fill="#dc2626" font-size="10">ACS on →&lt;/text>
&lt;text x="610" y="195" fill="#dc2626" font-size="10">up to root&lt;/text>
&lt;/svg>
&lt;h2 id="gpudirect-skipping-reception">GPUDirect: skipping reception&lt;/h2>
&lt;p>Without GPUDirect, moving a piece of data from the NIC (or the NVMe) to the GPU takes a mandatory detour: &lt;strong>device → host RAM → GPU&lt;/strong>. That bounce through RAM consumes CPU bandwidth, spends copies and adds latency. GPUDirect removes the bounce by letting the data go &lt;strong>straight from the device into the HBM&lt;/strong>. Three flavours:&lt;/p>
&lt;ul>
&lt;li>GPUDirect P2P — GPU↔GPU over PCIe (when there is no NVLink between them, or for traffic that does not use the bench).&lt;/li>
&lt;li>GPUDirect RDMA — GPU↔NIC: the network card writes directly into the HBM. It is what makes efficient multi-node viable (NCCL over InfiniBand/RoCE).&lt;/li>
&lt;li>GPUDirect Storage (GDS) — GPU↔NVMe: the disk writes straight into the HBM, with no host buffer. It is the lever of the &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">cold start&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>On an SXM node, the GPU↔GPU traffic of &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">tensor parallel&lt;/a> &lt;strong>does not use P2P over PCIe&lt;/strong>: it uses NVLink. That is why GPUDirect matters above all at the edges of the node: the network (RDMA, for multi-node) and the disk (GDS, for startup). That is where ACS does damage.&lt;/p>
&lt;h2 id="the-guard-why-acs-and-iommu-break-p2p">The guard: why ACS and IOMMU break P2P&lt;/h2>
&lt;p>&lt;strong>ACS (Access Control Services)&lt;/strong> is a PCIe security feature designed for virtualisation and isolation: it guarantees that one device cannot read from or write to another directly without the &lt;em>root complex&lt;/em> mediating. To achieve that, it &lt;strong>forces peer-to-peer transactions up to the root complex&lt;/strong> and back down (&lt;a href="https://docs.nvidia.com/gpudirect-storage/best-practices-guide/index.html">GDS best practices, NVIDIA&lt;/a>). It is exactly the opposite of what GPUDirect wants: the direct path stops being direct.&lt;/p>
&lt;p>The IOMMU (VT-d on Intel, the equivalent on AMD) translates addresses and isolates devices. If it is active and &lt;strong>not&lt;/strong> in passthrough mode, it also &lt;strong>redirects P2P traffic through the root complex&lt;/strong>, with the same effect: performance through the floor or, in extreme cases, &lt;em>hangs&lt;/em> (&lt;a href="https://docs.nvidia.com/deeplearning/nccl/user-guide/docs/troubleshooting.html">NCCL troubleshooting&lt;/a>).&lt;/p>
&lt;p>Summarised without detours (&lt;a href="https://morgangiraud.medium.com/multi-gpu-nvidia-p2p-capabilities-and-debugging-tips-fb7597b4e2b5">Giraud, debugging P2P&lt;/a>): ACS forces the trip through the root &lt;em>for security checks&lt;/em>; IOMMU forces it &lt;em>for isolation and virtualisation&lt;/em>. Both break the goal of P2P (direct communication with no intermediaries) and add overhead. If you do not need that security/virtualisation on that path, disabling them recovers the performance. The operational recipe for maximum GPUDirect performance: &lt;strong>ACS off&lt;/strong> on the switches along the path and &lt;strong>IOMMU in passthrough&lt;/strong> (&lt;code>iommu=pt&lt;/code>) or disabled.&lt;/p>
&lt;h2 id="the-maths-that-matter-how-much-the-detour-costs">The maths that matter: how much the detour costs&lt;/h2>
&lt;p>Take a &lt;strong>KV SWAP&lt;/strong> of 5 GB (preemption from the &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">scheduler&lt;/a> that sends KV to the host, or a transfer between nodes in &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a>):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Path&lt;/th>
&lt;th>Effective BW&lt;/th>
&lt;th>Time for 5 GB&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>NVLink (GPU↔GPU intra-node)&lt;/td>
&lt;td>~450 GB/s&lt;/td>
&lt;td>&lt;strong>~11 ms&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Direct PCIe Gen5 x16 (P2P, ACS off)&lt;/td>
&lt;td>~55 GB/s&lt;/td>
&lt;td>&lt;strong>~91 ms&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>PCIe via root complex (ACS on)&lt;/td>
&lt;td>~25-30 GB/s*&lt;/td>
&lt;td>&lt;strong>~170-200 ms&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>* The detour through the root does not just &amp;ldquo;add latency&amp;rdquo;: it saturates the host bridge bandwidth, contends with other traffic and, depending on the topology, can fall well below the direct path. The figure is an order of magnitude, to show the size of the problem, not a benchmark.&lt;/p>
&lt;p>The reading: on the path that does use PCIe (network, disk, swap), having ACS on can &lt;strong>double or triple&lt;/strong> the time. And if that time is on the critical path, a cold start, a preemption swap, an inter-node all-reduce, it shows up in the latency the user sees. What disabling ACS does &lt;strong>not&lt;/strong> fix: traffic that already went over NVLink (intra-node TP). There, ACS is irrelevant.&lt;/p>
&lt;h2 id="the-real-tension-performance-vs-isolation-and-ens">The real tension: performance vs isolation (and ENS)&lt;/h2>
&lt;p>Here the post gets serious, because the &amp;ldquo;disable ACS and IOMMU&amp;rdquo; recipe has a cost that in a regulated environment is not free. ACS and IOMMU &lt;strong>exist for a reason&lt;/strong>: to isolate devices. On a &lt;strong>dedicated bare-metal node&lt;/strong> for inference, with no virtualisation or multi-tenancy, you are not isolating anything that matters and disabling them is reasonable. But:&lt;/p>
&lt;ul>
&lt;li>If you do &lt;strong>GPU passthrough to VMs&lt;/strong> or use containers with strong isolation, the IOMMU is &lt;strong>necessary&lt;/strong>, not optional.&lt;/li>
&lt;li>In a multi-tenant scenario where several workloads share a node, ACS provides a guarantee that one device does not snoop on another.&lt;/li>
&lt;li>Under ENS (&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">see technical controls&lt;/a>), workload isolation and access traceability can be requirements; disabling bus isolation to gain 80 ms is a decision you have to &lt;strong>justify and document&lt;/strong>, not a silent tweak.&lt;/li>
&lt;/ul>
&lt;p>The design way out, when you need both: &lt;strong>keep the isolation where compliance requires it and design so that the hot path does not depend on P2P over PCIe&lt;/strong>. Concretely, on an SXM node, the bulk of the critical traffic (TP) already goes over NVLink and ACS does not affect it. For the network, dedicate one NIC per GPU on its own PCIe switch (PXB) and use GPUDirect RDMA only on the path you control. For the disk, cache weights on local NVMe. That way you do not pay the choice between performance and isolation: you avoid it on the path that matters.&lt;/p>
&lt;h2 id="the-10-knobs">The 10 knobs&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Knob&lt;/th>
&lt;th>What it controls&lt;/th>
&lt;th>Cost / risk&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>ACS off (switches on the path)&lt;/td>
&lt;td>P2P detour through root&lt;/td>
&lt;td>you lose bus isolation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>&lt;code>iommu=pt&lt;/code> / off&lt;/td>
&lt;td>P2P redirection through root&lt;/td>
&lt;td>breaks VM passthrough if off&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>&lt;code>nvidia-smi topo -m&lt;/code>&lt;/td>
&lt;td>audit the real topology&lt;/td>
&lt;td>— (always worth it)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>&lt;code>p2pBandwidthLatencyTest&lt;/code>&lt;/td>
&lt;td>measure P2P for real&lt;/td>
&lt;td>— (verify before assuming)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>NIC placement&lt;/td>
&lt;td>same PCIe switch as the GPU&lt;/td>
&lt;td>SYS if it crosses NUMA&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>NVMe placement&lt;/td>
&lt;td>NUMA-local to the GPU&lt;/td>
&lt;td>H2D crossing UPI&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>&lt;code>nvidia-peermem&lt;/code> (GDR)&lt;/td>
&lt;td>enables RDMA into HBM&lt;/td>
&lt;td>correct driver/kernel&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>PCIe gen/lanes (x16)&lt;/td>
&lt;td>width of the corridor&lt;/td>
&lt;td>GPU silently at x8&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>relaxed ordering / ASPM&lt;/td>
&lt;td>PCIe latency and power&lt;/td>
&lt;td>jitter if misconfigured&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>persistence mode&lt;/td>
&lt;td>avoids re-init of the path&lt;/td>
&lt;td>idle GPU still paid for&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="how-it-connects-with-the-rest-of-the-stack">How it connects with the rest of the stack&lt;/h2>
&lt;p>&lt;strong>With NVLink and NCCL.&lt;/strong> The &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">NVLink post&lt;/a> covers the GPU↔GPU bench; this one covers the &lt;em>other&lt;/em> bus, the one connecting to disk, network and host. They are complementary: ACS affects PCIe, not NVLink.&lt;/p>
&lt;p>&lt;strong>With the cold start.&lt;/strong> The GPUDirect Storage of the &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">disk→HBM post&lt;/a> is GPUDirect over the storage path; ACS on strangles it just as it strangles P2P.&lt;/p>
&lt;p>&lt;strong>With NUMA.&lt;/strong> The PCIe affinity of &lt;code>topo -m&lt;/code> &lt;strong>is&lt;/strong> the NUMA affinity of the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">host post&lt;/a>; placing NIC and NVMe on the correct NUMA avoids the SYS path.&lt;/p>
&lt;p>&lt;strong>With the network.&lt;/strong> NIC placement and GPUDirect RDMA is the subject of the &lt;a href="https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/">network NUMA post&lt;/a>; the same principle of &amp;ldquo;get the CPU out of the way&amp;rdquo;.&lt;/p>
&lt;p>&lt;strong>With PagedAttention and the scheduler.&lt;/strong> The preemption &lt;strong>SWAP&lt;/strong> (&lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">scheduler&lt;/a>) moves &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">KV&lt;/a> blocks over PCIe; that is why V1 prefers RECOMPUTE and why this bus matters.&lt;/p>
&lt;p>&lt;strong>With disaggregated serving.&lt;/strong> Transferring KV between pools in &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a> travels PCIe→NIC→PCIe; ACS and placement decide whether it is viable.&lt;/p>
&lt;p>&lt;strong>With ENS.&lt;/strong> Bus isolation is a technical control; see &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">ENS/42001/AI Act controls&lt;/a>.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;Disable ACS everywhere, it goes faster.&amp;rdquo;&lt;/strong> On a dedicated node, fine. On one with virtualisation, multi-tenancy or isolation requirements (ENS), you are removing a security control. The correct decision is &lt;em>per path&lt;/em> and documented, not global and silent.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;NVLink and PCIe are the same bus, more or less.&amp;rdquo;&lt;/strong> No. They are two buses with a 7× difference and different purposes. TP goes over NVLink; disk, network and host go over PCIe. Confusing them leads to &amp;ldquo;optimising&amp;rdquo; ACS for traffic that does not even go through PCIe.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;P2P just works, there is nothing to check.&amp;rdquo;&lt;/strong> P2P &lt;strong>is silently disabled&lt;/strong> with ACS/IOMMU active, and many distros enable them by default. Check with &lt;code>p2pBandwidthLatencyTest&lt;/code> and &lt;code>nvidia-smi topo -m&lt;/code>; do not assume you have the direct path just because the GPUs are on the same node.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;IOMMU off always, for performance.&amp;rdquo;&lt;/strong> If you do GPU passthrough to virtual machines, the IOMMU is mandatory; disabling it breaks the passthrough. The correct mode is usually &lt;code>passthrough&lt;/code> (&lt;code>iommu=pt&lt;/code>): it keeps the necessary mapping without penalising P2P.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;More PCIe lanes = faster GPU.&amp;rdquo;&lt;/strong> PCIe is the I/O path, not the compute path. A GPU at x8 instead of x16 takes longer to &lt;em>load&lt;/em> and to &lt;em>communicate over PCIe&lt;/em>, but generates tokens at the same speed once the weights are inside. The damage from x8 is in the cold start and in multi-node, not in decode throughput.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;GPUDirect fixes any I/O bottleneck.&amp;rdquo;&lt;/strong> GPUDirect removes the bounce through the CPU; if your bottleneck is the device itself (saturated NVMe, NIC at full tilt) or the topology (a SYS path crossing NUMA), GPUDirect does not touch it. Measure where the bottleneck is first.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>This whole series has been going down floors looking for where the time goes, and this one reaches the wiring of the building. Intuition treats the node as a homogeneous box where &amp;ldquo;the GPUs talk to everything&amp;rdquo;; the reality is that there are two buses with opposite purposes, an elite bench for the GPUs (NVLink) and some service corridors for everything else (PCIe), and a security guard in the corridors who, with the best of intentions, makes every parcel go up to reception before it is delivered. GPUDirect is the permission for direct delivery; ACS and IOMMU are the legitimate reasons why sometimes you are not given it. The lesson is not &amp;ldquo;disable the guard&amp;rdquo;: it is understanding &lt;strong>which path is critical&lt;/strong> (almost never the one you think) and &lt;strong>what the guard was watching&lt;/strong> before sending it home. On a dedicated node, the direct path is almost free and worth taking. On one that shares workloads or lives under ENS, bus isolation is a control you sacrifice with justification or do not sacrifice at all. Good design does not choose between performance and isolation blindly: it puts the critical traffic on the bench that needs no guard, and leaves the corridors for what can wait.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">The shared bench: NVLink, NVSwitch and NCCL&lt;/a> — the GPU↔GPU bus that ACS does not touch; complementary to this post.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM: cold start&lt;/a> — GPUDirect Storage over the NVMe path, strangled by ACS just like P2P.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">NUMA, hugepages and CPU isolation&lt;/a> — PCIe affinity is NUMA affinity; placing NIC and NVMe on the right socket.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/">Network NUMA, Cilium eBPF and DRANET&lt;/a> — NIC placement and GPUDirect RDMA, the same principle of getting the CPU out of the way.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention and the block manager&lt;/a> — the KV that travels over PCIe when a SWAP happens.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">The pass: vLLM&amp;rsquo;s scheduler step&lt;/a> — why V1 prefers RECOMPUTE to SWAP (it avoids the trip over PCIe).&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode separated&lt;/a> — transferring KV between nodes goes PCIe→NIC→PCIe.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls ENS / ISO 42001 / EU AI Act&lt;/a> — bus isolation as a security control to be justified.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>NVIDIA, &lt;em>GPUDirect Storage Best Practices Guide&lt;/em> (ACS, IOMMU, paths): &lt;a href="https://docs.nvidia.com/gpudirect-storage/best-practices-guide/index.html">https://docs.nvidia.com/gpudirect-storage/best-practices-guide/index.html&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>NCCL Troubleshooting&lt;/em> (IOMMU/VT-d and P2P): &lt;a href="https://docs.nvidia.com/deeplearning/nccl/user-guide/docs/troubleshooting.html">https://docs.nvidia.com/deeplearning/nccl/user-guide/docs/troubleshooting.html&lt;/a>.&lt;/li>
&lt;li>M. Giraud, &lt;em>Multi-GPU (NVIDIA) P2P capabilities and debugging tips&lt;/em>: &lt;a href="https://morgangiraud.medium.com/multi-gpu-nvidia-p2p-capabilities-and-debugging-tips-fb7597b4e2b5">https://morgangiraud.medium.com/multi-gpu-nvidia-p2p-capabilities-and-debugging-tips-fb7597b4e2b5&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>H100 Product Brief&lt;/em> (PCIe Gen5, NVLink 900 GB/s): &lt;a href="https://www.nvidia.com/content/dam/en-zz/Solutions/Data-Center/h100/PB-11773-001_v01.pdf">https://www.nvidia.com/content/dam/en-zz/Solutions/Data-Center/h100/PB-11773-001_v01.pdf&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>GPUDirect RDMA documentation&lt;/em>: &lt;a href="https://docs.nvidia.com/cuda/gpudirect-rdma/index.html">https://docs.nvidia.com/cuda/gpudirect-rdma/index.html&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>The kitchen door the maître d' never looked at: network NUMA, Cilium eBPF and DRANET, the fourth leg of pinning</title><link>https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/</link><pubDate>Sat, 06 Jun 2026 12:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/</guid><description>&lt;blockquote>
&lt;p>Fourth instalment, a coda, of &amp;ldquo;under the engine&amp;rdquo;. The series closed with three legs of locality: the &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">cable between GPUs&lt;/a>, the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">host by hand&lt;/a> and the &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">declarative orchestration of the kubelet&lt;/a>. But the maître d&amp;rsquo; of the last post seated the group looking at CPU, memory and GPU, and never asked &lt;strong>which door the dishes come through&lt;/strong>. That door is the NIC. Here is the fourth leg.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">Topology Manager&lt;/a> admits a pod under &lt;code>single-numa-node&lt;/code> if its CPUs, its memory and its GPU fit on the &lt;strong>same&lt;/strong> NUMA node. The &lt;strong>NIC is not part of that count&lt;/strong>: the kubelet has no Hint Provider for the network card. On an inference node with the network at 200/400 Gb/s, the case of &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a>, where the KV-cache travels over RDMA between the prefill pool and the decode pool, a NIC on the wrong socket makes &lt;strong>every packet cross the UPI/QPI&lt;/strong>, exactly the &amp;ldquo;remote NUMA&amp;rdquo; the series fights on the compute side, but coming in through the network door. And there is a second front: the &lt;strong>softirq&lt;/strong> (&lt;code>NET_RX&lt;/code>) that processes the datapath runs on the CPU that services the NIC&amp;rsquo;s IRQ; if that CPU is one of the cores &lt;code>isolcpus&lt;/code>/&lt;code>reserved-cpus&lt;/code> gave exclusively to vLLM, the softirq steals cycles from it and injects jitter into the p99 tail. &lt;strong>Cilium eBPF&lt;/strong> replaces two RKE2 pieces, &lt;code>kube-proxy&lt;/code> (with eBPF/XDP load balancing) and the default CNI &lt;strong>Canal&lt;/strong> (with a native datapath), and its own tuning guide tells you to &lt;strong>kill &lt;code>irqbalance&lt;/code> and pin the NIC IRQs&lt;/strong>: a &lt;strong>fourth list&lt;/strong> to align alongside &lt;code>isolcpus&lt;/code> and &lt;code>reserved-cpus&lt;/code>. The 2026 state of the art closes the gap from above: &lt;strong>netkit&lt;/strong> (kernel ≥6.8, zero namespace overhead), &lt;strong>BIG TCP&lt;/strong> (192k super-packets for 100Gb/s+), &lt;strong>host-routing&lt;/strong> (iptables bypass), and above all &lt;strong>DRA/DRANET&lt;/strong>, the network driver that finally co-schedules &lt;strong>NUMA-local GPU and NIC on the same PCIe root&lt;/strong>, enabling GPUDirect RDMA with &lt;strong>+59.6% bus bandwidth in &lt;code>all_gather&lt;/code> and +58.1% in &lt;code>all_reduce&lt;/code>&lt;/strong>. On a generic RKE2 cluster with 4×H100 SXM nodes.&lt;/p>
&lt;h2 id="where-you-are-the-network-plane-the-trilogy-did-not-open">Where you are: the network plane the trilogy did not open&lt;/h2>
&lt;div class="diagram" style="max-width:560px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 560 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The fourth leg: the network plane under the same NUMA as CPU, memory and GPU">
&lt;text x="280" y="24" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">The vertical stack · the fourth leg of locality&lt;/text>
&lt;rect x="120" y="40" width="320" height="38" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="64" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Engine · vLLM pod (TP, batching, KV-cache)&lt;/text>
&lt;rect x="120" y="84" width="320" height="38" rx="6" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.4"/>
&lt;text x="280" y="108" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Orchestration · kubelet: CPU/Mem/Topology Mgr (post 3)&lt;/text>
&lt;rect x="120" y="128" width="320" height="58" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="3"/>
&lt;text x="280" y="152" text-anchor="middle" font-family="sans-serif" font-size="12.5" font-weight="700" fill="#1f5c34">YOU ARE HERE · network plane: Cilium eBPF + DRA/NIC&lt;/text>
&lt;text x="280" y="170" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#2c6b42">NIC NUMA locality · IRQ · GPUDirect RDMA&lt;/text>
&lt;rect x="120" y="192" width="320" height="38" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="216" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Host · NUMA, hugepages, isolcpus (post 2)&lt;/text>
&lt;rect x="120" y="236" width="320" height="38" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="260" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">CUDA + NCCL + NVLink (post 1)&lt;/text>
&lt;rect x="120" y="280" width="320" height="38" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="304" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Hardware · 2 sockets, 4×H100 SXM, 400 Gb/s NIC&lt;/text>
&lt;text x="280" y="332" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="#777">CPU+memory+GPU are pinned by the kubelet; the NIC, until 2026, was pinned by nobody&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-door-the-dishes-come-through">The analogy: the door the dishes come through&lt;/h2>
&lt;p>Go back to the restaurant of the &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">previous post&lt;/a>. The maître d&amp;rsquo;, the Topology Manager, seated the party of eight at a single table (one NUMA node) because the diners (CPUs), the cutlery (memory) and the reserved bottle (the GPU) all fitted. A perfect table. But the maître d&amp;rsquo; &lt;strong>never looked at where the kitchen pass is&lt;/strong>: the door every dish comes in and goes out through.&lt;/p>
&lt;p>That door is the &lt;strong>NIC&lt;/strong>. The prompt comes in there, the tokens go out there, and, in disaggregated serving, the KV-cache that the prefill pool sends to the decode pool circulates there. If the table is in the left-hand room (socket 0) but the kitchen pass is in the right-hand one (socket 1), &lt;strong>every dish crosses the entire restaurant&lt;/strong> (the UPI/QPI), over and over, however impeccably the table is laid. The diner does not notice the perfect table: they notice the dish arriving late and cold.&lt;/p>
&lt;p>And there is a finer detail: the waiter who crosses the room with the dishes (the &lt;strong>softirq&lt;/strong> that processes the packets) is &lt;strong>one of the seated diners&lt;/strong>. If the maître d&amp;rsquo; gave them a chair of their own so they could eat in peace (a core isolated with &lt;code>isolcpus&lt;/code> for vLLM) but the restaurant also has them waiting on the far door, that diner does not eat: they spend the whole dinner crossing the room. The jitter shows up exactly where you thought you had bought calm.&lt;/p>
&lt;p>The trilogy levelled three legs of the table: the cable, the host and the orchestration. The fourth, &lt;strong>which door the dishes come through and who carries them&lt;/strong>, is levelled by no kubelet manager. Until 2026.&lt;/p>
&lt;h2 id="the-gap-why-the-topology-manager-does-not-look-at-the-nic">The gap: why the Topology Manager does not look at the NIC&lt;/h2>
&lt;p>The mechanism of &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">post 3&lt;/a> is a coordinator (Topology Manager) that consults three &lt;strong>Hint Providers&lt;/strong>: CPU Manager, Memory Manager and Device Manager (the GPU plugin). Each says on which NUMA node it can satisfy its part; the coordinator computes the intersection and admits or rejects.&lt;/p>
&lt;p>The problem is one of &lt;strong>census&lt;/strong>: the classic NIC is not a Device Manager &amp;ldquo;device&amp;rdquo;. A standard Ethernet/InfiniBand card is managed by the CNI and the kernel, it is not requested in the pod&amp;rsquo;s &lt;code>resources:&lt;/code> like &lt;code>nvidia.com/gpu&lt;/code>, and therefore &lt;strong>it emits no NUMA hint&lt;/strong>. The Topology Manager aligns CPU+memory+GPU and leaves the NIC wherever the hardware put it, which may be the other socket. The maître d&amp;rsquo; has three assistants and is missing the fourth: the one who knows which door the dishes come through.&lt;/p>
&lt;p>This did not matter when a node&amp;rsquo;s network was 10/25 Gb/s and the bottleneck was elsewhere. It matters &lt;strong>now&lt;/strong>, with two workloads that saturate the node&amp;rsquo;s network:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Disaggregated serving.&lt;/strong> The &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">KV-cache that travels between the prefill pool and the decode pool&lt;/a> moves over RDMA. These are large transfers, sensitive to latency and bandwidth, which in multi-node setups go out through the NIC.&lt;/li>
&lt;li>&lt;strong>Multi-node NCCL collectives.&lt;/strong> When &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">tensor/pipeline parallelism crosses the node boundary&lt;/a>, the &lt;code>all-reduce&lt;/code>/&lt;code>all-gather&lt;/code> operations no longer go over NVLink but over GPUDirect RDMA on the NIC.&lt;/li>
&lt;/ul>
&lt;p>In both, &lt;strong>where the NIC sits relative to the GPU and to the pod&amp;rsquo;s cores&lt;/strong> decides performance. And the kubelet, on its own, does not coordinate that.&lt;/p>
&lt;h2 id="the-network-datapath-under-numa-irq-softirq-and-dma">The network datapath under NUMA: IRQ, softirq and DMA&lt;/h2>
&lt;p>To see why NIC locality carries weight, you have to look at the path of an incoming packet:&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 820 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Path of a packet: NIC, IRQ, softirq and cross-NUMA DMA">
&lt;defs>&lt;marker id="nm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="410" y="22" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">Socket 0 (NIC here) vs Socket 1 (vLLM pod here): the crossing you do not see&lt;/text>
&lt;!-- Socket 0 -->
&lt;rect x="30" y="46" width="360" height="240" rx="10" fill="none" stroke="#4a6fa5" stroke-width="1.6" stroke-dasharray="5 3"/>
&lt;text x="210" y="66" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#4a6fa5">NUMA node 0 · PCIe root with the NIC&lt;/text>
&lt;rect x="60" y="80" width="120" height="44" rx="7" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.6"/>
&lt;text x="120" y="100" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">NIC 400 Gb/s&lt;/text>
&lt;text x="120" y="115" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">multi-queue, RSS&lt;/text>
&lt;rect x="60" y="150" width="120" height="44" rx="7" fill="#f7efda" stroke="#c79a32" stroke-width="1.6"/>
&lt;text x="120" y="170" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">IRQ + softirq&lt;/text>
&lt;text x="120" y="185" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">NET_RX on core 0..&lt;/text>
&lt;rect x="60" y="220" width="120" height="44" rx="7" fill="#dceede" stroke="#3c8c54" stroke-width="1.6"/>
&lt;text x="120" y="240" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">RAM node 0&lt;/text>
&lt;text x="120" y="255" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">packet DMA&lt;/text>
&lt;path d="M120,124 L120,150" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#nm)"/>
&lt;path d="M120,194 L120,220" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#nm)"/>
&lt;!-- Socket 1 -->
&lt;rect x="430" y="46" width="360" height="240" rx="10" fill="none" stroke="#a85454" stroke-width="1.6" stroke-dasharray="5 3"/>
&lt;text x="610" y="66" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#a85454">NUMA node 1 · the kubelet pinned the pod here&lt;/text>
&lt;rect x="550" y="150" width="160" height="44" rx="7" fill="#dceede" stroke="#3c8c54" stroke-width="2.2"/>
&lt;text x="630" y="170" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">vLLM pod + GPU&lt;/text>
&lt;text x="630" y="185" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">exclusive CPUs node 1&lt;/text>
&lt;!-- cross-numa arrow -->
&lt;path d="M180,242 C320,242 470,172 550,172" fill="none" stroke="#a85454" stroke-width="2.4" marker-end="url(#nm)"/>
&lt;text x="370" y="225" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#a85454">every packet crosses the UPI/QPI&lt;/text>
&lt;text x="370" y="240" text-anchor="middle" font-family="sans-serif" font-size="9" font-style="italic" fill="#a85454">+latency, +load on the inter-socket link&lt;/text>
&lt;text x="410" y="306" text-anchor="middle" font-family="sans-serif" font-size="10" font-style="italic" fill="#777">The Topology Manager did its job on node 1; the NIC stayed on node 0. Nobody aligned the two.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>Three kernel facts the analogy compresses:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>The IRQ has affinity.&lt;/strong> Each NIC queue raises an interrupt that the kernel services on a specific CPU (&lt;code>/proc/irq/&amp;lt;n&amp;gt;/smp_affinity&lt;/code>). The heavy processing is deferred to a &lt;strong>softirq&lt;/strong> (&lt;code>NET_RX&lt;/code>/&lt;code>NET_TX&lt;/code>), which runs on &lt;strong>that same CPU&lt;/strong>. If &lt;code>irqbalance&lt;/code> is left loose, it migrates them non-deterministically, poison for the p99.&lt;/li>
&lt;li>&lt;strong>The softirq competes with the pod.&lt;/strong> If the IRQ lands on a core that &lt;code>isolcpus&lt;/code> reserved for vLLM, that queue&amp;rsquo;s &lt;code>NET_RX&lt;/code> steals cycles from the model. The signal in &lt;code>/proc/softirqs&lt;/code>: a &lt;code>NET_RX&lt;/code> column that spikes on a single CPU. It is the same jitter from &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">post 2&lt;/a>, coming in through the network.&lt;/li>
&lt;li>&lt;strong>DMA has a NUMA origin.&lt;/strong> The NIC writes the packet by DMA into the RAM of the socket of its PCIe root. If the consumer (the pod&amp;rsquo;s thread) is on the other socket, it reads across the UPI/QPI. RFS (Receive Flow Steering) tries to bring the processing to the consumer&amp;rsquo;s CPU, but it cannot teleport the NIC to the other socket.&lt;/li>
&lt;/ol>
&lt;h3 id="one-number-with-its-caveat">One number, with its caveat&lt;/h3>
&lt;p>Take a 2-socket node, a &lt;strong>400 Gb/s = 50 GB/s&lt;/strong> NIC on the PCIe root of socket 0, and a decode pod pinned to socket 1. If the NIC saturates, those ~50 GB/s of receive traffic &lt;strong>cross the UPI&lt;/strong> towards socket 1. A UPI 2.0 link runs at around &lt;strong>20–40 GB/s&lt;/strong> per direction and link depending on generation; even with several links, 50 GB/s of network traffic &lt;strong>going against the grain&lt;/strong> eats a far from negligible fraction of the inter-socket budget, the same budget already contended by the pod&amp;rsquo;s remote memory accesses and, if there is multi-node traffic, the &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">KV-cache of the disaggregation&lt;/a>. I am not giving a firm &amp;ldquo;X% degradation&amp;rdquo; because it depends on CPU generation, number of UPI links, MTU and traffic pattern; without that methodology, any exact figure is marketing.&lt;/p>
&lt;p>What &lt;strong>is&lt;/strong> measured with public methodology is the aggregate effect of aligning GPU and NIC: the &lt;strong>DRANET&lt;/strong> project reports &lt;strong>+59.6% bus bandwidth in &lt;code>all_gather&lt;/code> and +58.1% in &lt;code>all_reduce&lt;/code>&lt;/strong> (NCCL collectives) when the assigned NIC is &lt;strong>NUMA-local to the GPU&lt;/strong> rather than not. That is the size of the gap the Topology Manager was leaving open.&lt;/p>
&lt;h2 id="what-rke2s-cilium-ebpf-replaces-and-why-it-touches-this-story">What RKE2&amp;rsquo;s Cilium eBPF replaces (and why it touches this story)&lt;/h2>
&lt;p>RKE2 ships &lt;strong>Canal&lt;/strong> (Flannel + Calico) by default as the CNI and &lt;strong>&lt;code>kube-proxy&lt;/code>&lt;/strong> (iptables/IPVS rules) for Service balancing. Switching to Cilium (&lt;code>cni: cilium&lt;/code> in &lt;code>/etc/rancher/rke2/config.yaml&lt;/code>) replaces both pieces with an eBPF datapath:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>RKE2 piece&lt;/th>
&lt;th>What it does&lt;/th>
&lt;th>What Cilium eBPF puts in&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>kube-proxy&lt;/code> (iptables/IPVS)&lt;/td>
&lt;td>Service balancing&lt;/td>
&lt;td>LB in eBPF; with &lt;code>kubeProxyReplacement=true&lt;/code>, and acceleration in &lt;strong>XDP&lt;/strong> (driver layer)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Canal (Flannel+Calico)&lt;/td>
&lt;td>VXLAN overlay + NetworkPolicy&lt;/td>
&lt;td>native datapath (&lt;code>routingMode=native&lt;/code>), L3/L4 and L7 NetworkPolicy in eBPF&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>veth per pod&lt;/td>
&lt;td>namespace interface pair&lt;/td>
&lt;td>&lt;strong>netkit&lt;/strong> (kernel ≥6.8): namespace overhead ~0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>host iptables traversal&lt;/td>
&lt;td>netfilter hooks&lt;/td>
&lt;td>eBPF &lt;strong>host-routing&lt;/strong>: bypasses iptables and the upper part of the stack&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Up to here this is pure networking and it &lt;strong>does not touch&lt;/strong> the kubelet&amp;rsquo;s resource managers: Cilium does not assign exclusive CPUs nor emit compute NUMA hints. The ten knobs of &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">post 3&lt;/a> stay identical whether you run Canal or Cilium.&lt;/p>
&lt;p>&lt;strong>But&lt;/strong> Cilium does enter the fourth leg through two doors. The first: its own &lt;a href="https://docs.cilium.io/en/stable/operations/performance/tuning/">tuning guide&lt;/a> recommends, literally, &lt;em>&amp;ldquo;kill &lt;code>irqbalance&lt;/code> and pin the NIC IRQs to specific CPUs for maximum workload isolation&amp;rdquo;&lt;/em>, along with the &lt;code>tuned network-latency&lt;/code> profile, the &lt;code>performance&lt;/code> governor and &lt;code>CONFIG_PREEMPT_NONE&lt;/code>. In other words: the eBPF datapath only really performs &lt;strong>if you coordinate IRQ affinity&lt;/strong>, and that affinity has to point at the &lt;strong>housekeeping&lt;/strong> cores (&lt;code>reserved-cpus&lt;/code>), never at the isolated ones. So a &lt;strong>fourth list&lt;/strong> appears that must be kept consistent with &lt;code>isolcpus&lt;/code> and &lt;code>reserved-cpus&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">isolcpus = 2-31,34-63 # cores exclusive to vLLM (host, post 2)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">reserved-cpus = 0-1,32-33 # kubelet housekeeping (post 3)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">IRQ affinity = 0-1,32-33 # NIC IRQs → housekeeping ONLY (this post)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> # never 2-31: there the softirq would steal from the model
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The second door: &lt;strong>netkit + host-routing + BIG TCP&lt;/strong> reduce how many times the packet crosses the stack and the namespace, which &lt;strong>cushions&lt;/strong> (does not eliminate) the cost of the NUMA crossing. BIG TCP assembles super-packets of up to 192k (against 64k) for 100Gb/s+; fewer stack traversals means less softirq work on the core, and therefore less pressure on the inter-socket budget. It is the &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">continuous batching&lt;/a> analogy applied to the network stack: amortising a fixed cost over larger batches.&lt;/p>
&lt;h3 id="cilium-performance-profile-state-119-kernel-68">Cilium performance profile (state 1.19, kernel ≥6.8)&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Helm, recommended performance profile (summary of the tuning guide)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">helm install cilium cilium/cilium --version 1.19.4 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --namespace kube-system &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set &lt;span class="nv">routingMode&lt;/span>&lt;span class="o">=&lt;/span>native &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set bpf.datapathMode&lt;span class="o">=&lt;/span>netkit &lt;span class="se">\ &lt;/span> &lt;span class="c1"># namespace overhead ~0 (kernel &amp;gt;=6.8)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --set bpf.masquerade&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set &lt;span class="nv">kubeProxyReplacement&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="se">\ &lt;/span> &lt;span class="c1"># replaces RKE2&amp;#39;s kube-proxy&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --set &lt;span class="nv">enableIPv4BIGTCP&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="se">\ &lt;/span> &lt;span class="c1"># 192k super-packets (mlx5/ice NIC)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --set &lt;span class="nv">enableIPv6BIGTCP&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set bpf.distributedLRU.enabled&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="se">\#&lt;/span> per-CPU BPF maps: less spinlock contention
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --set bpf.mapDynamicSizeRatio&lt;span class="o">=&lt;/span>0.08 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set &lt;span class="nv">bpfClockProbe&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Verification inside a Cilium pod:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">cilium status --verbose &lt;span class="p">|&lt;/span> grep -E &lt;span class="s2">&amp;#34;Device Mode|Host Routing|BIG TCP|XDP&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Device Mode: netkit · Host Routing: BPF · IPv4 BIG TCP: enabled · XDP Acceleration: Native&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Sceptical caveat: netkit and BIG TCP are &lt;strong>beta&lt;/strong> and require kernel ≥6.8 and specific NICs (mlx4/mlx5/ice). They are not in-place: they change datapath fundamentals and force you to restart pods or, better, to apply them by &lt;em>per-node config&lt;/em> only on new nodes. For an ENS cluster in production, that is a maintenance window, not a blind &lt;code>helm upgrade&lt;/code>.&lt;/p>
&lt;h2 id="the-2026-state-of-the-art-dra-and-dranet-the-maître-d-who-finally-looks-at-the-door">The 2026 state of the art: DRA and DRANET, the maître d&amp;rsquo; who finally looks at the door&lt;/h2>
&lt;p>What closes the gap at the root is not Cilium, it is the &lt;strong>admission mechanism&lt;/strong> the kubelet did not have for the NIC: &lt;strong>Dynamic Resource Allocation (DRA)&lt;/strong>, beta since Kubernetes 1.32 and with advances in every release up to 1.36 (May 2026). DRA generalises the &amp;ldquo;devices&amp;rdquo; model beyond the GPU: a driver discovers the hardware, publishes &lt;code>ResourceSlices&lt;/code> with its attributes, including the &lt;strong>NUMA topology and the PCIe root&lt;/strong>, and the scheduler resolves &lt;code>ResourceClaims&lt;/code> that can demand affinity between devices.&lt;/p>
&lt;p>&lt;strong>DRANET&lt;/strong> (a &lt;code>kubernetes-sigs&lt;/code> project) is the network DRA driver. It discovers the NICs (including the RDMA-capable ones), advertises them as &lt;code>ResourceSlices&lt;/code>, and via &lt;strong>NRI&lt;/strong> injects them into the pod&amp;rsquo;s namespace, compatible with whatever CNI you already have, Cilium included. The key piece for this story: combined with the &lt;strong>NVIDIA GPU DRA driver&lt;/strong>, it allows &lt;strong>co-scheduling GPU and NIC that share a PCIe root&lt;/strong> (the relationship NVIDIA calls &lt;code>NODE&lt;/code>), which is exactly the condition for &lt;strong>GPUDirect RDMA&lt;/strong>. The maître d&amp;rsquo; finally has a fourth assistant: &lt;em>&amp;ldquo;is there a NIC NUMA-local to this GPU?&amp;rdquo;&lt;/em>.&lt;/p>
&lt;p>The &lt;code>ResourceClaimTemplate&lt;/code> uses CEL selectors to request exactly that alignment:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># Request an RDMA NIC NUMA-local to the assigned GPU (illustrative DRANET/DRA schema)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">resource.k8s.io/v1beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ResourceClaimTemplate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gpu-nic-numa-aligned&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">devices&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">rdma-nic&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">deviceClassName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">dra.net &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># NICs published by DRANET&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">constraints&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;rdma-nic&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchAttribute&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;dra.net/pcieRoot&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># same PCIe root as the GPU&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># → enables GPUDirect RDMA over a NUMA-local path&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Why it matters for inference, not for abstract &amp;ldquo;AI training&amp;rdquo;: in &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a>, RDMA is what moves the KV-cache between the prefill pool and the decode pool with the latency that &lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">TTFT&lt;/a> demands; and in multi-node, GPUDirect RDMA replaces &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">NVLink&lt;/a> as the medium of the collective. Aligning GPU+NIC on the same PCIe root is what turns an &amp;ldquo;RDMA that works&amp;rdquo; into an &amp;ldquo;RDMA that performs&amp;rdquo;, the +60% bus bandwidth from DRANET.&lt;/p>
&lt;p>Status and caveats: DRA is &lt;strong>beta&lt;/strong> (gates to enable by hand), DRANET is young (a SIG project, still evolving) and the managed offering exists mostly in cloud (GKE managed DRANET in preview, AKS for RDMA). For on-premise ENS it is a &lt;strong>path, not a finished product&lt;/strong>: the value today is understanding that the fourth leg already has a standard OSS mechanism, and starting to pilot it on a lab node, not putting it into critical production this quarter.&lt;/p>
&lt;h2 id="how-it-connects-with-the-rest-of-the-stack">How it connects with the rest of the stack&lt;/h2>
&lt;p>&lt;strong>With the host (post 2).&lt;/strong> The NIC&amp;rsquo;s IRQ affinity is a &lt;strong>third list&lt;/strong> to marry with &lt;code>isolcpus&lt;/code> and &lt;code>reserved-cpus&lt;/code>. The IRQs go to housekeeping; the isolated cores, untouched. Uncoordinating them lets in through the network door the jitter that &lt;code>isolcpus&lt;/code> threw out through the compute one.&lt;/p>
&lt;p>&lt;strong>With the orchestration (post 3).&lt;/strong> DRA is the natural extension of the Topology Manager: the same &amp;ldquo;admit only if it fits on the NUMA node&amp;rdquo; principle carried over to the NIC. Where the Device Manager left the network out of the census, DRANET puts it in.&lt;/p>
&lt;p>&lt;strong>With the interconnect (post 1).&lt;/strong> Inside the node NVLink rules; on crossing the node boundary, GPUDirect RDMA over the NIC is the medium of the collective. The kubelet&amp;rsquo;s NUMA policy guarantees that GPU and CPUs share a socket; &lt;strong>DRANET adds that the NIC does too&lt;/strong>, and only then does the RDMA take the short path.&lt;/p>
&lt;p>&lt;strong>With disaggregated serving.&lt;/strong> The prefill→decode KV-cache is the traffic that punishes a badly placed NIC hardest. The fourth leg is what stops &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">separating prefill and decode&lt;/a> from being paid for in transfer latency.&lt;/p>
&lt;p>&lt;strong>With capacity planning.&lt;/strong> &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Sizing&lt;/a> gains a dimension: &amp;ldquo;GPUs per node and cores per NUMA node&amp;rdquo; is not enough; you have to count &lt;strong>how many NICs NUMA-local to a GPU&lt;/strong> the chassis has. A node with 4 GPUs and a single NIC on socket 0 has two GPUs &amp;ldquo;far from the door&amp;rdquo;.&lt;/p>
&lt;p>&lt;strong>With observability.&lt;/strong> What confirms that the fourth leg is properly set is not an application dashboard: it is &lt;code>/proc/softirqs&lt;/code> (is &lt;code>NET_RX&lt;/code> concentrated on housekeeping?), &lt;code>nvidia-smi topo -m&lt;/code> (is the GPU↔NIC relationship &lt;code>NODE&lt;/code>/&lt;code>PHB&lt;/code>?) and the NIC counters. It fits with &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability with DCGM&lt;/a>: the GPU &amp;ldquo;at 60% for no reason&amp;rdquo; may be the host waiting for packets that cross the socket.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>Believing that switching to Cilium &amp;ldquo;already optimises the network&amp;rdquo;.&lt;/strong> Cilium eBPF replaces kube-proxy and Canal and performs better out of the box, but the default deployment prioritises compatibility, not performance. Without &lt;code>irqbalance&lt;/code> disabled, without IRQs pinned to housekeeping and without netkit/host-routing, you leave most of the improvement on the table. Cilium&amp;rsquo;s docs say so; plenty of people do not read the tuning guide.&lt;/p>
&lt;p>&lt;strong>Pinning the NIC IRQs to isolated cores.&lt;/strong> The symmetric mistake to knob 6 of post 3: if you set IRQ affinity over &lt;code>isolcpus&lt;/code>, the &lt;code>NET_RX&lt;/code> softirq steals cycles from vLLM on exactly the cores you isolated so nobody would disturb it. The IRQs go to &lt;code>reserved-cpus&lt;/code>, always.&lt;/p>
&lt;p>&lt;strong>Assuming the Topology Manager already aligns the NIC.&lt;/strong> It does not: the classic NIC is not a Hint Provider. If you need NIC↔GPU locality, today the mechanism is DRA/DRANET, not a kubelet policy. Waiting for &lt;code>single-numa-node&lt;/code> to solve it is waiting for something that is not in its design.&lt;/p>
&lt;p>&lt;strong>Putting DRA/DRANET into ENS production this quarter.&lt;/strong> It is beta and young. The sensible move is to pilot it on a lab node, measure &lt;code>all_reduce&lt;/code>/&lt;code>all_gather&lt;/code> with and without alignment, and decide with data. The +60% figure is from a specific environment; reproduce it in yours before promising it.&lt;/p>
&lt;p>&lt;strong>BIG TCP / netkit without reading the requirements.&lt;/strong> Kernel ≥6.8, mlx4/mlx5/ice NICs, no tunnel or encryption for BIG TCP, and nothing in-place: it forces pod restarts or per-node config. On a cluster with IPsec or with unsupported NICs, part of this does not apply. Check &lt;code>cilium status --verbose&lt;/code> before taking for granted that it is active.&lt;/p>
&lt;p>&lt;strong>Confusing the eBPF datapath (kernel) with the Cilium agent (pod).&lt;/strong> &lt;code>cilium-agent&lt;/code> is a &lt;code>Burstable&lt;/code> DaemonSet that should live on housekeeping (covered by &lt;code>system-reserved&lt;/code>). But datapath processing runs in &lt;strong>softirq&lt;/strong>, governed by the host&amp;rsquo;s IRQ affinity, &lt;strong>not&lt;/strong> by &lt;code>reserved-cpus&lt;/code>. They are two different things; pinning the pod well does not pin the softirq.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>The &amp;ldquo;under the engine&amp;rdquo; series chased one idea: performance that looks like an engine problem (vLLM is slow) or a model problem (quantisation) is, far too often, a &lt;strong>locality&lt;/strong> problem one layer down. The trilogy covered three: the cable (NVLink not used), the host (remote NUMA, jitter) and the orchestration (pinning that never happened). The fourth was miss&lt;strong>ing&lt;/strong>: &lt;strong>the network&lt;/strong>. The Topology Manager seats the pod at a perfect NUMA table and never asks which door the dishes come through nor who carries them. On a node at 25 Gb/s it made no difference; on one at 400 Gb/s with the KV-cache crossing over RDMA, that door decides TTFT and the collective&amp;rsquo;s bandwidth. &lt;strong>Cilium eBPF&lt;/strong> replaces kube-proxy and Canal with a datapath that performs, if you coordinate IRQ affinity with &lt;code>isolcpus&lt;/code>/&lt;code>reserved-cpus&lt;/code>, a fourth list to align, and &lt;strong>DRA/DRANET&lt;/strong> finally provides the census that was missing: co-scheduling NUMA-local GPU and NIC on the same PCIe root, with an improvement of a size (+60% NCCL bus bandwidth) that measures how big the gap was. Going down a level is not snobbery: it is that the root cause lived, once again, one layer below where the dashboard looks.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets in the sovereign LLM stack: defence in depth&lt;/a> — the default-deny NetworkPolicies and mTLS with Cilium in the hardening of the stack.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/pcie-topology-gpudirect-p2p-acs-security-guard/">The corridors and the guard: PCIe, GPUDirect P2P and ACS&lt;/a> — the GPUDirect RDMA that DRANET places NUMA-locally is broken by ACS if it forces traffic through the root complex; the bus beneath NIC↔GPU locality.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">The maître d&amp;rsquo; who only seats you if you all fit at one table: resource managers on RKE2&lt;/a> — post 3, the direct parent of this one: the Topology Manager pins CPU+memory+GPU but not the NIC; here that fourth leg is opened.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">NUMA, hugepages and CPU isolation&lt;/a> — post 2; the NIC&amp;rsquo;s IRQ affinity is a third list to marry with &lt;code>isolcpus&lt;/code> and &lt;code>reserved-cpus&lt;/code>, and the &lt;code>NET_RX&lt;/code> softirq is the same jitter coming in through the network.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">NVLink, NVSwitch and NCCL&lt;/a> — post 1; on crossing the node, GPUDirect RDMA over the NIC replaces NVLink, and DRANET is what guarantees that RDMA takes the NUMA-local path.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode separated&lt;/a> — the case that punishes a badly placed NIC hardest: the prefill→decode KV-cache travels over RDMA and pays for every socket crossing.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">The on-premise LLM inference stack in seven layers&lt;/a> — the complete building; the network is the plane that holds up multi-node inference.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLMs on Kubernetes with KEDA&lt;/a> — every new replica does not only go through the kubelet&amp;rsquo;s NUMA admission; with DRA, also through the NIC &lt;code>ResourceClaim&lt;/code>&amp;rsquo;s.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise inference&lt;/a> — sizing gains a dimension: how many NICs NUMA-local to a GPU the chassis has, not just how many GPUs.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">Mixed NVIDIA + Intel environments&lt;/a> — NIC↔accelerator NUMA affinity gets complicated when the node mixes GPUs, accelerators and heterogeneous NICs.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability with DCGM&lt;/a> — how to confirm, metric in hand, that the &amp;ldquo;GPU at 60%&amp;rdquo; is not the host waiting for packets crossing the socket.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM: cold start and model loading&lt;/a> — the same &amp;ldquo;get the CPU out of the way&amp;rdquo; principle that GPUDirect RDMA gives here, applied to disk with GPUDirect Storage to load weights straight from NVMe to HBM.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">SM, CUDA streams and CUDA graphs&lt;/a> — one floor further down: once the data is in HBM, what happens in the silicon that executes it and why decode becomes launch-bound.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">The contractor with the master key: isolating AI agents from the workstation to the cluster&lt;/a> — the other use of this same kernel layer: on top of Cilium&amp;rsquo;s eBPF datapath, Tetragon hooks its kprobes to observe and kill what an AI agent does in the cluster. Its &lt;a href="https://blog.lo0.es/en/posts/runbook-caging-ai-agent-bubblewrap-tetragon/">runbook&lt;/a> brings the concrete &lt;code>TracingPolicy&lt;/code> objects.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Cilium, &lt;em>Tuning Guide&lt;/em> (netkit, host-routing, BIG TCP, XDP, pin the IRQs and kill irqbalance): &lt;a href="https://docs.cilium.io/en/stable/operations/performance/tuning/">https://docs.cilium.io/en/stable/operations/performance/tuning/&lt;/a>.&lt;/li>
&lt;li>Cilium 1.19 (February 2026), &lt;em>Cilium at Ten Years&lt;/em> — hardening of encryption, policies and observability: &lt;a href="https://www.infoq.com/news/2026/02/cilium-119/">https://www.infoq.com/news/2026/02/cilium-119/&lt;/a>.&lt;/li>
&lt;li>Isovalent, &lt;em>Cilium 1.18&lt;/em> (IPv6, encrypted overlay, ingress bandwidth, policy perf): &lt;a href="https://isovalent.com/blog/post/cilium-1-18/">https://isovalent.com/blog/post/cilium-1-18/&lt;/a>.&lt;/li>
&lt;li>RKE2, &lt;em>Network Options&lt;/em> (Canal by default; Cilium with kube-proxy replacement): &lt;a href="https://docs.rke2.io/networking/basic_network_options">https://docs.rke2.io/networking/basic_network_options&lt;/a>.&lt;/li>
&lt;li>Kubernetes, &lt;em>Dynamic Resource Allocation&lt;/em>: &lt;a href="https://kubernetes.io/docs/concepts/scheduling-eviction/dynamic-resource-allocation/">https://kubernetes.io/docs/concepts/scheduling-eviction/dynamic-resource-allocation/&lt;/a>.&lt;/li>
&lt;li>Kubernetes blog, &lt;em>v1.36: More Drivers, New Features, and the Next Era of DRA&lt;/em> (May 2026): &lt;a href="https://kubernetes.io/blog/2026/05/07/kubernetes-v1-36-dra-136-updates/">https://kubernetes.io/blog/2026/05/07/kubernetes-v1-36-dra-136-updates/&lt;/a>.&lt;/li>
&lt;li>DRANET (kubernetes-sigs), the network DRA driver and the paper &lt;em>The Kubernetes Network Driver Model&lt;/em> (+59.6% all_gather / +58.1% all_reduce): &lt;a href="https://github.com/kubernetes-sigs/dranet">https://github.com/kubernetes-sigs/dranet&lt;/a>.&lt;/li>
&lt;li>AKS Engineering, &lt;em>Optimizing RDMA performance for AI workloads on AKS with DRANET&lt;/em> (April 2026): &lt;a href="https://blog.aks.azure.com/2026/04/01/dranet-rdma-optimization-for-ai-on-aks">https://blog.aks.azure.com/2026/04/01/dranet-rdma-optimization-for-ai-on-aks&lt;/a>.&lt;/li>
&lt;li>Linux network tuning — IRQ affinity, RSS/RPS/RFS and NUMA softirq: &lt;a href="https://andreaskaris.github.io/blog/networking/rss-irq-affinity-and-rps/">https://andreaskaris.github.io/blog/networking/rss-irq-affinity-and-rps/&lt;/a>.&lt;/li>
&lt;/ul></description></item></channel></rss>