Operating Systems · Interrupts and Timers
NAPI and Interrupt Mitigation
How a network driver switches from one interrupt per packet to a budgeted poll, what the weight and time limits protect, and where the scheme loses packets.
A gigabit interface receiving minimum-size frames sees roughly 1.5 million packets per second, and each one is a candidate interrupt. If every packet raised one, the machine would spend its entire budget entering and leaving handlers, and the cost per packet would be dominated by the transition rather than by the protocol work. The arithmetic is unforgiving: at a few hundred cycles per entry and exit, a million interrupts per second consumes a substantial fraction of one core before a single byte is examined.
The fix is not to make interrupts cheaper. It is to stop taking them per packet. Under load, the driver disables its receive interrupt, polls the device for whatever is ready, and re-enables the interrupt only when the queue is empty. The scheme is called NAPI, and its shape, switching from interrupt-driven to polled and back, is the general pattern that interrupt mitigation follows everywhere.
The switch
The transition happens on the first interrupt. The handler does the minimum: it acknowledges the device, disables further receive interrupts, and schedules a poll. From that point the device’s completions are collected by polling, and the interrupt is silent until the poll decides the queue is empty and re-enables it.
The reason for disabling rather than merely ignoring is that the interrupt would otherwise keep firing. A level-triggered line stays asserted while the device has work; an MSI would fire again for each completion. Disabling at the controller, or in the device’s interrupt mask register, makes the switch explicit and bounds the number of interrupts to one per busy period rather than one per packet.
The poll runs in the receive softirq. That placement is deliberate: the softirq already has per-CPU state, already runs shortly after the interrupt, and already has a budget mechanism. The poll function is registered with a weight, and the weight is the number of packets the driver is willing to process in one call. A weight of 64 is conventional.
/* The shape of a NAPI poll, using the driver-facing API. */
static int demo_poll(struct napi_struct *napi, int budget)
{
struct demo_dev *d = container_of(napi, struct demo_dev, napi);
int done = 0;
while (done < budget) {
struct sk_buff *skb = demo_rx_one(d);
if (!skb)
break; /* ring empty */
napi_gro_receive(napi, skb); /* hand upward, maybe coalesce */
done++;
}
if (done < budget) {
/* Everything drained: stop polling and let the interrupt return. */
if (napi_complete_done(napi, done))
demo_rx_irq_enable(d);
return done;
}
return budget; /* still busy: stay in polling mode */
}
The return value is the contract. Returning less than the budget tells the softirq that the driver has nothing left to do, and the driver then re-enables the interrupt. Returning the budget tells the softirq that work remains, and the poll is scheduled again on the next pass. The distinction is what keeps the driver in polling mode across a burst without ever re-enabling an interrupt that would immediately fire.
Completion is not simply “drained”. A driver calls napi_complete_done once it has nothing left to process, passing the count it handled, and the call releases the instance’s ownership of the poll state so that a future interrupt can schedule it again. It can decline to do so, returning false, when the instance is in a busy-poll or deferred-interrupt state in which the poll is guaranteed to be called again anyway; the driver then leaves its interrupt masked, because re-enabling it would be pointless. The race the mechanism has to close is the one between the driver’s last check for an empty ring and the re-enable of the interrupt. A packet can arrive in that window; if the driver enabled the interrupt without the completion step re-establishing the instance’s state first, that packet could sit in the ring with no interrupt pending and no poll scheduled, and it would wait until the next unrelated packet arrived. Doing the re-enable after the completion call, and only when it returned true, means a packet arriving afterwards finds the instance schedulable and raises a fresh interrupt.
The budget, and why it is two budgets
The softirq budget and the NAPI weight are different limits at different levels. The weight bounds one driver’s poll. The system-wide budget bounds the total work one invocation of the receive softirq may do across all devices before it defers.
The system-wide limit is exposed as a packet count, netdev_budget, and a time limit, netdev_budget_usecs; the time limit exists because the packet count alone does not bound time, since a driver whose per-packet cost is high can run out of microseconds long before it runs out of packets. Polling exits when either limit is reached. The per-NAPI weight itself is set when the instance is registered, not through sysctl: the usual figure is 64, and it is fixed in the driver rather than tuned at runtime.
The interaction with Softirqs and the Per-CPU Deferred Queue is direct. When the receive softirq exhausts its budget, the remaining polls move to ksoftirqd on the same CPU. At the socket layer the corresponding limit is the receive buffer against which a queue’s backlog is measured, and packets are dropped at the interface or in the stack when that buffer cannot grow further.
That drop is the honest failure of the scheme. Polling cannot absorb an unbounded rate, so the mitigation converts an interrupt storm into a bounded drop rate. The alternative, letting the queue grow, would convert it into unbounded latency, which is worse for every protocol that carries timing information.
Coalescing in hardware
NAPI works after the interrupt has been taken. Hardware coalescing works before, by making the device raise fewer interrupts in the first place. The device accumulates completions for a period or a count and raises one interrupt for the batch.
The parameters are a microsecond timer and a frame count, applied separately to receive and transmit. A device configured to interrupt after eight frames or 64 microseconds will, at a high packet rate, interrupt on the frame count; at a low rate, on the timer. The timer therefore sets the latency floor for a sparse stream, and the frame count sets the interrupt rate ceiling for a dense one.
The trade is entirely in the latency tail. Coalescing at a large value reduces CPU time per packet and increases the delay before the first packet of a burst is seen. For bulk transfer the delay is irrelevant; for a request-response workload with small messages it dominates. This is why the settings are per-queue and why adaptive coalescing exists: the driver watches the interrupt rate and adjusts the parameters within bounds, trading latency for CPU when the rate is high and the reverse when it is low.
The configuration interface is ethtool, whose coalescing options are read with -c and set with -C. The exact parameter names are device-dependent, and a driver that does not implement coalescing reports that the operation is not supported rather than accepting a setting it will ignore.
Receive side scaling and the queue dimension
A single queue forces all receive processing onto whichever CPU the interrupt is affinitised to. Receive side scaling changes the dimension: the device computes a hash over the packet’s flow tuple, indexes a redirection table with it, and delivers the packet to one of several queues, each with its own interrupt and its own NAPI instance.
The consequence is that the receive path scales with queue count rather than with clock speed. It also means the per-CPU state that made NAPI cheap becomes per-queue state, and the ordering guarantee that a single queue provided for a single flow is preserved because the hash is stable for a given tuple. A flow does not migrate between queues, so reordering within a flow does not occur at this layer.
The failure mode is a hash that concentrates. A device with a poor hash function, or a workload dominated by a small number of flows, will put most traffic on one queue and leave the others idle. The redirection table can be rewritten to spread flows, but it cannot split one flow. Software receive packet steering exists for the case where the device’s queue assignment is too coarse, and it re-runs the hash in software to select a CPU for the softirq, at the cost of an inter-processor interrupt when the selected CPU is not the one that took the interrupt.
When the switch is wrong
The scheme assumes that a busy period is long enough to justify the transition. For a device that receives one packet every few milliseconds, the interrupt is cheaper than the poll, because the poll path has to be scheduled and the softirq has to run. NAPI handles this by re-enabling the interrupt as soon as the ring is empty, so a sparse stream pays one poll per packet and then returns to interrupt mode. The overhead is real but small.
The scheme also assumes that the driver can drain the ring faster than the device fills it. If the per-packet processing cost exceeds the arrival rate divided by the budget, the queue grows without bound and packets are dropped. No amount of tuning fixes that; the processing cost has to come down, or the rate has to be limited upstream.
The third case is a device whose interrupt cannot be cheaply disabled. Some hardware requires a register write to mask, and that write is itself on the bus. For those devices the transition cost is higher and the crossover point moves toward higher rates.
The fourth is the shared line. A NAPI instance is per device, but the interrupt may be shared with another driver. Disabling the line would silence the other device, so shared-line drivers must not disable at the controller; they rely on the device’s own mask register instead. This is one of the remaining reasons shared lines are avoided in new designs, as described in Interrupt Controllers from the 8259A to Message-Signalled Interrupts.
What the poll does not fix
The transition reduces the number of interrupts but not the amount of work per packet. A stack that spends most of its time in the protocol layers will still be limited by that cost, and the polling loop will simply move the same work into a different context. The measurements that matter are therefore not interrupt counts but the time per packet through the receive path, and the queue depth at which drops begin.
Nor does it fix the transmit side, which has its own completion model. Transmit completions are batched by the device and reclaimed in a softirq, and the same trade between latency and CPU applies, but the mechanism is the driver’s own rather than NAPI’s.
The relationship to latency measurement is where the scheme’s cost becomes visible. A poll that runs for the full budget delays every other softirq on that CPU, and the delay appears in the latency distribution as a step at the budget boundary. The techniques for observing that distribution are the subject of Measuring and Bounding Interrupt Latency.
References
- NAPI — The Linux kernel documentation
- Linux Device Drivers, third edition, Chapter 10: Interrupt Handling — Corbet, Rubini & Kroah-Hartman, O'Reilly Media
- ethtool(8) manual page — Linux man-pages project