From 53868c97ad74a7f5f9f39b1059ab6222c39067f6 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Tue, 10 Nov 2026 01:50:50 +0100 Subject: [PATCH 1/2] =?UTF-8?q?Windows=EF=BC=9A=E5=90=8C=E6=AD=A5=E5=86=99?= =?UTF-8?q?=E7=9B=98=E7=9A=84=E5=BE=80=E8=BF=94=E6=9B=B4=E7=9F=AD=EF=BC=88?= =?UTF-8?q?ioeventfd=3Doff=EF=BC=8C=E7=BB=99=E5=AE=A2=E4=BD=93=E4=B8=80?= =?UTF-8?q?=E5=BC=A0=20=5FCST=20=E8=A1=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 客体里一次 ext4 fsync 是三个串行的写请求,每个都要等宿主往返一趟。开机时给 QEMU 多带两个参数,往返变短,完成的条件不变(数据进了宿主文件才算完成): - -global virtio-blk-pci.ioeventfd=off:WHPX 没有内核里的 eventfd,QEMU 在用户态 模拟,一个请求要在主循环里转三圈、线程之间交接三次;关掉后由 vCPU 线程直接处理。 - -acpitable <每个 CPU 带 _CST 的 SSDT>:客体没有 cpuidle 驱动,空闲就停机,每次 完成都要等 Hyper-V 把停机的 vCPU 叫醒(20–24 µs)。表里声明一个用 HLT 进入、 退出延迟 100 µs 的 C1,客体自带的 acpi_idle 和 menu 就会在本核有任务等 I/O 时 先忙等一小段(最多 200 µs),其余时候照常停机。表按实例的核数生成,放在实例 目录里,用相对路径传给 QEMU;写不了或路径放不进命令行时不带表照常开机。 只在 Windows 上加。环境变量 MODAVD_SYNC_IO=off 时两个参数都不带。 11-10 在 6 核 6 GB 的测试实例上(0.39.0 的模拟器,带现有的忙等守护): - 仿下载负载(2 线程,每写 7 KB fsync 一次):20.5–21.0 → 33.3–35.7 MB/s; - 单线程写 4 KiB + fsync 的中位数:429–436 → 264 µs; - 1 MiB 写 + fsync、1 GB 顺序写:没有变慢; - 明日方舟资源下载(各一个 150 秒窗口,相邻):7.3 → 12.3 MB/s; - 空闲 40 秒里客体忙等合计 0.12 秒。 Refs ModAVD/ModAVD#35 --- internal/emu/launch.go | 8 +- internal/emu/syncio.go | 167 +++++++++++++++++++++++++ internal/emu/syncio_test.go | 236 ++++++++++++++++++++++++++++++++++++ 3 files changed, 408 insertions(+), 3 deletions(-) create mode 100644 internal/emu/syncio.go create mode 100644 internal/emu/syncio_test.go diff --git a/internal/emu/launch.go b/internal/emu/launch.go index 06eb92d..2c5df84 100644 --- a/internal/emu/launch.go +++ b/internal/emu/launch.go @@ -39,8 +39,10 @@ const adbServerStartTimeout = 20 * time.Second // #32). Wi-Fi then comes from the emulator's own hostapd and slirp, where it falls back when netsim is unreachable; // no Bluetooth, which the first boot turns off anyway. Uwb is on in the SDK defaults, only the image keeps it off. // +// qemuExtra comes last, still inside the -qemu part: the manager's own QEMU arguments (syncIOQemuArgs). +// // The window is not an instance option: it is decided at boot by whoever starts it. -func buildLaunchArgs(avdName string, console, memoryMB int, headless bool) []string { +func buildLaunchArgs(avdName string, console, memoryMB int, headless bool, qemuExtra ...string) []string { args := []string{ "-avd", avdName, "-no-snapshot", @@ -75,7 +77,7 @@ func buildLaunchArgs(avdName string, console, memoryMB int, headless bool) []str if root.CurrentPlatform().MirrorOS != "macos" { args = append(args, "-device", "virtio-balloon-pci,free-page-reporting=on") } - return args + return append(args, qemuExtra...) } // launchPlan is what prepareLaunch settled before emulator.exe is started. @@ -156,7 +158,7 @@ func (m *Manager) launchPlanFor(ctx context.Context, inst *avd.Instance, memoryM } headless := !window - args := buildLaunchArgs(inst.Name, port, memoryMB, headless) + args := buildLaunchArgs(inst.Name, port, memoryMB, headless, m.syncIOQemuArgs(inst)...) m.pruneLogs(i) logPath, err := emulatorLogPath(m.Root.LogsDir(), inst.Name) if err != nil { diff --git a/internal/emu/syncio.go b/internal/emu/syncio.go new file mode 100644 index 0000000..3317f0c --- /dev/null +++ b/internal/emu/syncio.go @@ -0,0 +1,167 @@ +package emu + +import ( + "bytes" + "encoding/binary" + "fmt" + "os" + "path/filepath" + "strings" + + "modavd/internal/avd" + "modavd/internal/root" +) + +// Synchronous disk I/O under WHPX (ModAVD/ModAVD#35). A guest fsync on ext4 is three write requests in a row (data, +// journal, commit), each one a round trip to QEMU that the guest waits for. Two QEMU arguments make that round trip +// cheaper without changing what "written" means (a request still completes only once the data is in the host file): +// +// - -global virtio-blk-pci.ioeventfd=off: WHPX has no in-kernel eventfd, QEMU emulates it in user mode, which costs +// a request three main-loop passes and three thread hand-offs. Off, the vCPU thread handles the request itself. +// - -acpitable : the guest has no cpuidle driver and halts whenever it has nothing to run, +// and Hyper-V needs 20-24 us to wake a halted vCPU for every completion. The table declares one C1 entered by +// HLT with an exit latency of cstLatencyUs. The guest's own acpi_idle driver and menu governor then busy-wait +// for up to 2 x cstLatencyUs when a task on that CPU waits for I/O (or the CPU is about to be needed again +// anyway) and halt otherwise. +// +// Measured 11-10 on a 6-core test instance (two writers, 7 KB + fsync each, the Arknights download pattern): +// 15-16 MB/s without, 19 with the first, 19-22 with the second, 33-36 with both; single-threaded 4 KiB + fsync +// 584-638 us -> 319 us. An idle guest spent 0.1 s of 240 CPU-seconds busy-waiting. +// +// Windows only: the other hosts use another accelerator and were not measured. +const ( + // envSyncIO set to "off" starts the emulator without the two arguments (A/B runs, and the way back). + envSyncIO = "MODAVD_SYNC_IO" + // cstFileName is the table in the instance's own folder, rewritten when the CPU count changes. + cstFileName = "modavd-cst.aml" + cstLatencyUs = 100 + // QEMU names the processor objects C000..C0FE (hw/acpi/cpu.c); one more would need another name format. + cstMaxCPUs = 255 +) + +// syncIOQemuArgs is what goes after -qemu for the two changes above. The table is optional: when it cannot be +// written or named on the command line, the instance starts without it. +func (m *Manager) syncIOQemuArgs(inst *avd.Instance) []string { + if !root.CurrentPlatform().Windows() || strings.EqualFold(strings.TrimSpace(os.Getenv(envSyncIO)), "off") { + return nil + } + args := []string{"-global", "virtio-blk-pci.ioeventfd=off"} + cpus, ok := inst.CPU() + if !ok || cpus < 1 || cpus > cstMaxCPUs { + m.Log.Warnf("实例 %d 读不出 CPU 核数(hw.cpu.ncore),这次开机不带空闲状态表", inst.Index) + return args + } + rel, err := writeCstTable(filepath.Dir(m.Root.EmulatorExe()), inst.Dir, cpus) + if err != nil { + m.Log.Warnf("实例 %d 这次开机不带空闲状态表: %v", inst.Index, err) + return args + } + return append(args, "-acpitable", "file="+rel) +} + +// writeCstTable puts the table for cpus processors into instDir and returns its path relative to emuDir, the +// emulator's working directory. Relative because QEMU splits file= at ':' and ',' and opens the name with the ANSI +// code page: \sdk\emulator -> ..\..\avd\mas_.avd\modavd-cst.aml is plain ASCII wherever the root is. +func writeCstTable(emuDir, instDir string, cpus int) (string, error) { + path := filepath.Join(instDir, cstFileName) + rel, err := filepath.Rel(emuDir, path) + if err != nil { + return "", fmt.Errorf("%s 和模拟器目录 %s 不在同一个盘上", path, emuDir) + } + for _, c := range rel { + if c >= 0x80 || c < 0x20 || c == ':' || c == ',' || c == '"' { + return "", fmt.Errorf("表的相对路径 %s 里有 QEMU 命令行放不下的字符", rel) + } + } + table := cstTable(cpus, cstLatencyUs) + if old, err := os.ReadFile(path); err == nil && bytes.Equal(old, table) { + return rel, nil + } + if err := os.WriteFile(path, table, 0o644); err != nil { + return "", err + } + return rel, nil +} + +// cstTable is an ACPI SSDT that adds to each of QEMU's processor objects \_SB.CPUS.C000 .. (one per CPU) +// +// Name (_CST, Package () { 1, Package () { ResourceTemplate () { Register (FFixedHW, 0, 0, 0) }, 1, latency, 1000 } }) +// +// i.e. one C-state of type C1. Bit offset 0 is not the MWAIT encoding, so Linux enters it with HLT ("ACPI HLT"). +// A table naming a processor QEMU did not create fails to load in the guest and changes nothing. +func cstTable(cpus, latencyUs int) []byte { + // Generic Register Descriptor (0x82, 12 bytes: FFixedHW, width 0, offset 0, access 0, address 0) + End Tag + res := append([]byte{0x82, 0x0c, 0x00, 0x7f, 0x00, 0x00, 0x00}, make([]byte, 8)...) + res = append(res, 0x79, 0x00) + buffer := amlPackage(0x11, append(amlInteger(len(res)), res...)) + state := []byte{0x04} + state = append(state, buffer...) + state = append(state, amlInteger(1)...) // type C1 + state = append(state, amlInteger(latencyUs)...) // worst-case exit latency, microseconds + state = append(state, amlInteger(1000)...) // power, milliwatts (unused by Linux) + cst := append([]byte{0x02}, amlInteger(1)...) // two elements: the count, then the one state + cst = append(cst, amlPackage(0x12, state)...) + term := append([]byte{0x08, '_', 'C', 'S', 'T'}, amlPackage(0x12, cst)...) + + var body []byte + for i := 0; i < cpus; i++ { + name := fmt.Sprintf("\\/\x03_SB_CPUSC%03X", i) // root, MultiNamePrefix, 3 segments + body = append(body, amlPackage(0x10, append([]byte(name), term...))...) + } + table := make([]byte, 36, 36+len(body)) + copy(table[0:], "SSDT") + binary.LittleEndian.PutUint32(table[4:], uint32(36+len(body))) + table[8] = 1 // revision + copy(table[10:], "MODAVD") + copy(table[16:], "CPUCST ") + binary.LittleEndian.PutUint32(table[24:], 1) + copy(table[28:], "MAVD") + binary.LittleEndian.PutUint32(table[32:], 1) + table = append(table, body...) + var sum byte + for _, b := range table { + sum += b + } + table[9] = -sum + return table +} + +// amlPackage is op, PkgLength, content. PkgLength counts its own bytes: one byte up to 63, else the low nibble in the +// lead byte (with the number of following bytes in its top two bits) and the rest in up to three more. +func amlPackage(op byte, content []byte) []byte { + out := []byte{op} + for n := 1; n <= 4; n++ { + total := len(content) + n + if n == 1 { + if total <= 0x3f { + out = append(out, byte(total)) + break + } + continue + } + if total < 1<<(4+8*(n-1)) { + out = append(out, byte(n-1)<<6|byte(total&0x0f)) + for k, rest := 1, total>>4; k < n; k, rest = k+1, rest>>8 { + out = append(out, byte(rest)) + } + break + } + } + return append(out, content...) +} + +// amlInteger is the shortest AML integer constant for v (0 <= v < 2^32). +func amlInteger(v int) []byte { + switch { + case v == 0: + return []byte{0x00} + case v == 1: + return []byte{0x01} + case v <= 0xff: + return []byte{0x0a, byte(v)} + case v <= 0xffff: + return []byte{0x0b, byte(v), byte(v >> 8)} + default: + return []byte{0x0c, byte(v), byte(v >> 8), byte(v >> 16), byte(v >> 24)} + } +} diff --git a/internal/emu/syncio_test.go b/internal/emu/syncio_test.go new file mode 100644 index 0000000..0c620a2 --- /dev/null +++ b/internal/emu/syncio_test.go @@ -0,0 +1,236 @@ +//go:build windows + +package emu + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "os" + "path/filepath" + "reflect" + "testing" + + "modavd/internal/adb" + "modavd/internal/avd" + "modavd/internal/logx" + "modavd/internal/root" +) + +// The table for 6 CPUs and 100 us as an independent generator wrote it (Python, 11-10); the emulator booted with +// exactly these bytes and the guest registered acpi_idle with C1 latency 100. +const cst6x100 = "535344547401000001a44d4f444156444350554353542020010000004d41564401000000" + + "10375c2f035f53425f4350555343303030085f43535412210201121d0411140a11820c007f00000000000000000000007900010a640be803" + + "10375c2f035f53425f4350555343303031085f43535412210201121d0411140a11820c007f00000000000000000000007900010a640be803" + + "10375c2f035f53425f4350555343303032085f43535412210201121d0411140a11820c007f00000000000000000000007900010a640be803" + + "10375c2f035f53425f4350555343303033085f43535412210201121d0411140a11820c007f00000000000000000000007900010a640be803" + + "10375c2f035f53425f4350555343303034085f43535412210201121d0411140a11820c007f00000000000000000000007900010a640be803" + + "10375c2f035f53425f4350555343303035085f43535412210201121d0411140a11820c007f00000000000000000000007900010a640be803" + +func TestCstTable(t *testing.T) { + want, err := hex.DecodeString(cst6x100) + if err != nil { + t.Fatal(err) + } + if got := cstTable(6, 100); !bytes.Equal(got, want) { + t.Errorf("6 CPUs, 100 us:\n got %x\nwant %x", got, want) + } + // other sizes against the same independent generator: the name of every CPU, the integer and PkgLength encodings + for _, c := range []struct { + cpus, latency, size int + sha256 string + }{ + {1, 100, 92, "88a6c245d7a6485ca0b9ec9fbceca1a85c44c97ddc90c14b0a30ca2bad992766"}, + {16, 100, 932, "c4c54a4ad368b999c6ac808b99e341b79a2dc0f257ccf1c98ac9bb4fa58a5236"}, + {255, 100, 14316, "829adc39854317053694b541a6524a6690011578d5c11bd3b46a28f3e6a583e1"}, + {6, 300, 378, "87d9798795647e88dcbc4ee3e31668d489304b5937ef91ac97d67bc13ed84464"}, + {6, 70000, 390, "33da78008ccec42e1c75154f6e623f7f05843990e45f0b0d342f42bfc5513cc7"}, + } { + got := cstTable(c.cpus, c.latency) + sum := sha256.Sum256(got) + if len(got) != c.size || hex.EncodeToString(sum[:]) != c.sha256 { + t.Errorf("%d CPUs, %d us: %d bytes, sha256 %x", c.cpus, c.latency, len(got), sum) + } + // what a guest checks before it looks inside: the length field and the byte sum + if n := binary.LittleEndian.Uint32(got[4:]); int(n) != len(got) { + t.Errorf("%d CPUs: length field %d, table %d bytes", c.cpus, n, len(got)) + } + var s byte + for _, b := range got { + s += b + } + if s != 0 { + t.Errorf("%d CPUs: bytes sum to %d, want 0", c.cpus, s) + } + } +} + +func TestAmlPackage(t *testing.T) { + for _, c := range []struct { + content int + want []byte + }{ + {0, []byte{0x12, 0x01}}, + {62, []byte{0x12, 0x3f}}, // 62 + 1 = 63: the last one-byte length + {63, []byte{0x12, 0x41, 0x04}}, // 63 + 2 = 65 = 0x41: nibble 1, then 0x04 + {4093, []byte{0x12, 0x4f, 0xff}}, // 4093 + 2 = 4095: the last two-byte length + {4094, []byte{0x12, 0x81, 0x00, 0x01}}, // 4094 + 3 = 4097 = 0x1001 + } { + got := amlPackage(0x12, make([]byte, c.content)) + if !bytes.Equal(got[:len(c.want)], c.want) || len(got) != len(c.want)+c.content { + t.Errorf("content %d: head %x (%d bytes), want %x", c.content, got[:len(c.want)], len(got), c.want) + } + } +} + +func TestWriteCstTable(t *testing.T) { + dir := t.TempDir() + emuDir := filepath.Join(dir, "sdk", "emulator") + instDir := filepath.Join(dir, "avd", "mas_3.avd") + for _, d := range []string{emuDir, instDir} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + rel, err := writeCstTable(emuDir, instDir, 4) + if err != nil || rel != `..\..\avd\mas_3.avd\modavd-cst.aml` { + t.Fatalf("rel %q err %v", rel, err) + } + path := filepath.Join(instDir, cstFileName) + if b, err := os.ReadFile(path); err != nil || !bytes.Equal(b, cstTable(4, cstLatencyUs)) { + t.Fatalf("file: %d bytes, err %v", len(b), err) + } + // the relative path names the file from the emulator's working directory + if b, err := os.ReadFile(filepath.Join(emuDir, rel)); err != nil || !bytes.Equal(b, cstTable(4, cstLatencyUs)) { + t.Errorf("through the relative path: %d bytes, err %v", len(b), err) + } + // another CPU count replaces it + if _, err := writeCstTable(emuDir, instDir, 6); err != nil { + t.Fatal(err) + } + if b, _ := os.ReadFile(path); !bytes.Equal(b, cstTable(6, cstLatencyUs)) { + t.Errorf("after the CPU count changed: %d bytes", len(b)) + } + // an unchanged table is left alone (a read-only file from the last start is not an error) + if err := os.Chmod(path, 0o444); err != nil { + t.Fatal(err) + } + if _, err := writeCstTable(emuDir, instDir, 6); err != nil { + t.Errorf("same table, read-only file: %v", err) + } + os.Chmod(path, 0o644) + + // names QEMU's command line cannot carry: nothing is written, the caller starts without the table + for _, bad := range []string{"mas,3.avd", "实例3.avd"} { + inst := filepath.Join(dir, "avd", bad) + if err := os.MkdirAll(inst, 0o755); err != nil { + t.Fatal(err) + } + if rel, err := writeCstTable(emuDir, inst, 4); err == nil { + t.Errorf("%s: got %q, want an error", bad, rel) + } + if _, err := os.Stat(filepath.Join(inst, cstFileName)); err == nil { + t.Errorf("%s: the table was written", bad) + } + } + // a folder that does not exist + if _, err := writeCstTable(emuDir, filepath.Join(dir, "avd", "mas_9.avd"), 4); err == nil { + t.Error("missing instance folder: want an error") + } +} + +func syncIOManager(t *testing.T) (*Manager, *avd.Instance) { + t.Helper() + dir := t.TempDir() + r, err := root.New(dir) + if err != nil { + t.Fatal(err) + } + instDir := filepath.Join(r.AvdHome(), "mas_3.avd") + for _, d := range []string{filepath.Dir(r.EmulatorExe()), instDir} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + cfg := filepath.Join(instDir, "config.ini") + if err := os.WriteFile(cfg, []byte("hw.cpu.ncore=4\n"), 0o644); err != nil { + t.Fatal(err) + } + ini, err := avd.ReadIni(cfg) + if err != nil { + t.Fatal(err) + } + m := &Manager{Root: r, Log: logx.Nop(), ADB: adb.New(r, logx.Nop())} + return m, &avd.Instance{Index: 3, Name: "mas_3", Dir: instDir, Config: ini} +} + +func TestSyncIOQemuArgs(t *testing.T) { + t.Setenv(envSyncIO, "") + m, inst := syncIOManager(t) + rel, err := filepath.Rel(filepath.Dir(m.Root.EmulatorExe()), filepath.Join(inst.Dir, cstFileName)) + if err != nil { + t.Fatal(err) + } + want := []string{"-global", "virtio-blk-pci.ioeventfd=off", "-acpitable", "file=" + rel} + if got := m.syncIOQemuArgs(inst); !reflect.DeepEqual(got, want) { + t.Errorf("got %q\nwant %q", got, want) + } + if b, err := os.ReadFile(filepath.Join(inst.Dir, cstFileName)); err != nil || !bytes.Equal(b, cstTable(4, cstLatencyUs)) { + t.Errorf("table for the instance's 4 CPUs: %d bytes, err %v", len(b), err) + } + // they end up after the manager's other QEMU arguments + args := buildLaunchArgs("mas_3", 20030, 4096, true, m.syncIOQemuArgs(inst)...) + if n := len(masArgsHeadless); !reflect.DeepEqual(args[:n], masArgsHeadless) || !reflect.DeepEqual(args[n:], want) { + t.Errorf("launch arguments: %q", args) + } + + // no CPU count: the disk argument alone, no table + os.Remove(filepath.Join(inst.Dir, cstFileName)) + for _, cfg := range []string{"", "hw.cpu.ncore=0\n", "hw.cpu.ncore=256\n", "hw.cpu.ncore=many\n"} { + path := filepath.Join(inst.Dir, "config.ini") + if err := os.WriteFile(path, []byte(cfg), 0o644); err != nil { + t.Fatal(err) + } + ini, err := avd.ReadIni(path) + if err != nil { + t.Fatal(err) + } + got := m.syncIOQemuArgs(&avd.Instance{Index: 3, Name: "mas_3", Dir: inst.Dir, Config: ini}) + if !reflect.DeepEqual(got, want[:2]) { + t.Errorf("config %q: got %q", cfg, got) + } + if _, err := os.Stat(filepath.Join(inst.Dir, cstFileName)); err == nil { + t.Errorf("config %q: a table was written", cfg) + } + } + + // the table cannot be written (the instance folder is gone): still the disk argument + gone := &avd.Instance{Index: 3, Name: "mas_3", Dir: filepath.Join(m.Root.AvdHome(), "mas_8.avd"), Config: inst.Config} + if got := m.syncIOQemuArgs(gone); !reflect.DeepEqual(got, want[:2]) { + t.Errorf("missing folder: got %q", got) + } +} + +func TestSyncIOOff(t *testing.T) { + m, inst := syncIOManager(t) + for _, v := range []string{"off", "OFF", " off "} { + t.Setenv(envSyncIO, v) + if got := m.syncIOQemuArgs(inst); got != nil { + t.Errorf("%s=%q: got %q", envSyncIO, v, got) + } + if got := buildLaunchArgs("mas_3", 20030, 4096, true, m.syncIOQemuArgs(inst)...); !reflect.DeepEqual(got, masArgsHeadless) { + t.Errorf("%s=%q: launch arguments %q", envSyncIO, v, got) + } + } + if _, err := os.Stat(filepath.Join(inst.Dir, cstFileName)); err == nil { + t.Error("switched off, yet a table was written") + } + // anything else is not "off" + for _, v := range []string{"0", "no", "on"} { + t.Setenv(envSyncIO, v) + if got := m.syncIOQemuArgs(inst); len(got) != 4 { + t.Errorf("%s=%q: got %q", envSyncIO, v, got) + } + } +} From d1ab980f56e1e63d5ce6d5f2a2f37fc1232d512c Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Tue, 10 Nov 2026 04:50:16 +0100 Subject: [PATCH 2/2] =?UTF-8?q?=E5=AE=A2=E4=BD=93=E8=87=AA=E5=B7=B1?= =?UTF-8?q?=E5=BF=99=E7=AD=89=E6=97=B6=E4=B8=8D=E5=86=8D=E8=B5=B7=E5=BF=99?= =?UTF-8?q?=E7=AD=89=E5=AE=88=E6=8A=A4=EF=BC=9BREADME=20=E5=86=99=E4=B8=8A?= =?UTF-8?q?=E6=96=B0=E7=9A=84=E5=BC=80=E6=9C=BA=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 带上 _CST 表以后,客体等读写时自己忙等(acpi_idle + menu),mas-idlepoll.sh 的 忙等循环不再加速,却在整段密集写盘期间把每个 vCPU 占满。开机后先读客体的 cpuidle 驱动,是 acpi_idle 就不起守护;表没带上时(MODAVD_SYNC_IO=off 等)照旧起。 11-10 明日方舟资源下载,6 核实例,各 150 秒窗口,旧参数、新参数交替: - 旧参数(带守护):6.94 / 7.73 / 7.84 MiB/s,qemu 约 5.9 个宿主核; - 新参数、带守护:11.75 / 10.36 / 10.91 MiB/s,qemu 约 6.0 个核; - 新参数、不起守护:12.07 / 11.30 / 11.10 MiB/s,qemu 约 3.8 个核。 仿下载负载不起守护是 33.8 / 34.2 MB/s(起守护 35.7 / 33.3);单线程写 4 KiB + fsync 的中位数 344 µs(起守护 264,旧参数 429–436)。 Refs ModAVD/ModAVD#35 --- README.en.md | 6 +++++- README.md | 6 +++++- internal/emu/afterboot.go | 16 +++++++++++++++- internal/emu/afterboot_test.go | 14 ++++++++++++++ internal/emu/syncio.go | 4 +++- 5 files changed, 42 insertions(+), 4 deletions(-) diff --git a/README.en.md b/README.en.md index 296a2e4..856ba2f 100644 --- a/README.en.md +++ b/README.en.md @@ -180,7 +180,11 @@ On Apple Silicon macOS, downloads and imports use Android 15 Google Play `arm64- - The group with the largest free share (free logical processors ÷ the group's logical processors) is chosen, the bigger L3 on a tie; a single instance gets the big-L3 group. - When no group has room for the instance's vCPUs, it is not pinned. - "Free" means after the vCPUs of every qemu on the machine, including those of other root folders and those started by AUTO-MAS. Those processes are only read, never changed. -- While the guest does many small disk writes (downloads, for example), it runs one busy loop per vCPU (`mas-idlepoll.sh`) so the vCPUs do not halt, which makes downloads faster. +- Many small disk writes (downloads, for example) are made faster on Windows by two extra QEMU arguments at boot. What "written" means stays the same: a request completes once the data is in the host file. + - `-global virtio-blk-pci.ioeventfd=off`: the vCPU thread handles disk requests itself, with fewer thread hand-offs. + - `-acpitable`: the guest gets an idle-state table (`modavd-cst.aml` in the instance folder, generated for its CPU count). While it waits for a disk request it busy-waits briefly (200 µs at most) before halting, and halts as usual otherwise. + - Boot with the environment variable `MODAVD_SYNC_IO=off` to leave both arguments out. +- Without the table (`MODAVD_SYNC_IO=off`, an instance whose CPU count cannot be read, or a table path that does not fit on QEMU's command line), the earlier method is used: while the guest does many small disk writes it runs one busy loop per vCPU (`mas-idlepoll.sh`) so the vCPUs do not halt. - The number of loops never exceeds what the host has left: logical processors minus the vCPUs of every qemu (this instance's included; counted within its group when pinned). - So on machines with fewer than twice as many logical processors as the instance has vCPUs, even a single instance gets fewer loops: a default 6-core instance gets 2 on an 8-thread machine and none on 6 threads or fewer, and downloads are slower than with all loops (measured on a 7950X3D, 1–2 loops do about as well as none). - This is a deliberate trade-off: it leaves processors for qemu's own threads, Windows and other programs. diff --git a/README.md b/README.md index ac52a7f..d254c26 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,11 @@ Apple Silicon macOS 的下载与导入使用 Android 15 Google Play `arm64-v8a` - 选空余比例(空余核数 ÷ 这组的核数)最大的一组,一样时选 L3 大的;只开一台时就是 L3 大的那组。 - 各组都放不下本实例的 vCPU 就不绑。 - 空余是扣掉整台机器上所有 qemu 的 vCPU 以后算的,包括别的根目录和 AUTO-MAS 起的。那些进程只读、不改。 -- 下载这类小块密集写盘时,客体里每个 vCPU 起一个忙等循环(`mas-idlepoll.sh`),不让 vCPU 停机,下载更快。 +- 下载这类小块密集写盘,在 Windows 上靠开机时多给 QEMU 的两个参数加速,「写完」的含义不变(数据进了宿主文件才算完成): + - `-global virtio-blk-pci.ioeventfd=off`:读写请求由 vCPU 线程直接处理,少几次线程交接。 + - `-acpitable`:给客体一张空闲状态表(实例目录里的 `modavd-cst.aml`,按核数生成)。客体等读写完成时先忙等一小段(最多 200 µs)再停机,其余时候照常停机。 + - 设环境变量 `MODAVD_SYNC_IO=off` 再开机,这两个参数都不带。 +- 表没带上时(`MODAVD_SYNC_IO=off`、实例读不出核数,或者表的路径放不进 QEMU 命令行),退回原来的办法:小块密集写盘时客体里每个 vCPU 起一个忙等循环(`mas-idlepoll.sh`),不让 vCPU 停机。 - 循环数不超过宿主的余量:逻辑核数减去所有 qemu 的 vCPU 数(含本实例;绑了核就按那一组算)。 - 所以逻辑核数不到本实例 vCPU 两倍的电脑上,只开一台也会少开:默认 6 核的实例在 8 线程的电脑上只开 2 个,6 线程及以下不开,下载会比开满时慢(在 7950X3D 上实测,1–2 个循环和不开差不多)。 - 这是有意的取舍:给 qemu 自己的线程、Windows 和别的程序留出核。 diff --git a/internal/emu/afterboot.go b/internal/emu/afterboot.go index 44691bd..84090d9 100644 --- a/internal/emu/afterboot.go +++ b/internal/emu/afterboot.go @@ -563,8 +563,22 @@ func idlepollCap(cores, spare int) int { return max(0, min(cores, spare)) } -// setupIdlepoll starts the watcher with at most limit busy loops per burst. +// guestPollsItself: the guest booted with the _CST table of syncIOQemuArgs and registered acpi_idle (driver is the +// content of /sys/devices/system/cpu/cpuidle/current_driver, "none" without the table). Its menu governor then +// busy-waits on the CPU whose task waits for I/O, for as long as that pays. +func guestPollsItself(driver string) bool { + return strings.TrimSpace(driver) == "acpi_idle" +} + +// setupIdlepoll starts the watcher with at most limit busy loops per burst, unless the guest busy-waits by itself: +// the loops then add nothing and keep every vCPU busy for the whole burst. 11-10, Arknights resource download on a +// 6-vCPU instance with the table: 10.4-11.8 MiB/s and 6.0 host cores for qemu with the loops, 11.3-12.1 MiB/s and +// 3.8 cores without. func (m *Manager) setupIdlepoll(ctx context.Context, i int, limit int) { + if guestPollsItself(m.shell(ctx, i, "cat /sys/devices/system/cpu/cpuidle/current_driver", 0).Out) { + m.Log.Infof("实例 %d 密集读写加速: 客体等读写时自己忙等(acpi_idle),不起忙等守护", i) + return + } remote, ok := m.pushGuestScript(ctx, i, guest.IdlePoll) if !ok { return diff --git a/internal/emu/afterboot_test.go b/internal/emu/afterboot_test.go index 9e3f3bf..5ea9c36 100644 --- a/internal/emu/afterboot_test.go +++ b/internal/emu/afterboot_test.go @@ -76,6 +76,20 @@ func TestIdlepollReport(t *testing.T) { } } +func TestGuestPollsItself(t *testing.T) { + // what `cat /sys/devices/system/cpu/cpuidle/current_driver` prints: acpi_idle with the _CST table, none without + for driver, want := range map[string]bool{ + "acpi_idle\n": true, "acpi_idle\r\n": true, " acpi_idle ": true, + "none\n": false, "": false, "haltpoll\n": false, "intel_idle\n": false, + "cat: /sys/devices/system/cpu/cpuidle/current_driver: No such file or directory\n": false, + "acpi_idle2\n": false, + } { + if got := guestPollsItself(driver); got != want { + t.Errorf("guestPollsItself(%q) = %v, want %v", driver, got, want) + } + } +} + func TestParseWriteback(t *testing.T) { if e, w, ok := parseWriteback("200\r\n100\r\n"); !ok || e != 200 || w != 100 { t.Errorf("good: %d %d %v", e, w, ok) diff --git a/internal/emu/syncio.go b/internal/emu/syncio.go index 3317f0c..d8d1648 100644 --- a/internal/emu/syncio.go +++ b/internal/emu/syncio.go @@ -26,7 +26,9 @@ import ( // // Measured 11-10 on a 6-core test instance (two writers, 7 KB + fsync each, the Arknights download pattern): // 15-16 MB/s without, 19 with the first, 19-22 with the second, 33-36 with both; single-threaded 4 KiB + fsync -// 584-638 us -> 319 us. An idle guest spent 0.1 s of 240 CPU-seconds busy-waiting. +// 584-638 us -> 319 us. An idle guest spent 0.1 s of 240 CPU-seconds busy-waiting. The Arknights resource download +// itself, three alternating 150 s windows each: 6.9-7.8 -> 10.4-11.8 MiB/s. With the table the busy loops of +// mas-idlepoll.sh are not started any more (setupIdlepoll). // // Windows only: the other hosts use another accelerator and were not measured. const (