Computer Architecture · Accelerators

Interrupt Moderation and Event Delivery at High Rates

Why one interrupt per completed transfer collapses at high packet rates, how coalescing timers and counters bound the damage, and what a polled-mode driver gives up.

An interrupt is a control transfer with a large fixed cost. The device asserts a line or sends a message, the interrupt controller routes it to a processor, the processor takes an exception, saves state, runs a handler, and returns. Each step is bounded and none is cheap; the total is on the order of a thousand cycles, and it does not shrink with the size of the work the interrupt reports. For a device completing one transfer per interrupt, that cost is paid per transfer, and at high transfer rates it exceeds the cost of the transfers themselves. Interrupt moderation is the set of techniques that break that proportionality, and each of them buys throughput by spending latency.

The arithmetic that forces the issue

Suppose a device completes transfers at a rate $R$ and generates one interrupt per completion. The interrupt overhead is $R \cdot c_i$ cycles per second, where $c_i$ is the cost of one interrupt in cycles. If the processor runs at frequency $f$, the fraction of one core consumed by interrupt handling alone is $R \cdot c_i / f$. A device completing a hundred thousand transfers per second with a thousand-cycle interrupt cost consumes a tenth of a core at 1 GHz — before the handler has done any work on the data. At a million transfers per second the same device consumes an entire core.

The overhead is worse than the raw cycle count suggests, for three reasons. Interrupts disrupt the processor’s caches: the handler’s code and data displace whatever the interrupted thread was using, and the thread pays to reload them afterwards. Interrupts on a multi-core system are routed to one processor unless the platform distributes them, so a device with a single interrupt vector concentrates all its handling on one core while the others idle. And the handler must read device state to discover what happened, which is an MMIO read across the interconnect, adding a round trip to the cycle count.

The first mitigation is trivial and nearly universal: one interrupt may report many completions. The device writes a completion count or a completion index into memory, and the handler reads that index, processes everything up to it, and acknowledges. The interrupt’s job is reduced to “there is new work”, not “here is one item of work”. This alone changes the cost from per-transfer to per-interrupt, and the ratio between them is the batch size.

Coalescing with a timer and a counter

The next step is to reduce the number of interrupts, which requires deciding when one is worth raising. A device that has just completed a single transfer does not know whether another will follow in a nanosecond or a millisecond. Two heuristics approximate the answer.

The first is a counter threshold: raise an interrupt once $n$ completions have accumulated. The device counts completions and suppresses the interrupt below the threshold. This bounds the number of interrupts to $R/n$ per second and bounds the latency added to any single completion by the time it takes to accumulate $n$ more — which is unbounded if the device goes idle after $n-1$.

The second is a timer: raise an interrupt when a fixed interval has elapsed since the last one, regardless of how many completions accumulated. This bounds latency absolutely, at the cost of raising interrupts for very small batches when the device is slow. A timer that fires with zero completions is pure waste, so implementations arm the timer only when there is unacknowledged work.

Real devices combine the two and add a third condition: raise immediately if the device has been idle, because a lone completion after a quiet period is usually a latency-sensitive request whose response the host is waiting for. The result is a rule with three clauses — count reached, timer expired, or queue about to drain — and the parameters that govern them are what an administrator tunes when balancing throughput against response time.

#include <stdint.h>
#include <stdbool.h>

/* Coalescing state kept by the device; the driver only sets the parameters. */
struct coal {
    uint32_t count;        /* completions since last interrupt */
    uint32_t threshold;    /* raise after this many */
    uint32_t usecs;        /* or after this long */
    bool     armed;        /* timer running */
    bool     idle;         /* no completions outstanding */
};

bool should_interrupt(struct coal *c, uint32_t new_completions)
{
    c->count += new_completions;

    if (c->count == 0)
        return false;                 /* nothing to report */
    if (c->idle && new_completions > 0) {
        c->idle = false;
        c->count = 0;                 /* the interrupt reports this batch */
        return true;                  /* first work after a quiet period */
    }
    if (c->count >= c->threshold) {
        c->count = 0;
        return true;                  /* batch full */
    }
    if (c->armed && c->usecs == 0) {
        c->count = 0;
        return true;                  /* timer expired */
    }
    return false;
}

The latency a coalescing timer adds is bounded by the timer interval but its effect is not: a request that is part of a synchronous chain — a read that must complete before the next request is issued — pays the full interval every time. Workloads that alternate request and response, rather than issuing many requests before waiting, are the ones that suffer most from aggressive coalescing, and they are also the ones whose throughput is already limited by round-trip latency rather than by interrupt cost.

Message-signalled interrupts and vector distribution

Older devices asserted a physical line, and the interrupt controller had a limited number of them. A device shared a line with others, and the handler had to interrogate every device on the line to find the one that fired. Message-signalled interrupts replaced the line with a memory write to a controller-defined address, which removes the sharing problem entirely: each vector has its own address, and the number of vectors is bounded by the message format rather than by wires.

The benefit is not only the removal of sharing. Multiple vectors let a device report different events through different entry points, so a handler for receive completions is distinct from a handler for transmit completions and from one for errors. Vectors can be routed to different processors, which distributes the handling load and keeps each processor’s cache warm for its own queue. This is the mechanism that makes multi-queue devices scale: a device with $q$ queues and $q$ vectors spreads across $q$ cores with no cross-core contention in the fast path.

The distribution has a failure mode. If the number of queues exceeds the number of available vectors, some vectors must serve several queues, and the handler must again interrogate to find which one fired. Systems therefore size the vector table to the queue count, and the practical limit is often the platform’s interrupt controller capacity rather than the device’s.

Polling as the limit case

If interrupts are expensive and coalescing adds latency, the logical extreme is to remove them entirely and have the processor check the device on its own schedule. In pure polling the driver spins on the completion index, and the cost per completion drops to a load and a comparison — a few cycles if the index is in cache.

The trade is stark and it is the reason polling is not the default. A polling driver occupies a processor completely, whether or not there is work, so its efficiency at low rates is zero. At high rates it wins because it eliminates both the interrupt cost and the cache disruption, and because it never pays the cost of discovering that there was nothing to do beyond one load.

The usual compromise is to poll for a bounded period and then fall back to interrupts. After an interrupt, the driver processes the backlog and then keeps polling for a short window, because a device that just delivered a burst is likely to deliver more. When the window expires with no new work, the driver re-enables interrupts and returns. The window is measured in a small number of microseconds, and its size is the parameter that trades wasted polling cycles against a return to the interrupt path.

This hybrid is what most high-rate network drivers implement, and its correctness rests on a race-free handoff: the driver must re-enable interrupts and re-check the completion index in an order that cannot lose a completion that arrives in between. Re-enabling first and then checking is safe, because a completion arriving after the enable raises an interrupt that the driver will see. Checking first and then enabling is not, because a completion arriving in the gap is neither seen by the check nor reported by an interrupt that was still masked.

Choosing the parameters

The three quantities that matter are the batch threshold, the timer interval, and the polling window, and they are not independent. A large threshold with a short timer behaves like a timer; a small threshold with a long timer behaves like a counter. The polling window should exceed the timer interval, or the driver will return to interrupts only to be woken immediately.

Workload characteristics set the useful range. Bulk transfer wants large batches and long timers, because the completion of any individual unit is not on a critical path. Request-response traffic wants small batches and short timers, because each completion unblocks a dependent request. Mixed traffic wants per-queue parameters rather than global ones, which is another argument for multi-queue devices: the parameters can be set per class of work instead of compromising.

The measurement that resolves the choice is the distribution of batch sizes the device actually sees, not the mean. A device whose completions arrive in bursts of hundreds separated by idle periods wants a large threshold, because the timer will rarely fire and the counter will rarely be reached. A device whose completions arrive singly and steadily wants a timer, because a counter will always add latency and never batch anything. The two cases look identical in average rate and behave completely differently under either policy.

Whatever the parameters, the notification is only half the contract. The other half is the memory ordering between the device’s completion write and the notification itself, which is the same release discipline that governs the submission path in Descriptor Rings and the Producer-Consumer Contract. A completion that is announced before its payload is visible produces a handler that reads stale data, and no amount of coalescing tuning fixes that.

References

  1. PCI Local Bus Specification, Revision 3.0 — Chapter 6, Interrupts — PCI-SIG
  2. Linux kernel documentation — NAPI — Linux kernel project
  3. Computer Architecture: A Quantitative Approach, 6th edition — Appendix on storage and I/O — Hennessy & Patterson; Morgan Kaufmann