feat(vmm): run virtio-net on vhost-net with configurable queue pairs - #1145
feat(vmm): run virtio-net on vhost-net with configurable queue pairs#1145kvinwang wants to merge 2 commits into
Conversation
c4e0615 to
a350589
Compare
a350589 to
0d3ad45
Compare
|
Reviewed against 1. 2. Queue-echo mismatch leaks the prepared interface; upgrade order is undocumented. 3. Existing nodes change their QEMU command line on upgrade with no config change. 4. Context that may be useful in the docs:
Separately, |
Problem
A CVM's virtio-net NIC cannot exceed one core's worth of packet processing, no
matter how many vCPUs it has. The VMM builds
-netdev bridge,...with novhost=, and a device line hardcoded tovirtio-net-pci,netdev=net0,mac=...with no
mq=on. Every packet is drained by QEMU's single main-loop thread:Sampled on a production CVM (
phala-tdx-prod7, pid 3543413, 16 vCPU / 32 GiB,-netdev bridge,id=net0,br=dstack-br0):That matches the reported symptom exactly — clean at 31k pps, 36% loss at 40k.
The three busy TAPs on that host had lifetime drop rates of 26.7% / 19.3% /
17.8%, hundreds of millions of packets each. The drops are at the TAP, so
guest-side
rx_droppedandsoftnet_statstay at zero and the cliff lookslike a network fault rather than a host CPU limit. Outbound guest traffic uses
the same thread, so a chatty guest pays it twice.
docs/bridge-networking.mdclaimed this was unavoidable: "vhost-net ... isnot enabled for bridge mode. TDX encrypts guest memory, which prevents the
host kernel from performing DMA-based packet offload." That is wrong. A TDX
guest's virtio rings and buffers live in shared, unencrypted memory precisely
so a host-side backend can reach them — which is why
vhost-vsock-pci, whichdstack has always used, works.
Fix
Two settings on
[cvm.networking], defaulting to vhost on and one queue pair,each overridable per VM through the deployment RPC:
Queue pairs are not a node setting: they default to the VM's vCPU count, capped
at
max_net_queues, because the useful number follows the VM rather than thehost. A deployment overrides that per VM.
vectorsis derived, never configured:2N + 2. A single queue pair emits nomq=onorvectors=at all, so a 1-vCPU VM and any VM deployed with--net-queues 1keep the historical command line byte for byte.Each backend needed different plumbing:
userbridgetap,br=,helper=,vhost=onbridgenetdev accepts neithervhost=norqueues=; the same setuidqemu-bridge-helperworks behind atapnetdev, so the VMM still needs noCAP_NET_ADMINbridge, >1 queuetap,ifname=,queues=Nmulti_queueTAP — now also for unfiltered nodesbridge+ libvirt filtertap,ifname=,vhost=onvhost=offis now the configured valuemacvtaptap,fds=a:b,vhost=on/dev/tapNonce per queue; netd creates the link with matchingnumtxqueues/numrxqueuescustomqueuesstill drives the device line, which is what makes a hand-written netdev usable with multiqueueBecause macvtap now consumes several descriptors per NIC, the fd layout moved
into one
macvtap_fd_layout()that both the launcher's open list and the-netdevarguments derive from, replacing the3 + indexconvention the twosites previously duplicated.
QEMU aborts when
vhost=oncannot open/dev/vhost-net, and it does so frominside the launcher where the reason is easy to miss. The VMM warns when the
device node is missing, but deliberately does not pre-flight its own access to
it: QEMU need not share the VMM's credentials, and with vhost defaulting to on,
a VMM that is merely not in the
kvmgroup would otherwise refuse everydeployment on a host where QEMU can open the device fine.
statneeds nopermission on the device, so the check stays a statement about the host.
Neither field affects
mr_config_id, which covers only the compose hash andinstance info, so retuning a NIC does not change app identity.
Verification
All five configurations deployed as real TDX CVMs on
phala-tdx-lab(kernel6.8, QEMU 8.2.2+tdx1.1, guest image
dstack-0.6.0), against an isolated bridgeand a dedicated VMM instance.
Generated command lines, read back from
/proc/<qemu>/cmdline:Host and guest state:
ethtool -l eth0combinedThe worker count is
net queue pairs + 1for vhost-vsock in every case, whichis what confirms the guest actually negotiated that many queue pairs.
netdcreated what was asked for, and cleaned up on VM removal:After removing all five VMs: no
dt*interfaces and no nwfilter bindings left.Filtering still works with vhost on.
docs/libvirt-network-filter.mdsaidflipping this bit needed "equivalent filter integration tests", so from inside
VM E, with
clean-trafficbound to the TAP andvhost=on:The nwfilter binding lives on the host TAP interface, so packets traverse it
whether QEMU or a vhost worker wrote them.
Node policy, over the RPC:
Throughput, 64-byte UDP host→guest via kernel pktgen, same 8 vCPU / 8 GiB CVM
shape on the same host (the lab host was running other tenants' CVMs, so treat
the absolute rates as noisy and the CPU columns as the signal):
The main thread goes from saturated to idle — that is the wall coming down, and
a whole core returned to the tenant. Note that vhost alone relocates the
ceiling rather than removing it: with one queue the guest's single receive queue
becomes the limit and drops reappear at a higher rate (the guest was at 99% CPU
in the 500k row). Multiqueue is what removes them. Hence vhost on by default,
queues raised deliberately.
A/B on the customer's own host (
phala-tdx-prod7, production short-connectionworkload, 4 alternating rounds to suppress noise) measured earlier in this
investigation: 10,922 → 13,933 conn/s (+27.6%), with the main thread going
94% → 0%.
Review follow-ups
A self-review pass found seven issues, all fixed in this branch:
filteroptional so netd can build unfilteredmultiqueue TAPs meant
mode = "libvirt"with an explicitfilter = ""nolonger failed — netd skipped
nwfilter-binding-createand every VM bootedonto an unbound TAP, where it previously refused to start. Config load now
rejects an empty filter in libvirt mode.
one_shot.rsstill gated its "cannot manage TAP lifecycle"error on
network_filter.mode == Libvirt, soqueues > 1on a bridge emitted-netdev tap,ifname=dt…,queues=4for a TAP nobody created. It now uses thesame
needs_netd_interfacepredicate as the server path.--net-queues 4alone was rejected despite theflag's "default: use global config" help, and on a node whose default backend
is not in
allowed_network_modes, restating the mode tripped node policy — soper-VM tuning was impossible there at all. A tuning-only request now keeps the
node's backend; policy still governs backends a caller chooses.
bridge_helper()hard-failed thelaunch when none of three hardcoded paths existed on the VMM's filesystem.
With vhost defaulting on, a working bridge node whose helper lives elsewhere
would have lost every VM on upgrade — the same mistake the
/dev/vhost-netprobe was already corrected for. It now falls back to the non-vhost
bridgenetdev with a warning.
multiqueue" warning tested the resolved value, and the shipped
vmm.tomlsets
mode = "user"withvhost = true, so it fired on every launch of astock node. Inheritance being ignored there is the documented design, not a
silent skip; the warning is gone.
resolve_requested_networkspersisted the merged values, so setting
vhost = falsenode-wide to roll backreached VMs deployed with no override but not those deployed with
--net bridge. Only what a deployment explicitly asks for is recorded now;identity-bearing fields are still pinned as before.
max_net_queueswas unvalidated.0rejected evenqueues = 1, and128let a request past the node cap only to be rejected against thedifferent
MAX_NET_QUEUES = 64bound.Re-verified on
phala-tdx-labafter the fixes:One unrelated observation from that run: these lab VMs exit after a stop/start
cycle, with
dstack-prepare.servicefailing on the second boot. A control VM onplain
usernetworking — whose netdev string this PR leaves byte-for-byteunchanged — reproduces it identically, so it is a property of the test app
(
key_provider: none), not of this change.Second review pass
Four more, all fixed:
remove_interfacedecided whether to delete an nwfilter binding by checking that
/usr/bin/virshexists. On a
mode = "none"node that has virsh installed but no reachablelibvirtd,delete_bindingfails on anything but the literal "binding notfound", and
prepare_bridgestarts by callingremove_interface— so everymultiqueue bridge VM would fail to start and leak its TAP.
Remove/Checknowcarry whether the interface was created with a binding, defaulting to true on
the wire so an older VMM's removals still clean up.
Checkreported healthy unfiltered TAPs as broken, since it rannwfilter-binding-dumpxmlfor every non-macvtap interface. Same flag.service, and
queuesis#[serde(default)], so an older netd silently built asingle-queue TAP while the VMM emitted
queues=N— the exactIFF_MULTI_QUEUEmismatch the adjacent comment warns about, surfacing only as a QEMU failure
inside the launcher. netd now echoes the queue count it created and the VMM
refuses to launch on a mismatch.
first-pass fix let
{"queues": 2}inherit the node's backend and skipallowed_network_modes, butresolve_requested_networksthen pinned thatmode, parent, and bridge into the manifest — so tuning was strictly more
powerful than naming the backend, and a later node change no longer reached
the VM. The rule is now uniform: pin what the caller named, inherit the rest.
Re-verified on
phala-tdx-labwithvirshinstalled andlibvirt_uripointed ata dead socket, which is the configuration that used to fail:
And the filtered path is unchanged, against a real libvirt:
Final regression on
phala-tdx-lab47 assertions against the final binary on a real TDX host (kernel 6.8, QEMU
8.2.2+tdx1.1, guest
dstack-0.6.0), on a dedicated bridge and VMM instance —config validation, RPC policy, generated command lines, host interface state,
guest-visible state, node rollback, and teardown. All pass.
Throughput on the final binary, 64-byte UDP host→guest via kernel pktgen, same
CVM shape on the same host:
The main thread going 88–94% → 0% is the wall coming down. The c1 column is the
nuance stated above: with one queue vhost relocates the ceiling into the guest's
receive queue rather than removing it (guest CPU 121% vs 99% in the 500k row),
which is what the multiqueue column fixes.
One observation worth recording: that multiqueue VM had 2 vCPUs, and although
QEMU was given
queues=8,vectors=18the guest reportedCombined: 2. Thevirtio-net driver uses at most one queue pair per vCPU. Over-provisioning is
therefore inert rather than harmful, and it is not rejected at deployment
because
resizecan raise the vCPU count later; this is now documented.Default queue count
Queue pairs default to
min(vcpu, 16). Two consequences are worth statingplainly, because they are behaviour changes on upgrade rather than opt-ins:
gains
mq=on,vectors=2N+2and N queue pairs where it previously had one. Thedevice is not measured —
mr_config_idcovers the compose hash and instanceinfo — so app identity is unaffected, but the guest does see a different NIC.
netdto get it.qemu-bridge-helperreturns a singledescriptor and cannot create a
multi_queueTAP. A node that has neverdeployed
netddoes not fail: bridge NICs fall back to one queue pair with awarning. A deployment that asked for a queue count explicitly still fails, so
the caller learns their request was not met rather than silently getting less.
The measured trade-off, from an 8-vCPU TDX CVM with only the guest's channel
count changed (
ethtool -L), is real in both directions:The same CVM moved 3.0 Mpps of 64-byte UDP with no loss at 8 queues against
roughly 600k at one. Bandwidth-bound workloads want the default; a VM serving
many short connections should set
--net-queues 1. Cross-vCPU wakeups cost anIPI and a VM exit under TDX, which is why the scaling is capped at 16 rather
than following large vCPU counts.
Verified on
phala-tdx-lab, 23 assertions:Surfaces and interactions
loop drains every queue on one thread, so extra queues buy little while still
costing a netd interface, more MSI-X vectors, and a changed guest device.
Anyone disabling vhost wants the old data plane, so they get the old shape.
An explicit queue count is still honoured without vhost, since that
combination is a deliberate request rather than a default.
max_net_queuesbounds what a deployment may ask for; the default's own cap is a fixed 16, so
a larger VM never silently acquires a worse default. Hard ceiling from any
source is 64.
UpdateVmchanges both fields, applying from the VM's next boot.vmm-cli.py updategrew--net,--net-vhost/--net-no-vhostand--net-queuesto reach it; the update path previously had no networkingoptions at all.
the mode selector. Leaving a control on its default emits no field, so the
node keeps owning that value.
Verified on
phala-tdx-lab(22 assertions):Third review pass
Eight more, all fixed. Three would have stopped a working node from launching
VMs, which is the failure mode a defaulted-on feature has to be judged by:
cvm.max_net_queuesnever bounded the default. Lowering it to 2 stillhanded a 16-vCPU VM sixteen queue pairs, contradicting both the vmm.toml
comment and the docs. Raising it above 16 still only widens what a caller may
request; lowering it below 16 now lowers the default too, because a node that
refuses a request for four should not hand out sixteen by itself.
dstack-vmm runbroke on every bridge VM. It never applied thenetd fallback, so with the default queue count a ≥2-vCPU bridge NIC hard-failed
with "does not manage netd interface lifecycle" on a node that worked before.
with
socket.exists(), but netd does not unlink its socket on shutdown, so astale file read as "netd is here" and the launch then failed to connect
instead of falling back. It now connects, the same way netd's own
bind-time staleness check does.
bail!and thepre-existing
response.device?returned without the rollback loop, so aversion-skewed netd left an interface on the bridge with nothing recorded to
clean it up. Both now unwind through one shared rollback.
paths still called
resolved_networks()rather than the clampedruntime_networks(), giving an already-running VM an unclamped queue countand failing
stop_vmwith a netd connect error.GetInforeported a data plane the NIC did not get. A bridge NIC thatfell back to the non-vhost
bridgenetdev for want ofqemu-bridge-helperstill reported
vhost: true. The QEMU arguments and the reported status nowread the same
effective_vhost.the NIC to user mode still submitted it — the deploy failed with no visible
control to fix.
queues/vhostare now scoped to non-user modes, likebridge_name.vmm-cli update --net-*replaced the whole NIC list, dropping extrainterfaces and un-pinning a bridge. It now merges into the VM's existing NIC
and refuses outright on a multi-NIC VM rather than guessing.
Fixing that last one surfaced a round-trip bug worth calling out on its own:
GetInforeportedparentandmacvtap_modeon every interface, includingbridge NICs that had merely inherited them from
[cvm.networking]— and thedeployment RPC rejects
parentoutside macvtap mode. Reported configurationcould be read but not sent back. Both fields are now scoped to macvtap the way
bridge_nameis scoped to bridge, with a test that a reported interfacesatisfies the RPC's own validation.
Verified on
phala-tdx-lab(11 assertions):Operator notes
/dev/vhost-net(
root:kvm 0660— add it tokvm). On prod7 this was already true and themodule autoloaded on first open; no root, no pre-created TAPs, no
CAP_NET_ADMIN. If it is not, QEMU exits at startup and the VM does notboot.
thread whose CPU time escapes the VM's cgroup. Since 6.4 it is a
vhost_taskinside the QEMU thread group — verified on the 6.8 lab host, where the worker
tids appear under
/proc/<qemu>/task/with the same cgroup — socpu.maxand cgroup accounting still attribute it to the CVM.
netd, includingwhen
network_filter.mode = "none".--net-no-vhostrestores the previous-netdev bridge,...command lineexactly, so the old behaviour is one flag away.