Skip to content

splice: optional SPLICE_F_MOVE on fuse replies - #1

Open
AliRamberg wants to merge 2 commits into
masterfrom
splice-f-move
Open

splice: optional SPLICE_F_MOVE on fuse replies#1
AliRamberg wants to merge 2 commits into
masterfrom
splice-f-move

Conversation

@AliRamberg

Copy link
Copy Markdown

/dev/fuse acts on SPLICE_F_MOVE even though the generic pipe-to-file splice path ignores it: fuse_dev_splice_write() sets cs.move_pages (fs/fuse/dev.c:2038), and the kernel then moves each pipe page into the target inode's page cache instead of copying into it.

For a filesystem that serves reads by splicing out of a local cache file, that turns a duplicate into a migration — the page leaves the cache file's mapping and becomes the fuse inode's, so the payload is neither copied a second time nor resident twice.

Measured on MLPerf Storage unet3d against an everest read-only mount, 1200 files / 162 GiB, 2 accelerators, kernel 6.8, three interleaved runs per arm:

warm epoch NVMe read cache file fuse inode
read-side fadvise 73.0 ± 0.70 s 113.6 GiB 33.5 GiB 81.2 GiB
SPLICE_F_MOVE 64.0 ± 0.45 s 98.6 GiB 1.6 GiB 111.2 GiB

Warm -12.3%, device reads -13%, and the duplicate is gone rather than reclaimed: fadvise recovers two thirds of it and pays a syscall per read, a move costs nothing and keeps the byte.

API

Opt-in per reply. A successful move removes the page from the source file's page cache and waits on its writeback first, so only the filesystem knows when it is sound:

return fuse.ReadResultPipeMove(pair, size), fs.OK   // instead of fuse.ReadResultPipe

A ReadResult can also opt in by implementing SpliceMove() bool.

The flag rides on the result type itself, not a wrapper: wrapping a ReadResult hides Stateful() from trySplice, which then falls back to copying the payload through userspace — silently, since errRecoverSplice is not logged. TestReadResultPipeSpliceMove fails on exactly that mistake.

Pair.WriteTo keeps its signature and flags=0 behaviour; the flag goes through a new Pair.WriteToFlags.

Limits

Only readahead reads are eligible — fuse_send_readpages() is the only caller that sets page_replace, and dev.c:1919 clears move_pages without it. Whole aligned pages only (dev.c:951), and any page that cannot be stolen falls back to the copy. Nothing is negotiated at INIT: the kernel advertises FUSE_SPLICE_MOVE and never reads it back.

Verification

gofmt, GOOS=linux/darwin/freebsd builds, go vet ./fuse/ ./splice/, and TestReadResultPipeSpliceMove on linux.

Upstream review: https://review.gerrithub.io/c/hanwen/go-fuse/+/1245412 — hanwen's comment on patch set 3 was that a MountOptions flag is all-or-nothing. This branch is that rework (per-reply, mount option deleted) and has not been pushed to Gerrit yet.

Everest consumes it via ReadResultPipeMove on read-only mounts; that branch needs a go.mod bump before it can merge.

The generic pipe-to-file splice path ignores SPLICE_F_MOVE, but
/dev/fuse acts on it: fuse_dev_splice_write() in fs/fuse/dev.c sets
cs.move_pages, and the kernel then moves each pipe page into the
target inode's page cache instead of copying into it. Only readahead
reads are eligible, as fuse_send_readpages() is the only caller that
sets page_replace, and a page that cannot be stolen falls back to the
copy, so this is a hint. Nothing is negotiated at INIT: the kernel
advertises FUSE_SPLICE_MOVE but never reads it back.

For a filesystem serving reads by splicing out of a local cache file
that turns a duplicate into a migration: the folio leaves the cache
file's mapping and becomes the fuse inode's, so the payload is neither
copied a second time nor resident twice. Measured on MLPerf Storage
unet3d, three runs per arm, kernel 6.8: the cache file holds 1.6 GiB
rather than 33.5 GiB, the inode 111.2 GiB rather than 81.2 GiB, and
the epoch takes 64.0s rather than 73.0s.

A successful move removes the folio from the source file's page cache
and waits on its writeback first, so only the filesystem knows when it
is sound. It opts in per reply, through ReadResultPipeMove or by
implementing SpliceMove() bool on its own ReadResult. The flag has to
ride on the result type itself: wrapping a ReadResult hides Stateful()
from trySplice, which then copies the payload through userspace
instead of splicing it.

Pair.WriteTo keeps its signature and flags=0 behaviour; the flag goes
through a new Pair.WriteToFlags.

Change-Id: Ice312bbcd81eb631a599d605ee0e72a1f9b509ff
@AliRamberg
AliRamberg requested a review from arielshaqed August 16, 2026 14:00

@arielshaqed arielshaqed 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.

Excitingly great change! But I think as-is it is a bit brittle. Suggestions inline.

Comment thread fuse/splice_linux.go Outdated
req.serializeHeader(payloadLen)

return r.trySplice(req, ReadResultPipe(pair, payloadLen))
return r.trySplice(req, &pipeReadResult{pair: pair, size: payloadLen, move: spliceFlags != 0})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

  1. I would have expected spliceFlags & unix.splice_F_MOVE != 0 - in future if we add more bits the existing code will break.
  2. Can we use ReadResultPipeMove?!

Comment thread fuse/splice_linux.go Outdated
// The filesystem decides per reply whether its pages may be moved.
spliceFlags := 0
if m, ok := readResult.(movableResult); ok && m.SpliceMove() {
spliceFlags = unix.SPLICE_F_MOVE

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

In future we might add more splice flags, so this is brittle.

Suggested change
spliceFlags = unix.SPLICE_F_MOVE
spliceFlags |= unix.SPLICE_F_MOVE

@AliRamberg
AliRamberg force-pushed the splice-f-move branch 2 times, most recently from 38ebe59 to 82af371 Compare August 16, 2026 14:40
@AliRamberg
AliRamberg requested a review from arielshaqed August 16, 2026 14:41
@AliRamberg
AliRamberg force-pushed the splice-f-move branch 4 times, most recently from 89fb90f to d628bbc Compare August 16, 2026 15:00

@arielshaqed arielshaqed 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.

Great, thanks! Excited about the performance improvements that this unlocks.

Comment thread fuse/read.go Outdated
Comment on lines +45 to +47
// flaggedResult is a ReadResult carrying splice(2) flags for the write to
// /dev/fuse.
type flaggedResult interface {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
// flaggedResult is a ReadResult carrying splice(2) flags for the write to
// /dev/fuse.
type flaggedResult interface {
// spliceFlaggedResult is a ReadResultPipe carrying splice(2) flags for the write to
// /dev/fuse.
type spliceFlaggedResult interface {

@AliRamberg

Copy link
Copy Markdown
Author

Absolutely! Bare in mind these numbers are also based on the O_DIRECT flow

@AliRamberg
AliRamberg force-pushed the splice-f-move branch 3 times, most recently from a84db19 to 3159c25 Compare August 18, 2026 11:57
Review fixes on top of the SPLICE_F_MOVE commit:

- pipeReadResult stores the splice(2) flags themselves instead of a move
  bool, so trySplice no longer converts int -> bool -> int across the
  short-read fixup. The flags go straight to Pair.WriteToFlags.
- ReadResultPipeMove becomes ReadResultPipeFlags(pipe, size, flags), and
  the opt-in interface becomes SpliceFlags() int; adding a second flag
  needs no new constructor.
- The recursive trySplice call is not a retry: the payload already sits
  in the pipe, so the second pass splices pipe->pipe behind the
  corrected header rather than reading the file again.

Signed-off-by: Yahli Ramberg <lryahli@gmail.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.

2 participants