Interrupt Coalescing and Encode Jitter: Why 60fps Contribution Ingest Is Not a Single-Knob Problem

Search for “tune NIC interrupts for 60fps contribution ingest” and you will find recipes: set rx-usecs to some number, disable adaptive coalescing, pin the IRQ. The premise is that coalescing is one knob and 60fps is one target. The kernel documentation describes something messier. There are at least three distinct batching and deferral layers between the wire and your encoder’s input buffer, and they interact. A change at one layer can be masked or amplified by another. If you tune one number and declare victory, you may have measured the wrong layer.

This article is about how to reason about those layers before you change anything. It is not a recipe. The retrieved sources do not contain a recommended microsecond value for contribution ingest, and I am not going to invent one.

What coalescing actually is

ethtool is the userspace interface for querying and controlling network device driver and hardware settings, particularly for wired Ethernet devices. Its coalescing subcommand exposes the parameters most guides talk about:

ethtool -c|--show-coalesce devname
ethtool -C|--coalesce devname [ adaptive-rx on | off ] [ adaptive-tx on | off ]
  [ rx-usecs N ] [ rx-frames N ] [ rx-usecs-irq N ] [ rx-frames-irq N ]
  [ tx-usecs N ] [ tx-frames N ] [ tx-usecs-irq N ] [ tx-frames-irq N ]

That is from the ethtool(8) man page. The same interface is exposed over netlink as ETHTOOL_MSG_COALESCE_GET and ETHTOOL_MSG_COALESCE_SET, per the kernel ethtool netlink documentation. So the device-level coalescing knobs are real, queryable, and settable. They are also not the whole story.

One caveat worth internalizing before you build a tuning procedure around ethtool: the man page notes that not all network drivers support its functionality in part or whole. A parameter that appears in the synopsis is not a guarantee that your NIC’s driver implements it meaningfully.

Layer one: device IRQ coalescing

The kernel’s NAPI documentation is explicit about where batching normally comes from: “NAPI does not perform any explicit event coalescing by default. In most scenarios batching happens due to IRQ coalescing which is done by the device.” That is the NAPI documentation. So the rx-usecs and rx-frames values you set with ethtool -C are the device’s decision about how long to wait or how many frames to accumulate before raising an interrupt. That is layer one.

The Intel igb driver documentation gives a sense of why the driver cares: in multiqueue mode, “All interrupts are throttled via interrupt moderation. Interrupt moderation must be used to avoid interrupt storms while the driver is processing one interrupt. The moderation value should be at least as large as the expected time for the driver to process an interrupt.” That is from the igb driver documentation. The framing there is about avoiding interrupt storms, not about hitting a frame rate. That distinction matters: the driver’s default is a throughput-protection default, not a latency default.

Layer two: NAPI software coalescing

This is the layer most tuning guides skip. NAPI can be configured to arm a repoll timer instead of unmasking hardware interrupts as soon as all packets are processed. The NAPI documentation describes the controls:

The gro_flush_timeout sysfs configuration of the netdevice is reused to control the delay of the timer, while napi_defer_hard_irqs controls the number of consecutive empty polls before NAPI gives up and goes back to using hardware IRQs.

These can be set globally, or per-NAPI via netlink through netdev-genl. When set per-NAPI, the parameter names use hyphens: gro-flush-timeout and napi-defer-hard-irqs. The kernel source tree ships a script, tools/net/ynl/pyynl/cli.py, that can drive this; the documentation gives an example of the form:

cli.py --spec Documentation/netlink/specs/netdev.yaml \
  --do napi-set \
  --json='{"id": 345, "defer-hard-irqs": 111, "gro-flush-timeout": 11111}'

Why does this matter for contribution ingest? Because if you set rx-usecs to a low value and leave gro_flush_timeout at a high value, you have not removed batching. You have moved it. The device may interrupt promptly, but NAPI may defer re-arming the interrupt and keep polling. From the application’s perspective, packets still arrive in bursts. A packet capture that shows bursty arrival does not tell you which layer produced the burst.

Layer three: IRQ suspension and busy polling

The NAPI documentation describes two more mechanisms that change who is allowed to defer interrupts and for how long.

Busy polling lets a user process check for incoming packets before the device interrupt fires. The documentation is direct about the tradeoff: “As is the case with any busy polling it trades off CPU cycles for lower latency (production uses of NAPI busy polling are not well known).” It is enabled by setting SO_BUSY_POLL on selected sockets, or via the global net.core.busy_poll and net.core.busy_read sysctls.

IRQ mitigation goes further. An application can set SO_PREFER_BUSY_POLL to pledge that it will perform a busy polling operation periodically, and “the driver should keep the device IRQs permanently masked.” The pledge is revoked if gro_flush_timeout passes without any busy poll call. This is a different regime from coalescing: the device is not batching, it is not interrupting at all, and the application is responsible for polling.

IRQ suspension masks device IRQs while epoll triggers NAPI packet processing. The per-NAPI irq-suspend-timeout parameter sets the maximum time an application can have its IRQs suspended. The documentation describes it as a way to balance CPU consumption with network processing efficiency, and notes that the timeout “serves as a safety mechanism to restart IRQ driver interrupt processing if the application has stalled.”

The documented tradeoff for gro_flush_timeout is worth quoting in full because it is directional, not a recommended value:

It is important to note that choosing a large value for gro_flush_timeout will defer IRQs to allow for better batch processing, but will induce latency when the system is not fully loaded. Choosing a small value for gro_flush_timeout can cause interference of the user application which is attempting to busy poll by device IRQs and softirq processing.

Read that carefully. There is no value that is simply “better.” A large value helps batching and hurts latency at low load. A small value helps a busy-polling application and lets device IRQs and softirq processing interfere with it. The right value depends on whether the host is busy-polling, how loaded it is, and what the application is doing between epoll_wait calls.

What changes on a PREEMPT_RT kernel

If the ingest host runs PREEMPT_RT, the picture shifts again. The San Vicente GutiƩrrez et al. paper on real-time Linux communications describes the relevant mechanics:

In PREEMPT-RT, most interrupt request (IRQ) handlers are forced to run in threads specifically created for that interrupt. These threads are called IRQ threads. Handling IRQs as kernel threads allows priority and CPU affinity to be managed individually.

And on softirq context:

From version 3.6.1-rt1, the soft IRQ handlers are executed in the context of the thread that raised that Soft IRQ. This means that the NET_RX soft IRQ will be normally executed in the context of the network device IRQ thread, which allows a fine control of the networking processing context.

That sounds good for determinism, and it can be. But the same paper documents the failure mode:

However, if the network IRQ thread is preempted or it exhausts its NAPI weight time slice, it is executed in the ksoftirqd/n (where n is the logical number of the CPU). Processing packets in the ksoftirqd/n context is troublesome for real-time because this thread is used by different processes for deferred work and can add latency. Also, as the ksoftirqd runs with SCHED_OTHER policy, the thread execution can be easily preempted.

This is the part that no coalescing parameter controls. Under load, softirq processing can migrate from the IRQ thread into ksoftirqd, which runs at SCHED_OTHER and can be preempted by anything. If your jitter appears only under load, and you have been tuning rx-usecs, you may be looking at the wrong layer entirely.

The paper also describes NAPI’s own adaptive behavior: “NAPI uses interrupt mitigation when high bandwidth incoming packets are detected. Then, the kernel switches to a polling-based processing, checking periodically if there are queued packets. When there is not as much load, the interrupts are re-enabled again.” And: “The Linux kernel uses the interrupt-driven mode by default and only switches to polling mode when the flow of incoming packets exceeds a certain threshold, known as the ‘weight’ of the network interface.” So NAPI itself changes mode based on load. A measurement taken at one load level may not predict behavior at another.

A diagnostic order for contribution ingest

The following is reasoning derived from the documented layering, not a benchmark result. I am not claiming any specific setting produces 60fps ingest. The point is to avoid changing a parameter at a layer that is not the one adding jitter.

Step 1: Determine whether batching is happening at the device or in software. The NAPI documentation says batching normally comes from device IRQ coalescing, but NAPI software coalescing exists and can be configured. If you disable or minimize NAPI software coalescing (gro_flush_timeout, napi_defer_hard_irqs) and the arrival pattern in your capture does not change, the batching is at the device. If it does change, you were looking at the software layer. Do this before touching ethtool -C.

Step 2: If the host runs PREEMPT_RT, check whether softirq processing is landing in ksoftirqd under load. The ar5iv paper describes this as a state change that adds latency and is not controlled by coalescing parameters. If your jitter correlates with load and with ksoftirqd activity, coalescing is not your first problem.

Step 3: Only then adjust coalescing parameters, one at a time, and re-measure. The three layers interact. A change to rx-usecs can be masked by a gro_flush_timeout that is still deferring interrupts, or amplified by a busy-polling application that is now fighting device IRQs. Change one thing, capture again, compare.

Step 4: Decide whether busy polling is appropriate for this host. The NAPI documentation notes that production uses of NAPI busy polling are not well known, and that busy polling trades CPU cycles for lower latency. On a dedicated contribution ingest host with spare cores, that trade may be acceptable. On a host also running the encoder, it may not be. This is a decision about the host’s role, not a tuning value.

What this article does not claim

It does not claim that any specific rx-usecs, rx-frames, gro_flush_timeout, or irq-suspend-timeout value produces 60fps contribution ingest. The retrieved sources do not contain such a value, and any number I gave you would be invented.

It does not claim that interrupt coalescing alone determines encode jitter. The sources describe coalescing as one layer among several, including NAPI software coalescing, IRQ suspension, busy polling, and on PREEMPT_RT the softirq context migration into ksoftirqd.

It does not claim that SRT, RIST, WebRTC, or QUIC behave differently under coalescing. No retrieved source addresses those protocols in this context. If you are choosing a transport for contribution ingest, that is a separate question from NIC tuning.

It does not claim that a particular NIC, driver, or kernel version is required or sufficient. The igb documentation is cited as an example of how one driver frames interrupt moderation, not as a recommendation.

FAQ

Does ethtool -C work on every NIC? No. The man page states that ethtool is not supported in part or whole on all network drivers. Check ethtool -c output on your specific device before assuming a parameter is settable.

What is the difference between rx-usecs and gro_flush_timeout? rx-usecs is a device-level coalescing parameter set via ethtool. gro_flush_timeout is a NAPI software coalescing parameter that controls how long NAPI waits before re-arming hardware interrupts. They operate at different layers and can both be active simultaneously.

Can I set gro_flush_timeout per queue instead of globally? Yes. The NAPI documentation states that gro_flush_timeout and napi_defer_hard_irqs can be set on a per-NAPI basis using netlink via netdev-genl, with hyphenated parameter names (gro-flush-timeout, napi-defer-hard-irqs).

What does SO_PREFER_BUSY_POLL actually do? It lets an application pledge to the kernel that it will perform busy polling periodically, and the driver should keep device IRQs permanently masked. The pledge is revoked if gro_flush_timeout passes without a busy poll call. This is a different mode from coalescing: the device is not batching, it is not interrupting.

Why does jitter appear only under load? Two documented reasons. First, NAPI switches from interrupt-driven to polling mode when incoming packet flow exceeds the interface’s weight threshold. Second, on PREEMPT_RT, if the network IRQ thread is preempted or exhausts its NAPI weight time slice, softirq processing runs in ksoftirqd, which runs at SCHED_OTHER and can be easily preempted. Neither is controlled by a coalescing value.

Is busy polling a good idea for contribution ingest? The NAPI documentation notes that production uses of NAPI busy polling are not well known, and that it trades CPU cycles for lower latency. Whether that trade is acceptable depends on whether the host has spare cores and whether the application can poll frequently enough to keep the pledge. That is a host-role decision, not a universal recommendation.