|
| 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