Skip to content

[NEEDS #267] Support Linux kernel FUSE passthrough - #268

Open
Kranzes wants to merge 19 commits into
cloud-hypervisor:masterfrom
Kranzes:kernel-passthrough-async
Open

Kranzes wants to merge 19 commits into
cloud-hypervisor:masterfrom
Kranzes:kernel-passthrough-async

Conversation

@Kranzes

@Kranzes Kranzes commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Since Linux 6.9, a FUSE server can register an open host file as a backing file and return its id from OPEN. The kernel then serves reads, writes and mmap of that file itself, and those requests never reach the server. This adds the transport support and uses it in PassthroughFs. It covers the passthrough reads and writes discussed in #17, which asks for zero-copy IO.

This is stacked on #267, because the async integration test drives FuseDevTask from its own tokio runtime. The first five commits belong to #267, and this PR starts at "fusedev: support Linux kernel FUSE passthrough". It conflicts with #270, #271 and #272, so I'll rebase it after they land.

fusedev gets FsOptions::PASSTHROUGH, OpenOptions::PASSTHROUGH, Server::set_max_stack_depth() and fuse_backing_open()/fuse_backing_close(). AsyncFileSystem::async_open() and async_create() now return a backing id like the sync ones. Their defaults forward the sync handlers' id, so this only breaks implementors that override them. PassthroughFs::set_backing_files() takes a small BackingFiles trait, so the passthrough crate doesn't depend on the transport, and into_shared() makes it work with the blocking thread pool. All handles of an inode share one backing id, because the kernel rejects a second backing file for an inode open in passthrough mode. Passthrough opens drop FOPEN_DIRECT_IO, which would send their IO through the daemon, so an O_DIRECT open does direct IO on the host file. If the kernel refuses a backing file, for example for a host file on overlayfs with the default max_stack_depth, the handle opens without passthrough and the daemon serves its IO. EPERM, which means no CAP_SYS_ADMIN, turns passthrough off.

With 4 fio jobs on an AMD Ryzen AI 9 HX 370 and Linux 7.2.7, compared with the daemon with the writeback cache:

workload sync async
seqread 3.8× 3.8×
seqwrite 3.5× 4.3×
randread-4k 15.2× 14.3×
randwrite-4k 2.7× 3.1×
filecreate 1.34× 0.77×
readdir 0.99× 0.86×

The kernel ignores FUSE_PASSTHROUGH along with FUSE_WRITEBACK_CACHE, so the passthrough runs have no writeback cache. The filecreate and readdir rows mostly show which cores the async workers ran on. This CPU mixes Zen 5 and Zen 5c cores, and pinning moves both workloads by about 30% even without passthrough. On the same cores, readdir is unchanged and filecreate is about 6% slower in async mode, from the backing ioctls and the second file the kernel opens per passthrough open.

The tests that mount need root in the initial user namespace and CONFIG_FUSE_PASSTHROUGH, and skip without them. In a user namespace on Linux 7.2.10, make check and the rest of the suite pass on both runtimes.

Two questions. Is the AsyncFileSystem signature change acceptable, or would you rather have a new method with a default? And under a Vfs, passthrough takes PASSTHROUGH in VfsOptions::out_opts, no_writeback and set_backing_files(), because Vfs::init ignores what the file system's init() returns (#198). Should Vfs drop WRITEBACK_CACHE by itself when PASSTHROUGH is set?

@Kranzes
Kranzes force-pushed the kernel-passthrough-async branch 5 times, most recently from 9744a4d to 88d6430 Compare October 15, 2026 00:32
@Kranzes
Kranzes marked this pull request as draft October 15, 2026 10:47
@Kranzes
Kranzes force-pushed the kernel-passthrough-async branch from 88d6430 to 61d22ab Compare October 19, 2026 16:42
@Kranzes
Kranzes marked this pull request as ready for review October 19, 2026 16:47
@Kranzes
Kranzes force-pushed the kernel-passthrough-async branch from 61d22ab to d28f903 Compare October 19, 2026 17:19
The crate detects the runtime type on first use, and only the
FUSE_BACKEND_RS_ASYNC_RUNTIME environment variable overrides it. A
daemon that drives FuseDevTask from its own tokio runtime has to pick
tokio, because that runtime can't poll tokio-uring files. Setting the
variable from the daemon needs std::env::set_var, which is unsafe once
other threads may read the environment.

Add async_runtime::set_runtime_type() to select the type in code. It
overrides the variable and fails if the type in effect is a different
one. Add async_runtime::runtime_type() to read the type, and make
RuntimeType public for both.

Signed-off-by: Ilan Joselevich <personal@ilanjoselevich.com>
With the tokio runtime, FuseDevTask reads the nonblocking fuse device
with plain preadv(). While no request is pending, the read fails with
EAGAIN, and poll_handler() yields and reads again right away, so an
idle task keeps a CPU busy.

Register the fd with the runtime's IO driver and wait until it is
readable before each read and after EAGAIN. Wait for errors too,
because the kernel reports a torn down connection with EPOLLERR alone.
A 100ms timer wakes the wait, so an idle task still returns once its
state flag is set. Without the timer, stopping a worker whose unmount
fails would hang. An error that isn't an OS error, such as the runtime
shutting down, ends the task. The io_uring runtime is unchanged.
AsyncFd::register_with_interest() needs tokio 1.53.3, which deprecates
with_interest().

Registering panics outside of a tokio runtime, so a task that another
executor polls keeps retrying reads as before. The docs say that
dropping poll_handler() to stop an idle task is only safe with the
tokio runtime. tokio-uring leaves its read pending in the kernel,
which then copies the next request into the freed buffer.

One test checks that an idle task uses little CPU and returns on
unmount. Before this change it used all of the 500ms it idled. Another
checks that the state flag stops an idle task while the mount stays
up. A third serves a mount without any tokio runtime.

Signed-off-by: Ilan Joselevich <personal@ilanjoselevich.com>
A daemon can hand its FUSE device fds to a new process, which then
serves the connection without a FUSE_INIT. The new Server can't learn
what the old one negotiated, so it parses requests as if no option
were enabled, such as the FUSE_EXT_GROUPS extension of create
requests.

Add Server::init_state() to read the protocol version and the options
enabled at INIT, and Server::restore_init() to set them on the new
server. Add abi::fuse_abi::UNIQUE_RESEND, the bit the kernel sets in
requests that notify_resend() makes it send again. The server already
replies with the unique id unchanged, and a test now covers that.

Document that a thread that serves requests must not call
notify_inval_entry(). It waits for the parent's lock, which a lookup
in that directory holds until its reply.

Signed-off-by: Ilan Joselevich <personal@ilanjoselevich.com>
Add tests that serve a mount with FuseDevTask on a current-thread
tokio runtime, from a file system that can make a lookup wait. They
cover two cases:

- A waiting lookup doesn't hold up another lookup in the same
  directory, and notify_inval_entry() from another thread works while
  the task serves.
- A second server takes over the connection from one that stopped with
  a lookup in flight. It restores the INIT state, sends
  notify_resend() and answers the lookup that the kernel sends again.

The file system enables FUSE_PARALLEL_DIROPS, because without it the
kernel serializes lookups in a directory.

Signed-off-by: Ilan Joselevich <personal@ilanjoselevich.com>
The async server logged a read error it didn't know and read again.
Now that a tokio task waits for the device to become readable, a device
that stays readable but fails every read made the task spin at full CPU
and log on every pass. EINVAL for a buffer smaller than the kernel's
requests does that.

Retry ENOENT, which the kernel returns when a request was interrupted
before the read, and stop the task on any other error. The synchronous
server does the same.

Signed-off-by: Ilan Joselevich <personal@ilanjoselevich.com>
Since Linux 6.9, the kernel can pass reads, writes and mmap of an open
file through to a backing file that the server registers, so these
requests never reach the daemon.

Add FsOptions::PASSTHROUGH, OpenOptions::PASSTHROUGH and
InitOut::max_stack_depth to the core ABI. Server::set_max_stack_depth()
sets the depth sent at INIT, 1 by default, and rejects depths other than
1 and 2, for which the kernel turns passthrough off. fuse_backing_open()
and fuse_backing_close() in the fusedev crate wrap the
FUSE_DEV_IOC_BACKING_OPEN and FUSE_DEV_IOC_BACKING_CLOSE ioctls, and the
umbrella crate re-exports them from transport.

The protocol minor stays at 33, because the kernel only checks the
FUSE_PASSTHROUGH and FUSE_INIT_EXT flags and the max_stack_depth. The
server already echoes INIT_EXT when flags2 is non-zero.

Signed-off-by: Ilan Joselevich <personal@ilanjoselevich.com>
FileSystem::open and create return an optional backing id that the
server copies into OpenOut, which is what a file system needs for
kernel passthrough. The async counterparts drop it. They return only
the handle and the open options, and the async server leaves
OpenOut::passthrough zero, so a file system served through the async
path can't use passthrough.

Make AsyncFileSystem::async_open and async_create return the same
Option<u32> as their sync counterparts and copy it into the reply.
PassthroughFs and Vfs now forward the id from the sync handlers they
relay to instead of discarding it. The default methods forward the id
from the sync handlers. This changes a public trait, and an implementor
that overrides them adapts by appending None, or the id from the sync
call.

Signed-off-by: Ilan Joselevich <personal@ilanjoselevich.com>
Serve the passthrough test's file system with FuseDevTask on the tokio
runtime too, so the backing id from async_open reaches the kernel. Like
the sync test, it needs root and skips without it.

Signed-off-by: Ilan Joselevich <personal@ilanjoselevich.com>
PassthroughFs answers open and create with a handle to a host file, and
every read and write of the file goes through the daemon. With kernel
FUSE passthrough the kernel does the IO on the host file itself, but the
file system has to register the host file as a backing file and return
its id from open.

Add the BackingFiles trait, which registers and releases backing files,
and PassthroughFs::set_backing_files(). When INIT enables
FsOptions::PASSTHROUGH, open and create of regular files register the
file of the new handle and return its id with OpenOptions::PASSTHROUGH.
Such an open drops OpenOptions::KEEP_CACHE, because the kernel fails a
passthrough open with it.
The kernel fails an open of an inode that is already open in passthrough
mode unless it uses the same backing file, so the handles of an inode
share one id, which is closed with the last of them. destroy() closes
the ids still open, because Vfs::umount() calls it while the connection
goes on.

The kernel ignores FUSE_PASSTHROUGH along with FUSE_WRITEBACK_CACHE, and
the ids come from open requests, so init() leaves passthrough off when
the writeback cache or no_open is enabled. It also stays off with
seal_size, which passthrough writes would skip, and with the Never and
Metadata cache policies, whose FOPEN_DIRECT_IO overrides passthrough for
reads and writes. It stays off when allow_direct_io is false too,
because the kernel opens the backing file with the client's O_DIRECT.
The trait keeps the crate independent of the transport. With fusedev, it
wraps fuse_backing_open() and fuse_backing_close().

Signed-off-by: Ilan Joselevich <personal@ilanjoselevich.com>
set_backing_files() takes &mut self, but new_shared() returns an Arc, so
a file system could use kernel passthrough or the blocking thread pool,
not both. The pool can help with passthrough too, because LOOKUP,
GETATTR and OPEN still reach the server and can block on a cold cache or
a slow source file system.

into_shared() shares a file system created with new() after
set_backing_files(), and new_shared() now uses it.

Signed-off-by: Ilan Joselevich <personal@ilanjoselevich.com>
--kernel-passthrough registers backing files on the FUSE device of the
session, so the kernel passes reads, writes and mmap of open files to
the source files. The kernel doesn't allow passthrough with the
writeback cache, so the flag turns the cache off too. --no-writeback
turns the writeback cache off alone, to tell the effects of the two
apart. bench_sync_async.sh passes BENCH_DAEMON_ARGS to the daemon in every
mode.

On an AMD Ryzen AI 9 HX 370 with Linux 7.2.7 and 4 fio jobs, passthrough
makes sequential IO 3.5 to 5.5 times and random 4k reads 14 to 17 times
faster than the daemon with the writeback cache. Without the writeback
cache, random 4k writes drop to a quarter or less, and passthrough makes
them 10 to 18 times faster again. In async mode, passthrough makes
filecreate and readdir 15 to 20% slower, for a reason not yet known.

Signed-off-by: Ilan Joselevich <personal@ilanjoselevich.com>
FUSE_DEV_IOC_BACKING_OPEN needs CAP_SYS_ADMIN in the initial user
namespace, so root under unshare -r got EPERM and the tests failed.
Skip on EPERM.

Signed-off-by: Ilan Joselevich <personal@ilanjoselevich.com>
KernelPassthrough::acquire() held the ids mutex while it registered the
backing file. With the blocking thread pool, a stalled ioctl blocked
inline RELEASE and RELEASEDIR and the opens of other inodes.

Register the file without the lock. If another open of the inode got an
id meanwhile, use that one and close the new id, because all
passthrough opens of an inode must share one backing file.

Signed-off-by: Ilan Joselevich <personal@ilanjoselevich.com>
Add the new API and the breaking AsyncFileSystem and InitOut changes.

Signed-off-by: Ilan Joselevich <personal@ilanjoselevich.com>
Passthrough survives a takeover with Server::restore_init() when the
new server serves the same PassthroughFs. Only a new instance hits the
files still open with the old backing ids, and the kernel fails those
opens with EIO, not EBUSY.

Signed-off-by: Ilan Joselevich <personal@ilanjoselevich.com>
do_open() sets DIRECT_IO for an O_DIRECT open when allow_direct_io is
on, but create() didn't, so IO on a created O_DIRECT file took the
page-cache path on an O_DIRECT host fd. create() also kept O_DIRECT on
the host file when allow_direct_io is off.

Compute the open options in one place for both, and drop O_DIRECT in
create() like open_inode() does.

Signed-off-by: Ilan Joselevich <personal@ilanjoselevich.com>
FOPEN_DIRECT_IO overrides FOPEN_PASSTHROUGH for reads, writes and
splice, so O_DIRECT opens still sent their IO through the daemon. The
kernel opens the backing file with the client's flags, so without
DIRECT_IO they do direct IO on the host file. For sync IO that open
decides, so clearing O_DIRECT later with fcntl() doesn't reach the
backing file.

The new test opens a file with O_DIRECT and checks that the server sees
no READ or WRITE. It needs root, like the other kernel passthrough
tests.

Signed-off-by: Ilan Joselevich <personal@ilanjoselevich.com>
…ng file

do_open() and create() failed the OPEN or CREATE when registering the
backing file failed. Without CAP_SYS_ADMIN, or with a host file on
overlayfs and the default max_stack_depth of 1, every open of a regular
file on the mount failed, even though init() had enabled passthrough.

Open the handle without passthrough instead, so the daemon serves its
IO. That is safe because registering only fails when the inode has no
backing id yet, so none of its handles is open in passthrough mode.
It logs the first refusal as a warning and later ones at debug level.
EPERM would fail every registration, so it turns passthrough off for
the instance. An inode that still has handles with a backing id keeps
sharing it. The kernel fails an open of an inode in passthrough mode
unless the open uses the same backing file.

An inode can now have handles with and without a backing id, so each
handle records whether it holds one, and only those drop a reference on
release. Before, releasing any handle of the inode dropped one, and the
id could close while a passthrough handle was still open.

Signed-off-by: Ilan Joselevich <personal@ilanjoselevich.com>
init() set `passthrough` when it enabled kernel passthrough, but never
cleared it. A takeover that calls init() again on the same instance,
with options that no longer allow passthrough, kept registering backing
ids for new files. Clear it first. Inodes with handles from before keep
sharing their backing ids until their last handle closes.

Signed-off-by: Ilan Joselevich <personal@ilanjoselevich.com>
@Kranzes
Kranzes force-pushed the kernel-passthrough-async branch from d28f903 to dadbae4 Compare October 28, 2026 15:05
@jiangliu

Copy link
Copy Markdown
Collaborator

This is an amazing feature,please help to rebase sync #267 has been merged.

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.

2 participants