Skip to content

net: batch per-connection wakeups in the virtio-net receiver - #1467

Open
gburd wants to merge 1 commit into
cloudius-systems:masterfrom
gburd:pr/net-batch-wakeup
Open

net: batch per-connection wakeups in the virtio-net receiver#1467
gburd wants to merge 1 commit into
cloudius-systems:masterfrom
gburd:pr/net-batch-wakeup

Conversation

@gburd

@gburd gburd commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

What

The virtio-net receiver drains many packets per RX pass but wakes their target connections one packet at a time: classifier::post_packet() calls net_channel::wake() for every packet (the long-standing // FIXME: find a way to batch wakes). Each wake runs a full thread::wake_impl() and, when the woken connection last ran on a different CPU, sends a wakeup IPI. Under many concurrent connections this is one wake round-trip, often one cross-CPU IPI, per packet, on the single receiver thread's hot path.

This adds a batched producer path. classifier::post_packet(m, batch) classifies and pushes the packet exactly as before but records the target channel in a small deduplicating net_channel_wake_batch instead of waking it. The receiver holds one rcu_read_lock across the whole drain pass, records every touched channel, then wakes each distinct channel once via batch.flush() at the end of the pass. Because the flushed wakes run back-to-back with no intervening reschedule, thread::wake_impl()'s per-target-CPU IPI coalescing (incoming_wakeups_mask) collapses them into at most one wakeup IPI per destination CPU per pass instead of one per packet, and a connection that received several packets in the pass is woken only once.

The single rcu_read_lock over the drain keeps the recorded net_channel pointers valid until flushed (net_channel is rcu_dispose()d on teardown). The batch's inline capacity avoids heap traffic on the RX hot path and spills to a vector only past 16 distinct connections per pass.

Selectable at runtime via OSV_NET_BATCH_WAKE ("0" disables, restoring the exact per-packet path) so it can be A/B measured on a single image; defaults on. The non-batched path is unchanged.

Correctness / status

  • Verified correct with 16 concurrent connections x 500 distinct payloads each: every echo exact with batching on; batched wakes deliver to the right channels, no lost or misrouted wakeups.
  • Single-CPU A/B (batching on vs off) shows no regression across 1..96 connections.

I want to be straight about the performance evidence: the cross-CPU IPI-coalescing benefit only appears at smp>1, and I have not yet been able to produce a clean smp>1 A/B throughput number. On this setup the guest wedges under concurrent RX load at smp>=2 independently of this change (it reproduces with OSV_NET_BATCH_WAKE=0, i.e. the current per-packet path, on both a plain tap and a vhost NIC), so I could not isolate the batching delta at scale. That looks like a separate pre-existing RX-wakeup issue I am investigating on its own.

So this PR is offered on its merits as a mechanism improvement: it removes a real per-packet wake/IPI cost on the receiver hot path, closes the in-tree FIXME, is off-by-a-flag reversible, and does not change the single-queue behavior. It is not making a measured throughput claim; the at-scale numbers depend on the separate wedge being resolved first.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a batched wakeup mechanism for virtio-net RX fast-path delivery, reducing per-packet net_channel::wake() calls by collecting distinct target channels during an RX drain pass and waking each channel once at the end of the pass (toggleable via OSV_NET_BATCH_WAKE).

Changes:

  • Add classifier::post_packet(m, batch) to record (deduplicated) target channels instead of waking per packet.
  • Introduce net_channel_wake_batch to collect and flush distinct channel wakeups.
  • Update virtio-net receiver to optionally hold a single RCU read lock across a drain pass, accumulate wake targets, and flush them at pass end; add env-var-controlled enablement.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
include/osv/net_channel.hh Adds net_channel_wake_batch and a batched classifier::post_packet() overload to deduplicate wakeups.
core/net_channel.cc Implements the new batched classifier::post_packet(m, batch) path alongside the existing per-packet wake path.
drivers/virtio-net.hh Declares net::batch_wakes_enabled() runtime toggle helper.
drivers/virtio-net.cc Implements env-var toggle and updates RX drain to batch channel wakeups and flush once per pass.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +191 to +206
// wake every distinct recorded channel once, then reset
void flush() {
for (unsigned i = 0; i < _n; i++) {
_inline[i]->wake();
}
_n = 0;
if (_spill) {
for (auto* c : *_spill) {
c->wake();
}
delete _spill;
_spill = nullptr;
}
}
bool empty() const { return _n == 0 && !_spill; }
~net_channel_wake_batch() { delete _spill; }
Comment thread drivers/virtio-net.cc Outdated
Comment on lines 568 to 572
bool fast_path = batch_wakes
? _ifn->if_classifier.post_packet(m_head, wake_batch)
: _ifn->if_classifier.post_packet(m_head);
if (!fast_path) {
(*_ifn->if_input)(_ifn, m_head);
@gburd

gburd commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Update after measuring this properly on a large multi-connection PostgreSQL workload (many-vCPU KVM guest, real tap+vhost NIC):

  1. A crash fix (added here): the batched-wake path held rcu_read_lock across if_input(), so the first slow-path RX packet took a page fault in a non-preemptable context and aborted on SMP. The new commit defers slow-path mbufs past the rcu unlock. Without this, batching could not be safely enabled under real traffic.

  2. Honest performance finding: with batching correctly enabled, an A/B (OSV_NET_BATCH_WAKE=0 vs 1) showed no measurable throughput change at 8/16/32 connections on this workload. The concurrency ceiling I had hoped this addressed turned out to be a different bottleneck entirely (a single global mutex in an unrelated mmu path), not the per-packet wake cost.

So I am not presenting this as a scaling fix. It is a mechanism cleanup that closes the in-tree FIXME: find a way to batch wakes and removes a real per-packet wake/IPI cost on the receiver hot path, now with the SMP crash fixed and off-by-a-flag reversible. It is correct and safe, but it does not move the numbers on the workloads I measured. Reviewers should weigh it on that basis; I am happy to hold or drop it if the micro-opt is not wanted without a demonstrated gain.

The receive poll thread classified each inbound packet to its net_channel and
woke that channel immediately, once per packet.  Under a many-connection load
that is one thread::wake() and, when the target runs on another CPU, one wakeup
IPI per packet, which defeats the scheduler's per-target-CPU IPI coalescing
(incoming_wakeups_mask) because the wakes are spread across the drain loop with
other work between them.  This closes the long-standing "FIXME: find a way to
batch wakes" in core/net_channel.cc.

Add classifier::post_packet(m, net_channel_wake_batch&): it classifies and
pushes the packet like the existing post_packet() but records the touched
net_channel in a small deduplicating batch instead of waking it.  The receiver
holds one osv::rcu_read_lock across the whole drain pass, collects the distinct
channels, and flushes one wake per channel at the end of the pass.  Because the
flushed wakes run back to back with no intervening reschedule, wake_impl()'s
per-destination-CPU IPI coalescing collapses them into at most one wakeup IPI
per destination CPU per pass instead of one per packet.

net_channel_wake_batch keeps a small inline array (spilling to the heap only on
overflow) so the hot path allocates nothing, and it must be used and flushed
under the same rcu_read_lock that guarded the post_packet() calls because the
recorded net_channel pointers are rcu_dispose()d on connection teardown.

The non-classified slow path runs the full BSD input stack, which may
demand-fault a page and therefore requires a preemptable context, which is
illegal under the rcu_read_lock the batch path holds across the drain.  Defer
those packets into a list and run them up the stack after the wake flush and
rcu unlock, when the thread is preemptable again.

OSV_NET_BATCH_WAKE=0 selects the original one-wake-per-packet path unchanged;
the batched path is the default.

Signed-off-by: Greg Burd <greg@burd.me>
@gburd
gburd force-pushed the pr/net-batch-wakeup branch from fdcad6a to aaa45d6 Compare August 4, 2026 17:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants