Skip to content

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

Draft
gburd wants to merge 1 commit into
cloudius-systems:masterfrom
gburd:pr/virtio-net-multiqueue
Draft

virtio-net: implement multiqueue (VIRTIO_NET_F_MQ)#1463
gburd wants to merge 1 commit 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.

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.  A concurrency sweep of a network-bound server
workload showed throughput saturating at a low connection count and then
regressing as contention grew, with most vCPUs idle: the load was serialized
on the single queue, not CPU-bound.

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.

Also add a process-wide MSI-X vector budget shared by all virtio devices.  The
x86-64 IDT has only 224 usable vectors (32..255), shared by every device.  A
high-vCPU guest with several multiqueue virtio devices (for example a handful
of multiqueue virtio-blk disks plus multiqueue virtio-net) could request more
one-vector-per-queue bindings than there are vectors and abort during driver
load, so the guest could not boot at high vCPU counts at all.  Now:

  - register_interrupt_handler() returns 0 (an impossible MSI vector, since
    0..31 are CPU exceptions) instead of aborting when the IDT is full, and
    msix_vector construction / request_vectors() treat that as a soft failure;
  - virtio_driver::reserve_msix_vectors() hands out vectors from a shared pool
    so each multiqueue device caps its interrupt-bearing queue count and still
    gets at least one vector.

virtio-blk and virtio-net both consult the budget, so a guest with many vCPUs
and several multiqueue virtio devices boots instead of crashing, and each
device runs as many queues as the budget allows.

Signed-off-by: Greg Burd <greg@burd.me>
@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.

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