Skip to content

[NEEDS #279, #281] fusedev: serve Linux 7.3 io_uring zero-copy reads and writes - #282

Draft
Kranzes wants to merge 8 commits into
cloud-hypervisor:masterfrom
Kranzes:uring-zerocopy-up
Draft

Kranzes wants to merge 8 commits into
cloud-hypervisor:masterfrom
Kranzes:uring-zerocopy-up

Conversation

@Kranzes

@Kranzes Kranzes commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Linux 7.3 can serve the reads and writes of an open with zero-copy over io_uring. Instead of copying a READ or WRITE's data into a payload buffer, the kernel registers the client's pages in the server's io_uring buffer table, and the server moves the data between them and a file with IORING_OP_READ_FIXED and IORING_OP_WRITE_FIXED. It needs buffer pools (#281) and CAP_SYS_ADMIN, and the file system asks for it per open with FOPEN_IO_URING_ZERO_COPY. This is the io_uring side of the zero-copy reads and writes that #17 asks for. #268 covers the kernel passthrough side.

The first commit adds the uAPI and two hooks in core. OpenOptions::IO_URING_ZERO_COPY is the open flag, and abi::fuse_uring gets the ADD_QUEUE and request flags and a setter for the buffer table slot at REGISTER. The server hands a WRITE's data to the file system as a Reader, which transports can't replace. So Reader::with_zero_copy() attaches data that only a ZeroCopySource from the transport can move, and Reader::read_to_at() hands it over. FileReadWriteVolatile::raw_fd() gives the transport the file to move it to. Plain reads, and files without a descriptor, get a copy through memory instead.

The second commit adds passthrough::Config::uring_zero_copy, which asks for zero-copy on the files PassthroughFs opens. Its reads and writes already go through write_from() and read_to() on the backing file. Files it opens with O_DIRECT don't get it, because their IO goes through an aligned bounce buffer, which zero-copy data reaches only through a slower copy in memory.

The third commit serves it in UringFuseServing with UringConfig::zero_copy. It adds the queues with FUSE_URING_ZERO_COPY and gives each worker ring an empty buffer table with a slot per entry, and each entry names its slot at REGISTER. A zero-copy READ gets a reply body that write_from_at() fills with READ_FIXED, and a zero-copy WRITE gets a Reader whose data WRITE_FIXED moves into the file. Both run on the worker's ring, because the kernel registered the pages in that ring's table, and entry completions that arrive meanwhile join the worker's queue. If the kernel refuses zero-copy with EPERM, or with EINVAL because it predates it, UringFuseServing adds the queues without it. Data that a file system reads or writes through memory, or with a file without a descriptor, takes a copy through a memory file per worker, so PassthroughFs's O_DIRECT bounce buffer keeps working on zero-copy opens.

The fourth commit makes that copy cheaper. Each worker maps its memory file once, at the size of a payload buffer, and the file system reads or writes the mapped bytes. A copy through memory now costs the fixed-buffer operation and one copy, with no buffer to allocate and no extra pread() or pwrite(). Reader::read_to() also stops copying through memory when its destination has a file descriptor. Without an offset, WRITE_FIXED writes at the file's position.

Served from a tmpfs as root, with 4 workers and 4 fio jobs doing direct random IO at depth 16, zero-copy made 1 MiB reads 30 to 50% faster (13896 to 15202 MiB/s against 9966 to 10651) and 1 MiB writes 35 to 55% faster (12299 to 12883 MiB/s against 8164 to 9070). At 128 KiB the two settings overlapped. docs/fuse-uring-performance.md has the setup and the table, and the benchmark daemon takes --uring-zero-copy and --no-backing-direct-io to compare.

The mount test runs with zero-copy too. Without CAP_SYS_ADMIN it covers the fallback, and as root it requires zero-copy, checks that requests went through it, and turns O_DIRECT on with fcntl() for one file to drive the copy through memory. It passed both ways on Linux 7.3-rc7, and the rest of the suite passes in a user namespace on both async runtimes, apart from the 14 passthrough tests that need real root and also fail on master. This PR carries #279 and #281, because zero-copy changes the write_from_at() that #279 fixes and needs #281's buffer pools.

The transport here serves the synchronous handlers. Zero-copy for AsyncFileSystem handlers needs async methods on ZeroCopySource and comes with the async io_uring transport, which builds on #267.

A uring worker freed its ring entries as soon as it stopped. When it
stopped on an error while the connection was still up, such as a failed
command or an interrupted io_uring_enter(), its other entries were still
registered, and the kernel could copy the next request into freed
memory. Closing the ring doesn't help in time, because the kernel
cancels the commands later, from a workqueue.

The worker now counts the entries whose command the kernel hasn't
completed, and waits for those completions before it frees them. Once
the connection is torn down they arrive at once. If they don't arrive
within a second, the worker leaks the entries instead of freeing memory
the kernel may still write to.

A signal that interrupts io_uring_enter() no longer stops the worker,
whether it was waiting or only submitting. If the wait in the drain
fails with another error, the worker logs that error.

The new mount test serves a PassthroughFs over io_uring and checks that
dropping the serving layer still returns well within that second. It
can't reproduce the race itself.

Signed-off-by: Ilan Joselevich <personal@ilanjoselevich.com>
Linux 7.3 lets a FUSE-over-io_uring server give each queue a pool of
payload buffers instead of a buffer per ring entry. abi::fuse_uring gets
the ADD_QUEUE and ADD_BUFPOOL commands, the FUSE_HAS_IO_URING_BUFPOOL
INIT flag, the offset field of the entry header and the bufpool field of
the command.

FsOptions doesn't know the INIT flag, so the server records it from the
raw INIT flags, together with the max_write and max_pages of its reply.
Server::uring_bufpool_supported() reports the flag.
Server::uring_payload_size() returns the size of the kernel's payload
buffers, computed the way fuse_uring_create() does.

Signed-off-by: Ilan Joselevich <personal@ilanjoselevich.com>
On Linux 7.3 and later, UringFuseServing gives each kernel queue a pool
of UringConfig::bufs_per_queue buffers before any entry registers. The
kernel lends a buffer to an entry while it serves a request with a
payload or a reply body, and reports where the buffer starts. Pooled
entries keep only their header and scratch area. Kernels that don't
offer pools at INIT keep a buffer per entry, as does bufs_per_queue = 0.
It defaults to 4, the default number of entries, and counts as at most
entries_per_queue.

A request without a payload and without out arguments gets no buffer,
and its offset of 0 looks like the first buffer. The opcode table now
records which requests have out arguments, and the others get an empty
reply body, so they can't write into another entry's buffer.

The benchmark daemon takes --uring-bufs N.

The benchmark daemon served a passthrough directory of 24 files of
256 MiB with --uring, 4 workers and 16 entries per queue, on a 24-CPU
machine with Linux 7.3-rc7. fio ran random 1 MiB and 4 KiB direct reads
with libaio at depth 16, one job pinned to each CPU, for 8 seconds,
through a fresh mount each run. The two settings alternated three times:

  buffers per queue  VmSize    VmRSS    1 MiB reads       4 KiB reads
  own per entry      1492 MiB  393 MiB  1518-1728 MiB/s   45.9-46.8k IOPS
  4                  431 MiB   106 MiB  1588-1709 MiB/s   44.5-45.7k IOPS

VmSize also counts each entry's request and reply scratch areas, which
are as large as its own payload buffer, so pooled entries save those
too.

Signed-off-by: Ilan Joselevich <personal@ilanjoselevich.com>
UringWriter::write_from_at() checked count against the free space of the
header slot and the body together, but it reads the file into the body
alone. While the header slot was still empty, a read could run up to 16
bytes past the end of the body. It now checks the body alone, and
accounts at most count bytes when the file reports more.

The new test reads 16 bytes into an 8-byte body behind an empty header
slot. Without the fix it overruns the body, which debug builds catch in
Vec::set_len().

Signed-off-by: Ilan Joselevich <personal@ilanjoselevich.com>
Linux 7.3 can serve the reads and writes of an open with zero-copy over
io_uring. The kernel registers the client's pages in the server's
io_uring buffer table, and the server moves the data between them and a
file with fixed-buffer operations, without copying it through its own
memory. An open asks for it with FOPEN_IO_URING_ZERO_COPY, now
OpenOptions::IO_URING_ZERO_COPY, and a queue offers it with the
FUSE_URING_ZERO_COPY flag of ADD_QUEUE. abi::fuse_uring gets those flags
and FuseUringCmdReq::set_ent_zero_copy_buf_index() for REGISTER.

The server hands a WRITE's data to the file system as a Reader, which
transports can't replace. Reader::with_zero_copy() attaches data behind
the Reader's buffers that only a ZeroCopySource from the transport can
move, and Reader::read_to_at() hands it over once the buffers are read.
FileReadWriteVolatile::raw_fd() gives the transport the file to move it
to, and is None by default. Plain reads, and files without a
descriptor, get a copy through memory from the ZeroCopySource. A cloned
Reader drops the data, so it can't be written twice.

Signed-off-by: Ilan Joselevich <personal@ilanjoselevich.com>
Config::uring_zero_copy makes PassthroughFs set
OpenOptions::IO_URING_ZERO_COPY on the files it opens and creates. Its
reads and writes already move data with ZeroCopyWriter::write_from()
and ZeroCopyReader::read_to() on the backing File, which zero-copy
needs. On other transports the option changes nothing.

Files that PassthroughFs opens with O_DIRECT don't get it, because
their IO goes through an aligned bounce buffer, which zero-copy data
reaches only through a slower copy in memory. A client that turns
O_DIRECT on later with fcntl() takes that slower path.

Signed-off-by: Ilan Joselevich <personal@ilanjoselevich.com>
With UringConfig::zero_copy, UringFuseServing adds its queues with
FUSE_URING_ZERO_COPY and gives each worker ring an empty buffer table
with a slot per entry, which each entry names at REGISTER. The kernel
then registers the client's pages of a READ or WRITE on an open that
asked for zero-copy in that slot, instead of copying them.

A zero-copy READ has no payload buffer. Its UringWriter gets a reply
body of ReadIn.size bytes that write_from_at() fills with READ_FIXED
from the file into the slot. A zero-copy WRITE gets a payload buffer
for WriteOut only, and its Reader carries the data as a ZeroCopySource
that runs WRITE_FIXED from the slot into the file. Both wait on the
worker's ring, because the kernel registered the pages in that ring's
table. Entry completions that arrive meanwhile join the worker's queue.

Data that a file system reads or writes through memory, or with a file
that has no descriptor, takes a copy through a memory file per worker.
The worker runs WRITE_FIXED from the client pages into it and reads it
back, or writes into it and runs READ_FIXED from it into the client
pages. That keeps PassthroughFs's O_DIRECT bounce buffer working on
zero-copy opens.

Zero-copy needs buffer pools and CAP_SYS_ADMIN. If the kernel refuses
the flag for the first queue with EPERM, or with EINVAL because it
predates zero-copy, UringFuseServing adds the queues without it. A
worker with more than 65536 entries goes without it too, because the
kernel takes a 16-bit slot index.

The benchmark daemon takes --uring-zero-copy. It also takes
--no-backing-direct-io, which keeps backing files buffered when clients
open with O_DIRECT, so that their requests can use zero-copy.

The benchmark daemon served 4 files of 1 GiB from a tmpfs with --uring
--no-backing-direct-io, 4 workers and 16 entries per queue, as root on
a 24-CPU machine with Linux 7.3-rc7. fio ran direct random IO with
libaio, 4 jobs at depth 16, for 8 seconds through a fresh mount each
run, and the two settings alternated three times:

  workload        copy               zero-copy
  1 MiB reads     9966-10651 MiB/s   13896-15202 MiB/s
  1 MiB writes    8164-9070 MiB/s    12299-12883 MiB/s
  128 KiB reads   15579-17626 MiB/s  15565-17028 MiB/s
  128 KiB writes  12700-14037 MiB/s  11911-13249 MiB/s

The mount test runs with zero-copy too. Without CAP_SYS_ADMIN it covers
the fallback. As root it requires zero-copy and checks that requests
went through it, and it turns O_DIRECT on with fcntl() for one file to
drive the copy through memory.

Signed-off-by: Ilan Joselevich <personal@ilanjoselevich.com>
The copy of zero-copy data through memory allocated a buffer for each
call and moved the data with pread() or pwrite() between it and the
worker's memory file, on top of the fixed-buffer operation. Each worker
now maps its memory file once, at the size of one payload buffer.
ZeroCopySource::read_to_memory() hands the reader the mapped bytes
instead of filling a buffer, and the sink's new fill_from() lets the
writer read a file straight into the mapping. A copy through memory now
costs the fixed-buffer operation and one copy.

Reader::read_to() wrote zero-copy data through memory even when its
destination had a file descriptor, because it has no offset.
ZeroCopySource::write_to_file() now takes an optional offset, and
without one WRITE_FIXED writes at the file's position.

Signed-off-by: Ilan Joselevich <personal@ilanjoselevich.com>
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.

1 participant