From: Yufeng Wang <[email protected]>
Thank you for the detailed feedback. We've completed benchmark
testing comparing the existing notification mechanisms against the
proposed SQ/CQ polling. The results are informative.
Test Modes Explained
--------------------
To answer your second question with data, we tested four modes:
1. **baseline**: Default vhost-scsi with event_idx auto-negotiated.
I/O engine: libaio. Submission uses MMIO kick (VM exit), completion
uses MSI-X interrupt (VM exit). The guest driver dynamically toggles
NO_NOTIFY/NO_INTERRUPT during completion processing, but these are
short windows — most I/O still incurs VM exits on both paths.
2. **poll-queue**: This mode combines the existing notification features
you mentioned:
- VIRTIO_RING_F_EVENT_IDX: auto-negotiated (same as baseline)
- VIRTQ_AVAIL_F_NO_INTERRUPT: effectively permanent — poll queue
VQs have callback=NULL and no MSI-X vector, so the guest never
receives completion interrupts regardless of the flag state
- VIRTQ_USED_F_NO_NOTIFY: vhost dynamically toggles this during
active processing (vhost_disable_notify/enable_notify)
- VIRTIO_F_NOTIFICATION_DATA: negotiated but vhost kernel does not
process notification data, so no practical effect
Implemented via virtio-scsi poll queues (virtscsi_poll_queues=16,
callback=NULL, no MSI-X vector) + io_uring IOPOLL mode (--hipri).
blk-mq polls the used ring for completions via virtscsi_mq_poll().
This eliminates completion-side VM exits. However, submission still
uses MMIO kick (VM exit). Note: libaio cannot use this mode because
aio.c strips IOCB_HIPRI ("no one is going to poll for this I/O").
3. **sqcq-poll**: The proposed SQ/CQ doorbell polling (this RFC) with
libaio. Guest writes sq->idx instead of MMIO kick (no VM exit).
Guest poll thread checks used ring via more_used() (no interrupt).
NEED_WAKEUP protocol enables adaptive sleep when idle. Eliminates
VM exits on BOTH submission and completion paths.
4. **sqcq-uring**: Same SQ/CQ polling but with io_uring + --hipri.
Included to isolate the I/O engine variable from the polling
mechanism.
Results
-------
Test configuration:
x86_64: Intel Xeon E5-2680 v4 @ 2.40GHz, 16 vCPUs
NVMe SAMSUNG MZ1LB960HAJQ-000MV (960GB)
arm64: Kunpeng 920 (2.6GHz), 16 vCPUs
NVMe INTEL SSDPED1K375GA (375GB)
Backend: vhost-scsi with TCM loopback to NVMe
arm64 IOPS (best of 3 runs, fio 4K random I/O):
Test baseline poll-queue sqcq-poll sqcq-uring
───────────── ──────── ────────── ───────── ──────────
randread QD1 23,751 29,892 29,680 26,427
randread QD32 NJ1 93,221 162,849 * 84,967 70,271
randread QD32 NJ4 184,939 186,859 340,780 235,310
randread QD32 NJ8 190,562 188,382 520,946 518,197
randwrite QD1 24,131 — 25,520 25,485
randwrite QD32 NJ1 84,725 — 70,652 68,518
randwrite QD32 NJ4 204,745 — 323,449 312,204
randwrite QD32 NJ8 193,802 — 522,322 516,448
* poll-queue NJ1 read shows high IOPS but hangs at NJ≥4 (see below)
x86_64 IOPS (best of 3 runs):
Test baseline poll-queue sqcq-poll sqcq-uring
───────────── ──────── ────────── ───────── ──────────
randread QD1 8,316 9,022 9,533 9,489
randread QD32 NJ1 124,909 146,744 165,761 165,929
randread QD32 NJ4 305,951 332,139 378,305 379,546
randread QD32 NJ8 344,223 356,328 374,932 374,976
randwrite QD1 20,106 24,464 27,265 27,768
randwrite QD32 NJ1 131,836 153,203 162,145 161,402
randwrite QD32 NJ4 232,908 234,559 232,976 231,336
randwrite QD32 NJ8 231,242 229,858 231,828 231,465
Key Findings
------------
1. **poll-queue excels at NJ=1 (arm64)**: On arm64 4K randread QD32
NJ1, poll-queue achieved 163K IOPS — significantly higher than
baseline (93K, +75%) and SQ/CQ polling (85K). io_uring IOPOLL
mode is very efficient for single-queue workloads because the
submitting task polls for completion directly, avoiding both
interrupt latency and poll thread scheduling overhead.
2. **SQ/CQ dramatically outperforms poll-queue at NJ≥4 (arm64)**:
At 4K randread QD32, poll-queue and SQ/CQ diverge sharply:
- NJ4: poll-queue 187K vs SQ/CQ 341K (+82% over poll-queue)
- NJ8: poll-queue 188K vs SQ/CQ 521K (+177% over poll-queue)
SQ/CQ also outperforms baseline by +84% (NJ4) and +173% (NJ8).
3. **libaio incompatibility**: The existing poll queue mechanism
requires io_uring IOPOLL mode. libaio explicitly strips IOCB_HIPRI
(fs/aio.c: "no one is going to poll for this I/O"), so poll queues
are inaccessible to libaio users. SQ/CQ works with any I/O engine
because the poll thread is independent of the submitting task.
Development Insights
--------------------
Beyond eliminating VM exits, two engineering decisions were critical
to achieving the performance shown above:
1. **TCM completion CPU steering**: Our initial goal was 5-10% IOPS
improvement at 4K random I/O QD1 NJ1. After implementing the basic
polling and NEED_WAKEUP protocol, this target was not met. The
bottleneck was that TCM completions were being scheduled on the same
CPU as the busy poll thread, starving the completion workqueue. We
added vhost_sqcq_pick_completion_cpu() to steer TCM completions to
a free CPU (avoiding busy poll cores), with fallback to next-CPU
when all cores are occupied. This closed the gap and achieved the
QD1 target.
2. **Adaptive idle policy with EMA**: After QD1 targets were met,
we tested higher concurrency (QD32 NJ4/NJ8) and found that
aggressive polling caused performance degradation under
multi-queue workloads — likely due to the poll thread consuming
excessive CPU cycles that competing vCPUs needed. We solved this
by implementing an EMA-based adaptive spin budget: the poll thread
spins within a time window derived from recent I/O latency, then
yields. Combined with the NEED_WAKEUP protocol, this allows the
poll thread to busy-spin when active but sleep when idle, avoiding
CPU contention with other vCPUs.
On the vDPA Question
--------------------
The generic vDPA device approach you suggested is indeed a better
direction. The QEMU-side zero-code advantage (leveraging existing
vdpa-dev.c) is very attractive. Our concern is that vhost-scsi's
TCM integration (SCSI CDB, ALUA, persistent reservations) would
need to be wrapped as a vDPA backend. This feels like a 0-to-1
engineering effort, and we're uncertain about the actual scope of
work involved. If there are additional guidelines or examples
available, we'd be very willing to explore this direction.
One More Thing: SQ/CQ + poll-queue Combined
--------------------------------------------
During arm64 testing, we accidentally ran the poll-queue benchmark
(--hipri, io_uring, virtscsi_poll_queues=16) on a kernel that also
had VIRTIO_F_SQCQ_POLL negotiated. This "poll-sqcq" mode combines
both mechanisms simultaneously:
- Submission: SQ/CQ doorbell (sq->idx write, no VM exit)
- Completion: both SQ/CQ poll thread AND blk-mq poll via io_uring
The results were surprisingly strong (arm64, 4K random I/O):
Test baseline poll-queue poll-sqcq sqcq-poll
───────────── ──────── ────────── ───────── ─────────
randread QD1 23,751 29,892 41,683 29,680
randread QD32 NJ1 93,221 162,849 193,029 84,967
randread QD32 NJ4 184,939 186,859 585,445 340,780
randread QD32 NJ8 190,562 188,382 560,727 520,946
randwrite QD1 24,131 — 38,591 25,520
randwrite QD32 NJ1 84,725 — 187,421 70,652
randwrite QD32 NJ4 204,745 — 598,419 323,449
randwrite QD32 NJ8 193,802 — 569,890 522,322
poll-sqcq outperforms both individual mechanisms across all test
cases. We haven't fully analyzed why, but suspect the combination
benefits from:
- SQ/CQ eliminating submission VM exits
- io_uring IOPOLL providing low-latency completion detection via
blk-mq (directly in submitting task context)
- SQ/CQ poll thread as a secondary completion poller covering VQs
that blk-mq doesn't reach
This is preliminary and accidental data, but we think it's worth
sharing as it suggests the two mechanisms are complementary rather
than mutually exclusive.
Benchmark scripts and raw fio JSON data are available and can be
sent as a follow-up upon request.
Best regards,
Yufeng
Signed-off-by: Yufeng Wang <[email protected]>