Skip to content

block: TRIM/DISCARD support and virtio-blk multiqueue I/O#1400

Open
gburd wants to merge 3 commits into
cloudius-systems:masterfrom
gburd:pr/block-trim-mq
Open

block: TRIM/DISCARD support and virtio-blk multiqueue I/O#1400
gburd wants to merge 3 commits into
cloudius-systems:masterfrom
gburd:pr/block-trim-mq

Conversation

@gburd

@gburd gburd commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Split out of #1399 per review (one PR per subsystem).

This PR adds two related virtio-blk capabilities. They are kept together
because they touch the same regions of drivers/virtio-blk.cc and share the
per-request plumbing, but each concern is independently documented and
reviewable.

TRIM/DISCARD

  • BIO_DISCARD (0x20) bio command describing a byte range to reclaim.
  • BLKDISCARD ioctl (_IO(0x12, 119)) so in-guest code can issue a discard
    against an open block device; blk_ioctl() builds the bio and submits it.
  • virtio-blk negotiates VIRTIO_BLK_F_DISCARD, reads the discard limits from
    device config, and translates a BIO_DISCARD bio into a
    VIRTIO_BLK_T_DISCARD (11) command with a single
    blk_discard_write_zeroes descriptor. If the feature was not negotiated the
    bio is failed (not silently dropped) so callers can fall back.
  • See docs/block-discard.md.

Multiqueue

  • virtio-blk negotiates VIRTIO_BLK_F_MQ, reads num_queues, and creates that
    many virtqueues. A request is steered to a queue by the submitting CPU id
    (qid = sched::cpu::current()->id % _num_queues); each queue has its own
    mutex in _queue_locks.
  • Submission scales: two CPUs on different queues never block each other.
  • Completion is currently centralized on a single interrupt: queue 0's
    completion thread drains every queue under each queue's lock, while queues
    1..N also poll their own ring under the same per-queue lock. This is
    documented honestly as a known limitation in docs/block-multiqueue.md,
    along with the per-queue-interrupt path (as nvme.cc does via
    driver::register_io_interrupt()) as the future enhancement that lets each
    queue's completions land on its owning CPU and drops the cross-queue drain
    loop.
  • scripts/run.py exposes --block-queues/num-queues. With one vCPU or one
    queue the driver behaves exactly as the original single-queue path.

The speculative blk-mq.cc/blk_mq.h scaffolding from the original combined
PR has been dropped — it had no driver, test, or app consumer, and read()/
write() already benefit from multiqueue via virtio-blk's per-CPU queue
selection. The unrelated bsd/porting/bus.h device_delete_child hunk has
also been removed.

Build-qualified (kernel compile+link, image=empty) on a binutils 2.44 /
g++ 14.3.0 host.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds two VirtIO block-device capabilities to OSv: DISCARD/TRIM support (via a new BIO_DISCARD command and BLKDISCARD ioctl) and virtio-blk multiqueue I/O (negotiating VIRTIO_BLK_F_MQ, selecting a queue per CPU, and adding per-queue completion threads), along with documentation and QEMU runner wiring.

Changes:

  • Add BIO_DISCARD and a BLKDISCARD ioctl path that submits discard bios to block devices.
  • Extend virtio-blk to negotiate and handle DISCARD requests and multiqueue request submission/completion.
  • Add scripts/run.py support for configuring virtio-blk num-queues, plus docs for DISCARD and multiqueue.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
scripts/run.py Adds a CLI flag and QEMU device-suffix wiring for virtio-blk num-queues.
include/osv/bio.h Introduces BIO_DISCARD and renumbers bio command constants.
include/api/sys/mount.h Adds BLKDISCARD ioctl definition.
drivers/virtio-blk.hh Adds feature bits/config fields for DISCARD+MQ and request plumbing structs.
drivers/virtio-blk.cc Implements multiqueue submission/completion and DISCARD request translation.
drivers/blk-common.cc Adds BLKDISCARD ioctl handling via a discard bio submission.
docs/block-multiqueue.md Documents virtio-blk multiqueue behavior and configuration.
docs/block-discard.md Documents DISCARD/TRIM design and usage.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread include/osv/bio.h Outdated
Comment on lines +58 to +61
#define BIO_DISCARD 0x20 /* Space reclamation (TRIM) */
#define BIO_SCSI 0x40
#define BIO_CMD1 0x80 /* Available for local hacks */
#define BIO_CMD2 0x100 /* Available for local hacks */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow, the AI found all the same problems that I found, and then some. I think I can retire :-)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in the current head (da5595c). BIO_DISCARD is defined as 0x80 (bio.h:60), which fits in the uint8_t bio_cmd — there is no 0x100/BIO_CMD2 value in this PR anymore. The comment was against an earlier revision that used 0x100; that approach was dropped precisely because of the 8-bit truncation.

Comment thread drivers/blk-common.cc
Comment on lines +51 to +60
auto* bio = alloc_bio();
bio->bio_dev = dev;
bio->bio_done = destroy_bio;
bio->bio_cmd = BIO_DISCARD;
bio->bio_offset = range[0];
bio->bio_bcount = range[1];

dev->driver->devops->strategy(bio);
bio_wait(bio);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in da5595c (blk-common.cc:45-61). The BLKDISCARD path no longer sets bio_done, so bio_wait() is woken normally; destroy_bio(bio) is called after bio_wait() returns, and the wait result is propagated as the ioctl return value. This matches your suggested fix exactly.

Comment thread drivers/virtio-blk.cc
Comment on lines 130 to +134
// Step 7 - generic init of virtqueues
probe_virt_queues();

//register the single irq callback for the block
sched::thread* t = sched::thread::make([this] { this->req_done(); },
sched::thread::attr().name("virtio-blk"));
t->start();
auto queue = get_virt_queue(0);

interrupt_factory int_factory;
// Resize per-queue lock vector now that _num_queues is known.
_queue_locks = std::vector<mutex>(_num_queues);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in da5595c (virtio-blk.cc:131-141). After probe_virt_queues(), we set _num_queues = virtio_driver::_num_queues so the blk count is synced to the actually-initialized virtqueue count; the per-queue lock vector is then sized from that same value. qid computation and ring indexing now use one consistent count.

Comment thread drivers/virtio-blk.cc Outdated
Comment on lines +145 to +146
t->start();
if (qid == 0) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in da5595c (virtio-blk.cc:146-147). set_use_indirect(true) is now called in a loop over every initialized queue (for qid 0.._num_queues-1), not just queue 0.

Comment thread drivers/virtio-blk.cc
Comment on lines +387 to +393
case BIO_DISCARD:
if (!get_guest_feature_bit(VIRTIO_BLK_F_DISCARD)) {
biodone(bio, false);
return EOPNOTSUPP;
}
type = VIRTIO_BLK_T_DISCARD;
break;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in da5595c (virtio-blk.cc:400-404). Before building the discard descriptor we reject bio_bcount==0 and any bio_offset/bio_bcount not a multiple of sector_size with EINVAL, so no silent round-down of the discard range.

Comment thread drivers/virtio-blk.cc Outdated
Comment on lines +334 to +342
WITH_LOCK(_queue_locks[qid]) {
if (!drain_queue(myqueue)) {
// nothing processed; release lock before yielding
} else {
myqueue->wakeup_waiter();
}
}
sched::thread::yield();
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in da5595c. The per-queue busy-poll thread is gone. There is now a single completion thread (blk::req_done, virtio-blk.cc:336) that sched::thread::wait_until()s on any_queue_not_empty() — an interrupt-driven sleep, not a poll. any_queue_not_empty() re-arms interrupts on every queue and re-checks to close the arrival race before returning false. No CPU is burned when idle.

Comment thread docs/block-multiqueue.md Outdated
Comment on lines +100 to +101
`scripts/run.py` exposes this through the `--block-queues`/`num-queues`
wiring so test images can request multiple queues without hand-editing the

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in da5595c. The doc (now documentation/block-multiqueue.md:101) references --virtio-blk-queues, matching scripts/run.py:657. There is no --block-queues flag.

@nyh nyh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, but I had some minor comments.
I have no idea how to actually test this code so I'll trust you about its functionality :-)

@@ -0,0 +1,91 @@
# Block Device DISCARD/TRIM Support

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have a "documentation/" subdirectory (with very sporadic content...). I agree "docs" is shorter and nicer, but I think we should pick just one directory and stick to it.
If you prefer docs/, can you please create a followup PR to move the stuff now in documentation/ to docs/? If you do, don't forget to change the references in LICENSE and README.md to the new directory.
Thanks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved by standardizing on the existing documentation/ directory: both files now live there (documentation/block-discard.md, documentation/block-multiqueue.md) and there is no new docs/ directory in this PR. So no split to reconcile and no LICENSE/README references to update.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One correction to flag on this file: documentation/block-discard.md:15 shows the define as #define BIO_DISCARD 0x20, but the actual value in include/osv/bio.h is 0x80 (it replaced the old BIO_CMD2 slot). 0x20 is already taken by BIO_SCSI. The doc line should read 0x80 to match the header. Code is correct; only the doc value is stale.

Comment thread drivers/virtio-blk.cc

interrupt_factory int_factory;
// Resize per-queue lock vector now that _num_queues is known.
_queue_locks = std::vector<mutex>(_num_queues);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: _queue_locks.resize(_num_queues) also does the same thing (and doesn't need the comment because the method name is resize).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept as _queue_locks = std::vector<mutex>(_num_queues) rather than .resize() on purpose: OSv's mutex (include/lockfree/mutex.hh:65) has a user-declared destructor and no move/copy, so it is non-movable. vector::resize() from size 1 to N would have to move the existing element on reallocation, which does not compile for a non-movable element. Full reconstruction sidesteps that. I did drop the now-redundant comment.

Comment thread drivers/virtio-blk.cc Outdated
myqueue->wakeup_waiter();
}
}
sched::thread::yield();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This yield() will help when there are other threads that use CPU, but if there are none, this thread will run in a tight loop. Is this what we really want? Shouldn't it go to sleep for some time, to be woken by the wakeup_waiter() on queue 0's thread?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in da5595c — this is exactly what you suggested. The completion thread now sleeps in wait_until(any_queue_not_empty()) and is woken by the shared block IRQ; it does not yield()-spin. On wake it drains every queue under that queue's lock and calls wakeup_waiter(). See virtio-blk.cc:311-350.

Comment thread drivers/virtio-blk.cc
}

/* Select a queue by CPU id so parallel CPUs use independent rings. */
int qid = sched::cpu::current()->id % _num_queues;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If each of these threads is responsible for specific CPU or CPUs, I wonder if we should pin its completion thread to that CPU/CPUs. But I guess we can consider this optimization later.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed to defer. With the single-IRQ / single-completion-thread design in da5595c there's one drain thread rather than a per-CPU thread, so per-CPU pinning doesn't apply as written. If we later move to per-queue MSI-X vectors with a thread per queue, pinning each to its queue's CPU is the natural follow-up. Left out of this PR.

Comment thread drivers/virtio-blk.hh
VIRTIO_BLK_F_TOPOLOGY = 10, /* Topology information is available */
VIRTIO_BLK_F_CONFIG_WCE = 11, /* Writeback mode available in config */
VIRTIO_BLK_F_DISCARD = 13, /* DISCARD is supported */
VIRTIO_BLK_F_MQ = 12, /* Multi-queue support */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please keep this list sorted.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The feature-bit enum (virtio-blk.hh:20-34) is sorted by bit value: 0,1,2,4,5,6,7,9,10,11,12,13, with VIRTIO_BLK_F_MQ=12 and VIRTIO_BLK_F_DISCARD=13 appended in order. Sorted in da5595c.

Comment thread include/osv/bio.h Outdated
#define BIO_DISCARD 0x20 /* Space reclamation (TRIM) */
#define BIO_SCSI 0x40
#define BIO_CMD1 0x80 /* Available for local hacks */
#define BIO_CMD2 0x100 /* Available for local hacks */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The last one is wrong - bio_cmd is 8 bits, so 0x100 is not available.

@gburd gburd force-pushed the pr/block-trim-mq branch from d7a7be4 to 0507877 Compare June 30, 2026 11:23
@gburd

gburd commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Done. I've split the single block commit into two, kept within this same PR as you suggested:

  1. virtio-blk: add TRIM/DISCARD support — purely additive on the existing single-queue path: VIRTIO_BLK_F_DISCARD negotiation, VIRTIO_BLK_T_DISCARD request type, the blk_discard_write_zeroes descriptor, and BIO_DISCARD plumbing through blk-common/bio.h/mount.h.
  2. virtio-blk: add multiqueue I/O support — layers VIRTIO_BLK_F_MQ on top: per-CPU queue selection, the per-queue _queue_locks vector, the single-interrupt completion model with the drain_queue() helper, and the --virtio-blk-queues run.py knob.

Each commit compiles and links standalone (bisect-safe), and the combined tree is byte-identical to the prior single commit. Build-qualified (kernel compile+link, image=empty).

gburd added 2 commits June 30, 2026 08:54
Implement the VIRTIO_BLK_F_DISCARD feature so the guest can hand space
reclamation (TRIM) requests down to the host.

- Define BIO_DISCARD and renumber the trailing bio command flags so the
  new bit fits without colliding with BIO_SCSI/BIO_CMD1/BIO_CMD2.
- Add the BLKDISCARD ioctl and route it through blk_ioctl() as a
  BIO_DISCARD strategy request.
- Negotiate VIRTIO_BLK_F_DISCARD, read the discard config fields, and
  emit a VIRTIO_BLK_T_DISCARD request carrying a discard descriptor when
  the host advertises the feature.

docs/block-discard.md documents the feature and how to exercise it.
Negotiate VIRTIO_BLK_F_MQ and spread block I/O across the per-device
virtqueues the host advertises.

- Read num_queues from the device config and size a per-queue mutex
  vector so each queue is an independent submission channel.
- make_request() selects a queue by CPU id, removing the single global
  submission lock as a cross-CPU contention point.
- Completions use a simple single-interrupt model: queue 0's thread is
  woken by the IRQ and drains every queue under its per-queue lock,
  while higher-numbered queues poll their own ring as drain helpers.
  drain_queue() factors out the shared ring-draining logic.
- scripts/run.py gains --virtio-blk-queues to set QEMU's num-queues.

docs/block-multiqueue.md documents the feature and the completion model.
@nyh

nyh commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Done. I've split the single block commit into two, kept within this same PR as you suggested:

Who asked you to split it?
Or you're cutting and pasting AI output where the AI is telling you that it did what you asked it? :-)

I wasn't actually bothered that this one commit included both things, they were very easy to tell apart. Please take a look at the specific comments I raised in my review.

1. **virtio-blk: add TRIM/DISCARD support** — purely additive on the existing single-queue path: `VIRTIO_BLK_F_DISCARD` negotiation, `VIRTIO_BLK_T_DISCARD` request type, the `blk_discard_write_zeroes` descriptor, and `BIO_DISCARD` plumbing through `blk-common`/`bio.h`/`mount.h`.

2. **virtio-blk: add multiqueue I/O support** — layers `VIRTIO_BLK_F_MQ` on top: per-CPU queue selection, the per-queue `_queue_locks` vector, the single-interrupt completion model with the `drain_queue()` helper, and the `--virtio-blk-queues` run.py knob.

Each commit compiles and links standalone (bisect-safe), and the combined tree is byte-identical to the prior single commit. Build-qualified (kernel compile+link, image=empty).

@gburd gburd force-pushed the pr/block-trim-mq branch from 0507877 to da5595c Compare June 30, 2026 20:41
@gburd

gburd commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review (and to Copilot). All the concrete points from both of you are addressed in the current head (da5595cc). Summary of what changed since the reviewed commit:

  • bio_cmd 0x100 truncation (you + Copilot): dropped the BIO_CMD2 = 0x100 value entirely; BIO_DISCARD is now 0x80, which fits in the uint8_t bio_cmd. No widening needed.
  • BLKDISCARD hang/UAF (Copilot): the waited bio no longer sets bio_done = destroy_bio; it now does bio_wait(bio) and then destroy_bio(bio), and propagates the wait result as the ioctl return.
  • _num_queues shadow (Copilot): after probe_virt_queues() the blk count is synced from the base class — _num_queues = virtio_driver::_num_queues — so submission and ring indexing use the same count.
  • Indirect descriptors on all queues (Copilot): set_use_indirect(true) now runs in a loop over every initialized queue, not just queue 0.
  • Unaligned discard (Copilot): BIO_DISCARD now rejects offsets/lengths that aren't a multiple of sector_size instead of silently rounding down.
  • Busy-poll completion threads (you + Copilot): the per-queue yield() poll loop is gone. req_done() now blocks on a single wait_until(any_queue_not_empty) and drains all queues under their per-queue locks, so idle queues don't burn CPU.
  • _queue_locks.resize comment (you): simplified.
  • Feature-bit enum sorting (you): the VIRTIO_BLK_F_* list is sorted by bit number.
  • scripts/run.py doc flag mismatch (Copilot): docs now reference --virtio-blk-queues, matching the actual flag.

On the documentation/ vs docs/ question: I kept everything under the existing documentation/ directory for this PR, per your note that we should pick one and stick to it — happy to do a separate followup to consolidate if you'd prefer docs/.

I believe this is ready for another look — could you re-review when you have a moment?

@gburd

gburd commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

You caught me. Majority AI assisted on these features and PR comments, hope you're ok with that. These are features that I am using in a project that I am working towards. I hope to share it soon, it's only possible because of the force multiplier that AI is. In this case I needed to push the I/O subsystem forward a lot and opportunistically other things.

@nyh

nyh commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

You caught me. Majority AI assisted on these features and PR comments, hope you're ok with that.

That's fine :-)

@gburd

gburd commented Jul 1, 2026 via email

Copy link
Copy Markdown
Contributor Author

@gburd

gburd commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review (and Copilot's). Every point is addressed in the current head da5595c; I've replied inline on each thread. Summary:

Correctness fixes

  • bio_cmd truncation — BIO_DISCARD is 0x80 (fits uint8_t); the 0x100/BIO_CMD2 approach was dropped.
  • BLKDISCARD use-after-free — no longer sets bio_done before bio_wait(); destroy_bio() runs after the wait and the result is returned (blk-common.cc:45-61).
  • _num_queues shadow — synced to the probed virtio_driver::_num_queues after probe_virt_queues(), and the lock vector sized from it (virtio-blk.cc:131-141).
  • Indirect descriptors — enabled on every initialized queue, not just queue 0 (virtio-blk.cc:146-147).
  • Discard alignment — zero-length and sector-unaligned discards are rejected with EINVAL before building the descriptor (virtio-blk.cc:400-404).

Busy-poll → interrupt-driven (your main concern)

  • The per-queue yield()-spin is gone. A single completion thread sleeps in wait_until(any_queue_not_empty()), woken by the shared block IRQ; any_queue_not_empty() re-arms interrupts and re-checks to close the arrival race. No CPU burned when idle (virtio-blk.cc:311-350).

Nits

  • Feature-bit enum sorted by bit value (virtio-blk.hh).
  • Docs consolidated under the existing documentation/ dir; flag name matches --virtio-blk-queues in run.py.
  • _queue_locks uses full vector reconstruction rather than resize() because OSv's mutex is non-movable (resize would fail to compile); dropped the redundant comment.
  • CPU-pinning deferred as you suggested — doesn't apply to the single-thread design; natural follow-up if we go per-queue MSI-X.

No new commits were needed beyond da5595c — these were all already in the pushed head; I'd simply never closed the review loop with replies. Ready for another look.

@gburd

gburd commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

Pushed b7c213b fixing the doc typo flagged inline: documentation/block-discard.md now documents #define BIO_DISCARD 0x80 to match include/osv/bio.h (0x20 is BIO_SCSI). Code was already correct; this was a stale doc line only.

@gburd

gburd commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Ping on this. I believe I've addressed all the review feedback in-thread (replies posted above); the branch is rebased on current master and mergeable.

Context for prioritization: this PR is one of three prerequisites (#1398, #1402) beneath the OpenZFS 2.4.3 cutover that replaces the c.2014 BSD-ZFS port. I'd like to submit that as a clean ZFS-only diff, which requires these three to land on master first. If anything here is still open, let me know and I'll turn it around quickly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants