Skip to content

mount: explore FUSE passthrough over the persistent on-disk cache (stock WSL2 kernel already has CONFIG_FUSE_PASSTHROUGH) #93

Description

@zcsizmadia

Idea

FUSE passthrough lets the kernel service read() and write() on a FUSE file directly against
a backing file descriptor
, with the userspace daemon out of the data path entirely. Reads then
run at native filesystem speed with zero upcalls.

This is the one kernel-level mechanism that could actually lower the Direction B serving floor
past what userspace tuning reaches, and it needs no custom kernel and no out-of-tree module.

It is compiled into the stock WSL2 kernel already. Verified on this machine:

$ uname -r
6.18.33.1-microsoft-standard-WSL2
$ zcat /proc/config.gz | grep FUSE
CONFIG_FUSE_FS=y
CONFIG_FUSE_DAX=y
CONFIG_FUSE_PASSTHROUGH=y

What it requires

  1. A real file to point at. Passthrough needs a backing descriptor on a local filesystem, so
    this only pays off once cached file content is materialized on disk inside the VM. That makes
    it a natural rider on the already-planned persistent on-disk cache follow-up rather than a
    standalone change.
  2. A newer libfuse. The FUSE_CAP_PASSTHROUGH / backing_id API is not in the installed
    libfuse 3.14.0 (no matches in /usr/include/fuse3/); it landed in libfuse 3.17. So this means
    bundling or requiring a newer libfuse, and keeping a non-passthrough fallback path.

Sketch

  • On open of a file whose content is fully present in the on-disk cache, register the cache file
    as a backing id and return the passthrough handle. Reads bypass wsldrive completely.
  • On invalidation, drop the backing registration so the next open re-fetches.
  • Writes are the hard part: a passthrough write lands in the cache file without wsldrive seeing
    it, so the write-through path needs a different mechanism (or passthrough stays read-only).

Status

Exploratory. Filed to record the finding and the verified kernel support. Should be scoped
only after the persistent on-disk cache exists, and after the cheap wins land, since those may
move the floor far enough that this is not worth the dependency on a bundled libfuse.

Context

Came out of an assessment of whether a custom WSL2 kernel module would speed up the boundary.
Conclusion was no: Direction A never touches the Linux kernel on its fast path, and virtiofs is
the in-kernel path yet measures ~20x slower here than wsldrive's userspace FUSE mount
(7686 ms vs 383 ms reading 3000 files). Passthrough is the exception worth keeping on the list
because it is a stock-kernel feature, not a module.

Activity

  1. zcsizmadia commented on Sep 13, 2026

    @zcsizmadia
    CollaboratorAuthor

    The sequencing note on #118 turned out to be wrong, in this issue's favour

    I suggested on #118 that landing this first would make the inotify bridge substantially smaller, and
    that the event path wanted the same mount infrastructure as the data path. Neither held up. #119
    merged without touching passthrough, without a newer libfuse, and without a persistent on-disk cache:
    the bridge raises events by replaying each far-side change as an ordinary syscall against the mount,
    so the only thing it needs from the mount is that the mount exists.

    Two consequences worth recording here.

    This issue is not blocking anything. It was filed as exploratory and the reason to defer it —
    that it should be scoped only after the persistent on-disk cache exists, and after the cheap wins
    land — is unchanged. Nothing now waits on it.

    One assumption in the sketch needs re-checking against what #119 established. The sketch has
    reads bypassing wsldrive entirely once a file is registered as a backing id. The change-notification
    path now depends on the mount seeing operations: op_getattr answers the bridge's own lookups, and
    op_create / op_unlink / op_rename recognise its pokes. Those are metadata operations, so
    passthrough of read/write should not disturb them — but it is worth confirming rather than
    assuming, because a passthrough handle that also serviced open would take the bridge's create poke
    out of the daemon's view, and the failure mode there is silent (the poke gets forwarded to the agent
    instead of recognised, which is how #119 briefly destroyed file content on the served side before CI
    caught it).

    Also relevant to the "is passthrough worth the dependency" question: the boundary is no longer the
    only thing Direction B is competing on. Change notification is a capability virtiofs does not have,
    and it costs nothing on the data path.

    🤖 Generated with Claude Code

  2. zcsizmadia commented on Sep 13, 2026

    @zcsizmadia
    CollaboratorAuthor

    Both prerequisites are now satisfied on a stock distro — the libfuse blocker is gone

    This issue records that FUSE_CAP_PASSTHROUGH / backing_id were absent from the installed libfuse
    3.14.0, landed in 3.17, and would therefore mean "bundling or requiring a newer libfuse". That is no
    longer true here. Ubuntu 26.04's stock libfuse3-dev:

    libfuse: 3.18.2
    kernel:  6.18.33.2-microsoft-standard-WSL2
    
    /usr/include/fuse3/fuse_common.h:500:   #define FUSE_CAP_PASSTHROUGH (1UL << 29)
    /usr/include/fuse3/fuse_common.h:119:   int32_t backing_id;
    /usr/include/fuse3/fuse_lowlevel.h:1455: int fuse_passthrough_open(fuse_req_t req, int fd);
    /usr/include/fuse3/fuse_lowlevel.h:1456: int fuse_passthrough_close(fuse_req_t req, int backing_id);
    

    Kernel side unchanged from what this issue verified: CONFIG_FUSE_PASSTHROUGH=y. So requirement 2
    costs nothing on a current distro, and only matters for the release binaries, which are built against
    GCC 12 / Ubuntu 22.04 for compatibility — that is where the version floor would still bite, not on
    the development machine.

    Three things the headers add to the sketch:

    • fuse_passthrough_open is offered on open, opendir and create, and returns a backing id
      to put in fi->backing_id. Requirement 1 in this issue — that it needs a real file to point at —
      is confirmed by the API shape rather than inferred.
    • One backing id per node, per the doc comment ("Currently there should be only one backing id
      per node / backing file"), which constrains how the cache would have to key registrations.
    • Stacking depth is capped at 1 backing file, because the kernel's
      FILESYSTEM_MAX_STACK_DEPTH of 2 counts the passthrough layer itself. A cache file on an ordinary
      ext4 is fine; a cache file that itself lived on another stacked filesystem would not be.

    Unrelated but worth recording while I was in there

    The same kernel now reports CONFIG_FUSE_IO_URING=y, which was not in this issue's original
    capability dump. That is a different lever on the same floor — it cuts per-request overhead for the
    whole FUSE path rather than bypassing it for reads only — and unlike passthrough it needs no backing
    file, so it does not depend on the persistent on-disk cache existing first. Might be the cheaper
    experiment of the two. Not filing it separately unless you want it as its own issue.

    Status unchanged

    Still exploratory, still correctly deferred behind the persistent on-disk cache. This only removes
    one of the two stated obstacles and sharpens the sketch; nothing here argues for scoping it now.

    🤖 Generated with Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions