feat: map virtio-blk virtqueues across an IOThread pool (#963)

* feat: map virtio-blk virtqueues across an IOThread pool

Boot volumes ran every virtqueue through a single IOThread. Size a pool
from the vCPU count and hand QEMU an iothread-vq-mapping so the queues
spread across it. Falls back to the scalar iothread= form for MMIO
machines, single-queue devices and a pool of one.

SPINIFEX_BLK_IOTHREADS is experiment scaffolding for sweeping the pool
size without a rebuild, and comes out before this merges.

* feat: name QEMU guests with debug-threads=on

Config.Name held the instance ID but was never emitted, so QEMU left
every thread called qemu-system-x86 and a host-side pidstat -t could
not tell an IOThread from a vCPU thread. Emit -name guest=<id> with
debug-threads=on, which is what libvirt does for the same reason.

* refactor: drop the IOThread pool-size experiment override

SPINIFEX_BLK_IOTHREADS existed to sweep the pool size on dev-prod
without a rebuild and redeploy per value. The sweep is done, so the
derived size is the only path and buildDrives keeps its own doc comment.
This commit is contained in:
Ben Duncan
2026-09-04 14:08:18 -05:00
committed by GitHub
parent d65c4f3673
commit 80f522e3fc
6 changed files with 279 additions and 8 deletions
+104
View File
@@ -1,6 +1,7 @@
package vm
import (
"encoding/json"
"fmt"
"strconv"
"strings"
@@ -93,6 +94,109 @@ func NetDevice(machineType, netdev, mac string, queues, mtu int) Device {
return Device{Value: b.String()}
}
// Blk IOThread pool sizing. One IOThread per virtio-blk device services every
// virtqueue on it, so a guest with several queues still funnels through a
// single host thread. QEMU 9.0+ can map queues across a pool instead.
//
// The pool is deliberately much smaller than the vCPU count. Red Hat's RHEL 9.4
// measurements used 4 IOThreads against 192 vCPUs and 96 queues, and OpenShift
// Virtualization documents 4 as the starting point with 8-16 reserved for very
// fast local storage — which an NBD export backed by object storage is not.
const (
// BlkIOThreadsPerVCPU divides the vCPU count. The threshold lands at 4
// vCPUs, matching the guest size below which neither source expects the
// feature to do anything.
BlkIOThreadsPerVCPU = 2
// MaxBlkIOThreads caps the pool. These are real host threads charged to
// the node, and a per-device pool multiplies by the volume count.
MaxBlkIOThreads = 4
)
// BlkIOThreadPoolSize returns how many IOThreads to create for one virtio-blk
// device on a guest with vcpus processors: vcpus/2, clamped to 1..4. A result
// of 1 reproduces the single-IOThread command line exactly.
func BlkIOThreadPoolSize(vcpus int) int {
n := vcpus / BlkIOThreadsPerVCPU
if n < 1 {
return 1
}
if n > MaxBlkIOThreads {
return MaxBlkIOThreads
}
return n
}
// BlkIOThreadID names the i'th IOThread in a device's pool. The single-thread
// case keeps the original unsuffixed name so an existing guest's command line
// is unchanged.
func BlkIOThreadID(base string, i, poolSize int) string {
if poolSize <= 1 {
return base
}
return fmt.Sprintf("%s-%d", base, i)
}
// iothreadVQMapping is one entry of virtio-blk's iothread-vq-mapping list.
// Marshalled rather than concatenated: the property is a QAPI struct list and
// the value has to be valid JSON, so hand-built strings are a silent-corruption
// risk for no gain.
type iothreadVQMapping struct {
IOThread string `json:"iothread"`
VQs []int `json:"vqs"`
}
// blkDeviceJSON is the -device value for a virtio-blk device carrying an
// iothread-vq-mapping. QEMU accepts a JSON object as one argv value, which is
// the only command-line form that can express a nested list.
type blkDeviceJSON struct {
Driver string `json:"driver"`
Drive string `json:"drive"`
NumQueues int `json:"num-queues,omitempty"`
BootIndex int `json:"bootindex,omitempty"`
Mapping []iothreadVQMapping `json:"iothread-vq-mapping"`
}
// BlkDeviceMapped returns a virtio-blk -device that spreads its virtqueues
// across a pool of poolSize IOThreads, round-robin. Falls back to BlkDevice's
// scalar iothread= form when poolSize <= 1, so the common small-guest case is
// byte-for-byte what it was before this existed.
//
// Every queue is mapped exactly once and the scalar iothread= property is
// absent: QEMU treats the two as alternative assignments and rejects both.
func BlkDeviceMapped(machineType, drive, iothreadBase string, queues, bootIdx, poolSize int) (Device, error) {
if poolSize <= 1 || queues <= 1 || IsMMIO(machineType) {
return BlkDevice(machineType, drive, iothreadBase, queues, bootIdx), nil
}
if poolSize > queues {
poolSize = queues
}
vqs := make([][]int, poolSize)
for q := range queues {
vqs[q%poolSize] = append(vqs[q%poolSize], q)
}
mapping := make([]iothreadVQMapping, 0, poolSize)
for i := range vqs {
mapping = append(mapping, iothreadVQMapping{
IOThread: BlkIOThreadID(iothreadBase, i, poolSize),
VQs: vqs[i],
})
}
b, err := json.Marshal(blkDeviceJSON{
Driver: "virtio-blk-pci",
Drive: drive,
NumQueues: queues,
BootIndex: bootIdx,
Mapping: mapping,
})
if err != nil {
return Device{}, fmt.Errorf("marshal virtio-blk iothread-vq-mapping: %w", err)
}
return Device{Value: string(b)}, nil
}
// BlkDevice returns the appropriate QEMU virtio-blk device string for
// machineType. iothread and queues are omitted when empty/zero. bootIdx is
// only emitted for PCI machines (MMIO has no bootindex concept).
+100
View File
@@ -1,6 +1,8 @@
package vm
import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -201,3 +203,101 @@ func TestNetDevice_MMIO_NoQueueSizes(t *testing.T) {
assert.NotContains(t, d.Value, "mq=on")
assert.Contains(t, d.Value, "host_mtu=1442")
}
func TestBlkIOThreadPoolSize(t *testing.T) {
// The threshold sits at 4 vCPUs and the ceiling at 4 threads, matching the
// only configurations Red Hat's two write-ups actually recommend.
for _, tc := range []struct{ vcpus, want int }{
{1, 1}, {2, 1}, {3, 1}, {4, 2}, {6, 3}, {8, 4}, {16, 4}, {192, 4},
} {
if got := BlkIOThreadPoolSize(tc.vcpus); got != tc.want {
t.Errorf("BlkIOThreadPoolSize(%d) = %d, want %d", tc.vcpus, got, tc.want)
}
}
}
func TestBlkIOThreadID(t *testing.T) {
// A pool of one keeps the unsuffixed name, so an existing guest's command
// line does not change when this code lands.
if got := BlkIOThreadID("ioth-os", 0, 1); got != "ioth-os" {
t.Errorf("pool of 1 = %q, want ioth-os", got)
}
if got := BlkIOThreadID("ioth-os", 1, 2); got != "ioth-os-1" {
t.Errorf("pool of 2 index 1 = %q, want ioth-os-1", got)
}
}
func TestBlkDeviceMapped_SingleThreadUnchanged(t *testing.T) {
// Byte-for-byte identical to the scalar form: this is the path every guest
// below 4 vCPUs takes, so a difference here is a silent behaviour change.
want := BlkDevice("q35", "os", "ioth-os", 2, 1)
for _, pool := range []int{0, 1} {
got, err := BlkDeviceMapped("q35", "os", "ioth-os", 2, 1, pool)
if err != nil {
t.Fatalf("pool %d: %v", pool, err)
}
if got.Value != want.Value {
t.Errorf("pool %d = %q, want %q", pool, got.Value, want.Value)
}
}
}
func TestBlkDeviceMapped_MMIOFallsBack(t *testing.T) {
// microvm has no PCI bus, so the JSON form names a driver it cannot use.
got, err := BlkDeviceMapped("microvm", "os", "ioth-os", 4, 1, 2)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(got.Value, "virtio-blk-device") {
t.Errorf("MMIO = %q, want the virtio-blk-device form", got.Value)
}
}
func TestBlkDeviceMapped_EveryQueueMappedOnce(t *testing.T) {
for _, tc := range []struct{ queues, pool int }{{4, 2}, {4, 4}, {8, 4}, {2, 4}, {3, 2}} {
got, err := BlkDeviceMapped("q35", "os", "ioth-os", tc.queues, 1, tc.pool)
if err != nil {
t.Fatalf("queues=%d pool=%d: %v", tc.queues, tc.pool, err)
}
var dev struct {
Driver string `json:"driver"`
NumQueues int `json:"num-queues"`
IOThread string `json:"iothread"`
Mapping []struct {
IOThread string `json:"iothread"`
VQs []int `json:"vqs"`
} `json:"iothread-vq-mapping"`
}
if err := json.Unmarshal([]byte(got.Value), &dev); err != nil {
t.Fatalf("queues=%d pool=%d: not valid JSON: %v (%s)", tc.queues, tc.pool, err, got.Value)
}
// The scalar property and the mapping are alternative assignments;
// QEMU rejects a device carrying both.
if dev.IOThread != "" {
t.Errorf("queues=%d pool=%d: scalar iothread= present alongside the mapping", tc.queues, tc.pool)
}
if dev.NumQueues != tc.queues {
t.Errorf("queues=%d pool=%d: num-queues=%d", tc.queues, tc.pool, dev.NumQueues)
}
seen := map[int]int{}
for _, m := range dev.Mapping {
for _, q := range m.VQs {
seen[q]++
}
}
for q := 0; q < tc.queues; q++ {
if seen[q] != 1 {
t.Errorf("queues=%d pool=%d: vq %d mapped %d times, want exactly 1 (%s)",
tc.queues, tc.pool, q, seen[q], got.Value)
}
}
if len(seen) != tc.queues {
t.Errorf("queues=%d pool=%d: %d distinct queues mapped", tc.queues, tc.pool, len(seen))
}
// A pool larger than the queue count would declare IOThread objects
// that no mapping entry references, which QEMU rejects.
if want := min(tc.pool, tc.queues); len(dev.Mapping) != want {
t.Errorf("queues=%d pool=%d: %d mapping entries, want %d", tc.queues, tc.pool, len(dev.Mapping), want)
}
}
}
+12 -3
View File
@@ -1380,9 +1380,18 @@ func buildDrives(requests []types.EBSRequest, cpuCount int, machineType string)
ReconnectDelay: NBDReconnectDelaySeconds,
}
iothreadID := "ioth-os"
cfg.IOThreads = append(cfg.IOThreads, IOThread{ID: iothreadID})
cfg.Devices = append(cfg.Devices, BlkDevice(machineType, drive.ID, iothreadID, cpuCount, 1))
// The boot disk's virtqueues are spread across a pool rather than
// all served by one host thread. A pool of 1 emits exactly the
// command line this used to, which is every guest below 4 vCPUs.
pool := BlkIOThreadPoolSize(cpuCount)
for i := range pool {
cfg.IOThreads = append(cfg.IOThreads, IOThread{ID: BlkIOThreadID("ioth-os", i, pool)})
}
dev, err := BlkDeviceMapped(machineType, drive.ID, "ioth-os", cpuCount, 1, pool)
if err != nil {
return driveConfig{}, fmt.Errorf("boot volume %s: %w", v.Name, err)
}
cfg.Devices = append(cfg.Devices, dev)
cfg.Drives = append(cfg.Drives, drive)
case v.EFI:
+26 -5
View File
@@ -122,7 +122,25 @@ func TestBuildDrives(t *testing.T) {
wantErr string
}{
{
name: "boot volume",
// 2 vCPUs resolves to a pool of one, which must keep the scalar
// iothread= form: this is the shape every small guest boots with.
name: "boot volume, pool of one keeps the scalar iothread",
requests: []types.EBSRequest{
{Name: "vol-boot", NBDURI: "nbd:unix:/tmp/boot.sock", Boot: true},
},
cpuCount: 2,
wantDrives: []Drive{
{File: "nbd:unix:/tmp/boot.sock", Format: "raw", If: "none", Media: "disk", ID: "os", Cache: "none", Werror: "stop", Rerror: "stop", ReconnectDelay: NBDReconnectDelaySeconds},
},
wantIOThreads: []IOThread{{ID: "ioth-os"}},
wantDevices: []Device{
{Value: "virtio-blk-pci,drive=os,iothread=ioth-os,num-queues=2,bootindex=1"},
},
},
{
// 4 vCPUs resolves to a pool of two, so the four virtqueues are
// split across two host threads rather than served by one.
name: "boot volume, four vCPUs map their queues across a pool of two",
requests: []types.EBSRequest{
{Name: "vol-boot", NBDURI: "nbd:unix:/tmp/boot.sock", Boot: true},
},
@@ -130,9 +148,9 @@ func TestBuildDrives(t *testing.T) {
wantDrives: []Drive{
{File: "nbd:unix:/tmp/boot.sock", Format: "raw", If: "none", Media: "disk", ID: "os", Cache: "none", Werror: "stop", Rerror: "stop", ReconnectDelay: NBDReconnectDelaySeconds},
},
wantIOThreads: []IOThread{{ID: "ioth-os"}},
wantIOThreads: []IOThread{{ID: "ioth-os-0"}, {ID: "ioth-os-1"}},
wantDevices: []Device{
{Value: "virtio-blk-pci,drive=os,iothread=ioth-os,num-queues=4,bootindex=1"},
{Value: `{"driver":"virtio-blk-pci","drive":"os","num-queues":4,"bootindex":1,"iothread-vq-mapping":[{"iothread":"ioth-os-0","vqs":[0,2]},{"iothread":"ioth-os-1","vqs":[1,3]}]}`},
},
},
{
@@ -234,7 +252,10 @@ func TestBuildDrives(t *testing.T) {
{Name: "vol-efi", NBDURI: "nbd:unix:/tmp/efi.sock", EFI: true},
{Name: "vol-data-a", NBDURI: "nbd:unix:/tmp/data-a.sock", HotplugPort: 3},
},
cpuCount: 4,
// 2 vCPUs, so the boot disk keeps the legacy scalar shape and this
// case still asserts what its name says it does. Data volumes are
// unaffected by the pool either way — they keep one IOThread each.
cpuCount: 2,
wantDrives: []Drive{
{File: "nbd:unix:/tmp/boot.sock", Format: "raw", If: "none", Media: "disk", ID: "os", Cache: "none", Werror: "stop", Rerror: "stop", ReconnectDelay: NBDReconnectDelaySeconds},
{File: "nbd:unix:/tmp/efi.sock", Format: "raw", If: "pflash", Unit: 1},
@@ -247,7 +268,7 @@ func TestBuildDrives(t *testing.T) {
{Value: "driver=nbd,node-name=nbd-vol-data-a,server.type=unix,server.path=/tmp/data-a.sock,export=,reconnect-delay=30"},
},
wantDevices: []Device{
{Value: "virtio-blk-pci,drive=os,iothread=ioth-os,num-queues=4,bootindex=1"},
{Value: "virtio-blk-pci,drive=os,iothread=ioth-os,num-queues=2,bootindex=1"},
{Value: "virtio-blk-pci,id=vdisk-vol-data-a,drive=nbd-vol-data-a,iothread=ioth-vol-data-a,serial=voldataa,bus=hotplug-ebs3,werror=stop,rerror=stop"},
},
wantHotplugPorts: []int{0, 0, 3},
+7
View File
@@ -383,6 +383,13 @@ type FwCfgEntry struct {
func (cfg *Config) Execute() (*exec.Cmd, error) {
args := []string{}
// debug-threads=on makes QEMU name its threads after what they do, so a
// host-side `pidstat -t` can tell an IOThread from a vCPU thread. Without it
// every row reads qemu-system-x86 and per-thread profiling is guesswork.
if cfg.Name != "" {
args = append(args, "-name", fmt.Sprintf("guest=%s,debug-threads=on", cfg.Name))
}
if cfg.PIDFile != "" {
args = append(args, "-pidfile", cfg.PIDFile)
}
+30
View File
@@ -57,6 +57,7 @@ func TestExecute(t *testing.T) {
assert.NotNil(t, cmd)
expectedArgs := []string{
"-name", "guest=test-vm,debug-threads=on",
"-smp", "2",
"-m", "1024",
"-drive", "file=disk.img,format=qcow2",
@@ -644,3 +645,32 @@ func TestExecute_FullConfig(t *testing.T) {
assert.Equal(t, "iothread,id=io0", argValue(args, "-object"))
assert.Equal(t, "user,id=net0", argValue(args, "-netdev"))
}
// debug-threads=on is what makes host-side per-thread profiling readable: QEMU
// names each thread after its role instead of leaving them all qemu-system-x86.
func TestExecute_NameCarriesDebugThreads(t *testing.T) {
cfg := Config{
Name: "i-0123456789abcdef0",
CPUCount: 2,
Memory: 512,
Architecture: "x86_64",
Drives: []Drive{{File: "disk.img", Format: "raw"}},
}
cmd, err := cfg.Execute()
assert.NoError(t, err)
assert.Equal(t, "guest=i-0123456789abcdef0,debug-threads=on", argValue(cmd.Args[1:], "-name"))
}
func TestExecute_NoNameEmitsNoNameArg(t *testing.T) {
cfg := Config{
CPUCount: 2,
Memory: 512,
Architecture: "x86_64",
Drives: []Drive{{File: "disk.img", Format: "raw"}},
}
cmd, err := cfg.Execute()
assert.NoError(t, err)
assert.False(t, argExists(cmd.Args[1:], "-name"))
}