Byte Queue Limits and Buffer Bloat
Bufferbloat is the high, variable latency that appears when a network device hides behind an oversized buffer: the buffer absorbs a backlog of packets, so nothing is dropped and Transmission Control Protocol (TCP) keeps the buffer full, but every packet now waits behind a long queue and round-trip times balloon from a few milliseconds to hundreds. The bufferbloat project defines it as “the undesirable latency that comes from a router or other network equipment buffering too much data” (bufferbloat.net). One of the worst offenders is the transmit (TX) ring inside a Network Interface Card (NIC) driver — the last buffer before the wire. Byte Queue Limits (BQL) is the Linux mechanism, authored by Tom Herbert at Google and merged in 3.3 (2012), that bounds how many bytes the stack is allowed to have sitting in that driver ring at once — and it adjusts that bound dynamically, keeping the ring “just full enough” to never starve the link but never so full that latency-sensitive packets pile up behind a wall of bulk traffic. This note traces the mechanism (the Dynamic Queue Limits /
dqllibrary underneath it) as of Linux 6.12 LTS and 6.18 LTS.
Mental Model: Keep the Ring Just Full Enough
A NIC drains its TX ring at line rate. If the ring holds far more bytes than the time it takes the stack to refill it, those extra bytes are pure standing latency — every newly enqueued packet waits behind them. If the ring holds too few bytes, the NIC can run dry between refills and waste link capacity (a “starve”). The optimum is the smallest amount of queued data that still prevents starvation: enough to cover the latency of the completion-and-refill loop, and not a byte more. The dql header states the goal exactly: dql’s job is to calculate the limit “as the minimum number of objects needed to prevent starvation” (include/linux/dynamic_queue_limits.h, v6.12), and the LWN write-up of the original patch explains why BQL measures those objects in bytes: “the number of queued bytes more accurately approximates the time required to empty the queue” (LWN, Tom Herbert’s patch).
flowchart LR QD["qdisc<br/>(fq_codel / fq / pfifo_fast)<br/>AQM can act HERE"] -->|"dequeue if dql_avail >= 0"| RING["driver TX ring<br/>BQL bounds bytes in flight<br/>limit ~= just enough"] RING -->|"DMA at line rate"| NIC["NIC -> wire"] NIC -->|"TX completion IRQ"| COMP["netdev_tx_completed_queue()<br/>dql_completed(): recompute limit,<br/>wake the queue"] COMP -.->|"if dql_avail < 0:<br/>__QUEUE_STATE_STACK_XOFF<br/>stops qdisc dequeue"| QD
BQL sits between the qdisc and the NIC. What it shows: the driver ring is bounded by BQL’s limit; when the bytes in flight would exceed it, the stack stops dequeuing from the qdisc (the __QUEUE_STATE_STACK_XOFF flag), so the backlog forms in the qdisc instead of in the unmanaged driver ring. TX-completion interrupts feed dql_completed(), which recomputes the limit and re-opens the queue. The insight: BQL does not improve throughput by itself — it relocates the standing queue from the dumb FIFO driver ring into the smart qdisc, which is the only place an Active Queue Management (AQM) algorithm like CoDel can actually see and control latency.
The Bufferbloat Problem in Detail
Bufferbloat is counter-intuitive because the buffer was added to help. Hardware and software designers historically tried to “drive packet losses to zero” by making buffers large, because a dropped packet looks like a defect. But TCP’s congestion control depends on loss (or Explicit Congestion Notification marking) as its signal to slow down: “the Internet’s normal mechanisms for avoiding congestion rely on the occasional packet loss to trigger them” (bufferbloat.net). When a buffer is so large that it never overflows, the loss signal never fires, so TCP keeps growing its window until the buffer is permanently full. A permanently full buffer means a permanently long queue, and queue depth is latency: a 1 MB buffer draining at 10 Mbps holds ~800 ms of delay. The symptom users feel is that a single large download or upload makes interactive traffic — DNS lookups, SSH keystrokes, video-call audio, game packets — lag horribly, even though there is “no packet loss” and “the link is not saturated” in throughput terms. The link is saturated; the buffer is just hiding it as latency instead of loss.
The cure has two complementary halves. Active Queue Management (AQM) — algorithms like CoDel and the combined fair-queueing scheduler fq_codel — runs inside a qdisc and deliberately drops or marks packets that have sat in the queue too long, restoring TCP’s loss signal and keeping the standing queue short (the qdisc machinery is covered in Queueing Disciplines qdisc and Traffic Control Overview). But AQM can only manage a queue it can see. The driver’s TX ring is below the qdisc and entirely unmanaged — a plain FIFO. If the qdisc instantly drains all its packets into a deep driver ring, the standing queue simply relocates into the ring where no AQM logic exists, and bufferbloat persists. This is the gap BQL closes.
How BQL Works: The Dynamic Queue Limits Algorithm
BQL is the networking-specific wrapper around a generic library, Dynamic Queue Limits (dql), in lib/dynamic_queue_limits.c. The header (include/linux/dynamic_queue_limits.h, v6.12) describes the producer/consumer model it solves: objects are queued up to a limit, a completion process periodically retires consumed objects, and “starvation occurs when [the] limit has been reached, all queued data has actually been consumed, but completion processing has not yet run so queuing new data is blocked.” The library exposes three core operations:
dql_queued(dql, count)— called when bytes are enqueued to the ring; records the count.dql_avail(dql)— returnsadj_limit - num_queued; a value< 0means over the limit.dql_completed(dql, count)— called from the TX-completion path; retires bytes and recomputes the limit.
The accounting hooks in the driver
A BQL-aware driver calls two inline functions from include/linux/netdevice.h. On enqueue, in its ndo_start_xmit, it reports the bytes it just handed to the ring:
static inline void netdev_tx_sent_queue(struct netdev_queue *dev_queue,
unsigned int bytes)
{
#ifdef CONFIG_BQL
dql_queued(&dev_queue->dql, bytes); /* add to in-flight byte count */
if (likely(dql_avail(&dev_queue->dql) >= 0))
return; /* still under limit: nothing to do */
WRITE_ONCE(dev_queue->trans_start, jiffies);
smp_mb__before_atomic();
set_bit(__QUEUE_STATE_STACK_XOFF, &dev_queue->state); /* over limit: STOP the queue */
smp_mb();
/* re-check in case a completion freed room in the meantime */
if (unlikely(dql_avail(&dev_queue->dql) >= 0))
clear_bit(__QUEUE_STATE_STACK_XOFF, &dev_queue->state);
#endif
}The key line is set_bit(__QUEUE_STATE_STACK_XOFF, ...): once the bytes in flight reach the limit, the stack marks the queue stopped. On the completion side — driven by the NIC’s TX-completion interrupt — the driver reports how many bytes the hardware actually drained:
static inline void netdev_tx_completed_queue(struct netdev_queue *dev_queue,
unsigned int pkts, unsigned int bytes)
{
#ifdef CONFIG_BQL
if (unlikely(!bytes))
return;
dql_completed(&dev_queue->dql, bytes); /* retire bytes AND recompute limit */
smp_mb();
if (unlikely(dql_avail(&dev_queue->dql) < 0))
return; /* still over: leave queue stopped */
if (test_and_clear_bit(__QUEUE_STATE_STACK_XOFF, &dev_queue->state))
netif_schedule_queue(dev_queue); /* room freed: WAKE the queue */
#endif
}So the loop is: enqueue accounts bytes and stops the queue when full; completion retires bytes, recomputes the limit, and wakes the queue when room reappears. The pkts argument to netdev_tx_completed_queue is “currently ignored” — BQL is purely byte-based. The docs on these inlines stress that completion must be “called at most once per TX completion round (and not per individual packet), so that BQL can adjust its limits appropriately.”
The limit-adjustment heuristic (dql_completed)
The actual intelligence is in dql_completed() (lib/dynamic_queue_limits.c). It runs once per completion round and nudges limit up or down based on what it observed:
- Increase on starvation. “Queue considered starved if [it] was over-limit in the last interval, and there is no more data in the queue” — i.e. the ring went over the limit and then ran completely dry, meaning the limit was too low to bridge the completion gap. In that case it raises the limit by the bytes sent-and-completed in the last interval plus any previous over-limit overshoot.
- Decrease on slack. If the queue stayed busy across the whole interval (never starved), the code measures slack — “the amount of excess data queued above the amount needed to prevent starvation” — and, “to avoid hysteresis,” tracks the minimum slack seen over several iterations before shrinking the limit by it. This conservative, slowest-decrease-first design stops the limit oscillating.
- Clamp. Finally
limit = clamp(limit, dql->min_limit, dql->max_limit)— the computed value is bounded by the user-set floor and ceiling.
The net effect is a control loop that hunts for the smallest ring occupancy that never starves the link under the current offered load and link speed — automatically, without the operator having to know the link rate. On a fast idle link the limit grows; on a slow or contended link it shrinks toward the floor, so the standing queue in the ring stays tiny and the qdisc above it does the real queueing.
Stall detection (6.9+, present in both 6.12 and 6.18)
Since kernel 6.9 (per the sysfs ABI dates), dql also detects TX completion stalls — periods where bytes were queued but no completion fired for longer than a configurable threshold, which would otherwise wedge the queue stopped forever. dql_check_stall() scans a small ring buffer (DQL_HIST_LEN = 4 words) of per-jiffy “something was queued” bits to find the oldest un-completed queue time, and bumps stall_cnt/stall_max counters. This is exposed via the stall_thrs/stall_cnt/stall_max sysfs files described below.
Minor 6.12 → 6.18 deltas
The
dqlalgorithm is essentially identical between 6.12 LTS and 6.18 LTS. Two small changes in v6.18’sdynamic_queue_limits.c:dql_reset()now setsdql->limit = dql->min_limit(rather than0), so a reset honours the configured floor; anddql_completed()readslast_obj_cntwithREAD_ONCE(). Thenetdev_tx_sent_queue/netdev_tx_completed_queueinline helpers are byte-for-byte identical across the two LTS lines (verified against both v6.12 and v6.18netdevice.h).
Why BQL Complements Qdiscs — The Causal Chain
This is the crux, and it is mechanical, not hand-wavy. The qdisc dequeue loop is __qdisc_run() → qdisc_restart() → sch_direct_xmit() in net/sched/sch_generic.c. Inside sch_direct_xmit() (v6.12), before handing a packet to the driver:
HARD_TX_LOCK(dev, txq, smp_processor_id());
if (!netif_xmit_frozen_or_stopped(txq)) /* <-- BQL gate */
skb = dev_hard_start_xmit(skb, dev, txq, &ret);
else
qdisc_maybe_clear_missed(q, txq);
HARD_TX_UNLOCK(dev, txq);netif_xmit_frozen_or_stopped() tests QUEUE_STATE_ANY_XOFF, which (from netdevice.h) is the OR of QUEUE_STATE_DRV_XOFF | QUEUE_STATE_STACK_XOFF. When BQL set __QUEUE_STATE_STACK_XOFF because the ring hit its byte limit, this predicate becomes true, so sch_direct_xmit stops feeding the driver. The qdisc dequeue loop therefore halts, and newly transmitted packets back up in the qdisc rather than flooding into the driver ring. That is the whole point: the qdisc — fq_codel, fq, or any AQM-capable discipline — is the only layer that can measure how long a packet has waited and drop or mark it; by bounding the driver ring, BQL guarantees the standing queue forms where the AQM can see it. Without BQL, a deep driver ring would swallow the entire backlog below the qdisc, and CoDel’s sojourn-time logic would see an empty qdisc and do nothing while latency exploded in the invisible ring. When the NIC drains and a completion interrupt calls netdev_tx_completed_queue → clears the XOFF bit → netif_schedule_queue, the qdisc run is rescheduled and dequeuing resumes.
Inspecting and Tuning BQL via sysfs
BQL state is exposed per TX queue under /sys/class/net/<iface>/queues/tx-<N>/byte_queue_limits/. The files (from net/core/net-sysfs.c and the Documentation/ABI/testing/sysfs-class-net-queues, v6.12):
$ cd /sys/class/net/eth0/queues/tx-0/byte_queue_limits
$ ls
hold_time inflight limit limit_max limit_min stall_cnt stall_max stall_thrs
$ cat limit # current dynamic limit in bytes (clamped to [limit_min, limit_max])
18000
$ cat inflight # bytes (objects) currently in flight on this TX queue
3028
$ cat limit_min # absolute floor; default 0
0
$ cat limit_max # absolute ceiling; see dynamic_queue_limits.h for default
1879048192
$ cat hold_time # ms over which slack is measured before a decrease; default 1000
1000Per the ABI doc: limit “is clamped to be within the bounds defined by limit_max and limit_min”; limit_min “Default value is 0”; hold_time “Default value is 1000” ms. The newer stall files (added in 6.9, “Jan 2024” per the ABI doc):
$ cat stall_thrs # ms; TX-completion stall detection threshold; 0 = disabled
0
$ cat stall_cnt # count of detected TX-completion stalls (read-only)
0
$ cat stall_max # longest detected stall in ms; write 0 to clear
0The ABI doc notes the detection guarantee: with stall_thrs set, “Kernel will guarantee to detect all stalls longer than this threshold but may also detect stalls longer than half of the threshold” — a consequence of the coarse jiffy-resolution history ring.
The two practical tuning knobs are limit_min (raise it to force a minimum ring occupancy if you see starvation on a very fast link) and limit_max (lower it to cap the worst-case ring latency, e.g. on a slow WAN uplink where you want to clamp bloat hard). In most cases the dynamic limit is left to self-tune and the operator instead focuses on choosing a good qdisc — BQL’s job is to make that qdisc effective, not to be tuned itself.
Failure Modes and Common Misunderstandings
- “BQL increases latency / throughput by itself.” No. BQL neither shapes nor schedules; it only bounds the driver ring. On its own it slightly reduces ring occupancy. The latency win materializes only because a smart qdisc (fq_codel/CoDel) now sees the backlog and can act. BQL and AQM are a pair; one without the other is half a fix.
limitstuck at 0 / queue never wakes. If a driver reportsnetdev_tx_sent_queuebut fails to callnetdev_tx_completed_queuefor the same bytes (or with mismatched byte counts),dql_completednever runs, the limit never grows, and the queue can wedge stopped. The@bytespassed to sent and completed “should exactly match” per the kernel docs; a mismatch is a driver bug. The 6.9+ stall detector exists precisely to surface this class of bug as a non-zerostall_cnt.- BQL only exists if
CONFIG_BQLis set and the driver opts in by calling the accounting helpers. A driver that does not callnetdev_tx_sent_queue/netdev_tx_completed_queuegets no BQL even on a BQL-enabled kernel — thebyte_queue_limits/sysfs directory will be absent or inert. Most mainstream drivers are BQL-aware —igbandixgbecall the accounting helpers, andvirtio_netdoes too on its NAPI-TX path (__netdev_tx_sent_queue/netdev_tx_completed_queue, verified in both v6.12 and v6.18drivers/net/virtio_net.c). - Bufferbloat reappears at a different layer. BQL bounds this host’s NIC ring. A bloated buffer in an upstream cable modem, switch, or Wi-Fi access point is outside the host’s control; BQL on the host does nothing for it. End-to-end bufferbloat requires AQM at every hop that can be a bottleneck.
Alternatives and Relationship to Other Mechanisms
BQL is not an alternative to AQM — it is the enabling substrate underneath it. The genuinely alternative approaches to the bottom-of-stack bloat problem are: static driver ring-size limits via ethtool -G (crude, link-rate-blind, and exactly what BQL replaces with a self-tuning byte bound); pure AQM with a deep ring (ineffective, because the queue hides below the qdisc); and TCP-side pacing (e.g. the fq qdisc’s pacing paired with the BBR congestion-control algorithm, see TCP Congestion Control), which limits how fast a sender injects bytes in the first place. In practice a well-tuned host uses all three layers cooperatively: TCP pacing limits injection rate, BQL bounds the driver ring, and an AQM qdisc manages the now-visible standing queue.
Uncertain
Verify: the default egress qdisc on a given system. Whether
fq_codel(orfq, orpfifo_fast) is the default is governed by thenet.core.default_qdiscsysctl and is frequently overridden by the distribution’ssystemd/init configuration — it is a userspace/distro decision, not a single kernel fact (this is the same caveat the parent Linux Networking Stack MOC flags). Reason: not pinned to a primary source in this note; the fq_codel references here treat it as a representative AQM qdisc, not as a guaranteed default. To resolve: readnet.core.default_qdischandling innet/core/sysctl_net_core.cand the distro’s sysctl config at the targeted LTS. uncertain
See Also
- The Network Transmit Path — where
netdev_tx_sent_queue/netdev_tx_completed_queueare called, between qdisc and NIC - Queueing Disciplines qdisc — the AQM-capable layer BQL makes effective (fq_codel, fq, CoDel)
- Traffic Control Overview — the umbrella over qdiscs, classes, and filters
- Segmentation Offloads GSO TSO — the other TX-side performance mechanism, deferring the per-segment split
- TCP Congestion Control — TCP-side pacing (fq + BBR) that limits byte injection at the source
- NET_RX and NET_TX Softirqs — the deferred-work context the TX-completion path runs in
- MOC: Linux Networking Stack MOC (§5, The Transmit Path and Segmentation Offloads)