Skip to content

Commit f60a082

Browse files
committed
docs: document virtio-net data plane tuning
1 parent 63277ee commit f60a082

4 files changed

Lines changed: 192 additions & 10 deletions

File tree

docs/bridge-networking.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Individual VMs can override the global networking mode via:
2525
- **Web UI**: Networking dropdown in the deploy dialog
2626
- **API**: `networking: { mode: "bridge" }` in `VmConfiguration`
2727

28-
Only the mode is per-VM; the bridge interface name always comes from the global config.
28+
The bridge interface name comes from the global config unless the node lists it in `cvm.allowed_bridges`. VMs may also override the vhost and queue settings — see [network-data-plane.md](network-data-plane.md).
2929

3030
## Host setup
3131

@@ -159,7 +159,7 @@ sudo chmod u+s /usr/lib/qemu/qemu-bridge-helper
159159

160160
## How it works
161161

162-
- VMM passes `-netdev bridge,id=net0,br=<bridge>` to QEMU
162+
- VMM passes `-netdev tap,id=net0,br=<bridge>,helper=<qemu-bridge-helper>,vhost=on` to QEMU, or `-netdev bridge,id=net0,br=<bridge>` when vhost is disabled
163163
- QEMU's bridge helper (setuid) creates a TAP device and attaches it to the bridge
164164
- Guest MAC address is derived from SHA256 of the VM ID, with an optional configurable prefix (stable across restarts for DHCP IP consistency)
165165
- The host DHCP server (dnsmasq) assigns an IP to the VM
@@ -201,6 +201,8 @@ Bridge and passt VMs can coexist. Set the global default in `vmm.toml` and overr
201201
vmm-cli.py deploy --name my-vm --image dstack-0.5.6 --compose app.yaml --net passt
202202
```
203203

204-
### vhost-net and TDX
204+
### vhost-net and multiqueue
205205

206-
vhost-net (kernel data plane offload for virtio-net) is **not enabled** for bridge mode. TDX encrypts guest memory, which prevents the host kernel from performing DMA-based packet offload. The default QEMU userspace virtio backend is used instead.
206+
Bridge NICs use the host kernel's vhost-net data plane by default, and can expose several virtio-net queue pairs. Both are configurable per node and per VM — see [network-data-plane.md](network-data-plane.md) for the knobs, the mode support matrix, and how to pick a queue count.
207+
208+
vhost-net works in a TDX guest: the virtio rings and buffers live in shared, unencrypted memory so that a host-side backend can reach them, which is the same mechanism `vhost-vsock-pci` has always relied on.

docs/libvirt-network-filter.md

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,24 @@ sudo dstack-vmm --config ./vmm.toml \
165165
User networking and bridge networking with `mode = "none"` never connect to
166166
`netd`. Libvirt mode fails closed if `netd` is unavailable.
167167

168-
Filtered TAP netdevs currently set `vhost=off`. This keeps the initial backend
169-
on the directly bound TAP path and avoids adding `/dev/vhost-net` permissions
170-
to the QEMU user. It is a deliberate security-first throughput tradeoff; a
171-
future configurable vhost mode requires equivalent filter integration tests.
168+
Filtered TAP netdevs follow the node's `vhost` and `queues` settings like any
169+
other TAP-backed NIC (see [network-data-plane.md](network-data-plane.md)). The
170+
nwfilter binding is installed on the host TAP interface, so packets traverse it
171+
whether they were written by QEMU or by a vhost worker; filtering is unaffected
172+
by the data plane choice. Enabling vhost does require the QEMU user to be able
173+
to open `/dev/vhost-net`.
174+
175+
`netd` also creates the TAP for unfiltered bridge NICs that ask for more than
176+
one queue pair, because `qemu-bridge-helper` returns a single descriptor and
177+
cannot create a `multi_queue` device. Those TAPs carry no nwfilter binding, so
178+
a multiqueue bridge node needs `netd` even when `network_filter.mode = "none"`.
179+
180+
An empty filter name is what selects that unfiltered TAP, so `mode = "libvirt"`
181+
with an empty `filter` is rejected at config load rather than quietly producing
182+
an unbound TAP.
183+
184+
Removal and `Check` carry the same distinction: the VMM tells `netd` whether the
185+
interface it is asking about was created with a binding. Unfiltered TAPs are
186+
never looked up in libvirt, so a node that has `virsh` installed but no reachable
187+
`libvirtd` can still create and destroy multiqueue TAPs. The flag defaults to
188+
true on the wire, so an older VMM's removals still drop their bindings.

docs/macvtap-networking.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ Configure a NIC through node configuration or an authorized VMM RPC request:
1919

2020
`parent` must name an existing host interface. `macvtap_mode` may be
2121
`private`, `bridge`, `vepa`, or `passthru`; an empty value selects `private`.
22+
Macvtap NICs also honour the `vhost` and `queues` settings described in
23+
[network-data-plane.md](network-data-plane.md); netd creates the interface with
24+
matching hardware queues and the launcher opens `/dev/tapN` once per queue.
2225
The configured netd socket permissions apply in the same way as for
2326
libvirt-filtered bridge networking.
2427

@@ -49,8 +52,9 @@ and the same deterministic MAC address passed to QEMU. Netd then:
4952
4. reads its kernel-assigned ifindex and waits for `/dev/tap<ifindex>`; and
5053
5. returns that runtime device path to the VMM.
5154

52-
The per-VM launcher opens the character device, places it at the fd referenced
53-
by QEMU's `-netdev tap,fd=...` argument, and then execs QEMU. This keeps device
55+
The per-VM launcher opens the character device once per queue pair, places the
56+
descriptors at the fds referenced by QEMU's `-netdev tap,fd=...` (or `fds=...`)
57+
argument, and then execs QEMU. This keeps device
5458
paths out of persistent VM
5559
configuration, works with both Supervisor and systemd process managers, and
5660
does not pass network fds through `sudo`.

docs/network-data-plane.md

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# virtio-net data plane tuning
2+
3+
Every CVM NIC has two knobs that decide how many packets it can move: whether
4+
the host kernel's vhost-net data plane is used, and how many virtio-net queue
5+
pairs the device exposes. Both are set per node and overridable per VM.
6+
7+
## Why it matters
8+
9+
Without vhost-net, QEMU drains every received packet on its single main-loop
10+
thread. That thread is the ceiling, and it does not grow with vCPUs:
11+
12+
```
13+
maximum packets per second ≈ 1 core ÷ per-packet main-loop cost
14+
```
15+
16+
The per-packet cost varies with traffic shape — a few microseconds for uniform
17+
synthetic streams, tens of microseconds for bidirectional short-connection
18+
traffic — so the ceiling is a property of the workload, not a fixed number.
19+
What is fixed is the shape of the failure: throughput climbs normally until the
20+
main loop saturates at 100% of one core, then packets are dropped at the TAP
21+
before they ever reach the guest. Guest-side counters stay clean, which makes
22+
the cliff easy to misdiagnose as a network problem.
23+
24+
`vhost=on` moves that work into the host kernel. Guest-side outbound traffic
25+
uses the same thread, so a busy guest pays the cost twice over.
26+
27+
That returns a whole core, but it relocates the ceiling rather than removing
28+
it: packets now arrive faster than a single guest receive queue can drain, and
29+
the drops reappear at a higher rate. Raising `queues` is what removes them.
30+
Enable vhost everywhere; raise the queue count for VMs that are actually
31+
throughput-bound.
32+
33+
## Configuration
34+
35+
```toml
36+
[cvm]
37+
# Largest queue pair count a deployment RPC caller may request.
38+
max_net_queues = 8
39+
40+
[cvm.networking]
41+
mode = "bridge"
42+
bridge = "dstack-br0"
43+
vhost = true
44+
queues = 1
45+
```
46+
47+
A VM overrides either value at deploy time:
48+
49+
```bash
50+
vmm-cli.py deploy --name my-vm --image dstack-0.5.9 --compose app.yaml \
51+
--net bridge --net-queues 4
52+
vmm-cli.py deploy --name latency-vm --image dstack-0.5.9 --compose app.yaml \
53+
--net bridge --net-no-vhost
54+
```
55+
56+
Both fields are also on `NetworkingConfig` in the deployment RPC. A request that
57+
sets only `vhost`/`queues` keeps the node's own networking mode, so tuning does
58+
not force a caller to restate — or be allowed to choose — a backend. `queues` is
59+
rejected above the node's `max_net_queues`; `vhost` is not otherwise restricted,
60+
since it only affects the requesting VM. `GetMeta` reports
61+
`networking.max_queues` so a client can present the real bound.
62+
63+
Only what a deployment explicitly asks for is recorded against the VM. Values it
64+
inherits stay owned by the node, so changing `[cvm.networking]` later — including
65+
setting `vhost = false` to roll the whole node back — still reaches VMs that were
66+
deployed with some other networking override. A request that names a backend
67+
does pin that backend's bridge or parent for the life of the VM, as before; one
68+
that only tunes pins nothing.
69+
70+
Neither field changes the CVM's measurement. `mr_config_id` covers the compose
71+
hash and instance info, so retuning a NIC does not change app identity or
72+
require an on-chain update.
73+
74+
## What each mode supports
75+
76+
| Mode | netdev | vhost | queues > 1 |
77+
|---|---|---|---|
78+
| `user` | `user,...` | no backend, ignored | not supported, ignored |
79+
| `bridge` | `bridge,br=` or `tap,br=,helper=` | yes | yes, through netd |
80+
| `bridge` with libvirt filtering | `tap,ifname=` | yes | yes, through netd |
81+
| `macvtap` | `tap,fd=` / `tap,fds=` | yes | yes |
82+
| `custom` | operator's own string | operator's own string | device line only |
83+
84+
QEMU's `bridge` netdev accepts neither `vhost=` nor `queues=`, so enabling
85+
vhost switches bridge mode to a `tap` netdev driven by the same setuid
86+
`qemu-bridge-helper`. The VMM still needs no network privileges. The helper has
87+
no compiled-in default path for the `tap` netdev, so the VMM probes the known
88+
distribution locations; set `cvm.qemu_bridge_helper` if yours is elsewhere. If
89+
no helper is found the NIC falls back to the non-vhost `bridge` netdev with a
90+
warning, because vhost is a default and a default must not stop a node from
91+
booting VMs.
92+
93+
The helper returns exactly one descriptor, which is why more than one queue
94+
pair in bridge mode is created by `netd` instead: it adds a persistent
95+
`multi_queue` TAP that QEMU then opens once per queue. That applies whether or
96+
not libvirt filtering is on, so a multiqueue bridge node must run `netd` (see
97+
[libvirt-network-filter.md](libvirt-network-filter.md)). `netd` reports back the
98+
queue count it created, and the VMM refuses to launch on a mismatch — a `netd`
99+
deployed separately as a root service can be older than the VMM asking it for
100+
multiqueue, and QEMU would otherwise reject the interface from inside the
101+
per-VM launcher.
102+
103+
For macvtap, the per-VM launcher opens the `/dev/tapN` character device once
104+
per queue pair and hands QEMU the descriptors as `fds=`. `netd` creates the
105+
interface with matching `numtxqueues`/`numrxqueues`.
106+
107+
Custom mode owns its whole netdev string, including any `vhost=`/`queues=`
108+
options. The `queues` field still applies to the generated device line, which
109+
is what makes a hand-written netdev usable with multiqueue.
110+
111+
## Choosing a queue count
112+
113+
More queues raise the packet ceiling for bandwidth-heavy workloads and lower it
114+
for latency-sensitive ones. Receive processing is spread over more vCPUs, and
115+
under TDX a cross-vCPU wakeup costs an IPI and a VM exit. Measured on one
116+
8-vCPU TDX CVM, changing only the guest's channel count:
117+
118+
| Queue pairs | Short-connection throughput |
119+
|---|---|
120+
| 1 | 22.3k conn/s |
121+
| 2 | ~20k conn/s |
122+
| 4 | 15–21k conn/s |
123+
| 8 | 6.2–7.7k conn/s |
124+
125+
The same CVM with 8 queues moved 3.0 Mpps of 64-byte UDP with no loss, against
126+
roughly 600k with one queue. Start at 1, raise it for throughput-bound
127+
workloads, and measure.
128+
129+
The guest driver uses at most one queue pair per vCPU, so a VM with fewer vCPUs
130+
than queues simply leaves the extra pairs idle — `ethtool -l eth0` reports the
131+
smaller number. That is not rejected at deployment, because `vmm-cli.py resize`
132+
can raise the vCPU count later.
133+
134+
`vectors` is derived, never configured: `2N + 2`, one vector per queue
135+
direction plus config and control. One queue pair emits no `mq=on` or
136+
`vectors=` at all, leaving the historical command line byte for byte identical.
137+
138+
## Requirements
139+
140+
The account running QEMU must be able to open `/dev/vhost-net`, which is
141+
`root:kvm 0660` on a stock host — add that account to the `kvm` group. The
142+
`vhost_net` module autoloads on first open.
143+
144+
If that account lacks access, QEMU exits at startup and the VM never boots. The
145+
VMM does not pre-check this: QEMU need not share the VMM's credentials, so
146+
refusing a launch on the VMM's own access would block deployments the host can
147+
run. It only warns when the device node is missing outright, which is a fact
148+
about the host rather than about either account.
149+
150+
vhost-net works normally in a TDX guest: the virtio rings and buffers live in
151+
shared, unencrypted memory precisely so a host-side backend can reach them.
152+
This is the same mechanism behind `vhost-vsock-pci`, which dstack has always
153+
used.
154+
155+
On host kernels older than 6.4 the vhost worker is a free-standing kernel
156+
thread and its CPU time is not charged to the VM's cgroup. Since 6.4 it is a
157+
`vhost_task` inside the QEMU thread group, so `cpu.max`, cgroup accounting, and
158+
`top -H` all attribute it to the CVM. Check the host kernel before relying on
159+
CPU limits for tenant VMs.

0 commit comments

Comments
 (0)