I/Opollinginterruptslatencythroughputcrossover

Polling versus Interrupts

Being told costs a fixed amount per event; asking costs a fixed amount per unit of time. Which is cheaper is arithmetic, and above a crossover rate the "wasteful" busy loop wins decisively.

Follow the mechanism

Software view, hardware view

The gap between what you wrote and what the machine does is where this whole domain lives.

The question
Should the CPU wait to be told an event happened, or keep asking — and what actually decides which one is cheaper?
What you wrote
Blocking calls feel free: the thread sleeps and wakes when data is ready. A busy loop that checks a flag feels obviously wasteful, because you can see the cycles being burned.
What the hardware does
Interrupt delivery has a substantial fixed cost per event: pipeline drain, privilege transition, cache displacement. Polling has a cost per unit of time and none per event. So the total cost of interrupts scales with event *rate*, while polling's does not.
This is one of the few places where the intuitive answer inverts under load. At low rates interrupts are obviously right and polling is obviously waste. At high rates the arithmetic flips, which is why the fastest networking and storage stacks in production poll — and why an engineer who has only internalised "busy-waiting is bad" will reject the correct design.
SourceCompilerInstructionsFront EndExecutionRegistersCachesMemoryI/OBehavior

Two ways to learn that something happened

Interrupts and polling answer the same question with opposite cost structures. An interrupt is *push*: the device pays nothing until it has news, then the CPU pays a fixed, fairly large price to receive it. Polling is *pull*: the CPU pays continuously whether or not anything happened, but each discovery is nearly free — a load and a branch.

That difference in shape, not in magnitude, is what decides the winner. Interrupt cost is rate × per_event_cost. Polling cost is duration × poll_overhead, independent of rate. Below the crossover, interrupts win and the idle core can do other work or sleep. Above it, the machine spends more time processing interrupt delivery than it would have spent spinning.

The other axis is latency, and here polling wins at every rate. A polled event is noticed within one loop iteration. An interrupt-delivered event waits for delivery, the mode transition and possibly a scheduler wakeup. For control paths where microseconds matter, that gap is the entire argument.

The same event, two mechanisms
InterruptsPolling
Cost scales withEvent rateWall-clock time
Cost when idleNothing — the core can sleep or workFull — a core is consumed regardless
Cost per eventHigh and fixed: drain, transition, cache displacementNear zero: a load and a predictable branch
Latency to noticeDelivery + transition + possible wakeupWithin one loop iteration
Effect on other threadsDisplaces their cache on whatever core takes itNone — the polling core is dedicated
Behaviour under overloadDegrades: delivery itself consumes the coreStable: the loop rate does not change

The crossover is arithmetic, not ideology

Because the two costs have different shapes, there is a rate at which they cross. Below it, interrupts; above it, polling. The crossover is not a matter of taste and it is not fixed — it moves with per-event interrupt cost (which depends on the machine and on how much the handler touches) and with how much useful work the core could otherwise be doing.

This is why high-rate networking and NVMe stacks poll. A NIC receiving millions of packets per second, taking an interrupt per packet, would spend most of the core on delivery overhead and never reach line rate. Dedicating a core to a spin loop looks profligate on a utilisation graph and is dramatically cheaper in reality.

The comparison below is the shape of that arithmetic, not a measurement of any machine. What transfers is that one line is flat and the other has slope — and therefore that they cross.

Interrupt-driven at high event rate
1per_event_cost = drain + transition + cache_displacement // large, fixed
2total = event_rate x per_event_cost
3
4// 2,000,000 events/sec x large fixed cost
5// -> delivery overhead consumes most of the core
6// -> throughput ceiling well below what the device can do
7// -> and every other thread on that core pays for the cache churn
Polled at the same rate
1per_event_cost = load + predictable_branch // near zero
2total = duration x loop_overhead // flat in rate
3
4// 2,000,000 events/sec on a dedicated core
5// -> per-event cost stays negligible
6// -> latency bounded by one loop iteration
7// -> one core fully consumed, and that is the price

Interrupt cost has slope in the event rate; polling cost is flat. Below the crossing point the flat line is above and interrupts win; above it, polling wins and keeps winning. Neither is "the efficient one" in general — the event rate decides.

Nobody actually picks one

Real systems refuse the binary. The standard shape is adaptive: take an interrupt when idle, and once an event arrives switch to polling for as long as events keep coming, only re-arming the interrupt when the queue goes quiet. This gets interrupt behaviour's zero idle cost and polling's per-event cost, at the price of substantially more complexity in the driver.

The device side has its own version. Coalescing makes an interrupt-driven path behave more like a polled one by amortising delivery across a batch — one interrupt per N events or per T microseconds, whichever comes first. It is the same trade in a different place: latency for throughput.

From application code all of this is invisible. What is visible is the consequence: a service can be latency-bound by an interrupt configuration nobody in the team chose, on a device nobody in the team configured. Which is the practical lesson — when tail latency has no explanation in your code, the I/O path's delivery mode is worth checking before the code is rewritten.

The adaptive shape used by most modern high-rate drivers
idle          -> interrupts armed, core free to sleep or do other work
event arrives -> take ONE interrupt
                 disable further interrupts for this queue
                 poll the queue until it drains (or a work budget is hit)
queue empty   -> re-arm interrupts, go idle

  low rate  : behaves like interrupts   (one interrupt per event, no spin)
  high rate : behaves like polling      (one interrupt per burst, then spin)
  overload  : work budget bounds the loop so one queue cannot starve others

Key points

  • Interrupt cost scales with event rate; polling cost scales with time. That difference in shape is the whole argument.
  • Above a crossover rate, polling is cheaper in total cycles despite looking wasteful on a utilisation graph.
  • Polling wins on latency at every rate, because there is no delivery or transition to wait through.
  • Real drivers are adaptive: interrupt when idle, poll under load, re-arm when the queue drains.
  • Coalescing is the device-side version of the same trade — batch delivery, exchanging latency for throughput.

Follow the mechanism

The path through the machine, hop by hop — and the conclusions it invites that are wrong.

  1. 1
    Device → controller → core (interrupt path): each event costs a delivery, a privilege transition and displaced cache, whether the core was busy or not.
  2. 2
    Core → device queue (polling path): a load of a descriptor status field and a branch, costing a few cycles and no transition.
  3. 3
    Rate → total cost: interrupt total grows linearly with event rate; polling total is flat, so there is a crossing point.
  4. 4
    Driver → device: an adaptive driver disables the queue's interrupts on the first event and polls until the queue drains, then re-arms.
  5. 5
    Device → delivery policy: coalescing settings decide how many events, or how much time, one interrupt represents.
What people conclude from this — wrongly
  • "Busy-waiting is always wasteful." It is wasteful per unit time and free per event, which makes it the cheaper option above the crossover rate.
  • "The polling core is at 100%, so it is the bottleneck." A dedicated polling core is *designed* to be at 100%; utilisation is not the right signal for it.
  • "Interrupts are more efficient because the core can sleep." True only while the event rate is low enough that delivery cost stays small.
  • "We enabled coalescing and throughput improved, so it is strictly better." It improved throughput by adding latency to every event in the batch.

Consequences, controls and cost

What it causes
  • • High-rate devices on per-event interrupts hit a throughput ceiling well below the hardware's capability.
  • • Latency-critical paths inherit delivery and wakeup latency they cannot see or control from application code.
  • • A polling core shows 100% utilisation while doing exactly the right thing, which routinely triggers the wrong alarm.
  • • Coalescing set for throughput silently raises the tail latency of every request on that path.
What you can do
  • • Match the mechanism to the event rate rather than to instinct: measure the rate first, then choose.
  • • For high-rate paths, prefer a driver or framework that polls adaptively rather than one that takes an interrupt per event.
  • • Tune coalescing explicitly for the workload — the default is a compromise chosen for neither your latency nor your throughput.
  • • Where a core is dedicated to polling, exclude it from general scheduling so the spin loop is not itself preempted.
How to see it
  • • Interrupts per second per queue against events per second — a ratio near 1:1 at high rates is the signature of an untuned path.
  • • Compare achieved throughput against the device's rated capability; a large gap with a busy core suggests delivery overhead.
  • • Measure end-to-end latency with coalescing off and on to see exactly what the batching is costing.
  • • Watch whether a "100% busy" core is spinning in a poll loop or doing work — utilisation alone cannot tell you.
What it costs
  • • Polling consumes a core completely, whether or not events arrive; that core is unavailable for anything else and burns power while idle-spinning.
  • • Adaptive designs get most of both but are meaningfully harder to implement and to reason about under overload.
  • • Coalescing improves throughput and worsens every individual event's latency, and the right setting differs per workload on the same hardware.

Scope

§224 — what these claims are specific to.

What these claims are specific to
  • GENERALThe cost-shape argument (per-event versus per-time) holds for any device and any OS. The crossover point does not transfer.
  • PLATFORM-SPECIFICAdaptive polling exists under different names and with different work-budget rules per OS and driver framework; coalescing knobs are device- and driver-specific.

Misconceptions

Claim
“Polling is a hack that real systems avoid.”
Reality
The highest-throughput networking and NVMe paths in production poll deliberately. It is the standard design above the crossover rate, not a workaround.
Claim
“Interrupts have no cost when nothing is happening, so they are strictly better at low rates.”
Reality
That part is true, and it is exactly why adaptive drivers use interrupts when idle. The claim only fails when the rate rises.
Claim
“Coalescing is free throughput.”
Reality
It is throughput bought with latency. Every event that waits for the batch to fill pays for the events that had not arrived yet.