<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
  
  <title>NextG Systems</title>
  <subtitle>Notes from NextG Systems LLC on building open, programmable, and secure systems, in software-defined wireless and AI infrastructure.</subtitle>
  <link href="https://nextghq.github.io/feed.xml" rel="self" />
  <link href="https://nextghq.github.io/" />
  <updated>2026-10-30T00:00:00Z</updated>
  <id>https://nextghq.github.io/</id>
  <author>
    <name>NextG Systems LLC</name>
  </author>
  <entry>
    <title>A 5G cell on an M.2 card: bringing up the LiteX M2SDR with OCUDU</title>
    <link href="https://nextghq.github.io/blog/m2sdr-ocudu/" />
    <updated>2026-10-06T00:00:00Z</updated>
    <id>https://nextghq.github.io/blog/m2sdr-ocudu/</id>
    <content type="html">&lt;p&gt;&lt;a href=&quot;https://gitlab.com/ocudu/ocudu&quot;&gt;OCUDU&lt;/a&gt; gives the community an open, carrier-grade CU/DU. The radio underneath it has mostly been a USRP. We wanted to know whether a small, fully open radio could carry a real cell: the &lt;a href=&quot;https://github.com/enjoy-digital/litex_m2sdr&quot;&gt;LiteX M2SDR&lt;/a&gt; from Enjoy-Digital, an M.2 2280 card with an Artix-7 XC7A200T and an AD9361, open from the gateware up to the SoapySDR driver.&lt;/p&gt;
&lt;p&gt;It can. With the changes described here, an OCUDU gNB runs on the M2SDR on band n78 (TDD, 30 kHz SCS, 20 MHz, 23.04 MSps) with OCUDU&#39;s stock lower PHY and default timing, plus one static RX timestamp correction. A commercial phone attaches and holds the link:&lt;/p&gt;
&lt;div class=&quot;stats&quot;&gt;
  &lt;div class=&quot;stat&quot;&gt;&lt;b&gt;14 min&lt;/b&gt;&lt;span&gt;soak with a phone attached and pinging&lt;/span&gt;&lt;/div&gt;
  &lt;div class=&quot;stat&quot;&gt;&lt;b&gt;0.003 %&lt;/b&gt;&lt;span&gt;TX frames late (292 of 9.5 million)&lt;/span&gt;&lt;/div&gt;
  &lt;div class=&quot;stat&quot;&gt;&lt;b&gt;0&lt;/b&gt;&lt;span&gt;RX discontinuities, “PUxCH request late” or “UL processor is busy”&lt;/span&gt;&lt;/div&gt;
  &lt;div class=&quot;stat&quot;&gt;&lt;b&gt;42&lt;/b&gt;&lt;span&gt;samples RX-to-TX offset, identical on 9 of 9 cold starts&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;Getting there was almost entirely about &lt;strong&gt;time&lt;/strong&gt;: when a sample leaves the antenna, what timestamp a received sample carries, and how late the host may be before something breaks. None of it was exotic, but each piece hid behind the next. This post walks through them in the order we found them. All of the work is on the &lt;a href=&quot;https://github.com/pvnis/litex_m2sdr/tree/hw-timed-tx&quot;&gt;&lt;code&gt;hw-timed-tx&lt;/code&gt;&lt;/a&gt; branch of our fork, with the full design notes in &lt;a href=&quot;https://github.com/pvnis/litex_m2sdr/blob/hw-timed-tx/doc/timed_tx_hw.md&quot;&gt;&lt;code&gt;doc/timed_tx_hw.md&lt;/code&gt;&lt;/a&gt; and &lt;a href=&quot;https://github.com/pvnis/litex_m2sdr/blob/hw-timed-tx/doc/rx_delivery_lag.md&quot;&gt;&lt;code&gt;doc/rx_delivery_lag.md&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;what-a-gnb-expects-from-a-radio&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/m2sdr-ocudu/#what-a-gnb-expects-from-a-radio&quot;&gt;&lt;span&gt;What a gNB expects from a radio&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;We connect OCUDU to the M2SDR through a SoapySDR radio driver we added to OCUDU (upstream ships UHD and ZMQ); it lives in &lt;code&gt;lib/radio/soapy/&lt;/code&gt; on the &lt;a href=&quot;https://github.com/pvnis/ocudu/tree/soapy&quot;&gt;&lt;code&gt;soapy&lt;/code&gt;&lt;/a&gt; branch of our fork. Whatever the driver, OCUDU, like srsRAN before it, assumes UHD semantics:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Timed TX.&lt;/strong&gt; The gNB writes each burst with a timestamp. The radio holds it until that instant, emits it on exactly that sample, and drops it if it arrives late. The timeline never moves.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Exact RX timestamps.&lt;/strong&gt; Every received buffer is labelled with the index of its first sample, and consecutive labels are contiguous.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Back-pressure.&lt;/strong&gt; A write blocks once the radio holds enough not-yet-due samples, so the gNB cannot run arbitrarily far ahead.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A UE synchronises to the downlink it actually &lt;em&gt;receives&lt;/em&gt;, then schedules its uplink against that. If the radio emits downlink samples a few microseconds away from where the gNB thinks it did, the uplink comes back off the gNB&#39;s slot grid. PRACH tolerates this: the format we use aliases the error modulo 33 µs, so the phone still gets through random access. PUSCH does not tolerate it, and the cell looks almost-working: attach starts, uplink decodes fail.&lt;/p&gt;
&lt;h2 id=&quot;starting-point%3A-timing-in-software&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/m2sdr-ocudu/#starting-point%3A-timing-in-software&quot;&gt;&lt;span&gt;Starting point: timing in software&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The M2SDR&#39;s original driver timed TX in software. It streamed through a free-running ring of 256 DMA buffers of 2048 samples each and anchored &amp;quot;sample N leaves at board time T&amp;quot; once per start, and again after every underflow. That anchor has one-buffer granularity, a race with the DMA start, and a whole-lap ambiguity (the ring is 22.76 ms at 23.04 MSps). So every start, the real emission time differed from the stamp by a random offset, and every re-anchor moved it again.&lt;/p&gt;
&lt;p&gt;It was possible to work around this on the OCUDU side by measuring the offset at start-up and shifting RX timestamps to match. That got a phone attached, but it is a patch over a radio that doesn&#39;t keep time. Hardware-timed radios (UHD, LimeSDR, bladeRF in metadata mode) don&#39;t need it, so we set out to give the M2SDR the same semantics.&lt;/p&gt;
&lt;h2 id=&quot;a-timed-tx-gate-in-the-fpga&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/m2sdr-ocudu/#a-timed-tx-gate-in-the-fpga&quot;&gt;&lt;span&gt;A timed TX gate in the FPGA&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Earlier attempts existed. A scheduler inside the TX header module (late 2025) never streamed in the right frame. A sys-domain hold/pass/drop arbiter (spring 2026) worked logically but sat upstream of the AD9361 core&#39;s TX FIFO, which has &lt;strong&gt;priming hysteresis&lt;/strong&gt;: it starts output only when half full and stops when empty. Anything released upstream of it lands in a FIFO whose fill time depends on DMA burst timing, so the release jitter was microseconds and every gap re-primed it.&lt;/p&gt;
&lt;p&gt;The new design, &lt;code&gt;TimedTXGate&lt;/code&gt;, sits between the TX header extractor and the loopback mux. Each 8 KiB DMA buffer already carries a 16-byte header with a 64-bit timestamp. On the first payload word of each frame the gate decides:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot;&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;condition&lt;/th&gt;
&lt;th&gt;action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;no timestamp, or gate disabled&lt;/td&gt;
&lt;td&gt;pass (untimed)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;due in the future&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;hold&lt;/strong&gt;: stall the DMA reader; the PHY outputs zeros until the frame is due&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;late by less than the margin&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;pass&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;late by more than the margin&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;drop&lt;/strong&gt; the frame and count it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p&gt;While the gate is enabled, the SoC forces the RFIC TX FIFO into its started state, which removes the priming hysteresis. After a hold the downstream FIFOs are empty, so release-to-air latency is a fixed pipeline depth. In a continuous stream only the first frame after a start is actually held; the rest arrive on time and pass. After a host stall, the frames that missed their time are dropped and the stream re-aligns on the first on-time frame. No re-anchor, no timeline shift.&lt;/p&gt;
&lt;p&gt;On the host side, the SoapySDR plugin gets a &lt;code&gt;timed_tx=hardware&lt;/code&gt; device argument: it enables the DMA header, stamps every buffer with the board time of its first sample, and reports frames the gate dropped as &lt;code&gt;SOAPY_SDR_TIME_ERROR&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;what-the-air-taught-us&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/m2sdr-ocudu/#what-the-air-taught-us&quot;&gt;&lt;span&gt;What the air taught us&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The gate passed simulation on the first day. Over the air, with OCUDU on top, it took a series of fixes before it was visible at all. Every one of these was in the driver or the plugin, not the gate:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The DMA synchronizer waited for PPS.&lt;/strong&gt; The kernel armed the TX DMA to start on the next PPS edge, up to a second later. Every frame stamped less than a second ahead was already late. Hardware mode now bypasses the synchronizer; the stamps carry the timing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The DMA reader prefetches, and the ring goes stale.&lt;/strong&gt; The reader free-runs over the ring and prefetches two or three buffers ahead of its index. Slots not rewritten since the previous lap get re-read with their old stamps. The fix: zero the ring before starting (a zero header means untimed silence), and keep the write pointer a few slots ahead of the reader&#39;s &lt;em&gt;live&lt;/em&gt; index rather than the kernel&#39;s count, which only refreshes every 8 buffers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One part per million of rounding.&lt;/strong&gt; OCUDU stamps only the start of a burst; later writes are stamped from the plugin&#39;s own timeline, which advanced by &lt;code&gt;llround(n × 1e9 / rate)&lt;/code&gt; nanoseconds per chunk. That 0.3 ns per chunk is about 1 ppm, so emission drifted ~20 samples per second until the burst restarted. The timeline is now anchor plus an exact sample count.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The late margin becomes pipeline fill.&lt;/strong&gt; A frame that arrives late but within the margin is emitted immediately, and its lateness stays in the TX pipeline as a fill level that never drains, because every later frame is then held until its stamp. With a one-frame margin the offset settled anywhere within a frame (1866 vs 168 samples on two starts). We cut the margin to 1 µs, so every frame goes through the hold path; the fine gate described below later let it grow again.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stale re-reads are not late frames.&lt;/strong&gt; OCUDU ends its TX burst on every &lt;code&gt;TIME_ERROR&lt;/code&gt;. The resulting gap made the reader sweep stale slots, whose drops were reported as more &lt;code&gt;TIME_ERROR&lt;/code&gt;s: a self-sustaining loop after any host stall. Stale drops now go to their own counter and are not reported to the application.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Loopback can&#39;t measure what matters.&lt;/strong&gt; In the AD9361&#39;s PHY loopback, RX only produces samples while TX flows, so RX time freezes during a hold. Loopback proves hold, release, continuity and drops; the release offset has to be measured over the air.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;the-bug-was-on-the-receive-side&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/m2sdr-ocudu/#the-bug-was-on-the-receive-side&quot;&gt;&lt;span&gt;The bug was on the receive side&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;With TX fixed, the cell still had a start-dependent failure: on about half of the starts the phone attached and then the uplink died, with OCUDU logging a permanent storm of &amp;quot;PUxCH request late&amp;quot; and &amp;quot;UL processor is busy&amp;quot;. It looked like a TX timing problem. It wasn&#39;t.&lt;/p&gt;
&lt;p&gt;RX frames were labelled with each frame&#39;s FPGA nanosecond timestamp. Those stamps carry quantisation noise, so converted back to samples the labels were not always contiguous: off by ±1 sample, 2,000 to 3,000 times per second, depending on the sub-sample phase at start. OCUDU&#39;s lower PHY treats any such mismatch as lost alignment and discards RX up to the next subframe. Across 8 starts the correlation was exact: zero label jumps meant zero late requests; 42k to 66k jumps meant the storm.&lt;/p&gt;
&lt;p&gt;The fix was to stop using nanoseconds as the source of truth.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Labels by counting.&lt;/strong&gt; The plugin labels RX buffers by counting samples from an anchor, using the FPGA stamp only to detect real gaps.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A sample counter in the gateware.&lt;/strong&gt; Then we moved the counting into the FPGA. A 64-bit counter in the RFIC clock domain advances on every PHY word by the number of samples it carries. Each RX word travels through the clock-domain FIFO and pipeline together with its tick, and the header inserter stamps each frame with the tick of its first sample. On TX, the sys-domain gate still decides hold, pass or drop per frame, but now passes the frame&#39;s stamp through a small clock-domain FIFO to a new &lt;code&gt;TXFineGate&lt;/code&gt; right in front of the PHY. That gives every word its own tick and emits it in the PHY slot carrying that tick. A word whose slot has passed is discarded; an early word waits. The stream can lose samples but is never shifted.&lt;/p&gt;
&lt;figure class=&quot;diagram&quot;&gt;
&lt;svg viewBox=&quot;0 0 720 250&quot; role=&quot;img&quot; aria-labelledby=&quot;txpath-title&quot;&gt;
  &lt;title id=&quot;txpath-title&quot;&gt;Hardware-timed TX path: host DMA, header extractor, TimedTXGate in the sys clock domain, clock-domain FIFO, TXFineGate in the RFIC clock domain, AD9361 PHY&lt;/title&gt;
  &lt;defs&gt;&lt;marker id=&quot;ah&quot; viewBox=&quot;0 0 10 10&quot; refX=&quot;9&quot; refY=&quot;5&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; orient=&quot;auto-start-reverse&quot;&gt;&lt;path d=&quot;M0 0L10 5L0 10z&quot; class=&quot;dg-arrow&quot;&gt;&lt;/path&gt;&lt;/marker&gt;&lt;/defs&gt;
  &lt;rect class=&quot;dg-zone&quot; x=&quot;148&quot; y=&quot;22&quot; width=&quot;300&quot; height=&quot;180&quot; rx=&quot;10&quot;&gt;&lt;/rect&gt;
  &lt;text class=&quot;dg-z&quot; x=&quot;160&quot; y=&quot;42&quot;&gt;sys clock domain&lt;/text&gt;
  &lt;rect class=&quot;dg-zone&quot; x=&quot;470&quot; y=&quot;22&quot; width=&quot;236&quot; height=&quot;180&quot; rx=&quot;10&quot;&gt;&lt;/rect&gt;
  &lt;text class=&quot;dg-z&quot; x=&quot;482&quot; y=&quot;42&quot;&gt;RFIC domain · 245.76 MHz&lt;/text&gt;
  &lt;rect class=&quot;dg-box&quot; x=&quot;14&quot; y=&quot;80&quot; width=&quot;112&quot; height=&quot;64&quot; rx=&quot;8&quot;&gt;&lt;/rect&gt;
  &lt;text class=&quot;dg-t&quot; x=&quot;70&quot; y=&quot;106&quot; text-anchor=&quot;middle&quot;&gt;Host&lt;/text&gt;
  &lt;text class=&quot;dg-s&quot; x=&quot;70&quot; y=&quot;124&quot; text-anchor=&quot;middle&quot;&gt;PCIe DMA&lt;/text&gt;
  &lt;rect class=&quot;dg-box&quot; x=&quot;164&quot; y=&quot;80&quot; width=&quot;118&quot; height=&quot;64&quot; rx=&quot;8&quot;&gt;&lt;/rect&gt;
  &lt;text class=&quot;dg-t&quot; x=&quot;223&quot; y=&quot;106&quot; text-anchor=&quot;middle&quot;&gt;Header&lt;/text&gt;
  &lt;text class=&quot;dg-s&quot; x=&quot;223&quot; y=&quot;124&quot; text-anchor=&quot;middle&quot;&gt;strip stamp&lt;/text&gt;
  &lt;rect class=&quot;dg-box key&quot; x=&quot;310&quot; y=&quot;80&quot; width=&quot;124&quot; height=&quot;64&quot; rx=&quot;8&quot;&gt;&lt;/rect&gt;
  &lt;text class=&quot;dg-t&quot; x=&quot;372&quot; y=&quot;106&quot; text-anchor=&quot;middle&quot;&gt;TimedTXGate&lt;/text&gt;
  &lt;text class=&quot;dg-s&quot; x=&quot;372&quot; y=&quot;124&quot; text-anchor=&quot;middle&quot;&gt;hold/pass/drop&lt;/text&gt;
  &lt;rect class=&quot;dg-box key&quot; x=&quot;484&quot; y=&quot;80&quot; width=&quot;112&quot; height=&quot;64&quot; rx=&quot;8&quot;&gt;&lt;/rect&gt;
  &lt;text class=&quot;dg-t&quot; x=&quot;540&quot; y=&quot;106&quot; text-anchor=&quot;middle&quot;&gt;TXFineGate&lt;/text&gt;
  &lt;text class=&quot;dg-s&quot; x=&quot;540&quot; y=&quot;124&quot; text-anchor=&quot;middle&quot;&gt;word = tick&lt;/text&gt;
  &lt;rect class=&quot;dg-box&quot; x=&quot;620&quot; y=&quot;80&quot; width=&quot;76&quot; height=&quot;64&quot; rx=&quot;8&quot;&gt;&lt;/rect&gt;
  &lt;text class=&quot;dg-t&quot; x=&quot;658&quot; y=&quot;106&quot; text-anchor=&quot;middle&quot;&gt;AD9361&lt;/text&gt;
  &lt;text class=&quot;dg-s&quot; x=&quot;658&quot; y=&quot;124&quot; text-anchor=&quot;middle&quot;&gt;PHY&lt;/text&gt;
  &lt;line class=&quot;dg-line&quot; x1=&quot;126&quot; y1=&quot;112&quot; x2=&quot;160&quot; y2=&quot;112&quot; marker-end=&quot;url(#ah)&quot;&gt;&lt;/line&gt;
  &lt;line class=&quot;dg-line&quot; x1=&quot;282&quot; y1=&quot;112&quot; x2=&quot;306&quot; y2=&quot;112&quot; marker-end=&quot;url(#ah)&quot;&gt;&lt;/line&gt;
  &lt;line class=&quot;dg-line&quot; x1=&quot;434&quot; y1=&quot;112&quot; x2=&quot;480&quot; y2=&quot;112&quot; marker-end=&quot;url(#ah)&quot;&gt;&lt;/line&gt;
  &lt;line class=&quot;dg-line&quot; x1=&quot;596&quot; y1=&quot;112&quot; x2=&quot;616&quot; y2=&quot;112&quot; marker-end=&quot;url(#ah)&quot;&gt;&lt;/line&gt;
  &lt;text class=&quot;dg-s&quot; x=&quot;223&quot; y=&quot;176&quot; text-anchor=&quot;middle&quot;&gt;frame + stamp&lt;/text&gt;
  &lt;text class=&quot;dg-s&quot; x=&quot;372&quot; y=&quot;176&quot; text-anchor=&quot;middle&quot;&gt;per frame, on ticks&lt;/text&gt;
  &lt;text class=&quot;dg-s&quot; x=&quot;540&quot; y=&quot;176&quot; text-anchor=&quot;middle&quot;&gt;per word, exact slot&lt;/text&gt;
  &lt;text class=&quot;dg-s&quot; x=&quot;459&quot; y=&quot;232&quot; text-anchor=&quot;middle&quot;&gt;sample counter (64-bit, RFIC domain) drives both gates and every RX stamp&lt;/text&gt;
&lt;/svg&gt;
&lt;figcaption&gt;The TX path on &lt;code&gt;hw-timed-tx&lt;/code&gt;. The coarse gate decides per frame; the fine gate places every word on its exact sample tick in front of the PHY.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Two more details closed the loop. The PHY&#39;s TX word counter now follows the RX word counter instead of free-running, so the RX-sample-to-TX-slot relationship is the same after every initialisation. And with &lt;code&gt;time_base=samples&lt;/code&gt;, every timestamp crossing the SoapySDR API is the sample tick itself rather than nanoseconds, so nothing depends on rounding.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Timed RX start and back-pressure.&lt;/strong&gt; OCUDU seeds its timeline from &amp;quot;RX begins at &lt;code&gt;init_time&lt;/code&gt;&amp;quot;, but the plugin started RX about 95 ms earlier. A small gateware block now holds the RX header inserter until the requested tick, so the first frame delivered &lt;em&gt;is&lt;/em&gt; that sample. And where a USRP blocks &lt;code&gt;send()&lt;/code&gt; once its FIFO is full, the M2SDR accepted 22.7 ms of samples; OCUDU stamps its first frames 100 ms ahead, so its downlink ran 177 to 194 slots ahead of RX at start. The plugin now bounds the number of submitted-but-not-due frames (default 96, about 8.5 ms) and &lt;code&gt;writeStream&lt;/code&gt; times out beyond that.&lt;/p&gt;
&lt;p&gt;Result: on every start, the first RX frame is exactly OCUDU&#39;s &lt;code&gt;init_time&lt;/code&gt;; zero non-contiguous labels and zero late uplink requests on 8 of 8 starts; the RX-minus-TX offset measured on the SSB is &lt;strong&gt;42 samples on 9 of 9 cold starts&lt;/strong&gt;, each including an AD9361 re-initialisation. A constant offset is just a calibration value: OCUDU applies it once as a static RX timestamp shift, and the residual offset measures 0 samples.&lt;/p&gt;
&lt;h2 id=&quot;latency%3A-from-472-%C2%B5s-to-8-%C2%B5s&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/m2sdr-ocudu/#latency%3A-from-472-%C2%B5s-to-8-%C2%B5s&quot;&gt;&lt;span&gt;Latency: from 472 µs to 8 µs&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;With timing deterministic, the next limit was the latency budget. OCUDU&#39;s lower PHY releases a downlink slot when the newest RX timestamp reaches the slot start minus &lt;code&gt;rx_to_tx_delay&lt;/code&gt; (1 ms by default). Every microsecond the RX path spends delivering a buffer comes out of the time TX has to reach the FPGA.&lt;/p&gt;
&lt;p&gt;Measured against the gateware sample counter, the age of the newest sample in each buffer when the receiving thread got it had a median of &lt;strong&gt;472 µs&lt;/strong&gt;. Two causes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;One interrupt per 8 buffers.&lt;/strong&gt; Buffers arrived in batches of 8, the first of each batch already 621 µs old.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The completed-buffer count was one short.&lt;/strong&gt; The DMA table&#39;s status is the index of the descriptor that completed &lt;em&gt;last&lt;/em&gt;, not a count. The driver used the bare index, so the newest complete buffer was withheld until the next interrupt.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Fixing both (one interrupt per RX buffer by default, count = index + 1, and &lt;code&gt;POLLIN&lt;/code&gt; as soon as one buffer is ready) brought the median to &lt;strong&gt;8 µs&lt;/strong&gt;:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot;&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;mode&lt;/th&gt;
&lt;th style=&quot;text-align:right&quot;&gt;p50&lt;/th&gt;
&lt;th style=&quot;text-align:right&quot;&gt;p99&lt;/th&gt;
&lt;th style=&quot;text-align:right&quot;&gt;p99.9&lt;/th&gt;
&lt;th style=&quot;text-align:right&quot;&gt;IRQ/s&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;before: 8 buffers/IRQ, count one short&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;472 µs&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;724 µs&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;764 µs&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;2.8 k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 buffer/IRQ, count fixed (new default)&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;8 µs&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;38–46 µs&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;204–320 µs&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;12.7 k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;busy-poll (&lt;code&gt;rx_poll=busy&lt;/code&gt;)&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;4 µs&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;8–22 µs&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;130–208 µs&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;1.5 k&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p&gt;As seen by OCUDU&#39;s lower PHY, one thread hop later, that&#39;s 355 µs average before and 20 µs now.&lt;/p&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;p class=&quot;callout-title&quot;&gt;Do we need a DPDK-style poll-mode driver?&lt;/p&gt;
&lt;p&gt;For RX delivery, no. Busy-polling halves the median (8 → 4 µs, against an 88.7 µs frame and a 1 ms budget) and costs a full core. The p99.9 and the maximum don&#39;t move at all: on our test laptop they are set by firmware stalls of up to ~0.2 ms at least once a second, measured with the &lt;code&gt;hwlat&lt;/code&gt; tracer. A poll-mode driver only improves the tail on a host prepared for it, with isolated cores and quiet firmware. The stream itself is 11,272 frames/s, nowhere near the packet rates DPDK exists for.&lt;/p&gt;
&lt;/div&gt;
&lt;h2 id=&quot;the-last-piece%3A-a-tx-dma-that-stops&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/m2sdr-ocudu/#the-last-piece%3A-a-tx-dma-that-stops&quot;&gt;&lt;span&gt;The last piece: a TX DMA that stops&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;With RX fast, OCUDU was handing each downlink slot to the driver about 940 µs ahead of its time. That should be plenty. It wasn&#39;t, because of what happened when the host was late &lt;em&gt;once&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;The TX DMA ran in LitePCIe&#39;s loop mode: the reader free-runs around a 256-slot ring, paced only by the gate holding the frame at its head. When a host stall let the reader catch up with the write pointer, nothing held it any more. Every slot ahead was a frame from the previous lap, so the gate dropped them as stale at PCIe speed; the reader lapped the ring every ~2 ms, and the host, writing 11 frames per millisecond, could never get ahead of a reader moving 125 slots per millisecond. One stall of a millisecond turned into an 80 ms episode costing ~500 late and ~4000 stale frames. With the default 1 ms delay, episodes were continuous; we had been running OCUDU with a 4–5 ms delay to stay clear of them.&lt;/p&gt;
&lt;p&gt;What TX needed was FIFO semantics: the reader should stop when there&#39;s nothing new, like a USRP&#39;s flow-controlled TX FIFO. LitePCIe already supports this through its descriptor table&#39;s &lt;em&gt;prog&lt;/em&gt; mode, which executes each descriptor once and stops when the table is empty. No gateware change was needed: a new ioctl selects FIFO mode, and each submit queues one descriptor per buffer. A frame is now fetched as soon as it&#39;s submitted, nothing is ever read twice, and a frame submitted too late is simply dropped by the gate.&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot;&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;rx_to_tx_delay&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;loop mode&lt;/th&gt;
&lt;th&gt;FIFO mode&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5 ms&lt;/td&gt;
&lt;td&gt;clean&lt;/td&gt;
&lt;td&gt;clean&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 ms&lt;/td&gt;
&lt;td&gt;episode when the phone attached&lt;/td&gt;
&lt;td&gt;0 late, 0 stale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.5 ms&lt;/td&gt;
&lt;td&gt;8–12 episodes per 45 s&lt;/td&gt;
&lt;td&gt;0 late, 0 stale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 ms (OCUDU default)&lt;/td&gt;
&lt;td&gt;continuous episodes, unusable&lt;/td&gt;
&lt;td&gt;0 late, 0 stale&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p&gt;That is the configuration behind the numbers at the top: stock OCUDU timing, 14 minutes with a phone attached and pinging, 292 late frames out of 9.5 million, ping 2769/2800. Each host stall now costs exactly the frames that were late and nothing more. HARQ error rates dropped too, from DL 3–19 % and UL 10–28 % with the 4 ms workaround to DL ~4 % and UL 4–9 %.&lt;/p&gt;
&lt;p&gt;Along the way, every OCUDU-side workaround came back out. Earlier in the bring-up we had a configurable RX-to-TX delay in the lower PHY, a larger UL request pool, a wider RA response window and a raised &lt;code&gt;max_proc_delay&lt;/code&gt;. All of them are gone. OCUDU&#39;s &lt;code&gt;lib/phy&lt;/code&gt; is identical to upstream; the radio driver is the only OCUDU change the M2SDR needs, which is how it should be.&lt;/p&gt;
&lt;h2 id=&quot;try-it&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/m2sdr-ocudu/#try-it&quot;&gt;&lt;span&gt;Try it&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Two branches, both public:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Radio:&lt;/strong&gt; &lt;a href=&quot;https://github.com/pvnis/litex_m2sdr/tree/hw-timed-tx&quot;&gt;&lt;code&gt;hw-timed-tx&lt;/code&gt;&lt;/a&gt; in our M2SDR fork — gateware, kernel module, &lt;code&gt;libm2sdr&lt;/code&gt; and the SoapySDR plugin.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;gNB:&lt;/strong&gt; &lt;a href=&quot;https://github.com/pvnis/ocudu/tree/soapy&quot;&gt;&lt;code&gt;soapy&lt;/code&gt;&lt;/a&gt; in our OCUDU fork — the SoapySDR radio driver (&lt;code&gt;lib/radio/soapy/&lt;/code&gt;), the cell config and the operational scripts. The full bring-up notes are in &lt;a href=&quot;https://github.com/pvnis/ocudu/blob/soapy/docs/m2sdr_soapy_bringup.md&quot;&gt;&lt;code&gt;docs/m2sdr_soapy_bringup.md&lt;/code&gt;&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Build and load the radio side:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;# Gateware (Vivado 2026.1)&lt;/span&gt;
python3 litex_m2sdr.py &lt;span class=&quot;token parameter variable&quot;&gt;--variant&lt;/span&gt; m2 --with-pcie --pcie-gen &lt;span class=&quot;token number&quot;&gt;2&lt;/span&gt; --pcie-lanes &lt;span class=&quot;token number&quot;&gt;4&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;--build&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;# Flash, reload, then rescan the PCIe bus&lt;/span&gt;
m2sdr_util flash-write &lt;span class=&quot;token operator&quot;&gt;&amp;lt;&lt;/span&gt;bitstream&lt;span class=&quot;token operator&quot;&gt;&gt;&lt;/span&gt;_operational.bin
m2sdr_util flash-reload

&lt;span class=&quot;token comment&quot;&gt;# Rebuild and reload the kernel module (litex_m2sdr/software/kernel),&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;# rebuild user/ and the SoapySDR module, install them, and check:&lt;/span&gt;
SoapySDRUtil &lt;span class=&quot;token parameter variable&quot;&gt;--find&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Build OCUDU from the &lt;code&gt;soapy&lt;/code&gt; branch as usual, then start from &lt;a href=&quot;https://github.com/pvnis/ocudu/blob/soapy/configs/gnb_soapy_m2sdr_hwtimed.yml&quot;&gt;&lt;code&gt;configs/gnb_soapy_m2sdr_hwtimed.yml&lt;/code&gt;&lt;/a&gt;. The radio section is the interesting part:&lt;/p&gt;
&lt;pre class=&quot;language-yaml&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;ru_sdr&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;device_driver&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; soapy
  &lt;span class=&quot;token key atrule&quot;&gt;device_args&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; driver=LiteXM2SDR&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;ad9361_fir_profile=bypass&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;rx_worker_packets=2048&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;freq_corr_ppm=1.327&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;timed_tx=hardware&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;time_base=samples
  &lt;span class=&quot;token key atrule&quot;&gt;srate&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;23.04&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;tx_gain&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;-10&lt;/span&gt;      &lt;span class=&quot;token comment&quot;&gt;# the driver maps TX gain to AD9361 attenuation: 10 dB here&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;rx_gain&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;40&lt;/span&gt;
  &lt;span class=&quot;token key atrule&quot;&gt;expert_cfg&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;tx_mode&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; continuous&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;freq_corr_ppm&lt;/code&gt; is the crystal error of &lt;em&gt;our&lt;/em&gt; board; measure yours. &lt;code&gt;timed_tx=hardware&lt;/code&gt; also selects the FIFO TX queue and the gateware sample counter.&lt;/p&gt;
&lt;p&gt;The one runtime setting is the static RX timestamp correction, passed as an environment variable:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token assign-left variable&quot;&gt;OCUDU_SOAPY_RX_TS_SHIFT&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt;-42 ./apps/gnb/gnb &lt;span class=&quot;token parameter variable&quot;&gt;-c&lt;/span&gt; configs/gnb_soapy_m2sdr_hwtimed.yml&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;42 samples is the over-the-air value for our board at 23.04 MSps; &lt;code&gt;scripts/m2sdr/align.py&lt;/code&gt; measures it from TX and RX captures (keep the phone in airplane mode while you measure, or its uplink swamps the SSB the tool correlates on). The phone registers even without the shift, since 2 µs is well inside the PRACH detection window. For long runs, &lt;code&gt;scripts/m2sdr/guardian_hw.sh&lt;/code&gt; starts the gNB with the shift and restarts it on real-time trouble.&lt;/p&gt;
&lt;p&gt;While the cell runs, the gate&#39;s counters should show &lt;code&gt;late&lt;/code&gt; and &lt;code&gt;stale&lt;/code&gt; flat and &lt;code&gt;passed&lt;/code&gt; tracking the frame rate (11,272 frames/s at 23.04 MSps). Without RF, &lt;code&gt;software/soapysdr/test_hw_timed_tx.py --lead-ms 300 --reps 4 --late-test&lt;/code&gt; in the M2SDR repo exercises hold, release, sample continuity and late drops through the AD9361 PHY loopback.&lt;/p&gt;
&lt;h2 id=&quot;what&#39;s-next&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/m2sdr-ocudu/#what&#39;s-next&quot;&gt;&lt;span&gt;What&#39;s next&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Upstream.&lt;/strong&gt; This all started from Enjoy-Digital&#39;s excellent base design, and the goal is to get the timed TX gate, the sample counter and the driver fixes back into &lt;a href=&quot;https://github.com/enjoy-digital/litex_m2sdr&quot;&gt;enjoy-digital/litex_m2sdr&lt;/a&gt;, and the SoapySDR radio driver into OCUDU.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Higher rates.&lt;/strong&gt; At 61.44 MSps 2R2T a DMA frame is 16.6 µs, which would mean 60k interrupts per second at one interrupt per buffer. A larger interrupt period or busy-polling is the answer there.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Better hosts.&lt;/strong&gt; Everything above was measured on a six-core laptop with SMI-heavy firmware and Wi-Fi that re-associates every half hour. The remaining tail is the host, not the radio.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lower user-plane latency.&lt;/strong&gt; With deterministic timing, the margins can shrink. On a PREEMPT_RT kernel with &lt;code&gt;max_proc_delay&lt;/code&gt; at its minimum of 1, ping RTT through the cell went from 28 ms with the default configuration to 12 ms (one-way UL 21.8 → 7.75 ms, DL 4.6 ms), with no loss. Experiments with an event-driven downlink go further and are on the &lt;a href=&quot;https://github.com/pvnis/ocudu/tree/low-latency-phy&quot;&gt;&lt;code&gt;low-latency-phy&lt;/code&gt;&lt;/a&gt; branch; that&#39;s a post of its own.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We&#39;ll be at the OCUDU developer summit and happy to talk through any of this, or to see an M2SDR running your cell.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Stop asking, start predicting: SAGE cuts 5G uplink latency by 2.5×</title>
    <link href="https://nextghq.github.io/blog/sage-sigcomm26/" />
    <updated>2026-10-07T00:00:00Z</updated>
    <id>https://nextghq.github.io/blog/sage-sigcomm26/</id>
    <content type="html">&lt;p&gt;Cellular networks got fast a while ago. What they never got is &lt;em&gt;prompt&lt;/em&gt;, and most of the delay is on the way up: before a phone may send a single uplink byte, it has to ask the base station for permission, and usually it has to ask twice. Our paper &lt;strong&gt;&lt;a href=&quot;https://doi.org/10.1145/3789240.3829112&quot;&gt;SAGE: A Real-Time AI System for Reducing Latency in NextG Cellular Networks&lt;/a&gt;&lt;/strong&gt;, written with Haitham Hassanieh&#39;s group at EPFL and published at ACM SIGCOMM 2026 in Denver, takes that waiting out of the loop. The gNB learns each user&#39;s traffic, predicts when the next uplink burst will arrive and how big it will be, and has a grant ready when it does.&lt;/p&gt;
&lt;p&gt;It needs no change to the 3GPP protocol, the phone or the radio: SAGE is software at the gNB and its RAN controllers. On a real over-the-air testbed with commercial 5G modules and eight real applications:&lt;/p&gt;
&lt;div class=&quot;stats&quot;&gt;
  &lt;div class=&quot;stat&quot;&gt;&lt;b&gt;2.53×&lt;/b&gt;&lt;span&gt;lower uplink latency than grant-based access, on average&lt;/span&gt;&lt;/div&gt;
  &lt;div class=&quot;stat&quot;&gt;&lt;b&gt;1.59×&lt;/b&gt;&lt;span&gt;the radio resources of grant-based access, on average&lt;/span&gt;&lt;/div&gt;
  &lt;div class=&quot;stat&quot;&gt;&lt;b&gt;37.5×&lt;/b&gt;&lt;span&gt;fewer resources than grant-free baselines (up to), at comparable latency&lt;/span&gt;&lt;/div&gt;
  &lt;div class=&quot;stat&quot;&gt;&lt;b&gt;51 µs&lt;/b&gt;&lt;span&gt;average inference time per prediction, on one CPU thread&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;This post is the short version: why the uplink is slow, why predicting it is harder than it sounds, and what we built. The paper has the full design, the analytical model and many more experiments.&lt;/p&gt;
&lt;h2 id=&quot;why-the-uplink-waits&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/sage-sigcomm26/#why-the-uplink-waits&quot;&gt;&lt;span&gt;Why the uplink waits&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;A 5G phone with new data can&#39;t just transmit. In &lt;strong&gt;grant-based access&lt;/strong&gt; it waits for its next Scheduling Request (SR) opportunity, which can be as far as 40 ms away in commercial networks, and sends an SR. The gNB answers with a small fixed grant, just enough for a sliver of data and a Buffer Status Report (BSR) saying how much is still queued. Only then does the gNB grant the rest. That is at least two request/grant round trips before the bulk of a burst moves.&lt;/p&gt;
&lt;figure class=&quot;diagram&quot;&gt;
&lt;svg viewBox=&quot;0 0 720 270&quot; role=&quot;img&quot; aria-labelledby=&quot;ul-title&quot;&gt;
  &lt;title id=&quot;ul-title&quot;&gt;Timeline comparison. Grant-based: a burst arrives, the UE waits for an SR slot and an SR grant, sends a BSR and waits for a second grant, then sends the data. SAGE: a grant is already scheduled around the predicted arrival, so the data goes out immediately.&lt;/title&gt;
  &lt;defs&gt;&lt;marker id=&quot;ah2&quot; viewBox=&quot;0 0 10 10&quot; refX=&quot;9&quot; refY=&quot;5&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; orient=&quot;auto-start-reverse&quot;&gt;&lt;path d=&quot;M0 0L10 5L0 10z&quot; class=&quot;dg-arrow&quot;&gt;&lt;/path&gt;&lt;/marker&gt;&lt;/defs&gt;
  &lt;text class=&quot;dg-z&quot; x=&quot;20&quot; y=&quot;30&quot;&gt;grant-based (today)&lt;/text&gt;
  &lt;line class=&quot;dg-line&quot; x1=&quot;20&quot; y1=&quot;96&quot; x2=&quot;700&quot; y2=&quot;96&quot; marker-end=&quot;url(#ah2)&quot;&gt;&lt;/line&gt;
  &lt;line class=&quot;dg-line&quot; x1=&quot;80&quot; y1=&quot;44&quot; x2=&quot;80&quot; y2=&quot;96&quot;&gt;&lt;/line&gt;
  &lt;text class=&quot;dg-s&quot; x=&quot;80&quot; y=&quot;40&quot; text-anchor=&quot;middle&quot;&gt;burst arrives&lt;/text&gt;
  &lt;rect class=&quot;dg-box&quot; x=&quot;80&quot; y=&quot;60&quot; width=&quot;210&quot; height=&quot;30&quot; rx=&quot;6&quot;&gt;&lt;/rect&gt;
  &lt;text class=&quot;dg-t&quot; x=&quot;185&quot; y=&quot;80&quot; text-anchor=&quot;middle&quot;&gt;wait SR slot · SR → grant&lt;/text&gt;
  &lt;rect class=&quot;dg-box&quot; x=&quot;294&quot; y=&quot;60&quot; width=&quot;180&quot; height=&quot;30&quot; rx=&quot;6&quot;&gt;&lt;/rect&gt;
  &lt;text class=&quot;dg-t&quot; x=&quot;384&quot; y=&quot;80&quot; text-anchor=&quot;middle&quot;&gt;small data + BSR → grant&lt;/text&gt;
  &lt;rect class=&quot;dg-box key&quot; x=&quot;478&quot; y=&quot;60&quot; width=&quot;110&quot; height=&quot;30&quot; rx=&quot;6&quot;&gt;&lt;/rect&gt;
  &lt;text class=&quot;dg-t&quot; x=&quot;533&quot; y=&quot;80&quot; text-anchor=&quot;middle&quot;&gt;data&lt;/text&gt;
  &lt;text class=&quot;dg-s&quot; x=&quot;185&quot; y=&quot;116&quot; text-anchor=&quot;middle&quot;&gt;1st round trip&lt;/text&gt;
  &lt;text class=&quot;dg-s&quot; x=&quot;384&quot; y=&quot;116&quot; text-anchor=&quot;middle&quot;&gt;2nd round trip&lt;/text&gt;
  &lt;text class=&quot;dg-z&quot; x=&quot;20&quot; y=&quot;168&quot;&gt;SAGE&lt;/text&gt;
  &lt;line class=&quot;dg-line&quot; x1=&quot;20&quot; y1=&quot;234&quot; x2=&quot;700&quot; y2=&quot;234&quot; marker-end=&quot;url(#ah2)&quot;&gt;&lt;/line&gt;
  &lt;rect class=&quot;dg-zone&quot; x=&quot;50&quot; y=&quot;190&quot; width=&quot;170&quot; height=&quot;38&quot; rx=&quot;6&quot;&gt;&lt;/rect&gt;
  &lt;text class=&quot;dg-s&quot; x=&quot;135&quot; y=&quot;256&quot; text-anchor=&quot;middle&quot;&gt;grants pre-allocated around predicted slot&lt;/text&gt;
  &lt;line class=&quot;dg-line&quot; x1=&quot;80&quot; y1=&quot;182&quot; x2=&quot;80&quot; y2=&quot;234&quot;&gt;&lt;/line&gt;
  &lt;text class=&quot;dg-s&quot; x=&quot;80&quot; y=&quot;178&quot; text-anchor=&quot;middle&quot;&gt;burst arrives&lt;/text&gt;
  &lt;rect class=&quot;dg-box key&quot; x=&quot;84&quot; y=&quot;198&quot; width=&quot;110&quot; height=&quot;30&quot; rx=&quot;6&quot;&gt;&lt;/rect&gt;
  &lt;text class=&quot;dg-t&quot; x=&quot;139&quot; y=&quot;218&quot; text-anchor=&quot;middle&quot;&gt;data&lt;/text&gt;
  &lt;text class=&quot;dg-s&quot; x=&quot;420&quot; y=&quot;218&quot; text-anchor=&quot;middle&quot;&gt;both round trips skipped&lt;/text&gt;
&lt;/svg&gt;
&lt;figcaption&gt;Grant-based access spends two request/grant round trips before most of a burst moves. If the gNB knows when the burst is coming and how big it is, it can schedule the grant in advance and skip both.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The alternative, &lt;strong&gt;grant-free access&lt;/strong&gt;, pre-allocates uplink resources to the phone in every slot. Latency drops, but the resources sit idle whenever there is nothing to send, and the approach does not scale with many users. So operators choose between keeping devices waiting and wasting spectrum.&lt;/p&gt;
&lt;p&gt;Predicting traffic breaks that trade-off: allocate just the right amount, just in time. Prior work on cellular traffic prediction mostly looked at minutes to days, for planning and load balancing. The few real-time systems we know of handle periodic traffic with payloads of a few hundred bytes or less, or need a GPU and tens of milliseconds per inference. We wanted bursty, non-periodic, real-application traffic: video, AR, games, video calls, industrial control. Predicted at millisecond granularity, on a CPU.&lt;/p&gt;
&lt;h2 id=&quot;the-gnb-doesn&#39;t-see-the-traffic&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/sage-sigcomm26/#the-gnb-doesn&#39;t-see-the-traffic&quot;&gt;&lt;span&gt;The gNB doesn&#39;t see the traffic&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The first obstacle is that the gNB never observes what actually arrives at the phone. It sees SRs, BSRs and decoded payloads (SDUs), all shaped by the protocol configuration, the scheduler and the channel:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;One burst becomes many events.&lt;/strong&gt; A burst that doesn&#39;t fit in one slot&#39;s grant is fragmented into several SDUs and triggers several BSRs. Change only the channel bandwidth, 40 to 60 MHz, and the same 35 KB burst produces a completely different sequence.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;BSRs are quantized.&lt;/strong&gt; A BSR reports a buffer &lt;em&gt;level&lt;/em&gt;, not a byte count: anything from 3910 to 5446 bytes reports as index 20. Subtract consecutive reports and you invent &amp;quot;ghost arrivals&amp;quot; of data that never existed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Retransmissions move things in time.&lt;/strong&gt; A transport block that fails decoding shows up at the gNB later than it was sent.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Train a predictor on this raw view and it learns the network&#39;s quirks rather than the application.&lt;/p&gt;
&lt;h2 id=&quot;traffic-trains&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/sage-sigcomm26/#traffic-trains&quot;&gt;&lt;span&gt;Traffic trains&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Our answer is an abstraction we call a &lt;strong&gt;traffic train&lt;/strong&gt;. The gNB first derives arrivals from pairs of consecutive BSRs and the SDUs received in between, then groups closely spaced arrivals into one train, ended by a configurable idle gap (the &lt;em&gt;train detection threshold&lt;/em&gt;, a few UL slots). Short thresholds suit URLLC slices whose bursts fit in a slot; longer ones aggregate eMBB bursts that span several.&lt;/p&gt;
&lt;p&gt;A few details make trains robust:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Size from the lowest BSR.&lt;/strong&gt; A train&#39;s size is the SDUs delivered up to its final BSR, plus that BSR, minus the last BSR before the train. The final BSR is the smallest level seen, so quantization error is minimized. And since size estimation is decoupled from detection, a train can be closed early, for timely prediction, without waiting for the rest of its SDUs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SRs open trains early.&lt;/strong&gt; An SR after an idle period starts a provisional train, timed at the SR, before any BSR arrives.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HARQ as ground truth for time.&lt;/strong&gt; HARQ remembers the slot of each transmission&#39;s first attempt. When a retransmitted SDU finally decodes, SAGE puts it back where it belongs: it can create a train that was missed, correct an existing train&#39;s time or size, or merge two trains a lost packet had split.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The result is a sequence of burst-level events that tracks what the application sent, not how the RAN happened to carry it. That matters for accuracy (in a micro-benchmark on mobile AR and SLAM, predictors trained on trains were much more accurate than ones trained on raw derived arrivals) and for portability: with two national operators&#39; configurations we measured 3.2–4.1× lower latency than grant-based access &lt;em&gt;without retraining any model&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id=&quot;one-small-model-per-traffic-profile&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/sage-sigcomm26/#one-small-model-per-traffic-profile&quot;&gt;&lt;span&gt;One small model per traffic profile&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The second obstacle is diversity. A single universal predictor big enough for every application is too slow for millisecond deadlines and expensive to retrain each time a new application shows up, and retraining it for a new profile can degrade the old ones.&lt;/p&gt;
&lt;p&gt;SAGE instead keeps a &lt;strong&gt;traffic-aware model database&lt;/strong&gt; of small, profile-specific predictors. Each profile is keyed by a 16-number fingerprint, eight statistics (mean, median, IQR, 10th and 90th percentile, skewness, MAD, lag-1 autocorrelation) of inter-arrival times and eight of sizes. A new user&#39;s trains are fingerprinted and matched to the nearest profile; the lookup scales to 10,000 profiles in 200 ms on an Intel NUC. The predictors in our evaluation are LSTMs of 3,490 to 50,818 parameters. Predictors are plug-ins, so a profile can use anything from EWMA to a transformer; in our comparison, modern learned predictors performed similarly to each other and consistently beat EWMA.&lt;/p&gt;
&lt;h2 id=&quot;the-control-loop&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/sage-sigcomm26/#the-control-loop&quot;&gt;&lt;span&gt;The control loop&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;SAGE spans the gNB, a real-time RIC co-located with it, a near-real-time RIC and the model database. A new user goes through five stages:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Traffic characterization.&lt;/strong&gt; The user starts in ordinary grant-based or grant-free mode while the gNB records its trains. The near-RT RIC tests the series for stationarity; if it is predictable, it retrieves the matching model.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Model verification.&lt;/strong&gt; The model predicts in the shadows and the gNB scores it against the trains that actually arrive.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Prediction and scheduling.&lt;/strong&gt; Once verified, the user switches to &lt;em&gt;prediction mode&lt;/em&gt;. The gNB pre-allocates resources over a window around the predicted slot, asymmetric because bursts start at the predicted slot and spill forward: fewer resources before it, more after. The window widens when predictions start missing and shrinks when they are good, with two thresholds for hysteresis.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Continual learning.&lt;/strong&gt; If verification fails, the model is fine-tuned online, one epoch per new train, and re-verified.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Database update.&lt;/strong&gt; If that still fails, the near-RT RIC trains a new model on a longer series and registers it as a new profile.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Traffic that stays unpredictable falls back to grant-based or grant-free access, and for grant-free an analytical latency/resource model picks the guaranteed grant size at the knee of the trade-off curve. Prediction is used when it&#39;s reliable, suspended when it isn&#39;t, and improved over time.&lt;/p&gt;
&lt;p&gt;The real-time parts are cheap. On a single thread of an AMD Ryzen 7 7840HS, inference averages &lt;strong&gt;0.051 ms&lt;/strong&gt; and a continual-learning step &lt;strong&gt;0.451 ms&lt;/strong&gt;. Against an average inter-arrival time of 59.4 ms across our applications, one thread fits over a thousand inferences between consecutive trains.&lt;/p&gt;
&lt;h2 id=&quot;results&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/sage-sigcomm26/#results&quot;&gt;&lt;span&gt;Results&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;We built SAGE on srsRAN, with Open5GS as the core and our own RICs, all containerized. The gNB and RT RIC run on a Dell Precision 5860 with a USRP X410 radio, band n78, and the UEs are commercial 5G modules. Every experiment is over the air. The applications: surveillance video, mobile AR, SLAM on a mobile robot, Counter-Strike 2, Google Meet, WhatsApp video calls, an industrial-control trace and a SCADA (Modbus/TCP) trace.&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot;&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Uplink latency vs. grant-based&lt;/th&gt;
&lt;th&gt;Resources vs. grant-based&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Eight applications, one UE each (average)&lt;/td&gt;
&lt;td&gt;2.53× lower&lt;/td&gt;
&lt;td&gt;1.59×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ten concurrent commercial UEs, four applications&lt;/td&gt;
&lt;td&gt;1.42–2.41× lower&lt;/td&gt;
&lt;td&gt;1.15–1.55×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two national operators&#39; configurations&lt;/td&gt;
&lt;td&gt;3.2–4.1× lower&lt;/td&gt;
&lt;td&gt;1.4–1.5×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p&gt;A few findings worth calling out:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Averages don&#39;t work for bursty traffic.&lt;/strong&gt; Granting a 2 Mbps application its average rate is about 500 bytes per slot, nowhere near a 40 KB burst. Granting 4× the average still loses on latency and wastes resources while idle. SAGE matches the grant-free baselines on latency with up to 37.5× fewer resources.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bigger grants can be slower.&lt;/strong&gt; For small-payload traffic (Counter-Strike 2, industrial control, SCADA), oversized grants get filled with MAC padding that both ends must process, so even &amp;quot;full grid&amp;quot; grant-free loses to a right-sized grant. Below about 0.1 Mbps, SAGE can even use &lt;em&gt;fewer&lt;/em&gt; resources than grant-based access, whose fixed SR grant (512 bytes in srsRAN) over-allocates for tiny packets.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It holds up with many users.&lt;/strong&gt; With ten UEs from four vendors, Jain&#39;s fairness index stayed above 0.99 under both proportional-fair and round-robin schedulers; SAGE submits its grants through the operator&#39;s existing MAC scheduler rather than replacing it. Fixed equal-share grant-free sometimes did worse than grant-based, because a burst larger than its reservation queues even while other UEs&#39; reservations sit idle.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It holds up on bad channels.&lt;/strong&gt; Up to 20% BLER, SAGE stayed close to full-grid grant-free on latency while using resources close to grant-based. Saturating the uplink with best-effort traffic in another slice left SAGE&#39;s latency essentially unchanged.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Timing matters more than size.&lt;/strong&gt; Median arrival-time error was under 4 ms, median size error under 20%. The prediction window absorbs size errors; a missed slot is what costs latency.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Applications feel it.&lt;/strong&gt; For streamed video, per-frame network latency fell 2.07–2.54×, and end-to-end display latency 1.13–1.23×.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The cost is honest: in steady state, SAGE over-provisions by a constant factor (the ~1.6× above), which reduces how many users a cell carries before airtime saturates compared with pure grant-based access. The paper&#39;s appendix models that overhead as a function of user count and bitrate, so an operator can decide per slice where it&#39;s worth paying.&lt;/p&gt;
&lt;h2 id=&quot;where-this-fits&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/sage-sigcomm26/#where-this-fits&quot;&gt;&lt;span&gt;Where this fits&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;SAGE continues a line of work with the same group on what latency open 5G can actually deliver: &lt;a href=&quot;https://doi.org/10.1145/3696348.3696862&quot;&gt;&lt;em&gt;Ultra-Reliable Low-Latency in 5G: A Close Reality or a Distant Goal?&lt;/em&gt;&lt;/a&gt; (HotNets &#39;24), &lt;a href=&quot;https://doi.org/10.1145/3750718.3750743&quot;&gt;&lt;em&gt;Towards URLLC with Open-Source 5G Software&lt;/em&gt;&lt;/a&gt; (OpenRIT6G &#39;25), and &lt;a href=&quot;https://arxiv.org/abs/2511.21277&quot;&gt;&lt;em&gt;LatencyScope&lt;/em&gt;&lt;/a&gt;, a system-level model of 5G RAN latency. The recurring lesson is that the slow parts are protocol and scheduling, not the radio&#39;s raw speed.&lt;/p&gt;
&lt;p&gt;That&#39;s also why it pairs with our &lt;a href=&quot;https://nextghq.github.io/blog/m2sdr-ocudu/&quot;&gt;M2SDR and OCUDU work&lt;/a&gt;. There, the job was to make an open radio keep exact time, so the gNB&#39;s timeline can be trusted. SAGE works one layer up: once the timeline is trustworthy, the scheduler can stop waiting for the phone to ask.&lt;/p&gt;
&lt;p&gt;Open directions from the paper: better train detection under long SR periods, adding channel prediction to size grants and windows, and coordinating predictions across slices.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Read more:&lt;/strong&gt; the &lt;a href=&quot;https://doi.org/10.1145/3789240.3829112&quot;&gt;paper&lt;/a&gt; (open access, CC BY 4.0) and the &lt;a href=&quot;https://sens.epfl.ch/research/sage&quot;&gt;project page at EPFL&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Authors: Aoyu Gong and Raphael Cannatà (co-first authors), Arman Maghsoudnia, Néstor Lomba Lomba and Haitham Hassanieh (EPFL), and Dan Mihai Dumitriu (Pavonis).&lt;/em&gt;&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Why Now: Open Source, AI, and Open Hardware Meet NextG Wireless</title>
    <link href="https://nextghq.github.io/blog/why-now-for-nextg-wireless/" />
    <updated>2026-10-07T00:00:00Z</updated>
    <id>https://nextghq.github.io/blog/why-now-for-nextg-wireless/</id>
    <content type="html">&lt;p&gt;&lt;em&gt;This is the first post in a series about what we&#39;re building at NextG
Systems and why. Future posts will go deeper on the technical pieces.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;who-we-are&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/why-now-for-nextg-wireless/#who-we-are&quot;&gt;&lt;span&gt;Who we are&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;NextG Systems LLC builds open, programmable, and secure NextG wireless
systems. Our focus is mission-critical communications — defense, emergency
services, and other settings where a network has to be trustworthy, not just
fast.&lt;/p&gt;
&lt;p&gt;We come to wireless from an unusual direction. Our team spent more than a
decade working on software-defined networking (SDN) in the data center and
the cloud. Over the last several years we&#39;ve turned that experience toward
5G: standing up private 5G testbeds with commercial radio units, running the
major open-source cores (Open5GS, free5GC, SD-Core), building RAN
intelligent controller applications, and working deeply with the srsRAN
codebase and its successor, OCUDU. We also work closely with academic
collaborators, including the wireless research group at Princeton
University.&lt;/p&gt;
&lt;h2 id=&quot;we&#39;ve-seen-this-movie-before&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/why-now-for-nextg-wireless/#we&#39;ve-seen-this-movie-before&quot;&gt;&lt;span&gt;We&#39;ve seen this movie before&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;When we started in data center networking, the network was a set of sealed
boxes. Switches and routers came from a handful of large vendors, bundled
hardware with software, and exposed whatever management interfaces the vendor
chose to support. If you wanted a new feature, you filed a request and waited
for the next release.&lt;/p&gt;
&lt;p&gt;Over roughly a decade, that changed completely:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The control plane was separated from the data plane&lt;/strong&gt;, so the logic that
decides where traffic goes could run as ordinary software on ordinary
servers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Open protocols&lt;/strong&gt; replaced proprietary ones at the boundaries between
components.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Merchant silicon&lt;/strong&gt; made high-performance switching ASICs available to
anyone, so hardware stopped being a moat.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Open network operating systems&lt;/strong&gt; let operators run their own software on
the switch itself.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Model-driven configuration&lt;/strong&gt; (OpenConfig, gNMI, and friends) replaced
screen-scraping CLIs with structured, programmable interfaces.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The result was not just cheaper networks. It was networks that operators
could reason about, automate, test, and change on their own schedule. The
large cloud providers built their businesses on this.&lt;/p&gt;
&lt;h2 id=&quot;the-same-pattern-is-arriving-in-wireless&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/why-now-for-nextg-wireless/#the-same-pattern-is-arriving-in-wireless&quot;&gt;&lt;span&gt;The same pattern is arriving in wireless&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;More than four years ago, the emergence of &lt;strong&gt;O-RAN&lt;/strong&gt; made us think wireless
was next. O-RAN defined open interfaces between pieces of the radio access
network that had always been sold as one integrated product. That is the
same move as separating the control and data planes: once the boundaries are
open, each piece can be built, replaced, and improved independently.&lt;/p&gt;
&lt;p&gt;Open-source software already existed. &lt;a href=&quot;https://openairinterface.org/&quot;&gt;OpenAirInterface&lt;/a&gt;
and &lt;a href=&quot;https://www.srsran.com/&quot;&gt;srsRAN&lt;/a&gt; offered full RAN stacks, and several
open-source 5G cores were maturing. But software alone doesn&#39;t make an
ecosystem, and for a few years momentum built slowly. Running these stacks
was a research exercise, not a deployment option.&lt;/p&gt;
&lt;p&gt;We started building on srsRAN anyway, because we believed the direction was
right. That bet has paid off.&lt;/p&gt;
&lt;h2 id=&quot;ocudu-changes-the-game&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/why-now-for-nextg-wireless/#ocudu-changes-the-game&quot;&gt;&lt;span&gt;OCUDU changes the game&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;In 2025 the U.S. Department of Defense&#39;s FutureG office selected SRS and
DeepSig to lead &lt;strong&gt;OCUDU&lt;/strong&gt; — Open RAN Centralized Unit and Distributed Unit —
an open-source 5G/6G RAN initiative built on the srsRAN lineage. OCUDU now
lives at the Linux Foundation, with an ecosystem foundation whose members
include operators, chipmakers, and equipment vendors. In May 2026,
&lt;a href=&quot;https://www.linuxfoundation.org/press/qualcomm-ocudu-ecosystem-foundations-premier-membership-to-accelerate-open-source-software-for-open-ran-infrastructure&quot;&gt;Qualcomm joined as a Premier Member&lt;/a&gt;,
alongside companies like AMD, AT&amp;amp;T, Ericsson, Nokia, NVIDIA, Samsung, and
Verizon.&lt;/p&gt;
&lt;p&gt;This is what was missing. Sustained government backing provides a long-term
anchor customer and a reason for the community to invest. Vendor
participation means the open stack will run on real hardware and be tested
against real requirements. For the first time, an open-source RAN looks less
like a lab tool and more like the foundation of a product category.&lt;/p&gt;
&lt;h2 id=&quot;merchant-silicon%2C-with-a-risc-v-accent&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/why-now-for-nextg-wireless/#merchant-silicon%2C-with-a-risc-v-accent&quot;&gt;&lt;span&gt;Merchant silicon, with a RISC-V accent&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The data center story needed merchant silicon, and so does wireless. That is
starting to happen, in fits and starts. Companies like
&lt;a href=&quot;https://www.picocom.com/&quot;&gt;Picocom&lt;/a&gt;, &lt;a href=&quot;https://edgeq.io/&quot;&gt;EdgeQ&lt;/a&gt;, and
LeapFrog Semiconductor are building baseband and signal-processing silicon
for 5G infrastructure that isn&#39;t locked to a single equipment vendor.&lt;/p&gt;
&lt;p&gt;What stands out to us is that these designs share a common thread: &lt;strong&gt;RISC-V&lt;/strong&gt;.
An open instruction set at the heart of the baseband means the openness can
extend all the way down — not just to the software interfaces, but to the
processor architecture itself. Combined with open FPGA and SDR platforms, it
is becoming possible to build and inspect the whole stack.&lt;/p&gt;
&lt;h2 id=&quot;thinking-about-the-whole-system&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/why-now-for-nextg-wireless/#thinking-about-the-whole-system&quot;&gt;&lt;span&gt;Thinking about the whole system&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Open components are necessary but not sufficient. Data center SDN succeeded
because operators could manage the network as one coherent system. The
Open Networking Foundation&#39;s
&lt;a href=&quot;https://opennetworking.org/news-and-events/blog/onfs-aether-project-moving-to-lf/&quot;&gt;Aether&lt;/a&gt;
project (now hosted at the Linux Foundation) was an early and important glimpse of what that looks like for 5G:
an integrated management environment spanning the RAN, the core, and edge
compute, with multi-tenancy built in. It showed us what a private 5G network
could feel like if it were designed by people who had built cloud
infrastructure.&lt;/p&gt;
&lt;h2 id=&quot;why-ai-makes-this-urgent&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/why-now-for-nextg-wireless/#why-ai-makes-this-urgent&quot;&gt;&lt;span&gt;Why AI makes this urgent&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The final ingredient is AI, and it cuts both ways.&lt;/p&gt;
&lt;p&gt;On one side, AI makes open, programmable networks dramatically more
valuable. AI-native signal processing, intelligent RAN control, and
automated operations all depend on being able to get inside the stack —
something closed systems don&#39;t allow.&lt;/p&gt;
&lt;p&gt;On the other side, AI raises the stakes on security. As agentic AI makes
attacks faster and cheaper, some defenses will have to be just as
responsive. But for critical infrastructure, the stronger answer is to
eliminate whole classes of vulnerabilities before they can be exploited.
That requires systems you can inspect and, increasingly, verify. Open source
and open hardware make that possible; closed boxes don&#39;t.&lt;/p&gt;
&lt;h2 id=&quot;there&#39;s-still-a-long-way-to-go&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/why-now-for-nextg-wireless/#there&#39;s-still-a-long-way-to-go&quot;&gt;&lt;span&gt;There&#39;s still a long way to go&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;We want to be honest: NextG wireless is not where data center SDN is today.
Interoperability is still hard. Operations tooling is immature. Performance
and reliability of open stacks still lag the best integrated systems in many
settings. Silicon options are few. Security assurance for these systems is
largely unaddressed.&lt;/p&gt;
&lt;p&gt;That gap is the opportunity. We&#39;ve watched one industry make this journey,
and we think we know some of the shortcuts and some of the potholes. NextG
Systems exists to help wireless make the same journey, with security and
mission-critical reliability built in from the start.&lt;/p&gt;
&lt;p&gt;In the coming posts we&#39;ll go into specifics: what we&#39;ve learned running
open-source 5G testbeds, where we think formal methods fit in the RAN and
core, and what a truly programmable private 5G network should look like.&lt;/p&gt;
&lt;p&gt;We&#39;re on that journey. Come along.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Slicing an NVIDIA GPU between tenants who don&#39;t trust each other</title>
    <link href="https://nextghq.github.io/blog/nvidia-gpu-slicing/" />
    <updated>2026-10-14T00:00:00Z</updated>
    <id>https://nextghq.github.io/blog/nvidia-gpu-slicing/</id>
    <content type="html">&lt;p&gt;A GPU server shared by several teams is only worth sharing if each team gets the slice it was promised, and none of them can take more by misbehaving. That second half is what existing tools mostly don&#39;t do. The common answers either need datacenter hardware with fixed partition sizes, give up isolation, or put the thing doing the limiting inside the container it is supposed to limit.&lt;/p&gt;
&lt;p&gt;This post describes how we slice NVIDIA GPUs between mutually untrusting containers. Memory quotas are enforced in &lt;a href=&quot;https://gvisor.dev&quot;&gt;gVisor&lt;/a&gt;&#39;s userspace kernel, the Sentry. Compute shares are enforced by the GPU&#39;s own scheduler, driven from the host through a small broker we added to NVIDIA&#39;s open kernel modules. The policy is a credit scheduler that a tenant can&#39;t game by forking. It runs on consumer and datacenter parts alike: an RTX 5070, an RTX A6000, an A100 and an 8× B300 box. The code is on the &lt;code&gt;gpuslicing&lt;/code&gt; branches of our forks of &lt;a href=&quot;https://github.com/pvnis/gvisor/tree/gpuslicing&quot;&gt;gVisor&lt;/a&gt; and of &lt;a href=&quot;https://github.com/pvnis/open-gpu-kernel-modules/tree/gpuslicing&quot;&gt;open-gpu-kernel-modules&lt;/a&gt;.&lt;/p&gt;
&lt;div class=&quot;stats&quot;&gt;
  &lt;div class=&quot;stat&quot;&gt;&lt;b&gt;0&lt;/b&gt;&lt;span&gt;ioctls during 13,000 CUDA kernel launches, which is why compute is hard&lt;/span&gt;&lt;/div&gt;
  &lt;div class=&quot;stat&quot;&gt;&lt;b&gt;0.78 → 2.75&lt;/b&gt;&lt;span&gt;share ratio for a 3 : 1 request once the low-weight tenant forks, before and after credit scheduling&lt;/span&gt;&lt;/div&gt;
  &lt;div class=&quot;stat&quot;&gt;&lt;b&gt;99.9 %&lt;/b&gt;&lt;span&gt;of solo throughput for a lone tenant with the scheduler running&lt;/span&gt;&lt;/div&gt;
  &lt;div class=&quot;stat&quot;&gt;&lt;b&gt;696 GB/s&lt;/b&gt;&lt;span&gt;NCCL all-reduce bus bandwidth across 8 GPUs, inside a gVisor sandbox&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;h2 id=&quot;nothing-inside-the-container&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/nvidia-gpu-slicing/#nothing-inside-the-container&quot;&gt;&lt;span&gt;Nothing inside the container&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The design follows from one rule: &lt;strong&gt;no limit may depend on anything inside the container.&lt;/strong&gt; On a shared GPU the workload is the adversary, and a limit it can reach is a limit it can lift.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://project-hami.io&quot;&gt;HAMi&lt;/a&gt; shows why this matters. Its scheduler is genuinely good: it understands requests like &lt;code&gt;nvidia.com/gpumem&lt;/code&gt; and &lt;code&gt;nvidia.com/gpucores&lt;/code&gt; and places pods so they fit on a device. Its enforcement is &lt;code&gt;libvgpu.so&lt;/code&gt;, preloaded into the container to intercept CUDA calls, and it reads an environment variable, &lt;code&gt;CUDA_DISABLE_CONTROL&lt;/code&gt;, that switches it off. On a live cluster, the same pod three ways:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot;&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;HAMi limiter&lt;/th&gt;
&lt;th&gt;Sentry limit&lt;/th&gt;
&lt;th&gt;Device size the pod sees&lt;/th&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;loaded&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;512 / 512 MiB&lt;/td&gt;
&lt;td&gt;refused at 512 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;not loaded&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;11347 / 11790 MiB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allocated all 768 MiB it asked for&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;not loaded&lt;/td&gt;
&lt;td&gt;512 MiB&lt;/td&gt;
&lt;td&gt;350 / 512 MiB&lt;/td&gt;
&lt;td&gt;refused at 320 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p&gt;Row two is what a tenant gets by setting one variable. Row three is the same pod with the limit enforced somewhere it can&#39;t reach. So we kept HAMi&#39;s placement unchanged and rebuilt the enforcement elsewhere.&lt;/p&gt;
&lt;h2 id=&quot;three-pieces%2C-and-who-trusts-whom&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/nvidia-gpu-slicing/#three-pieces%2C-and-who-trusts-whom&quot;&gt;&lt;span&gt;Three pieces, and who trusts whom&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;gVisor runs a container against the Sentry, a kernel written in Go that runs in userspace, instead of the host kernel. Every syscall the application makes, including every ioctl on &lt;code&gt;/dev/nvidiactl&lt;/code&gt;, &lt;code&gt;/dev/nvidia0&lt;/code&gt; and &lt;code&gt;/dev/nvidia-uvm&lt;/code&gt;, lands in the Sentry first. Upstream gVisor already proxies these (&lt;code&gt;nvproxy&lt;/code&gt;), checking each call against an allowlist of driver structures before forwarding it. That boundary is about the &lt;em&gt;shape&lt;/em&gt; of each call. We added &lt;em&gt;quantity&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;There are three pieces:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;nvproxy&lt;/code&gt; in the Sentry&lt;/strong&gt; enforces memory quotas, because every allocation passes through it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;runsc gpu-scheduler&lt;/code&gt;&lt;/strong&gt;, one process per node on the host, holds the compute policy: who has which weight, and on which GPU.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A broker in the open kernel modules&lt;/strong&gt; applies that policy to the GPU&#39;s own scheduler. Only kernel code can do that.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The split is deliberate. The Sentry is outside the container, but it runs that tenant&#39;s code paths, so we treat it as no more trusted than the tenant it serves. Anything that can affect &lt;em&gt;other&lt;/em&gt; tenants, like detaching their work from the GPU, lives in the host scheduler and the driver, never in a Sentry.&lt;/p&gt;
&lt;figure class=&quot;diagram&quot;&gt;
&lt;svg viewBox=&quot;0 0 720 430&quot; role=&quot;img&quot; aria-labelledby=&quot;nv-title&quot;&gt;
  &lt;title id=&quot;nv-title&quot;&gt;A CUDA application in a gVisor sandbox makes ioctls that the Sentry&#39;s nvproxy checks and charges against a memory quota before forwarding them to the NVIDIA kernel driver. The application submits compute work by writing to mapped memory and ringing a doorbell, which goes straight to the GPU without any syscall. A host process, runsc gpu-scheduler, reads each tenant&#39;s activity and channel-group count from the driver&#39;s broker interface and writes timeslices and detach or attach commands, each committed with a runlist restart, to the GPU&#39;s firmware scheduler.&lt;/title&gt;
  &lt;defs&gt;&lt;marker id=&quot;ah-nv&quot; viewBox=&quot;0 0 10 10&quot; refX=&quot;9&quot; refY=&quot;5&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; orient=&quot;auto-start-reverse&quot;&gt;&lt;path d=&quot;M0 0L10 5L0 10z&quot; class=&quot;dg-arrow&quot;&gt;&lt;/path&gt;&lt;/marker&gt;&lt;/defs&gt;
  &lt;rect class=&quot;dg-zone&quot; x=&quot;50&quot; y=&quot;20&quot; width=&quot;310&quot; height=&quot;212&quot; rx=&quot;8&quot;&gt;&lt;/rect&gt;
  &lt;text class=&quot;dg-z&quot; x=&quot;66&quot; y=&quot;40&quot;&gt;gvisor sandbox · one tenant&lt;/text&gt;
  &lt;rect class=&quot;dg-box&quot; x=&quot;66&quot; y=&quot;52&quot; width=&quot;278&quot; height=&quot;46&quot; rx=&quot;6&quot;&gt;&lt;/rect&gt;
  &lt;text class=&quot;dg-t&quot; x=&quot;205&quot; y=&quot;72&quot; text-anchor=&quot;middle&quot;&gt;CUDA application&lt;/text&gt;
  &lt;text class=&quot;dg-s&quot; x=&quot;205&quot; y=&quot;90&quot; text-anchor=&quot;middle&quot;&gt;unmodified · nothing preloaded&lt;/text&gt;
  &lt;line class=&quot;dg-line&quot; x1=&quot;205&quot; y1=&quot;98&quot; x2=&quot;205&quot; y2=&quot;150&quot; marker-end=&quot;url(#ah-nv)&quot;&gt;&lt;/line&gt;
  &lt;text class=&quot;dg-s&quot; x=&quot;215&quot; y=&quot;128&quot;&gt;ioctls&lt;/text&gt;
  &lt;rect class=&quot;dg-box key&quot; x=&quot;66&quot; y=&quot;152&quot; width=&quot;278&quot; height=&quot;66&quot; rx=&quot;6&quot;&gt;&lt;/rect&gt;
  &lt;text class=&quot;dg-t&quot; x=&quot;205&quot; y=&quot;172&quot; text-anchor=&quot;middle&quot;&gt;Sentry · nvproxy&lt;/text&gt;
  &lt;text class=&quot;dg-s&quot; x=&quot;205&quot; y=&quot;190&quot; text-anchor=&quot;middle&quot;&gt;memory quota, charged before forwarding&lt;/text&gt;
  &lt;text class=&quot;dg-s&quot; x=&quot;205&quot; y=&quot;206&quot; text-anchor=&quot;middle&quot;&gt;device sizes reported as the quota&lt;/text&gt;
  &lt;polyline class=&quot;dg-line&quot; points=&quot;66,75 40,75 40,370&quot; style=&quot;stroke-dasharray:5 4&quot; marker-end=&quot;url(#ah-nv)&quot;&gt;&lt;/polyline&gt;
  &lt;text class=&quot;dg-s&quot; transform=&quot;translate(30,225) rotate(-90)&quot; text-anchor=&quot;middle&quot;&gt;doorbell write · never a syscall&lt;/text&gt;
  &lt;rect class=&quot;dg-box key&quot; x=&quot;420&quot; y=&quot;52&quot; width=&quot;280&quot; height=&quot;66&quot; rx=&quot;6&quot;&gt;&lt;/rect&gt;
  &lt;text class=&quot;dg-t&quot; x=&quot;560&quot; y=&quot;74&quot; text-anchor=&quot;middle&quot;&gt;runsc gpu-scheduler (host)&lt;/text&gt;
  &lt;text class=&quot;dg-s&quot; x=&quot;560&quot; y=&quot;92&quot; text-anchor=&quot;middle&quot;&gt;weights · one credit planner per GPU&lt;/text&gt;
  &lt;text class=&quot;dg-s&quot; x=&quot;560&quot; y=&quot;108&quot; text-anchor=&quot;middle&quot;&gt;pid announced by runsc at start&lt;/text&gt;
  &lt;line class=&quot;dg-line&quot; x1=&quot;205&quot; y1=&quot;218&quot; x2=&quot;205&quot; y2=&quot;270&quot; marker-end=&quot;url(#ah-nv)&quot;&gt;&lt;/line&gt;
  &lt;text class=&quot;dg-s&quot; x=&quot;215&quot; y=&quot;250&quot;&gt;forwarded if within quota&lt;/text&gt;
  &lt;line class=&quot;dg-line&quot; x1=&quot;530&quot; y1=&quot;118&quot; x2=&quot;530&quot; y2=&quot;270&quot; marker-end=&quot;url(#ah-nv)&quot;&gt;&lt;/line&gt;
  &lt;text class=&quot;dg-s&quot; x=&quot;522&quot; y=&quot;178&quot; text-anchor=&quot;end&quot;&gt;ts · detach · attach&lt;/text&gt;
  &lt;text class=&quot;dg-s&quot; x=&quot;522&quot; y=&quot;194&quot; text-anchor=&quot;end&quot;&gt;+ RESTART_RUNLIST&lt;/text&gt;
  &lt;line class=&quot;dg-line&quot; x1=&quot;620&quot; y1=&quot;270&quot; x2=&quot;620&quot; y2=&quot;120&quot; marker-end=&quot;url(#ah-nv)&quot;&gt;&lt;/line&gt;
  &lt;text class=&quot;dg-s&quot; x=&quot;630&quot; y=&quot;178&quot;&gt;poll&lt;/text&gt;
  &lt;text class=&quot;dg-s&quot; x=&quot;630&quot; y=&quot;194&quot;&gt;active, tsgs&lt;/text&gt;
  &lt;rect class=&quot;dg-box&quot; x=&quot;66&quot; y=&quot;272&quot; width=&quot;634&quot; height=&quot;58&quot; rx=&quot;6&quot;&gt;&lt;/rect&gt;
  &lt;text class=&quot;dg-t&quot; x=&quot;383&quot; y=&quot;294&quot; text-anchor=&quot;middle&quot;&gt;NVIDIA open kernel modules + broker hooks&lt;/text&gt;
  &lt;text class=&quot;dg-s&quot; x=&quot;383&quot; y=&quot;314&quot; text-anchor=&quot;middle&quot;&gt;/proc/driver/nvidia/gpusched · root only · not visible in any sandbox&lt;/text&gt;
  &lt;line class=&quot;dg-line&quot; x1=&quot;383&quot; y1=&quot;330&quot; x2=&quot;383&quot; y2=&quot;370&quot; marker-end=&quot;url(#ah-nv)&quot;&gt;&lt;/line&gt;
  &lt;rect class=&quot;dg-box&quot; x=&quot;20&quot; y=&quot;372&quot; width=&quot;680&quot; height=&quot;44&quot; rx=&quot;6&quot;&gt;&lt;/rect&gt;
  &lt;text class=&quot;dg-t&quot; x=&quot;360&quot; y=&quot;398&quot; text-anchor=&quot;middle&quot;&gt;GPU · GSP runlist scheduler · channel groups (TSGs) · VRAM&lt;/text&gt;
&lt;/svg&gt;
&lt;figcaption&gt;Memory goes through the Sentry. Compute submission doesn&#39;t, so compute is divided below it, by the GPU&#39;s own runlist scheduler, under the control of a host process the tenant can&#39;t reach.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2 id=&quot;memory%3A-quotas-in-the-sentry&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/nvidia-gpu-slicing/#memory%3A-quotas-in-the-sentry&quot;&gt;&lt;span&gt;Memory: quotas in the Sentry&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;GPU memory is allocated through ioctls, so the Sentry can count it. The idea is simple: charge each allocation against the sandbox&#39;s quota and refuse the one that would cross it. The details are where the work went.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Charge before forwarding.&lt;/strong&gt; The charge is taken before the ioctl reaches the driver and returned if the driver refuses. Otherwise two concurrent allocations could both be admitted against the same headroom. A red-team run with four processes racing allocations behind a barrier summed correctly, with no overshoot.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Count aliases once.&lt;/strong&gt; &lt;code&gt;NV_ESC_RM_DUP_OBJECT&lt;/code&gt; creates a second handle to existing memory, so charges are reference counted. They are released from the cascade that frees an object&#39;s dependents, the only place that sees all of them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Unified memory is charged at reservation.&lt;/strong&gt; CUDA unified memory is populated by GPU page faults that the host&#39;s &lt;code&gt;nvidia-uvm&lt;/code&gt; module services internally, without any ioctl the Sentry could see. What the Sentry &lt;em&gt;can&lt;/em&gt; see is the address-space reservation, so that is what it charges, together with device memory. It&#39;s a sound bound, but a workload that deliberately oversubscribes pays for what it reserved.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Every allocation class makes an explicit decision.&lt;/strong&gt; A class added to the allowlist without an accounting rule fails a test instead of silently going uncharged.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The Sentry also makes the limit &lt;em&gt;visible&lt;/em&gt;. It rewrites the sizes the driver reports, so &lt;code&gt;cuMemGetInfo()&lt;/code&gt;, NVML and &lt;code&gt;nvidia-smi&lt;/code&gt; inside the sandbox show the quota, not the card. Frameworks size their memory pools from those calls, so an unmodified PyTorch or vLLM fits itself into its slice instead of confidently allocating past it. An over-quota allocation fails with &lt;code&gt;CUDA_ERROR_OUT_OF_MEMORY&lt;/code&gt;, exactly as on a full GPU.&lt;/p&gt;
&lt;p&gt;Multi-GPU pods needed one more step. HAMi&#39;s &lt;code&gt;gpumem&lt;/code&gt; is per device, so a pod with two GPUs at 50000 MiB each should get 50000 MiB on each, not 100000 MiB to spend anywhere. Spending it anywhere would let a pod stack its whole allowance on one GPU, into memory promised to another tenant. So nvproxy attributes every allocation to the device it was made on and enforces a per-device limit as well as the total. On a B300, such a pod sees 50000 MiB on each GPU, fits 45000 MiB on both, and is refused 10000 MiB more on either.&lt;/p&gt;
&lt;p&gt;In red-team runs, every buffer a tenant allocated next to a victim holding a secret came back zeroed, and out-of-bounds accesses faulted at the GPU&#39;s per-context MMU.&lt;/p&gt;
&lt;h2 id=&quot;compute%3A-why-the-sentry-can&#39;t-do-it&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/nvidia-gpu-slicing/#compute%3A-why-the-sentry-can&#39;t-do-it&quot;&gt;&lt;span&gt;Compute: why the Sentry can&#39;t do it&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The natural next step is to meter compute at the same boundary. It can&#39;t be done. Once a CUDA channel is set up, submitting work doesn&#39;t enter the kernel: the application writes commands into a ring buffer in memory it has already mapped, then writes to a mapped device register, the doorbell. We measured &lt;strong&gt;13,000 kernel launches producing zero ioctls&lt;/strong&gt;. There&#39;s nothing for the Sentry to intercept.&lt;/p&gt;
&lt;p&gt;Our first attempt worked around that, following &lt;a href=&quot;https://www.usenix.org/conference/atc25&quot;&gt;Krypton (USENIX ATC &#39;25)&lt;/a&gt;. Submission needs the ring buffer to be &lt;em&gt;mapped&lt;/em&gt;, so the Sentry revokes its own mapping of the command buffer when a sandbox&#39;s share of each period runs out. The next write faults into the Sentry, which holds the task until its share comes round. Against a kernel-launch loop it divided cleanly: 76.2 %, 51.0 % and 25.8 % of the GPU against 75, 50 and 25 % configured.&lt;/p&gt;
&lt;p&gt;Then we ran cuBLAS. On Volta and later, cuBLAS GEMMs and especially CUDA graph replay, which is how an inference server like vLLM spends most of its time, don&#39;t rewrite the command buffer per launch. They replay commands written once and ring the doorbell. The revoked mapping is never touched again, so the fault never arrives. Two cuBLAS tenants weighted 3 : 1 under this gate ran at &lt;strong&gt;706 and 706&lt;/strong&gt; matmul/s.&lt;/p&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;p class=&quot;callout-title&quot;&gt;The benchmark shared the mechanism&#39;s blind spot&lt;/p&gt;
&lt;p&gt;The kernel-launch loop that validated the gate is the one workload shape the gate can bind. The clean 76 / 51 / 26 table measured the exception, not the rule. The fix wasn&#39;t cleverness, it was testing with the workloads that would actually run.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;The gate also had a cost: while it holds a sandbox, the faulting task holds the sandbox&#39;s address-space lock, so other threads stall on anything that needs it. A thread doing continuous &lt;code&gt;mmap&lt;/code&gt; and &lt;code&gt;munmap&lt;/code&gt; fell to about 20 % of its normal rate under a 25 % cap. The gate is still in the tree, but on a node where the broker is running it stands down (more on why below).&lt;/p&gt;
&lt;h2 id=&quot;the-gpu-already-has-a-scheduler&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/nvidia-gpu-slicing/#the-gpu-already-has-a-scheduler&quot;&gt;&lt;span&gt;The GPU already has a scheduler&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The GPU divides its own time. Work is organised in channel groups (TSGs), and the GSP firmware runs a &lt;strong&gt;runlist&lt;/strong&gt; scheduler that rotates between them, giving each a timeslice. It acts &lt;em&gt;below&lt;/em&gt; the submission path, so a doorbell can&#39;t dodge it. The controls that configure it, setting a channel group&#39;s timeslice and removing it from or restoring it to the runlist, are resource-manager calls that need kernel privilege. A container can&#39;t issue them. A trusted host component can.&lt;/p&gt;
&lt;p&gt;We tried them at kernel privilege with the open driver, and for several days every one appeared to fail. The timeslice control returned &lt;code&gt;NV_OK&lt;/code&gt;, and a 16 : 1 setting produced 1 : 1. Detaching a running tenant&#39;s channels did nothing. The spatial partition controls returned &lt;code&gt;0x57&lt;/code&gt;, which we read as &amp;quot;not supported.&amp;quot; We wrote a careful, detailed conclusion: consumer Blackwell honours none of these, and compute isolation for arbitrary CUDA needs MIG-capable datacenter silicon.&lt;/p&gt;
&lt;p&gt;It was three mistakes of our own:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;A misread status code.&lt;/strong&gt; &lt;code&gt;0x56&lt;/code&gt; is &lt;code&gt;NV_ERR_NOT_SUPPORTED&lt;/code&gt;. &lt;code&gt;0x57&lt;/code&gt; is &lt;code&gt;NV_ERR_OBJECT_NOT_FOUND&lt;/code&gt;. The firmware hadn&#39;t refused the control; it couldn&#39;t find the object the control named.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The wrong call site.&lt;/strong&gt; We issued the controls from the constructor of the object they named, before it was registered with the firmware. From the deferred scheduling path, after registration, the identical control returns &lt;code&gt;NV_OK&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A missing commit.&lt;/strong&gt; GSP doesn&#39;t act on a staged runlist change until the runlist is restarted. &lt;code&gt;NVA06F_CTRL_CMD_RESTART_RUNLIST&lt;/code&gt; is that commit. Without it, both the timeslice change and the detach looked inert.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Each mistake alone produced a plausible negative that reproduced every time. Together, they turned three software bugs into a confident statement about hardware. With the restart added, the levers work, on the A100 first: detaching one of two running tenants handed the survivor the whole GPU (710 → 1551 matmul/s), reattaching restored the even split, and a 16 : 1 timeslice ratio shifted throughput 14 : 1.&lt;/p&gt;
&lt;h2 id=&quot;the-broker&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/nvidia-gpu-slicing/#the-broker&quot;&gt;&lt;span&gt;The broker&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The broker is a small set of hooks in the open kernel modules (in the channel-group and context-share code) that expose one root-only file:&lt;/p&gt;
&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;# echo poll &gt; /proc/driver/nvidia/gpusched     # refresh each tenant&#39;s GP_PUT
# cat /proc/driver/nvidia/gpusched             # (example output)
pid 41822 active 1 tsgs 3
dev 0000:07:00.0 pid 41822 active 1 tsgs 3
pid 41907 active 1 tsgs 12
dev 0000:07:00.0 pid 41907 active 1 tsgs 12
stats cmds_ok 1840 cmds_failed 0 restarts_ok 1840 restarts_teardown 2 restarts_failed 0 table_full 0

# echo &quot;ts 41822 4000 0000:07:00.0&quot;     &gt; /proc/driver/nvidia/gpusched
# echo &quot;detach 41907 0000:07:00.0&quot;      &gt; /proc/driver/nvidia/gpusched
# echo &quot;attach 41907 0000:07:00.0&quot;      &gt; /proc/driver/nvidia/gpusched&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;poll&lt;/code&gt; makes the driver re-read each channel&#39;s GPFIFO put pointer, and reading the file then reports, per tenant and per GPU, whether any of its channel groups advanced (a doorbell-aware activity signal the tenant can&#39;t forge) and how many channel groups it holds. &lt;code&gt;ts&lt;/code&gt; sets a tenant&#39;s timeslice, and &lt;code&gt;detach&lt;/code&gt; and &lt;code&gt;attach&lt;/code&gt; remove its channel groups from the runlist or restore them. The driver commits each with &lt;code&gt;RESTART_RUNLIST&lt;/code&gt;, from an RM work item that holds the GPU locks. The optional PCI address restricts a command to one GPU.&lt;/p&gt;
&lt;p&gt;Tenants are identified by host pid, and gVisor makes that unusually clean. The driver attributes a channel group to the process that opened the device, which for a sandbox is always its Sentry. So every process inside one sandbox, however many the tenant forks, shows up as &lt;strong&gt;one pid&lt;/strong&gt;. The scheduler never has to guess which processes belong together.&lt;/p&gt;
&lt;p&gt;The broker is mechanism only. It holds no policy, and it isn&#39;t reachable by tenants: it&#39;s root-only in the host&#39;s &lt;code&gt;/proc&lt;/code&gt;, and gVisor&#39;s synthetic &lt;code&gt;/proc&lt;/code&gt; doesn&#39;t expose it. We checked from inside a sandbox, because a sandboxed process often runs as root and could otherwise detach its neighbours. A tenant can still issue the unprivileged timeslice control on its own channel groups through nvproxy, which can clamp it, but it can&#39;t issue the privileged restart that would make the change take effect.&lt;/p&gt;
&lt;h2 id=&quot;the-policy%3A-runsc-gpu-scheduler&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/nvidia-gpu-slicing/#the-policy%3A-runsc-gpu-scheduler&quot;&gt;&lt;span&gt;The policy: &lt;code&gt;runsc gpu-scheduler&lt;/code&gt;&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The scheduler runs one per node and keeps one planner per GPU. It learns about sandboxes in two ways. When &lt;code&gt;runsc&lt;/code&gt; starts a GPU sandbox, it connects to the scheduler and hands the open socket to the Sentry, because the Sentry&#39;s seccomp filter forbids it to &lt;code&gt;connect()&lt;/code&gt; to anything. That connection carries the sandbox&#39;s weight. Separately, &lt;code&gt;runsc&lt;/code&gt; announces the sandbox&#39;s host pid once the Sentry process exists, which is the first moment the pid is known.&lt;/p&gt;
&lt;p&gt;Getting from a working mechanism to an automatic one took three more fixes, each hiding the next:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The pid announcement could be lost.&lt;/strong&gt; It was a single message with no retry, and the scheduler forgot it if the weight connection dropped. It now retries, and the pid is kept as a property of the sandbox.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The plan has to be re-asserted.&lt;/strong&gt; A plan written once, a few seconds into a pod&#39;s life, landed before the tenant had created its cuBLAS channel groups, so the real ones appeared later at the driver default. The scheduler now re-asserts the full plan every ~2 s, binding any channel group created since.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The gate has to stand down.&lt;/strong&gt; If the Sentry&#39;s gate revokes a sandbox&#39;s mappings, the sandbox stops submitting, its GPFIFO drains, and the driver reports it idle, which disturbs the division. With the broker running, every sandbox gets a full-period window, so the gate never revokes and the runlist alone does the dividing.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The first policy that worked set each tenant&#39;s timeslice proportional to its weight. On an A100, fully automatic, two cuBLAS pods weighted 3 : 1 divided &lt;strong&gt;1067 : 344 (3.10 : 1)&lt;/strong&gt; at the same aggregate throughput as no enforcement. For a day, we thought we were done.&lt;/p&gt;
&lt;h2 id=&quot;the-packing-attack&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/nvidia-gpu-slicing/#the-packing-attack&quot;&gt;&lt;span&gt;The packing attack&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The red team broke it by forking. A timeslice binds a &lt;em&gt;channel group&lt;/em&gt;, and a tenant owns as many as it creates: each CUDA context creates several, and a tenant that forks four processes creates four times as many. With the share encoded as a per-group timeslice, a tenant&#39;s share tracked (channel groups × timeslice). On an A6000, a tenant granted a quarter of the GPU ran four processes in its own pod and took more than a neighbour weighted three times higher: &lt;strong&gt;0.78 : 1&lt;/strong&gt; against the requested 1 : 3. Memory and containment held; the share didn&#39;t.&lt;/p&gt;
&lt;p&gt;The obvious fix, dividing a tenant&#39;s timeslice among its channel groups, restored the ratio but cost &lt;strong&gt;42 %&lt;/strong&gt; of aggregate throughput, because smaller timeslices multiply context switches.&lt;/p&gt;
&lt;p&gt;The fix that worked is credit accounting per tenant, the scheme described in the GVM paper (&amp;quot;OS-Level GPU Virtualization&amp;quot;, Berkeley/UCLA). It is in &lt;a href=&quot;https://github.com/pvnis/gvisor/blob/gpuslicing/pkg/gpusched/credit.go&quot;&gt;&lt;code&gt;pkg/gpusched/credit.go&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Every channel group gets the same large quantum&lt;/strong&gt;, 4 ms. The quantum only sets how often the GPU switches, not who gets what.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Weight sets how fast a tenant accrues credit.&lt;/strong&gt; Each tick, the time a tenant consumed is charged back against its credit.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The charge is by channel-group share&lt;/strong&gt;, read from the broker&#39;s &lt;code&gt;tsgs&lt;/code&gt; field. A tenant holding &lt;em&gt;n&lt;/em&gt; of the &lt;em&gt;N&lt;/em&gt; attached groups really does take &lt;em&gt;n/N&lt;/em&gt; of the device, so that&#39;s what it&#39;s charged. Charging every tenant the same would under-charge exactly the attacker this exists to stop.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A tenant that overdraws is detached, all its groups together&lt;/strong&gt;, and reattached when it recovers. Forking can&#39;t help: fifteen channel groups drain one credit pool fifteen times faster.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Two details turned out to be load-bearing. A &lt;strong&gt;dead band&lt;/strong&gt; is required: detaching at zero credit makes tenants that are getting exactly their share flap on and off every tick. And a &lt;strong&gt;detached tenant still counts as wanting to run&lt;/strong&gt;. Once detached, its GPFIFO stops advancing and the driver reports it idle forever, so the scheduler, not the activity signal, has to remember it.&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot;&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th style=&quot;text-align:right&quot;&gt;Weight 75 tenant&lt;/th&gt;
&lt;th style=&quot;text-align:right&quot;&gt;Weight 25 tenant&lt;/th&gt;
&lt;th style=&quot;text-align:right&quot;&gt;Ratio&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A6000, timeslice scheme, honest&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;126.8&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;40.7&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;3.12 : 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A6000, timeslice scheme, weight-25 tenant forks 4&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;70.5&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;90.9&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;&lt;strong&gt;0.78 : 1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A6000, credit, honest&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;100.9&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;35.3&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;2.86 : 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A6000, credit, weight-25 tenant forks 4&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;93.9&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;41.4&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;&lt;strong&gt;2.27 : 1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RTX 5070, credit, honest&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;131.4&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;40.0&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;3.29 : 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RTX 5070, credit, weight-25 tenant forks 4 (3 → 12 TSGs)&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;125.7&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;45.7&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;&lt;strong&gt;2.75 : 1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p&gt;All figures are cuBLAS SGEMM matmul/s, counted as completed work over the run window. The packer keeps a small edge, 31 % of the A6000 and 27 % of the 5070 against a granted 25 %, so this is blunted rather than perfect. The edge moves &lt;em&gt;inversely&lt;/em&gt; to the attacker&#39;s channel-group advantage, which tells us it isn&#39;t under-charging. It tracks how much spare capacity the device has, which brings us to cost.&lt;/p&gt;
&lt;p&gt;The same result held with real tenants. Four tenants, each in its own vCluster on one RTX 5070, three serving vLLM (Qwen2.5-0.5B) under load at weights 100 / 50 / 25 and one adversary: the honest split was 4.25 : 2.12 : 1 against a nominal 4 : 2 : 1, and when the adversary went from one process to four (3 → 12 channel groups), its throughput went from 31 to 27 matmul/s.&lt;/p&gt;
&lt;h2 id=&quot;what-enforcement-costs&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/nvidia-gpu-slicing/#what-enforcement-costs&quot;&gt;&lt;span&gt;What enforcement costs&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Enforcement has no fixed cost. What it can give up is an overlap bonus, which only exists when the GPU has slack.&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot;&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th style=&quot;text-align:right&quot;&gt;RTX 5070&lt;/th&gt;
&lt;th style=&quot;text-align:right&quot;&gt;RTX A6000&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;one tenant, no scheduler&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;171.4&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;139.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;one tenant, credit scheduler&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;173.3 (no measurable cost)&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;139.3 (99.9 %)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;two tenants, no enforcement&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;—&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;167.5 (120 % of solo)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;two tenants, credit scheduler&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;171.4 (= solo)&lt;/td&gt;
&lt;td style=&quot;text-align:right&quot;&gt;136.2 (98 % of solo)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p&gt;A lone tenant pays nothing. On the 5070, one cuBLAS stream already saturates the card, so two enforced tenants together get exactly what one gets alone. On the A6000, one stream doesn&#39;t saturate it, so two &lt;em&gt;unenforced&lt;/em&gt; tenants interleave and fill each other&#39;s gaps, beating a lone tenant by 20 %. Enforcing a share serialises them and gives that bonus up. We first reported the A6000 figure as 20 % scheduler overhead, which it isn&#39;t. Measuring the single-tenant baseline, the run everyone skips, is what told the two apart.&lt;/p&gt;
&lt;h2 id=&quot;in-kubernetes&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/nvidia-gpu-slicing/#in-kubernetes&quot;&gt;&lt;span&gt;In Kubernetes&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;None of this helps in a cluster unless something turns what a pod &lt;em&gt;asks for&lt;/em&gt; into what &lt;code&gt;runsc&lt;/code&gt; &lt;em&gt;enforces&lt;/em&gt;. HAMi&#39;s scheduler, device plugin and webhook run unchanged: they place pods and keep the books. Its limiter is no longer preloaded into containers. An in-tree mutating webhook restates each pod&#39;s admitted request as Sentry flags: &lt;code&gt;nvidia.com/gpumem&lt;/code&gt; becomes a memory limit (per device, for multi-GPU pods), and &lt;code&gt;nvidia.com/gpucores&lt;/code&gt; becomes a scheduler weight.&lt;/p&gt;
&lt;p&gt;Two properties matter when the pod spec is written by the tenant:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Narrow-only.&lt;/strong&gt; The value configured on the runtime is a ceiling that a pod annotation can lower but never raise, and the webhook overwrites any self-written annotation above what the pod requested. A tenant who requested 1024 MiB and annotated itself 40 GiB saw 1024 MiB. On the B300, a tenant that asked for the host&#39;s runc runtime and annotated 256 GiB at maximum weight ran under gVisor at the 20000 MiB and weight 20 it had requested.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fail closed.&lt;/strong&gt; The webhook runs with &lt;code&gt;failurePolicy: Fail&lt;/code&gt;, and a GPU sandbox that can&#39;t reach the scheduler doesn&#39;t start. An unmutated or unscheduled pod would run at the whole-device ceiling, so failing open would be a quota escape.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We run each tenant in its own vCluster, as cluster-admin of a virtual cluster with every control applied on the host side; the runtime class is forced to gVisor whatever the pod asks for. That layer is a separate post.&lt;/p&gt;
&lt;h2 id=&quot;eight-b300s&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/nvidia-gpu-slicing/#eight-b300s&quot;&gt;&lt;span&gt;Eight B300s&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The B300 node (eight GPUs on NVSwitch) was where we met multi-GPU, and its bugs were the most instructive in the project, because three of them failed &lt;em&gt;open&lt;/em&gt;, silently.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The broker&#39;s table never freed entries.&lt;/strong&gt; Every channel group took a slot, and a slot was never released. An 8-GPU NCCL pod takes about 110, so after ten such sandboxes the table was full, new tenants weren&#39;t tracked, and a 75 / 25 pair split &lt;strong&gt;645 : 644&lt;/strong&gt; with nothing logged.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Every broker command went to one GPU&lt;/strong&gt;, whichever had last registered a group. 147 of 168 timeslice calls on an 8-GPU pod failed with &lt;code&gt;0x57&lt;/code&gt;, and a tenant opening a context on one GPU could take enforcement away from a pair on another.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One credit pool served the whole node&lt;/strong&gt;, so sandboxes on different GPUs competed for credit. A weight-25 pod alone on its GPU got 894 TFLOPS instead of ~1330.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All three are fixed: slots are freed at channel-group teardown, each group carries its own GPU, and the scheduler keeps one planner per GPU. That left the obvious follow-up attack: fill the table with idle contexts, and new work goes untracked. A weight-25 tenant took &lt;strong&gt;814 TFLOPS&lt;/strong&gt; against its weight-75 neighbour&#39;s 371 that way. Now a channel group is recorded before the firmware enables it, a group that can&#39;t be recorded is refused once a scheduler is running, and one tenant can hold at most a quarter of the table.&lt;/p&gt;
&lt;p&gt;With that in place, from inside a gVisor sandbox:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot;&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;8-GPU pod&lt;/td&gt;
&lt;td&gt;10.86 PFLOPS, ~718 GiB/s to every peer, NCCL all-reduce 696 GB/s bus bandwidth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4-GPU pod, from a tenant&lt;/td&gt;
&lt;td&gt;all-pairs P2P, ~715 GiB/s NVLink copy, NCCL 624 GB/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;75 / 25 on one GPU, cuBLAS, from a tenant&lt;/td&gt;
&lt;td&gt;2.80 : 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;weight-25 pod alone on its GPU, beside a 75 / 25 pair&lt;/td&gt;
&lt;td&gt;1332 TFLOPS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;weight-25 pod on two GPUs, one shared with a weight-75 pod&lt;/td&gt;
&lt;td&gt;1313 TFLOPS on its own GPU, 329 : 1022 on the shared one&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p&gt;&lt;code&gt;nvidia-smi topo -m&lt;/code&gt; also needed fixing: NVML places each GPU by walking &lt;code&gt;/sys/bus/pci/devices&lt;/code&gt;, which a sandbox didn&#39;t have. gVisor now gives nvproxy sandboxes a read-only snapshot of the GPUs&#39; PCI topology, so NCCL sees the NVLink fabric.&lt;/p&gt;
&lt;h2 id=&quot;what-doesn&#39;t-work-yet&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/nvidia-gpu-slicing/#what-doesn&#39;t-work-yet&quot;&gt;&lt;span&gt;What doesn&#39;t work yet&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A patched driver.&lt;/strong&gt; The broker is host kernel code in our fork of the open modules. Memory quotas work on a stock driver; compute shares don&#39;t.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The scheduler is a single point of failure.&lt;/strong&gt; It fails closed, on purpose: with it down, GPU pods don&#39;t start.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Long kernels overshoot.&lt;/strong&gt; A GPU can&#39;t be made to abandon work already submitted. A kernel longer than its turn runs to completion, and credit charge-back recovers the time afterwards instead of preventing it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The packing edge is blunted, not gone&lt;/strong&gt;, at 2 to 6 points above the granted share in our measurements.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Spatial partitioning is a ceiling, not concurrency.&lt;/strong&gt; The TPC partition control works once issued from the right place (13, 18 and 24 of the 5070&#39;s 24 TPCs give 272, 372 and 458 matmul/s), but two tenants on disjoint halves perform the same as two on the same half, because separate contexts still take turns on the engine.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Memory accounting gaps.&lt;/strong&gt; Unified memory is charged at reservation, and fabric memory and EGM aren&#39;t accounted yet.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Telemetry.&lt;/strong&gt; A sandbox can still read the GPU&#39;s live power draw, a low-bandwidth signal of a neighbour&#39;s activity.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No checkpoint/restore&lt;/strong&gt; of a CUDA sandbox: several object types every CUDA context creates don&#39;t implement restore in nvproxy.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Each GPU gets measured.&lt;/strong&gt; The runlist path is confirmed on GB205 (RTX 5070), GA102 (RTX A6000), GA100 (A100) and the B300. We no longer predict a new die from its class; that is the mistake that cost us the most.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;what-we&#39;d-tell-ourselves-two-months-ago&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/nvidia-gpu-slicing/#what-we&#39;d-tell-ourselves-two-months-ago&quot;&gt;&lt;span&gt;What we&#39;d tell ourselves two months ago&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Work submitted to a GPU can&#39;t be intercepted, and we treated that as a limit on compute isolation. It&#39;s a limit on &lt;em&gt;interception&lt;/em&gt;. The GPU will divide its own time between tenants if you ask it correctly, and &amp;quot;correctly&amp;quot; was the entire difficulty: the right control, on a live object, followed by the call that makes the firmware act. For several days, three small mistakes about &lt;em&gt;how to ask&lt;/em&gt; looked exactly like a fact about &lt;em&gt;what&#39;s possible&lt;/em&gt;. What settled it wasn&#39;t a better argument. It was reading one status code correctly and moving one call to a later point in the driver.&lt;/p&gt;
&lt;p&gt;The other lesson is to test like the tenant is lying to you. Graph replay, process packing and a silently full table were each invisible to a benchmark that behaved itself, and each was found by someone trying to cheat.&lt;/p&gt;
&lt;p&gt;The design overview is &lt;a href=&quot;https://github.com/pvnis/gvisor/blob/gpuslicing/GPU-ISOLATION.md&quot;&gt;&lt;code&gt;GPU-ISOLATION.md&lt;/code&gt;&lt;/a&gt;, the driver side is &lt;a href=&quot;https://github.com/pvnis/open-gpu-kernel-modules/blob/gpuslicing/DRIVER-CHANGES.md&quot;&gt;&lt;code&gt;DRIVER-CHANGES.md&lt;/code&gt;&lt;/a&gt;, and the red-team record is &lt;a href=&quot;https://github.com/pvnis/gvisor/blob/gpuslicing/SECURITY-FINDINGS.md&quot;&gt;&lt;code&gt;SECURITY-FINDINGS.md&lt;/code&gt;&lt;/a&gt;. AMD turned out to be a mirror image, with both kinds of division reachable from the Sentry on a stock driver. That&#39;s another post.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Conformant Is Not Performant: What OpenFlow Can Teach O-RAN</title>
    <link href="https://nextghq.github.io/blog/what-openflow-can-teach-o-ran/" />
    <updated>2026-10-29T00:00:00Z</updated>
    <id>https://nextghq.github.io/blog/what-openflow-can-teach-o-ran/</id>
    <content type="html">&lt;p&gt;If you worked in networking around 2010, you remember the excitement around
OpenFlow. Pull the control plane out of the switch, put it on a server, speak
a standard protocol between the two, and the network becomes software. Vendor
lock-in would end. Researchers and operators would invent new things without
waiting on a vendor&#39;s release schedule.&lt;/p&gt;
&lt;p&gt;Some of that came true. Most of it didn&#39;t happen the way anyone expected.&lt;/p&gt;
&lt;p&gt;If you work in wireless today, the story of &lt;strong&gt;O-RAN&lt;/strong&gt; should sound familiar.
Open the interfaces between the pieces of the radio access network. Let
operators mix and match vendors. Add a RAN Intelligent Controller (RIC) so
third parties can write apps that tune the network. It&#39;s the same promise,
and it&#39;s running into some of the same walls.&lt;/p&gt;
&lt;p&gt;We spent more than a decade in data center SDN before turning to 5G, so we
have watched one industry go through this already. This post compares the two
journeys: what each set out to do, where each got stuck, and what the
eventual success in data center networking does and doesn&#39;t tell us about
where wireless should go next.&lt;/p&gt;
&lt;h2 id=&quot;same-goal%2C-different-decade&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/what-openflow-can-teach-o-ran/#same-goal%2C-different-decade&quot;&gt;&lt;span&gt;Same goal, different decade&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;OpenFlow and O-RAN were both reactions to the same problem: a vertically
integrated box whose internals the buyer couldn&#39;t see or change. In the data
center it was the switch: ASIC, operating system, and protocols from one
vendor. In wireless it&#39;s the base station: radio, baseband, and software
from one vendor.&lt;/p&gt;
&lt;p&gt;Both proposed the same cure: define open interfaces between the pieces, and
let an ecosystem grow around them. OpenFlow separated the control plane from
the forwarding plane and defined a protocol between them. O-RAN split the
RAN into a radio unit (RU), distributed unit (DU), and central unit (CU),
defined interfaces between them, and added a RIC with its own interfaces for
third-party applications.&lt;/p&gt;
&lt;p&gt;Both changed what buyers believed was possible. Both also standardized
things that turned out to be much harder to make interoperable in practice
than on paper.&lt;/p&gt;
&lt;h2 id=&quot;what-happened-to-openflow&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/what-openflow-can-teach-o-ran/#what-happened-to-openflow&quot;&gt;&lt;span&gt;What happened to OpenFlow&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;OpenFlow&#39;s core bet was that a switch could be reduced to a generic
match-action table, programmed by a controller over a standard protocol.&lt;/p&gt;
&lt;p&gt;The problem wasn&#39;t the protocol. It was the gap between what the
specification allowed and what implementations actually did. Real switching
ASICs have fixed pipelines with many tables of different sizes and
capabilities. OpenFlow 1.0 assumed one flexible table; later versions added
multiple tables, but couldn&#39;t make every ASIC look the same. Much of the
spec was optional. Vendors shipped switches that were OpenFlow-conformant but
supported a different subset of matches and actions, had flow tables a
fraction of the size you needed, or quietly sent some rules to a slow
software path. OpenFlow 1.3 became the version everyone used; later versions
added features few switches implemented. Efforts like Table Type Patterns
and Broadcom&#39;s OF-DPA tried to describe what a given switch could actually
do, which was an admission that &amp;quot;supports OpenFlow&amp;quot; didn&#39;t mean much on its
own.&lt;/p&gt;
&lt;p&gt;So for a controller application to work, you had to find the intersection of
what your controller used and what your particular switch got right. That
intersection was often small.&lt;/p&gt;
&lt;p&gt;Where OpenFlow did work, one party controlled both ends. Google&#39;s B4 WAN used
it successfully, with switches and controllers Google built itself. Open
vSwitch, a software switch, made OpenFlow the basis of network
virtualization. In neither case was there a multi-vendor interoperability
problem to solve.&lt;/p&gt;
&lt;p&gt;The hyperscalers ended up getting programmability a different way: BGP in
the data center, running on switches whose software they controlled. That
brings us to SONiC. But first, O-RAN.&lt;/p&gt;
&lt;h2 id=&quot;o-ran&#39;s-report-card&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/what-openflow-can-teach-o-ran/#o-ran&#39;s-report-card&quot;&gt;&lt;span&gt;O-RAN&#39;s report card&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;h3 id=&quot;open-fronthaul%3A-real-success%2C-real-growing-pains&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/what-openflow-can-teach-o-ran/#open-fronthaul%3A-real-success%2C-real-growing-pains&quot;&gt;&lt;span&gt;Open fronthaul: real success, real growing pains&lt;/span&gt;&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;The O-RAN open fronthaul (OFH) interface between the DU and the radio unit,
the 7.2x split, is the clearest success of the O-RAN program. Before it, the
link between baseband and radio was a vendor-specific variant of CPRI, and
you bought both ends from the same vendor. Today there&#39;s a genuine market of
O-RUs from many suppliers, and open-source DUs can drive them. That&#39;s a
structural change.&lt;/p&gt;
&lt;p&gt;But it isn&#39;t plug-and-play yet. OFH leaves a lot of room for choices:
compression schemes, section types and extensions, beamforming methods,
timing windows, synchronization configurations. Two products can each pass
conformance testing and still not work well together, or work but deliver a
fraction of the expected throughput. As a wise colleague in the OCUDU
community puts it, &lt;strong&gt;&amp;quot;conformant is not necessarily performant.&amp;quot;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;That is exactly the OpenFlow experience. A switch that implements the spec
correctly but has a 2,000-entry flow table is conformant. An O-RU that
accepts every message correctly but needs hand-tuned timing offsets and one
particular compression mode to reach line rate is conformant, too. Neither
is what you were hoping to buy.&lt;/p&gt;
&lt;p&gt;OFH is getting better, through interoperability profiles, plugfests, and
hard-won integration experience. It&#39;s improving because the interface is
narrow and the incentive is strong: nobody can sell an O-RU that doesn&#39;t
work with the DUs people are actually deploying.&lt;/p&gt;
&lt;h3 id=&quot;e2-and-the-ric%3A-the-openflow-of-o-ran&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/what-openflow-can-teach-o-ran/#e2-and-the-ric%3A-the-openflow-of-o-ran&quot;&gt;&lt;span&gt;E2 and the RIC: the OpenFlow of O-RAN&lt;/span&gt;&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;The near-real-time RIC is a great idea. Exposing RAN telemetry and control to
third-party applications is how wireless gets the same innovation curve that
data center networking got from programmability. Traffic steering, slicing,
energy savings, interference management, mobility optimization: there&#39;s a
long list of valuable applications that fit comfortably within the RIC&#39;s
10-millisecond-to-1-second control loop.&lt;/p&gt;
&lt;p&gt;The interface that makes it work, &lt;strong&gt;E2&lt;/strong&gt;, is the most unevenly supported
interface in O-RAN, and for the same reason OpenFlow struggled. E2 itself
(E2AP) is a fairly thin transport. The meaning lives in the &lt;em&gt;service models&lt;/em&gt;:
E2SM-KPM for performance metrics, E2SM-RC for RAN control, and others. Those
service models are where implementations diverge:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;KPM is fairly widely implemented&lt;/strong&gt;, but which metrics a given RAN
actually reports, and at what granularity, varies.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RC is implemented partially&lt;/strong&gt;, with each RAN supporting a different
subset of control styles and actions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Other service models are rare&lt;/strong&gt; outside specific vendor and research
combinations.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So &amp;quot;my RAN supports E2&amp;quot; is a lot like &amp;quot;my switch supports OpenFlow.&amp;quot; It tells
you the protocol will connect. It doesn&#39;t tell you whether the xApp you want
to run will work. An xApp developer is back to finding the intersection of
what their app needs and what a particular RAN implements, and that
intersection is often small.&lt;/p&gt;
&lt;p&gt;The cost of implementing service models properly is a big part of why.
They&#39;re defined in ASN.1, versioned independently, and tied to large
specifications. Adding a new metric or control means writing spec-compliant
ASN.1, generating code for it, and mapping it carefully into the RAN&#39;s
internals. That&#39;s a lot of work for a RAN developer whose users aren&#39;t asking
for it yet, and it&#39;s a chicken-and-egg problem: there are few xApps because
support is spotty, and support is spotty because there are few xApps.&lt;/p&gt;
&lt;h3 id=&quot;the-tell%3A-everyone-builds-around-e2&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/what-openflow-can-teach-o-ran/#the-tell%3A-everyone-builds-around-e2&quot;&gt;&lt;span&gt;The tell: everyone builds around E2&lt;/span&gt;&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Here&#39;s the clearest sign: people who want to do real work with a RAN often
skip E2 entirely. Project after project has added its own interface directly
into the DU: ZeroMQ sockets, WebSocket APIs, shared memory queues, custom gRPC
services. It is simply easier to add the hook you need, in the format you
want, than to define and implement a conformant service model.&lt;/p&gt;
&lt;p&gt;Some of this work also targets applications that really do need to run
faster than E2 allows, like per-slot scheduling decisions or processing I/Q
samples and channel estimates that are too large to ship across a network.
Microsoft Research&#39;s Janus work (now the open-source jbpf framework) loads
small, verified eBPF &amp;quot;codelets&amp;quot; directly into the RAN&#39;s hot path.
Northeastern University&#39;s dApps work defines an &lt;strong&gt;E3&lt;/strong&gt; interface for
applications that run right next to the DU. These are valuable additions.
But the main reason people go around E2 isn&#39;t timing. It&#39;s the cost of
conformance.&lt;/p&gt;
&lt;p&gt;We&#39;ve seen this before, too. In the SDN era, operators who wanted
programmability without OpenFlow&#39;s pain used NETCONF, OVSDB, BGP with custom
route injection, or simply ran their own agents on the switch. The demand
for a programmable network was real. It just found a different path.&lt;/p&gt;
&lt;h2 id=&quot;why-sonic-succeeded-where-openflow-didn&#39;t&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/what-openflow-can-teach-o-ran/#why-sonic-succeeded-where-openflow-didn&#39;t&quot;&gt;&lt;span&gt;Why SONiC succeeded where OpenFlow didn&#39;t&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;OpenFlow didn&#39;t deliver an open data center network. &lt;strong&gt;SONiC&lt;/strong&gt; did, along with
the hardware ecosystem around it. Microsoft built SONiC for Azure,
open-sourced it in 2016, and moved it to the Linux Foundation in 2022. It now
runs on switches from many vendors and ASIC families, on millions of ports.
A few choices made the difference.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It standardized the inside of the box, not the wire.&lt;/strong&gt; Instead of a remote
protocol every switch had to speak, SONiC relied on the &lt;strong&gt;Switch Abstraction
Interface (SAI)&lt;/strong&gt;, a C API defined at the Open Compute Project that each ASIC
vendor implements once for its chip. The interoperability boundary became a
local driver API, where chip differences are easier to handle.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It reused protocols that already worked.&lt;/strong&gt; SONiC didn&#39;t replace BGP with a
controller. It ran BGP (through Quagga, later FRR) on the switch.
Centralized intelligence came in through automation and policy that
configured those protocols, not by replacing them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The code came first.&lt;/strong&gt; SONiC was a working production system before it was
a community standard. &amp;quot;It runs SONiC&amp;quot; became the practical definition of
interoperability. Features were added when a real operator needed them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It had an anchor customer running it in production.&lt;/strong&gt; Azure ran SONiC at
scale. That gave vendors a reason to write SAI drivers and gave the code a
level of hardening no lab could.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Its components share a common state store.&lt;/strong&gt; Inside SONiC, every component,
from the BGP daemon to the process that programs the ASIC, communicates
through a set of Redis databases: configuration, application state,
ASIC state, and operational state. Components don&#39;t call each other; they
write the state they own and subscribe to the state they need. Adding a new
feature means adding a container that reads and writes those tables. This is
the officially sanctioned version of the &amp;quot;hack your own interface&amp;quot; that
people are doing to the DU today.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;ONIE separated buying hardware from choosing software.&lt;/strong&gt; The Open Network
Install Environment, contributed by Cumulus Networks to OCP, gave bare-metal
switches a standard way to boot and install a network operating system.&lt;/p&gt;
&lt;p&gt;The result is that today you can buy a white-box switch, install SONiC, run
real routing protocols on it, and add your own software alongside them.
That&#39;s what OpenFlow promised. It just arrived by a different route.&lt;/p&gt;
&lt;h2 id=&quot;where-the-analogy-holds%2C-and-where-it-breaks&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/what-openflow-can-teach-o-ran/#where-the-analogy-holds%2C-and-where-it-breaks&quot;&gt;&lt;span&gt;Where the analogy holds, and where it breaks&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;It&#39;s tempting to map SONiC&#39;s pieces one-to-one onto wireless. Some of them
map well. Some don&#39;t, and the places where they don&#39;t are the most
interesting part.&lt;/p&gt;
&lt;h3 id=&quot;ocudu-is-in-sonic&#39;s-position&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/what-openflow-can-teach-o-ran/#ocudu-is-in-sonic&#39;s-position&quot;&gt;&lt;span&gt;OCUDU is in SONiC&#39;s position&lt;/span&gt;&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;The strongest parallel is the software. &lt;strong&gt;OCUDU&lt;/strong&gt; is an open CU/DU stack with
government backing, hosted at the Linux Foundation, with operators,
chipmakers, and equipment vendors as members. If it becomes the stack that
real deployments run, &amp;quot;it runs OCUDU&amp;quot; can become the practical definition of
interoperability, the way &amp;quot;it runs SONiC&amp;quot; did. That&#39;s the code-first path,
and it&#39;s the one that worked.&lt;/p&gt;
&lt;h3 id=&quot;there&#39;s-no-single-sai%2C-because-the-phy-is-too-big&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/what-openflow-can-teach-o-ran/#there&#39;s-no-single-sai%2C-because-the-phy-is-too-big&quot;&gt;&lt;span&gt;There&#39;s no single SAI, because the PHY is too big&lt;/span&gt;&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;SAI worked because a switching ASIC is a well-bounded thing. It does packet
forwarding at line rate, in fixed-function hardware, and the operating system
programs it through tables. The boundary between &amp;quot;commodity silicon&amp;quot; and
&amp;quot;software&amp;quot; is obvious.&lt;/p&gt;
&lt;p&gt;The RAN&#39;s physical layer is a different animal. It&#39;s the bulk of the compute,
it&#39;s algorithmically rich, and the quality of the receiver is where a lot of
real differentiation lives. And OCUDU implements the PHY itself, in software,
with accelerators where they help. So the obvious candidate for &amp;quot;the SAI of
wireless,&amp;quot; the Small Cell Forum&#39;s FAPI interface between the PHY and the
layer above it, doesn&#39;t fit. FAPI is the right boundary for merchant-PHY
silicon that ships a complete PHY, but in an OCUDU deployment the PHY is
part of the open stack, not under it.&lt;/p&gt;
&lt;p&gt;If the PHY is in software, where is the hardware boundary? There are really
three:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Lookaside accelerators&lt;/strong&gt; for functions like LDPC decoding, reached through
driver frameworks like DPDK&#39;s bbdev and the O-RAN acceleration abstraction
layer. This looks less like SAI and more like the driver model of a
software router running on servers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The fronthaul to the radio unit.&lt;/strong&gt; With the 7.2x split, the RU does the
low PHY (FFT, beamforming, digital front end), and the DU programs it every
slot through control-plane messages. With split 8, the RU is essentially
converters and RF, and the DU does everything else.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The compute platform itself&lt;/strong&gt;: CPUs, GPUs, and the timing hardware that
keeps it all synchronized.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Of these, the radio unit is the closest thing to a switching ASIC: fixed
function, line rate, built on vendor silicon. That makes the RU interface the
truest analogue of SAI. In fact, OCUDU already has an internal radio-unit
abstraction with two kinds of backends: open fronthaul for 7.2x O-RUs, and
SDR drivers for split-8 radios. That internal abstraction is arguably the
SAI of the open RAN, with 7.2x and split 8 as its &amp;quot;drivers.&amp;quot;&lt;/p&gt;
&lt;p&gt;But the analogy breaks in an important way. SAI is a local API whose driver
is written by the silicon vendor. The 7.2x boundary is a wire protocol with
tight timing, and on the other side is RU firmware the operator can&#39;t see or
change. That&#39;s why &amp;quot;conformant is not performant&amp;quot; bites so hard: you can&#39;t
fix or even inspect the other half.&lt;/p&gt;
&lt;h3 id=&quot;the-biggest-gap%3A-the-radio-unit-is-still-mostly-closed&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/what-openflow-can-teach-o-ran/#the-biggest-gap%3A-the-radio-unit-is-still-mostly-closed&quot;&gt;&lt;span&gt;The biggest gap: the radio unit is still mostly closed&lt;/span&gt;&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Data center networking succeeded with white-box switches because a merchant
silicon vendor shipped the ASIC, the SDK, and the SAI driver, and a
white-box vendor built the board around it. Anyone could then run SONiC.&lt;/p&gt;
&lt;p&gt;Radio units are only starting down that road. Take an RU built on the NXP LA12xx platform.
NXP provides the programmable baseband silicon and a low-level SDK, not a
complete PHY with a standard interface on top, open or closed. Building the
low PHY, the RU side of the 7.2x protocol, the management plane, and the
timing integration is left to the RU maker. We&#39;re not picking on NXP; this
is the state of the layer across the industry. The &amp;quot;driver&amp;quot; for an RU isn&#39;t a
thin shim. It&#39;s an entire low-PHY implementation.&lt;/p&gt;
&lt;p&gt;The most serious effort to change this has come, fittingly, from the Open
Compute Project, the same community that gave data center networking SAI and
ONIE. Meta&#39;s &lt;strong&gt;Evenstar&lt;/strong&gt; program built a modular reference design for a
radio unit, with Jabil manufacturing commercial units from it, and in 2023
Meta moved the work to OCP. The contribution included the hardware
specification and design files, DPD and CFR firmware, and test automation,
and Meta contributed its Open M-Plane management software to the Linux
Foundation. The design is built so that power amplifiers, filters, and even
the radio silicon can come from different suppliers. That&#39;s the white-box
idea, applied to radios.&lt;/p&gt;
&lt;p&gt;OCP has since folded Evenstar into a broader &lt;strong&gt;AI-Native Wireless Access&lt;/strong&gt;
effort. Two of its workstreams stand out. An AI-accelerated radio unit
continues the open RU work. And a &lt;strong&gt;Radio Abstraction Interface&lt;/strong&gt; aims to
define how third-party applications run on the radio unit itself. Even the
name echoes SAI, and it targets exactly the layer this post argues is
missing. It&#39;s early, with specifications still to come, but it&#39;s pointed in
the right direction.&lt;/p&gt;
&lt;p&gt;Still, wireless today has merchant silicon for radios, an open reference
design for the hardware, and no widely available open software stack for the
RU. Most O-RUs remain closed boxes at the bottom of an otherwise increasingly
open system. That&#39;s a bigger gap than anything at the top of the stack, and
we think it&#39;s where some of the most important work is.&lt;/p&gt;
&lt;h3 id=&quot;is-e3-the-state-bus%3F&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/what-openflow-can-teach-o-ran/#is-e3-the-state-bus%3F&quot;&gt;&lt;span&gt;Is E3 the state bus?&lt;/span&gt;&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;The other missing piece is SONiC&#39;s shared state store. The open DU has no
equivalent, which is why every project invents its own hook.&lt;/p&gt;
&lt;p&gt;The leading candidate is &lt;strong&gt;E3&lt;/strong&gt;, and it&#39;s moving fast. The O-RAN Alliance
published E3 in a research report (not yet a specification), authored by
Northeastern, SoftBank, and NVIDIA. DeepSig, one of OCUDU&#39;s leads, has
released a preview of a dApp platform for OCUDU with its own E3: an in-process
C interface that runs in microseconds, plus an E3 management protocol and a
data profile for streaming telemetry like PUSCH and SRS data.&lt;/p&gt;
&lt;p&gt;It&#39;s still early, and it&#39;s a real step forward. But is it a SONiC-style
state bus? Not yet, and maybe not by design. A few observations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;It&#39;s an application interface, not an internal integration fabric.&lt;/strong&gt;
SONiC&#39;s databases are how SONiC&#39;s own components talk to each other. E3 is
how outside applications talk to the DU. DeepSig&#39;s design is explicit that
dApps don&#39;t call each other. The model is subscribe, indicate, and control,
the same as E2.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It inherits E2&#39;s machinery.&lt;/strong&gt; The management protocol uses ASN.1 over
SCTP and follows E2&#39;s setup, subscription, and indication structure. That
makes bridging to E2 easy, but it brings along some of the same
implementation weight.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;There are already two E3s.&lt;/strong&gt; By DeepSig&#39;s own description, its E3 is not a
ratified O-RAN interface, and its wire format isn&#39;t byte-compatible with
the libe3 implementation from the research-report lineage. That&#39;s
understandable for a preview, but it&#39;s exactly how fragmentation starts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It lives in the reference implementation.&lt;/strong&gt; This is the big difference
from E2. If the dominant open DU ships E3, with versioned schemas and a
frozen ABI, then the service model gap may never open in the first place,
because there&#39;s one implementation everyone uses.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So E3 could go either way. If it becomes &amp;quot;the interface OCUDU ships,&amp;quot; with
one implementation and a well-defined state model behind it, it gets the
code-first advantage SONiC had. If it becomes a family of compatible-in-spirit
protocols implemented differently by each RAN, it will have E2&#39;s problem at
a faster timescale.&lt;/p&gt;
&lt;h2 id=&quot;what-we-expect&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/what-openflow-can-teach-o-ran/#what-we-expect&quot;&gt;&lt;span&gt;What we expect&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Here&#39;s what we expect over the next several years.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;E2 will survive, in a narrower role.&lt;/strong&gt; It will be used for telemetry and
slower policy, with KPM as the most widely used piece, much as OpenFlow
survives in software switches and specific deployments. It will matter most
where one vendor controls both the RAN and the RIC.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Real programmability will move toward the open DU itself.&lt;/strong&gt; Extension
points inside the open stack, like codelets and dApps, will become the main
way to add intelligence, because it&#39;s easier to build on one implementation
than to wait for many to converge on a spec.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The open stack will become the reference.&lt;/strong&gt; Interop results, test
harnesses, and deployment experience built around OCUDU will matter more
than paper conformance.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Open fronthaul will keep maturing&lt;/strong&gt; through profiles and testing, and
will become boring, which is the best thing that can happen to an
interface.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The RU will remain the hardest part to open,&lt;/strong&gt; until someone treats the
low PHY and RU software as a product category in its own right.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;what-we-should-push-for&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/what-openflow-can-teach-o-ran/#what-we-should-push-for&quot;&gt;&lt;span&gt;What we should push for&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Code-first interfaces.&lt;/strong&gt; An interface should be validated by a working open
implementation, with tests, before it&#39;s finalized. A service model that
doesn&#39;t exist in the open stack isn&#39;t ready.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One state model for the open DU.&lt;/strong&gt; Rather than each application interface
mapping separately into RAN internals, the DU should have one well-defined,
versioned model of its own state and controls, the way SONiC has its
databases. E2 and E3 can then be views onto that model, and much of each
service model could be generated rather than hand-written. That would make
conformant implementations far cheaper, and it would make E3 much less likely
to repeat E2&#39;s story.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Safe extension inside the node.&lt;/strong&gt; Code running in the hot path of critical
infrastructure must not be able to crash it or leak data from it. Sandboxing
and verification should be built in from the start, and the interfaces
need authentication before they leave localhost. This is where formal methods
earn their keep.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;An open RU.&lt;/strong&gt; Open low-PHY software and open RU-side fronthaul and
management, on open FPGA and SDR platforms as well as merchant baseband
silicon. Split 8 radios are a practical starting point, because the boundary
is simple and the low PHY can run in the open stack. OCP&#39;s open RU and Radio
Abstraction Interface work deserves support from everyone who wants an open
RAN, including the open-source CU/DU community. Until the RU&#39;s software is as
open as its hardware design, the bottom of the open RAN is still a closed
box.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Performance, not just conformance.&lt;/strong&gt; Plugfests and certification for open
fronthaul should publish performance results (throughput, latency, timing
margins) alongside pass/fail, using open test harnesses anyone can
reproduce. &amp;quot;Conformant and performant&amp;quot; should be the bar.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Anchor deployments in production.&lt;/strong&gt; SONiC succeeded because Azure ran it.
Open RAN needs real networks, with real users, running the open stack.
Government programs are in a unique position to provide that, and to require
openness all the way down as a condition.&lt;/p&gt;
&lt;h2 id=&quot;the-lesson&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/what-openflow-can-teach-o-ran/#the-lesson&quot;&gt;&lt;span&gt;The lesson&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;OpenFlow wasn&#39;t wrong about the goal. It was wrong to trust a specification to
deliver interoperability that only working code and real deployments could.
Data center networking got there in the end by standardizing the inside of
the box, reusing what worked, and letting an open implementation become the
standard.&lt;/p&gt;
&lt;p&gt;Wireless won&#39;t follow that path exactly. The PHY is bigger than a switching
ASIC, and the radio unit is still closed. But O-RAN has already done the
hardest part: it convinced an industry that the RAN should be open. Open
fronthaul shows it can work. With OCUDU, wireless now has its SONiC
candidate. What it needs next is an internal state model to build on, and an
open radio at the bottom of the stack. We&#39;d like to help build both.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>A tensor accelerator in PolarFire fabric, 81× over the board&#39;s own CPUs</title>
    <link href="https://nextghq.github.io/blog/vta-polarfire-soc/" />
    <updated>2026-10-30T00:00:00Z</updated>
    <id>https://nextghq.github.io/blog/vta-polarfire-soc/</id>
    <content type="html">&lt;p&gt;A Microchip PolarFire SoC Discovery Kit has four RISC-V application cores and a large block of FPGA fabric beside them. We put &lt;a href=&quot;https://tvm.apache.org/docs/topic/vta/index.html&quot;&gt;Apache TVM&#39;s VTA&lt;/a&gt; — an open, parameterised INT8 tensor accelerator — into that fabric, drove it from Linux on the MSS cores, and compiled real ResNet-18 convolution layers onto it through TVM.&lt;/p&gt;
&lt;p&gt;The result is worth the fabric it occupies: 49 GOPS across ten convolution layers, 84 % of the configuration&#39;s theoretical peak while the array is running, and 81× the throughput of the same layers on all four application cores. This post covers how it is put together, what it measures, and the one PolarFire-specific RTL requirement that a port like this depends on.&lt;/p&gt;
&lt;div class=&quot;stats&quot;&gt;
  &lt;div class=&quot;stat&quot;&gt;&lt;b&gt;49 GOPS&lt;/b&gt;&lt;span&gt;ten ResNet-18 conv layers, end to end through TVM&lt;/span&gt;&lt;/div&gt;
  &lt;div class=&quot;stat&quot;&gt;&lt;b&gt;81×&lt;/b&gt;&lt;span&gt;against the same layers on the board&#39;s four RISC-V cores&lt;/span&gt;&lt;/div&gt;
  &lt;div class=&quot;stat&quot;&gt;&lt;b&gt;84 %&lt;/b&gt;&lt;span&gt;of theoretical peak while the array is running&lt;/span&gt;&lt;/div&gt;
  &lt;div class=&quot;stat&quot;&gt;&lt;b&gt;91 %&lt;/b&gt;&lt;span&gt;of wall time spent inside the accelerator&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;h2 id=&quot;how-it-is-put-together&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/vta-polarfire-soc/#how-it-is-put-together&quot;&gt;&lt;span&gt;How it is put together&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;VTA sits in the fabric behind FIC0, the MSS&#39;s AXI port into the fabric, with its control registers on one interconnect and its DMA master on another. The configuration is a 1 × 16 × 16 INT8 array at 125 MHz: 256 multiply-accumulate units, two operations each, giving 64 GOPS of theoretical peak. Synthesis maps the array onto 128 of the MPFS095T&#39;s 292 MATH blocks, two multipliers per block in dual 9 × 9 mode, and the scratchpads onto LSRAM.&lt;/p&gt;
&lt;p&gt;The software side needs no kernel module. A userspace driver maps VTA&#39;s registers and a reserved, non-cached DRAM region through &lt;code&gt;/dev/mem&lt;/code&gt;. Using the non-cached alias is what keeps the CPU and the accelerator coherent without any cache maintenance: there is no cached view of those buffers to go stale, so the driver never has to flush or invalidate. The region is reserved &lt;code&gt;no-map&lt;/code&gt; in the device tree, so Linux never allocates from it.&lt;/p&gt;
&lt;p&gt;On top of that the ordinary TVM path works end to end: write a schedule, &lt;code&gt;vta.build&lt;/code&gt; it, cross-compile the host side for riscv64, ship it to the board over RPC, execute on the accelerator, and check the result against a NumPy reference. Two details are specific to this board. The RISC-V target needs both its ISA string and its ABI pinned — the distribution&#39;s compiler defaults to an extension set whose vector code will not run on this part, and LLVM otherwise emits soft-float-ABI objects that the &lt;code&gt;lp64d&lt;/code&gt; linker refuses to merge with the rootfs libraries. And modules are exported as cross-compiled shared libraries rather than object files for the RPC server to link, which it cannot do here.&lt;/p&gt;
&lt;h2 id=&quot;what-it-measures&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/vta-polarfire-soc/#what-it-measures&quot;&gt;&lt;span&gt;What it measures&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Every convolution layer of ResNet-18, each compiled twice from the same TVM source — once for the accelerator, once for the board&#39;s own CPUs:&lt;/p&gt;
&lt;div class=&quot;table-wrap&quot;&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Shape&lt;/th&gt;
&lt;th&gt;CPU ms&lt;/th&gt;
&lt;th&gt;VTA ms&lt;/th&gt;
&lt;th&gt;VTA GOPS&lt;/th&gt;
&lt;th&gt;Speed-up&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;C2&lt;/td&gt;
&lt;td&gt;56² 64→64 3×3&lt;/td&gt;
&lt;td&gt;286.5&lt;/td&gt;
&lt;td&gt;4.75&lt;/td&gt;
&lt;td&gt;48.67&lt;/td&gt;
&lt;td&gt;60×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C3&lt;/td&gt;
&lt;td&gt;56² 64→128 3×3 /2&lt;/td&gt;
&lt;td&gt;153.3&lt;/td&gt;
&lt;td&gt;2.38&lt;/td&gt;
&lt;td&gt;48.61&lt;/td&gt;
&lt;td&gt;64×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C4&lt;/td&gt;
&lt;td&gt;56² 64→128 1×1 /2&lt;/td&gt;
&lt;td&gt;15.7&lt;/td&gt;
&lt;td&gt;1.06&lt;/td&gt;
&lt;td&gt;12.16&lt;/td&gt;
&lt;td&gt;15×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C5&lt;/td&gt;
&lt;td&gt;28² 128→128 3×3&lt;/td&gt;
&lt;td&gt;294.9&lt;/td&gt;
&lt;td&gt;4.47&lt;/td&gt;
&lt;td&gt;51.73&lt;/td&gt;
&lt;td&gt;66×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C6&lt;/td&gt;
&lt;td&gt;28² 128→256 3×3 /2&lt;/td&gt;
&lt;td&gt;222.9&lt;/td&gt;
&lt;td&gt;2.17&lt;/td&gt;
&lt;td&gt;53.31&lt;/td&gt;
&lt;td&gt;103×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C7&lt;/td&gt;
&lt;td&gt;28² 128→256 1×1 /2&lt;/td&gt;
&lt;td&gt;25.6&lt;/td&gt;
&lt;td&gt;0.74&lt;/td&gt;
&lt;td&gt;17.26&lt;/td&gt;
&lt;td&gt;34×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C8&lt;/td&gt;
&lt;td&gt;14² 256→256 3×3&lt;/td&gt;
&lt;td&gt;448.4&lt;/td&gt;
&lt;td&gt;4.15&lt;/td&gt;
&lt;td&gt;55.70&lt;/td&gt;
&lt;td&gt;108×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C9&lt;/td&gt;
&lt;td&gt;14² 256→512 3×3 /2&lt;/td&gt;
&lt;td&gt;230.5&lt;/td&gt;
&lt;td&gt;2.08&lt;/td&gt;
&lt;td&gt;55.52&lt;/td&gt;
&lt;td&gt;111×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C10&lt;/td&gt;
&lt;td&gt;14² 256→512 1×1 /2&lt;/td&gt;
&lt;td&gt;25.4&lt;/td&gt;
&lt;td&gt;0.69&lt;/td&gt;
&lt;td&gt;18.66&lt;/td&gt;
&lt;td&gt;37×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C11&lt;/td&gt;
&lt;td&gt;7² 512→512 3×3&lt;/td&gt;
&lt;td&gt;459.8&lt;/td&gt;
&lt;td&gt;4.10&lt;/td&gt;
&lt;td&gt;56.45&lt;/td&gt;
&lt;td&gt;112×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1310 MOP&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2163&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;26.6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;49.28&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;81×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p&gt;Two things keep that table honest. The CPU baseline uses all four cores — measured at 4.09 cores across four threads, by sampling &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/stat&lt;/code&gt; deltas rather than trusting &lt;code&gt;ps&lt;/code&gt;, whose &lt;code&gt;%CPU&lt;/code&gt; is a lifetime average and reads near zero on a busy worker. And neither side is autotuned: TVM reports a fallback schedule for both, because &lt;code&gt;tophub&lt;/code&gt; carries no entries for this board. So 81× is the right order of magnitude rather than a precise figure, and tuning would likely help the CPU proportionally more, since the accelerator is already near its ceiling and the CPU is nowhere near its own.&lt;/p&gt;
&lt;p&gt;The CPU is flat at 0.5–0.8 GOPS whatever the layer: it issues scalar INT8 multiply-accumulates, so its time tracks op count and nothing else. VTA ranges from 12 to 56 GOPS depending on how well the shape suits the array. The gap widens with channel depth — 60× at 64 channels, 112× at 512 — because deeper channels fill a 16 × 16 array better and the CPU gains nothing from them. The 1 × 1 layers are the array&#39;s weakest case: they move nearly as much data for a ninth of the arithmetic, so they are bound by DMA rather than by compute. Note that the CPU holds its usual rate on those same layers, so the narrower gap there is the accelerator slowing down, not the CPU catching up.&lt;/p&gt;
&lt;p&gt;Reading VTA&#39;s own cycle counter alongside wall time separates accelerator time from system time. Of 26.6 ms, the array is busy for 24.3 ms: a 91 % duty cycle, 53.9 GOPS while running, 84 % of peak, rising to 96 % on C11. MAC utilisation climbs as spatial size shrinks and channels grow, from 83 % on C2 to 96 % on C11, because fewer and larger tiles mean fewer instructions issued per unit of arithmetic.&lt;/p&gt;
&lt;h2 id=&quot;instruction-queues-must-be-synchronous-read-on-polarfire&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/vta-polarfire-soc/#instruction-queues-must-be-synchronous-read-on-polarfire&quot;&gt;&lt;span&gt;Instruction queues must be synchronous-read on PolarFire&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;One RTL change is a hard requirement for this port, and it is not obvious from the Chisel.&lt;/p&gt;
&lt;p&gt;VTA&#39;s LOAD and STORE modules each buffer their instruction stream in a queue. Upstream both declare a plain Chisel &lt;code&gt;Queue&lt;/code&gt;, which is an &lt;strong&gt;asynchronous-read&lt;/strong&gt; &lt;code&gt;Mem&lt;/code&gt;. At 512 entries × 128 bits, PolarFire synthesis maps that into LSRAM — which can only read synchronously. The instruction presented at the queue&#39;s output then does not match what was written into it. COMPUTE, whose micro-op and accumulator loads are driven the same way, has always used &lt;code&gt;SyncQueue&lt;/code&gt; and is unaffected.&lt;/p&gt;
&lt;pre class=&quot;language-scala&quot;&gt;&lt;code class=&quot;language-scala&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// Load.scala and Store.scala&lt;/span&gt;
&lt;span class=&quot;token operator&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;val&lt;/span&gt; inst_q &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; Module&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;new&lt;/span&gt; Queue&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;UInt&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;INST_BITS&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;W&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; p&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;CoreKey&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;instQueueEntries&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;token operator&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;val&lt;/span&gt; inst_q &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; Module&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;new&lt;/span&gt; SyncQueue&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;UInt&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;INST_BITS&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;W&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; p&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;CoreKey&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;instQueueEntries&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The corruption is visible directly in hardware. Adding read-only counters to VTA&#39;s control register block, tapping the instruction at the queue&#39;s input and again at the moment the tensor load starts, the two disagree:&lt;/p&gt;
&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;Fetch → LOAD queue   LOAD inp  xsize=2  ysize=1  dram=205520912   written
tensor-load start    LOAD inp  xsize=2  ysize=0  dram=205520896   read back&lt;/code&gt;&lt;/pre&gt;
&lt;div class=&quot;callout&quot;&gt;
&lt;p class=&quot;callout-title&quot;&gt;What a corrupted instruction field does downstream&lt;/p&gt;
&lt;p&gt;Corrupt &lt;code&gt;xsize&lt;/code&gt; to 0 and the instruction decodes as a &lt;strong&gt;sync&lt;/strong&gt;: the load is skipped silently, no input data reaches the MAC array, and every product becomes a multiplication by zero. The weight load in the following instruction is untouched, so weights look perfectly healthy.&lt;/p&gt;
&lt;p&gt;Corrupt &lt;code&gt;ysize&lt;/code&gt; to 0 and the DMA command generator finishes at line index &lt;code&gt;ysize-1&lt;/code&gt;, which wraps to 65535. It issues no command at all and never completes.&lt;/p&gt;
&lt;p&gt;Which bits corrupt varies between builds, so the same root cause presents as wrong results, as a stall, or intermittently as neither.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;With both queues on &lt;code&gt;SyncQueue&lt;/code&gt; the full suite passes at every size, sixty consecutive executions are correct, and timing improves as a side effect — worst accelerator slack goes to +0.980 ns — because &lt;code&gt;SyncQueue&lt;/code&gt; maps onto LSRAM cleanly instead of fighting the inference.&lt;/p&gt;
&lt;p&gt;The portable lesson is worth stating plainly, because upstream VTA runs this same code on Zynq without trouble: &lt;strong&gt;an asynchronous-read &lt;code&gt;Mem&lt;/code&gt; is fine in Xilinx distributed RAM and silently wrong in PolarFire LSRAM.&lt;/strong&gt; Nothing in the Chisel marks the difference, and synthesis does not warn.&lt;/p&gt;
&lt;h2 id=&quot;what&#39;s-left&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;https://nextghq.github.io/blog/vta-polarfire-soc/#what&#39;s-left&quot;&gt;&lt;span&gt;What&#39;s left&lt;/span&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The accelerator is now the limit rather than the software around it, which sets the order of what to do next.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Raise the clock.&lt;/strong&gt; The build closes at 125 MHz with +0.980 ns of slack and a minimum period of 6.885 ns, implying roughly 145 MHz of headroom. Peak scales linearly and the array already runs at 84 % of peak, so this converts almost directly into throughput. It requires the MSS FIC clock configuration to match the fabric clock.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Run a whole network.&lt;/strong&gt; Compiling ResNet-18 as a single Relay graph — quantise, pack to the accelerator&#39;s tensor layout, build, run under the graph executor — keeps weights resident and intermediates on the device, and turns ten measured layers into an actual classifier. The first thing to measure there is the CPU-side share: the 7 × 7 × 3 first layer, pooling and softmax stay on the application cores, and on an in-order core those could cost more than the 24 ms the accelerator spends on everything else. The CPU column above makes that predictable rather than a surprise.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Autotuning needs an upstream fix first,&lt;/strong&gt; and is worth less than it sounds. VTA&#39;s &lt;code&gt;schedule_conv2d_packed&lt;/code&gt; assumes its output is an epilogue operation, so when autotvm builds the configuration space the output &lt;em&gt;is&lt;/em&gt; the convolution stage and gets scheduled twice. Beyond that, the payoff is capped: the 3 × 3 layers already run at 81–96 % MAC utilisation, and the 1 × 1 layers where the headroom actually sits account for 2.5 ms of 26.6 ms.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A wider array&lt;/strong&gt; has the largest ceiling. The design uses 128 of the part&#39;s 292 MATH blocks; raising the block or batch factor raises peak proportionally, bounded by scratchpad LSRAM capacity and by timing — the path from the input scratchpad to the multiplier inputs is the one that tightens first.&lt;/p&gt;
&lt;p&gt;The RTL, driver, test harnesses, debug-register decoder and the full Libero build flow are on the &lt;code&gt;polarfire-timing&lt;/code&gt; branch of our &lt;a href=&quot;https://github.com/pvnis/tvm-vta/tree/polarfire-timing&quot;&gt;tvm-vta fork&lt;/a&gt;; the TVM-side target support is on &lt;a href=&quot;https://github.com/pvnis/tvm/tree/polarfire-mpfs&quot;&gt;&lt;code&gt;polarfire-mpfs&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
</content>
  </entry>
</feed>