Skip to content

virtio-net: implement multiqueue (VIRTIO_NET_F_MQ) - #1463

Open
gburd wants to merge 2 commits into
cloudius-systems:masterfrom
gburd:pr/virtio-net-multiqueue
Open

virtio-net: implement multiqueue (VIRTIO_NET_F_MQ)#1463
gburd wants to merge 2 commits into
cloudius-systems:masterfrom
gburd:pr/virtio-net-multiqueue

Conversation

@gburd

@gburd gburd commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What

OSv's virtio-net driver hardcodes a single Rx/Tx virtqueue pair drained by one receiver() poll thread. Every guest's inbound network I/O funnels through that one queue on one CPU. VIRTIO_NET_F_MQ exists as a feature constant but was never negotiated.

This implements multiqueue:

  • Negotiate VIRTIO_NET_F_MQ + VIRTIO_NET_F_CTRL_VQ; read max_virtqueue_pairs from device config; use nqp = min(advertised, num_vCPUs, MSI-X budget).
  • _rxq/_txq single structs become N-element vectors; N Rx poll threads, one per pair, pinned across distinct CPUs.
  • Per-queue MSI-X vectors; Tx queue selected by CPU id; VIRTIO_NET_CTRL_MQ_VQ_PAIRS_SET issued on the control queue.
  • The nqp == 1 path (device without MQ, e.g. QEMU SLIRP) is unchanged.

Prerequisite fix: MSI-X vector budget (also fixes a boot crash)

Enabling multiqueue on a many-vCPU guest exposed a pre-existing crash: OSv abort()s in interrupt_descriptor_table::register_interrupt_handler when the 224 usable IDT vectors (32..255) are exhausted. With several multiqueue virtio-blk devices at high -smp this already crashed at boot before any net change. This PR adds a process-wide MSI-X vector budget: register_interrupt_handler returns 0 (no vector) instead of aborting when the IDT is full, and virtio-blk and virtio-net cap their interrupt-bearing queues against the shared pool.

Validation

  • Multiqueue activates: with a vhost NIC advertising 8 pairs (mq=on), the driver negotiates F_MQ, spins up 8 Rx threads pinned to 8 distinct CPUs, and the device ACKs CTRL_MQ_VQ_PAIRS_SET(8) reaching DRIVER_OK. A single-queue device falls back to nqp=1.
  • Boot stability: native-example boots to ready 10/10 at each of -smp 4, -smp 16, and -smp 48.
  • MSI-X budget A/B: identical config (8x virtio-blk num-queues=48 + virtio-net mq=on, -smp 48): stock master aborts 3/3 in register_interrupt_handler; patched boots 5/5.

Note on throughput

The motivation is concurrent-workload scaling (single-queue net serializes many concurrent connections' RX through one thread). I verified the MQ mechanism (8 active queues/threads) and boot stability here; the end-to-end concurrent-throughput curve is best shown with a many-connection server workload and I have not included those numbers in this PR. Bulk single-stream TCP is not a faithful measure (vhost GRO coalesces it so one Rx thread suffices). The change is a strict superset of the current single-queue behavior (nqp=1 unchanged), so it is safe independent of the throughput delta.

@gburd
gburd marked this pull request as draft August 1, 2026 15:30
@gburd

gburd commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Update: benchmark results and a regression to fix before merge

I ran a direct A/B (multiqueue nqp>=8 vs single-queue nqp=1, same image, same NIC, an external client) with PostgreSQL/pgbench across concurrency levels, and the results change what I can claim for this PR. Converting to draft.

Two honest findings:

1. The multiqueue feature does not improve concurrent throughput on this workload. Single-queue (nqp=1) equals or beats nqp>=8 at every concurrency level (e.g. read-only tps at 16 connections: nqp=1 gave ~66k vs nqp=16 ~25k, reproduced). The throughput ceiling this workload hits is a per-connection CPU/scheduler cost and a storage write path, not RX-queue depth. So multiqueue is not the scaling win I expected; it is correct and it activates (N queues, N Rx threads, device ACKs CTRL_MQ_VQ_PAIRS_SET), but it does not move the numbers here.

2. The MSI-X vector budget introduces a virtio-blk regression under load. When virtio-net claims 2*nqp vectors at high vCPU count, it starves the several virtio-blk devices; the soft-fail path (register_interrupt_handler returning 0 instead of aborting) then leaves a blk queue with no interrupt vector, so completions are never delivered and reads fail with EIO under sustained write. The pre-MQ image was clean. This needs fixing: a queue that cannot get its own vector must fall back to a shared/polled vector rather than run interrupt-less, and net must not be allowed to starve blk.

What stands: the vector-budget change does fix a real pre-existing boot crash (many-vCPU guests with several multiqueue virtio-blk devices previously aborted in register_interrupt_handler on IDT vector exhaustion; they now boot). That part is worth keeping.

Plan: I will either (a) fix the vector arbitration so blk always keeps a working (shared/polled) vector and re-take the A/B, or (b) split the boot-crash MSI-X fix into its own PR and hold the multiqueue feature until it demonstrates a real gain. Marking draft until then.

@wkozaczuk

Copy link
Copy Markdown
Collaborator

I am not surprised by your performance findings. I am seeing similar ones with the virtio-blk multi-queue multi-consumer -threads changes. Single queue, single consumer thread seems to be beating all other setups.

@gburd

gburd commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

That lines up with what I found, and it points at a shared root cause rather than anything queue-specific.

I profiled the concurrency collapse separately (a many-connection server workload over the network) and the guest was ~90% idle while throughput fell: threads were piling onto a few CPUs while the rest sat idle. The scheduler places a new thread on its creator's CPU and re-wakes a blocked thread on its last CPU, and the load balancer only migrated one thread per 100ms, far too slow to disperse a fan-out of many worker threads. That is why more queues (net or blk) did not help: adding consumers does not change where the woken threads land, so they still serialize on the same few CPUs.

I opened #1464 for the scheduler side (run the balancer more often and drain the whole imbalance per pass); it recovered +53% to +73% at 32 to 64 connections in an A/B. Your virtio-blk observation is consistent with the same effect: single queue/single consumer wins because it avoids paying migration/placement overhead for consumers that the balancer cannot spread anyway.

Given all that, I am holding this virtio-net MQ PR as draft. The multiqueue feature does not earn its keep on its own, and the vector-budget change in it also needs the virtio-blk starvation fix I noted above. The genuinely useful part is the MSI-X vector-budget boot fix (many-vCPU guests with several multiqueue virtio-blk devices previously aborted at boot); if useful I can split that out into its own small PR and drop the multiqueue machinery.

@gburd
gburd force-pushed the pr/virtio-net-multiqueue branch from 3ed80cc to 00f2b61 Compare August 4, 2026 17:21
@gburd

gburd commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Reworked: the MSI-X vector-budget logic this originally carried is factored out into #1465 (so blk can never be starved of interrupt vectors), and this PR now stacks on it with the multiqueue mechanism alone (N Rx virtqueues + N receiver threads pinned across CPUs, N Tx queues selected by CPU, CTRL_MQ_VQ_PAIRS_SET).

Honest performance note: on a many-connection PostgreSQL/pgbench workload I measured MQ vs single-queue on an otherwise-fixed multi-CPU guest and saw no reliable throughput change (gains within run-to-run noise; that workload is bottlenecked on wake/round-trip latency, not RX-queue depth, so more queues don't help it). So I am not claiming a benchmark win here.

The case for it is architectural, not workload-specific: single-queue serializes all inbound traffic through one receiver thread on one CPU, which caps any RX-bound concurrent workload regardless of core count. Multiqueue removes that serialization for the workloads where RX genuinely is the bottleneck. I am fine keeping it as draft pending an RX-bound benchmark that demonstrates the gain, or landing it as a general capability with the MSI-X budget (#1465) that makes it safe. Reviewers' call on that basis.

gburd added 2 commits August 6, 2026 06:46
The x86-64 IDT exposes only 224 usable interrupt vectors (32..255), and
every device shares that pool.  A guest with many vCPUs and several
multiqueue virtio devices can request one MSI-X vector per queue and
exhaust the pool during boot.  Two things then went wrong:

  - register_interrupt_handler() called abort() when no IDT vector was
    free, crashing the boot instead of letting the device degrade.
  - request_vectors() pushed a msix_vector whose allocation had failed,
    and ~msix_vector() then unregistered vector 0.

Fix both and add a soft cap so devices stay within budget in the first
place:

  - register_interrupt_handler() returns 0 (an impossible real vector,
    since 0..31 are CPU exceptions) as an "exhausted" sentinel instead
    of aborting.  msix_vector's destructor skips the unregister when its
    vector is 0, and request_vectors() stops at the first failed
    allocation and returns the short vector it did get; the existing
    easy_register() already treats a short result as failure, so a
    driver falls back to fewer queues rather than crashing.
  - Add virtio_driver::reserve_msix_vectors(): a process-wide soft
    budget that a multiqueue driver consults to cap how many queues it
    arms with a distinct interrupt, leaving headroom for other devices
    and always granting at least one.  virtio-blk uses it to cap its
    active queue count; extra probed virtqueues are simply left unused.

Signed-off-by: Greg Burd <greg@burd.me>
The virtio-net driver hardcoded a single Rx/Tx queue pair drained by one
receive poll thread.  Every inbound packet for every connection funnelled
through that one queue and one thread on one CPU, so receive-side network
throughput could not scale past what a single core can drain, regardless of
how many vCPUs the guest had.

Negotiate VIRTIO_NET_F_MQ (and VIRTIO_NET_F_CTRL_VQ, required to activate it)
and drive N Rx/Tx queue pairs:

  - Read max_virtqueue_pairs from config space and pick the pair count as
    min(advertised pairs, number of vCPUs, MSI-X vector budget).
  - Replace the single rxq/txq with vectors of queue pairs.  Each Rx queue has
    its own poll thread, pinned to a distinct CPU so receivers run in parallel;
    each Tx queue has its own xmitter.  Transmit selects a Tx queue by CPU id.
  - Register one MSI-X vector per data virtqueue (entry = virtqueue index, per
    the 1:1 mapping setup_queue() programs) so a completion on queue i wakes
    that queue's thread.
  - Send VIRTIO_NET_CTRL_MQ_VQ_PAIRS_SET on the control virtqueue to tell the
    device how many pairs the guest will use.

When the device does not offer VIRTIO_NET_F_MQ (or offers a single pair, as
with the MMIO transport's shared interrupt) the driver uses exactly one queue
pair and behaves as before.

The per-queue MSI-X vectors are drawn from the shared vector budget added in
the preceding change (virtio_driver::reserve_msix_vectors), so a high-vCPU
guest with this multiqueue net device plus other multiqueue virtio devices
stays within the 224 usable IDT vectors and each device still gets at least
one interrupt-bearing queue instead of exhausting the pool.

Signed-off-by: Greg Burd <greg@burd.me>
@gburd

gburd commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up: an RX-bound benchmark now demonstrates the gain

Earlier I held this draft because an isolated MQ-vs-single-queue A/B showed no reliable throughput change: that workload was bottlenecked on wake/dispatch latency downstream of RX, so more RX queues could not help it, and the effect of multiqueue was masked.

With that downstream bottleneck fixed as well (the idle-CPU pull dispatch work in #1472), the picture separates cleanly and multiqueue earns its keep. On a workload with many concurrent network connections driven over a real NIC from an external client:

  • Single-queue funnels all inbound traffic through one receiver thread on one CPU. Under rising concurrency the aggregate guest CPU actually drops while throughput falls, and sampling shows CPUs idle-spinning waiting for the one receiver to dispatch: a classic single-serializer collapse on an otherwise-idle guest.
  • With multiqueue on, inbound work spreads across the queues' receiver threads. Under the same concurrent load 7 of 8 CPUs go busy (versus the single receiver funnel), and mid-to-tail throughput rises by roughly 20-25% across the higher concurrency levels, 0-failed.

So the architectural claim I made before is now backed by a measured RX-bound result: N concurrent connections spread across N CPUs instead of serializing through a single receiver. Multiqueue is necessary but not sufficient on its own; paired with the dispatch fix it is a real, repeatable win.

Dependency: this still stacks on the MSI-X vector-budget fix (#1465, so many-queue guests keep working interrupt vectors for every device); #1465 should land first. Marking ready for review on that basis.

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