NAPI and Polled Receive

NAPI — historically “New API,” though the kernel documentation now states the name “no longer stands for anything in particular” (napi.rst, v6.12) — is the interrupt-mitigation scheme that drives the Linux receive (RX) path. Under load, raising one hardware interrupt per packet melts a CPU: a 10 Gbps link delivering 64-byte frames can produce ~14.8 million packets per second, and no machine can take 14.8 M interrupts per second and still do useful work. NAPI solves this by switching the receive path from interrupt-driven to poll-driven the moment work arrives. The first RX interrupt does almost nothing except schedule a poll and mask further interrupts from that queue; the kernel then polls the network interface card (NIC) ring buffer in software-interrupt context, draining up to a budget of packets per poll, and re-enables the hardware interrupt only when the ring runs dry. The result is that one interrupt amortizes over many packets — the system self-tunes between “interrupt per packet” at low rates and “pure polling” at line rate. This note traces the mechanism: struct napi_struct, napi_schedule(), the poll() callback and its weight/budget, napi_complete_done(), and the modern refinements — threaded NAPI, napi_defer_hard_irqs, and gro_flush_timeout.

NAPI is the canonical instance of the bottom-half pattern from Linux Interrupts and Deferred Work MOC: a hard interrupt handler does the bare minimum and defers the heavy lifting to a softirq. The softirq that runs NAPI polls is NET_RX_SOFTIRQ — see NET_RX and NET_TX Softirqs for the softirq machinery itself. This note focuses on NAPI as a polling abstraction; the higher-level packet journey it sits inside is The Network Receive Path.

Mental Model

Think of NAPI as a doorbell with a snooze button. When the NIC has packets, it rings the doorbell (raises an interrupt). Instead of answering once per ring, the kernel answers the door, disables the doorbell, and keeps collecting packages until the porch is empty — then re-enables the doorbell and walks away. If packages keep arriving faster than they can be collected, the doorbell stays disabled and the kernel just keeps polling in a loop: at saturation, interrupts effectively stop and the system runs as a pure poller, which is exactly the regime where per-interrupt overhead would otherwise be ruinous.

flowchart TB
  subgraph HARDIRQ["Hard IRQ context (microseconds, IRQs off)"]
    IRQ["NIC raises RX interrupt"] --> SCHED["driver ISR:<br/>napi_schedule_prep()<br/>mask NIC IRQ<br/>__napi_schedule()"]
    SCHED --> RAISE["raise NET_RX_SOFTIRQ<br/>add napi to poll_list"]
  end
  RAISE -.deferred.-> SOFTIRQ
  subgraph SOFTIRQ["Softirq context — net_rx_action()"]
    LOOP["for each napi on poll_list:<br/>work = poll(napi, weight=64)"] --> CHECK{"work < weight?<br/>(ring drained)"}
    CHECK -->|yes| DONE["napi_complete_done()<br/>unmask NIC IRQ"]
    CHECK -->|no, budget hit| REPOLL["keep on poll_list<br/>poll again, IRQ stays masked"]
    REPOLL --> LOOP
  end
  DONE -.ring empties, IRQ re-armed.-> IRQ

The NAPI interrupt-then-poll cycle. What it shows: the hard IRQ handler (top) is tiny — it masks the device interrupt and schedules a poll, nothing more. All real work happens in the softirq (bottom), where the driver’s poll() callback drains packets up to a budget of 64. The insight: the hardware interrupt is re-enabled (napi_complete_done) only when the poll comes back having processed fewer packets than its weight — proof the ring is empty. As long as the ring stays full, the loop re-polls with interrupts masked, so under load the interrupt rate collapses toward zero while throughput stays high.

Why Polling Beats Interrupting Under Load

A hardware interrupt is expensive: it flushes pipelines, saves and restores register state, traverses the interrupt controller, and disrupts cache locality. At a few thousand packets per second this cost is invisible; at millions per second it is fatal. Worse, the classic failure mode is receive livelock — the CPU spends so much time in interrupt context acknowledging the NIC that it never gets to run the softirq that actually consumes the packets, so the receive queue fills, packets are dropped after the kernel already paid to interrupt for them, and goodput drops to zero while CPU usage pegs at 100%. NAPI was designed (originally for Linux 2.4/2.6) precisely to defeat livelock: by masking the device interrupt the instant a poll is scheduled, the kernel guarantees that the consuming softirq runs without being preempted by yet more receive interrupts. The interrupt only comes back once the consumer has caught up.

The trade-off runs the other way at low rates: a pure poller wastes CPU spinning on an empty ring, and adds latency if it polls on a timer. NAPI’s elegance is that it is adaptive without a tuning knob for the common case — it is interrupt-driven when idle (no wasted cycles, low latency to first packet) and poll-driven when busy (no interrupt storm). The driver does not choose; the arrival rate chooses.

The struct napi_struct

A NAPI instance is one struct napi_struct, defined in include/linux/netdevice.h. Modern multi-queue NICs have many — typically one NAPI instance per RX/TX queue pair, mapped 1:1:1 to a hardware interrupt (napi.rst, “Instance to queue mapping”). At v6.12 the structure is:

struct napi_struct {
	struct list_head	poll_list;   /* node on the per-CPU sd->poll_list */
	unsigned long		state;       /* NAPI_STATE_* bits (SCHED, MISSED, ...) */
	int			weight;      /* per-poll Rx budget, default 64 */
	u32			defer_hard_irqs_count;
	unsigned long		gro_bitmask;
	int			(*poll)(struct napi_struct *, int);  /* driver callback */
	int			list_owner;  /* CPU currently servicing this NAPI */
	struct net_device	*dev;
	struct gro_list		gro_hash[GRO_HASH_BUCKETS]; /* GRO coalescing state */
	struct sk_buff		*skb;
	struct list_head	rx_list;     /* pending GRO_NORMAL skbs */
	int			rx_count;
	unsigned int		napi_id;     /* exposed via SO_INCOMING_NAPI_ID */
	struct hrtimer		timer;       /* the gro_flush_timeout repoll timer */
	struct task_struct	*thread;     /* set iff threaded NAPI is on */
	/* control-path-only fields follow */
	...
};

(netdevice.h, v6.12, line ~348)

The fields that matter for the polling loop: poll is the driver’s callback; weight is its Rx budget per invocation; state holds the bits that arbitrate who owns the instance; poll_list threads the instance onto the per-CPU softnet_data.poll_list that net_rx_action() walks; timer is the high-resolution timer used by the deferred-IRQ feature; thread is non-NULL only when threaded NAPI is enabled.

Uncertain

The exact field layout changed between 6.12 and 6.18. At v6.18, gro_flush_timeout, defer_hard_irqs, and a new irq_suspend_timeout were moved into struct napi_struct (they were per-net_device at 6.12), the per-bucket gro_hash[] + gro_bitmask were replaced by a single struct gro_node gro, and a struct napi_config *config pointer plus an index were added to support persistent per-NAPI configuration. Verify: the precise 6.18 field set. Reason: layout differs across the two LTS branches consulted (netdevice.h v6.18, line ~379). To resolve: read struct napi_struct at the exact LTS being targeted. The behaviour (per-poll weight 64, defer-IRQ counter, GRO flush timer) is unchanged; only where the config lives moved. uncertain

A NAPI instance is registered with netif_napi_add(dev, napi, poll), which calls netif_napi_add_weight(dev, napi, poll, NAPI_POLL_WEIGHT) with #define NAPI_POLL_WEIGHT 64 (netdevice.h, v6.12, line ~2620). Instances are added disabled; napi_enable()/napi_disable() manage that state, and the documentation warns the control API is not idempotent — calling napi_disable() twice deadlocks (napi.rst, “Control API”).

Mechanical Walk-through

1. The interrupt schedules a poll

When the NIC raises an RX interrupt, the driver’s interrupt service routine calls napi_schedule() (or the lower-level pair napi_schedule_prep() + __napi_schedule()). The documented idiom for drivers that must mask interrupts in software is (napi.rst, “Scheduling and IRQ masking”):

if (napi_schedule_prep(&v->napi)) {
    mydrv_mask_rxtx_irq(v->idx);   /* mask AFTER prep, BEFORE schedule, to avoid races */
    __napi_schedule(&v->napi);
}

napi_schedule_prep() is a compare-and-swap on state: it sets NAPI_STATE_SCHED and returns true only if it was not already set (dev.c, v6.12, napi_schedule_prep, line ~6178). If the instance was already scheduled, it instead sets NAPI_STATE_MISSED and returns false, recording that another poll is needed — this is how a second interrupt arriving mid-poll is not lost. __napi_schedule() then disables local IRQs and calls ____napi_schedule(), which adds the instance to the per-CPU softnet_data.poll_list and, if not already inside net_rx_action, raises the softirq:

list_add_tail(&napi->poll_list, &sd->poll_list);
WRITE_ONCE(napi->list_owner, smp_processor_id());
if (!sd->in_net_rx_action)
    __raise_softirq_irqoff(NET_RX_SOFTIRQ);

(dev.c, v6.12, ____napi_schedule, line ~4537)

The hard IRQ handler is now done. Critically, the NIC’s interrupt for this queue stays masked — either masked in software by the driver, or auto-masked by the hardware. No further interrupts will fire from this queue until polling re-enables them.

2. The softirq polls the ring

NET_RX_SOFTIRQ runs net_rx_action(), which walks the poll_list and invokes each instance via napi_poll()__napi_poll(). The heart of __napi_poll():

weight = n->weight;                 /* 64 */
work = 0;
if (napi_is_scheduled(n)) {
    work = n->poll(n, weight);      /* the driver callback */
    trace_napi_poll(n, work, weight);
}
if (likely(work < weight))
    return work;                    /* ring drained — done, will complete */
...
*repoll = true;                     /* budget hit — keep polling */
return work;

(dev.c, v6.12, __napi_poll, line ~6765)

The driver’s poll(napi, budget) callback does the actual work: it walks the NIC’s receive descriptor ring, builds a struct sk_buff (see struct sk_buff) per received frame, feeds each up the stack via napi_gro_receive() (which runs Generic Receive Offload to coalesce same-flow segments), and returns the number of Rx packets processed. The contract from the documentation is precise (napi.rst, “Datapath API”):

  • The driver must process at most budget Rx packets (TX completions may be cleaned regardless of budget — they are cheap).
  • If it exhausted the budget (more packets remain), it returns exactly budget and does not call napi_complete_done(). The instance stays on poll_list and is polled again — interrupts stay masked.
  • If it drained the ring (processed fewer than budget), it calls napi_complete_done() and returns the lower count.

This work < weight test is the entire adaptive mechanism. “I processed fewer than 64” means “the ring is empty,” which means “it is safe to go back to interrupts.” “I processed exactly 64” means “there is more — keep polling, don’t re-arm the interrupt.”

3. The budget and the time limit

net_rx_action() itself enforces a global ceiling so one CPU cannot spend forever in the softirq starving everything else (dev.c, v6.12, net_rx_action, line ~6931):

unsigned long time_limit = jiffies +
    usecs_to_jiffies(READ_ONCE(net_hotdata.netdev_budget_usecs));
int budget = READ_ONCE(net_hotdata.netdev_budget);  /* 300 */
...
budget -= napi_poll(n, &repoll);
if (unlikely(budget <= 0 || time_after_eq(jiffies, time_limit))) {
    sd->time_squeeze++;
    break;
}

Two limits — both tunable via /proc/sys/net/core/ — bound a single softirq run:

  • netdev_budget — total packets across all NAPI instances on this CPU per softirq invocation, default 300 (hotdata.c, v6.12).
  • netdev_budget_usecs — wall-clock limit, default 2 * USEC_PER_SEC / HZ = 2 ms at HZ=1000 (hotdata.c, v6.12).

Note the two-level budget: each instance gets weight=64 packets per poll, and the whole CPU gets netdev_budget=300 packets and 2 ms per softirq run. When either CPU-level limit is hit, net_rx_action increments time_squeeze (visible in /proc/net/softnet_stat — the third hex column, per softnet_seq_show() in net-procfs.c, v6.12) and breaks out, re-raising NET_RX_SOFTIRQ so the remaining instances are polled in the next softirq pass. A climbing time_squeeze is the textbook signal that a CPU is receive-bound. The deferral to a later softirq, and the eventual handoff to ksoftirqd, are owned by NET_RX and NET_TX Softirqs.

4. napi_complete_done re-enables interrupts

When the driver drained the ring it calls napi_complete_done(napi, work_done). The documented driver idiom unmasks the device IRQ only on success (napi.rst, “Scheduling and IRQ masking”):

if (budget && napi_complete_done(&v->napi, work_done)) {
    mydrv_unmask_rxtx_irq(v->idx);
    return min(work_done, budget - 1);
}

napi_complete_done() clears NAPI_STATE_SCHED (releasing ownership), but with two subtleties from the source (dev.c, v6.12, napi_complete_done, line ~6220):

new = val & ~(NAPIF_STATE_MISSED | NAPIF_STATE_SCHED | ...);
new |= (val & NAPIF_STATE_MISSED) / NAPIF_STATE_MISSED * NAPIF_STATE_SCHED;
...
if (unlikely(val & NAPIF_STATE_MISSED)) {
    __napi_schedule(n);
    return false;   /* DON'T unmask — re-poll instead */
}

If NAPI_STATE_MISSED was set (an interrupt fired during the poll), napi_complete_done() re-schedules the instance and returns false, so the driver does not unmask the hardware interrupt — it will be re-polled instead, closing the race where a packet arrived in the window between “ring looked empty” and “interrupt re-armed.” Only when no interrupt was missed does it return true, the driver unmasks, and the cycle resets to step 1.

There is a sharp corner case the docs flag: if the driver finishes all packets but used exactly budget, there is no way to signal “done but full,” so the driver must either skip napi_complete_done() (and wait to be re-polled) or return budget - 1 (napi.rst warning). This is why the idiom above returns min(work_done, budget - 1).

Software IRQ Coalescing: napi_defer_hard_irqs and gro_flush_timeout

NAPI does no event coalescing by default — batching usually comes from the hardware’s interrupt coalescing (the NIC delaying its interrupt). But the kernel offers a software coalescing knob for cases where hardware coalescing is too coarse (napi.rst, “Software IRQ coalescing”). It works by arming a repoll timer instead of unmasking the hardware interrupt when the ring drains:

  • gro_flush_timeout — the delay, in nanoseconds, of the napi->timer high-resolution repoll timer. After a poll empties the ring, instead of re-enabling the IRQ immediately, NAPI starts this timer; when it fires it re-polls. This batches the next burst of packets into a single softirq instead of taking an interrupt for each early arrival.
  • napi_defer_hard_irqs — a counter of how many consecutive empty polls NAPI will tolerate (deferring the hardware IRQ each time) before giving up and going back to true interrupt-driven operation.

The interaction lives in napi_complete_done() (dev.c, v6.12, line ~6235):

if (work_done) {
    if (n->gro_bitmask)
        timeout = READ_ONCE(n->dev->gro_flush_timeout);
    n->defer_hard_irqs_count = READ_ONCE(n->dev->napi_defer_hard_irqs);
}
if (n->defer_hard_irqs_count > 0) {
    n->defer_hard_irqs_count--;
    timeout = READ_ONCE(n->dev->gro_flush_timeout);
    if (timeout)
        ret = false;     /* tell driver NOT to unmask the IRQ */
}
...
if (timeout)
    hrtimer_start(&n->timer, ns_to_ktime(timeout), HRTIMER_MODE_REL_PINNED);

When a poll does work, the defer counter is reloaded from napi_defer_hard_irqs. While that counter is positive and a gro_flush_timeout is set, napi_complete_done() returns false — the driver keeps the IRQ masked — and instead arms the timer. After napi_defer_hard_irqs consecutive empty polls, the counter hits zero, napi_complete_done() returns true, and the device interrupt is finally re-armed. The effect is a low-latency polled regime that gracefully falls back to interrupts when traffic genuinely stops — essentially software-controlled adaptive coalescing.

Both knobs are exposed per-interface under /sys/class/net/<dev>/ (the gro_flush_timeout and napi_defer_hard_irqs sysfs files), and at v6.18 they can additionally be set per-NAPI via the netdev-genl netlink interface, using hyphenated names (gro-flush-timeout, defer-hard-irqs) and queried/set with tools/net/ynl/pyynl/cli.py (napi.rst, v6.18).

Uncertain

At v6.18 the source reads these via per-NAPI accessors napi_get_defer_hard_irqs(n) / napi_get_gro_flush_timeout(n) (the values live in napi->config, seeded from the per-net_device defaults), rather than the direct n->dev->... reads shown above from v6.12 (dev.c v6.18, lines ~6666, ~7345). Verify: that the global sysfs files still seed the value at 6.18 and per-NAPI netlink overrides it. Reason: implementation moved between the two LTS branches. To resolve: trace napi_get_defer_hard_irqs at the targeted LTS. The behaviour is the same; only the storage location moved. uncertain

Threaded NAPI

By default, NAPI polls run in softirq context, which is borrowed from whatever the CPU was doing (or from ksoftirqd under load). Threaded NAPI moves polling into dedicated kernel threads instead. Each threaded NAPI instance spawns one thread named napi/${ifc-name}-${napi-id} (napi.rst, “Threaded NAPI”). The dispatch decision is made in ____napi_schedule():

if (test_bit(NAPI_STATE_THREADED, &napi->state)) {
    thread = READ_ONCE(napi->thread);
    if (thread) {
        set_bit(NAPI_STATE_SCHED_THREADED, &napi->state);
        wake_up_process(thread);   /* wake the kthread instead of raising softirq */
        return;
    }
}

(dev.c, v6.12, ____napi_schedule, line ~4518)

When threaded, scheduling a NAPI wakes its kthread rather than raising NET_RX_SOFTIRQ. The thread runs napi_threaded_poll()napi_threaded_poll_loop(), which calls the same __napi_poll() inside a local_bh_disable()/local_bh_enable() window (dev.c, v6.12, napi_threaded_poll_loop, line ~6882) — so the polling logic is identical; only the execution context differs.

Why thread it? Softirq processing is anonymous — it steals CPU from whatever task happened to be running and is invisible to the scheduler, which makes RX-heavy workloads hard to account, isolate, and pin. A kernel thread is a schedulable entity: you can pin it to a specific CPU (the docs recommend pinning each to the same CPU that services its interrupt), assign it a real-time priority, and see its CPU usage in top. This gives much better control on latency-sensitive and CPU-isolated deployments. Threaded NAPI is enabled per-netdevice by writing 1 to /sys/class/net/<dev>/threaded, which affects all NAPI instances of that device (napi.rst, v6.12). At v6.18 it can additionally be toggled for a single NAPI via netlink (napi-set ... "threaded": 1) (napi.rst, v6.18).

Threaded NAPI is verified present at v6.12 LTS (the threaded sysfs file and napi_threaded_poll are both in-tree). The feature has existed since v5.12 (Jan 2021): the kernel ABI documentation dates the /sys/class/net/<iface>/threaded attribute to “Jan 2021, KernelVersion 5.12” (sysfs-class-net, v6.12), corresponding to the sysfs-threaded-mode patch series by Wei Wang et al. (net-next v10, patchwork). The 6.12-era napi.rst documents it; the 6.18 doc adds the per-NAPI netlink toggle.

Configuration and Observability

# Show NAPI's CPU budget and time limit (per softirq invocation)
$ cat /proc/sys/net/core/netdev_budget          # 300  (packets)
$ cat /proc/sys/net/core/netdev_budget_usecs     # 2000 (microseconds = 2 ms)
 
# Per-CPU softirq RX stats: col1=processed col2=dropped col3=time_squeeze (hex)
$ cat /proc/net/softnet_stat
 
# Enable threaded NAPI on eth0 (spawns napi/eth0-<id> kthreads)
$ echo 1 | sudo tee /sys/class/net/eth0/threaded
$ ps -eo comm | grep '^napi/'                    # napi/eth0-8195 ...
 
# Software IRQ coalescing on eth0: defer hard IRQ for 8 empty polls, 50 us repoll timer
$ echo 8     | sudo tee /sys/class/net/eth0/napi_defer_hard_irqs
$ echo 50000 | sudo tee /sys/class/net/eth0/gro_flush_timeout   # nanoseconds

Line by line: netdev_budget/netdev_budget_usecs are the CPU-wide ceilings on one net_rx_action pass — raise netdev_budget if time_squeeze (column 3 of softnet_stat) is climbing and you have CPU headroom, so each softirq drains more before punting. Enabling threaded makes RX polling schedulable and pinnable. The coalescing pair trades a little latency for far fewer interrupts: with napi_defer_hard_irqs=8 and a 50 µs gro_flush_timeout, NAPI keeps polling on a timer for up to 8 empty rounds before re-arming the hardware interrupt — useful for AF_XDP and high-PPS forwarders that prefer a steady poll cadence over interrupt churn.

Failure Modes and Diagnosis

  • Climbing time_squeeze. Column 3 of /proc/net/softnet_stat rising fast means net_rx_action is repeatedly hitting netdev_budget or the 2 ms limit and punting work to the next softirq (and eventually to ksoftirqd). Symptom: latency spikes, RX softirq CPU saturation on a single core. Fixes: spread interrupts across cores (Receive Side Scaling and Packet Steering), raise netdev_budget, or move to threaded NAPI. This is not a drop — packets are delayed, not lost — but sustained squeezing precedes drops.
  • dropped column climbing (non-NAPI path). That counts overflow of the per-CPU backlog used by the legacy netif_rx() path, not the NAPI ring — owned by NET_RX and NET_TX Softirqs. NAPI-driver drops show up in ethtool -S rx-drop counters and in the NIC ring being full when the poll could not keep up.
  • A buggy driver poll() that returns work > weight. The kernel catches this and logs "NAPI poll function %pS returned %d, exceeding its budget of %d" once (dev.c, v6.12, line ~6786) — a sign of a driver bug, since it breaks the budget accounting.
  • The “exactly budget, but done” hazard. A driver that calls napi_complete_done() after processing exactly budget packets and then also unmasks the IRQ can wedge: if it should have kept polling, packets sit in the ring with the interrupt masked, or vice versa. The mandated idiom (return min(work_done, budget - 1)) avoids it.
  • Threaded NAPI without pinning. Spawning the kthreads but leaving them free to migrate can worsen cache locality versus plain softirq. The docs explicitly recommend pinning each napi/* thread to the CPU that services its interrupt.

Alternatives and Relationship to Other Mechanisms

  • Pure interrupt-per-packet — what NAPI replaced. Simple, lowest latency at trivial rates, but livelocks under load. No modern driver uses it.
  • Pure busy-polling (SO_BUSY_POLL, net.core.busy_poll) — a userspace process polls the NAPI instance directly from recv()/epoll_wait() before the interrupt fires, trading CPU for the lowest possible latency (napi.rst, “Busy polling”). NAPI is the substrate this builds on (it polls the same poll() callback); busy-polling is a latency optimization, threaded NAPI is a scheduling/isolation one, and napi_defer_hard_irqs is an interrupt-rate one — they compose.
  • Hardware interrupt coalescing (ethtool -C, rx-usecs/rx-frames) — the NIC itself delays its interrupt to batch packets. This is the default batching source and is orthogonal to NAPI: hardware coalescing reduces how often NAPI is scheduled; NAPI’s budget reduces how much each schedule does.
  • XDP (XDP Express Data Path) — runs an eBPF program inside the driver’s poll()/RX routine, before an sk_buff is even allocated, and can drop/redirect at line rate. XDP runs within the NAPI poll context; it does not replace NAPI, it short-circuits the per-packet skb cost for packets it handles.

Production Notes

NAPI tuning is a staple of high-throughput network engineering. The two most common production interventions are (1) RSS + IRQ affinity so each RX queue’s NAPI instance lands on its own core (otherwise a single core’s time_squeeze caps the whole box — see Receive Side Scaling and Packet Steering), and (2) napi_defer_hard_irqs + gro_flush_timeout for AF_XDP and software-router workloads that want polling cadence without an interrupt storm — this pair was specifically motivated by AF_XDP and forwarding use cases (napi.rst, “IRQ mitigation”). Threaded NAPI has become the default choice for latency-isolated and real-time deployments because it makes RX a first-class schedulable entity. Always validate changes by watching time_squeeze, the per-NIC ethtool -S drop counters, and tail latency — not just average throughput.

See Also