Repository navigation
Conversation
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>
Kranzes
force-pushed
the
uring-zerocopy-up
branch
from
October 28, 2026 15:19
bdd9d09 to
67ec117
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_FIXEDandIORING_OP_WRITE_FIXED. It needs buffer pools (#281) and CAP_SYS_ADMIN, and the file system asks for it per open withFOPEN_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_COPYis the open flag, andabi::fuse_uringgets 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 aReader, which transports can't replace. SoReader::with_zero_copy()attaches data that only aZeroCopySourcefrom the transport can move, andReader::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 filesPassthroughFsopens. Its reads and writes already go throughwrite_from()andread_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
UringFuseServingwithUringConfig::zero_copy. It adds the queues withFUSE_URING_ZERO_COPYand 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 thatwrite_from_at()fills withREAD_FIXED, and a zero-copy WRITE gets aReaderwhose dataWRITE_FIXEDmoves 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,UringFuseServingadds 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()orpwrite().Reader::read_to()also stops copying through memory when its destination has a file descriptor. Without an offset,WRITE_FIXEDwrites 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.mdhas the setup and the table, and the benchmark daemon takes--uring-zero-copyand--no-backing-direct-ioto 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 thewrite_from_at()that #279 fixes and needs #281's buffer pools.The transport here serves the synchronous handlers. Zero-copy for
AsyncFileSystemhandlers needs async methods onZeroCopySourceand comes with the async io_uring transport, which builds on #267.