Skip to content

Fix key performance issues in 6.17 with byte range locks - #202

Open
hbirth wants to merge 31 commits into
DDNStorage:redfs-ubuntu-hwe-6.17.0-16.16-24.04.1from
hbirth:redfs-ubuntu-hwe-6.17.0-16.16-24.04.1
Open

Fix key performance issues in 6.17 with byte range locks#202
hbirth wants to merge 31 commits into
DDNStorage:redfs-ubuntu-hwe-6.17.0-16.16-24.04.1from
hbirth:redfs-ubuntu-hwe-6.17.0-16.16-24.04.1

Conversation

@hbirth

@hbirth hbirth commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

@hbirth
hbirth force-pushed the redfs-ubuntu-hwe-6.17.0-16.16-24.04.1 branch from 588473f to 7ffa485 Compare August 25, 2026 11:27
@hazhou-ddn

Copy link
Copy Markdown

What does this patch do? Do I still need to rebase my PR 201 by merge the range lock interval tree with DLM cache interval?

@hbirth
hbirth requested a review from achhenderson August 25, 2026 12:12
@hbirth
hbirth force-pushed the redfs-ubuntu-hwe-6.17.0-16.16-24.04.1 branch 6 times, most recently from 839c405 to 9fb952d Compare August 26, 2026 09:52
@hbirth

hbirth commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

This is doing pretty well in xfstests
Failures: generic/020 generic/062 generic/069 generic/091 generic/184 generic/263 generic/355 generic/434 generic/531 generic/568 generic/617 generic/631 generic/633 generic/683 generic/760
Failed 15 of 783 tests

hbirth added 16 commits August 26, 2026 21:37
fuse_dlm_buffered_write() sends the unaligned edges of a write to the
server itself so no partly written block is dirtied, and cuts at
PAGE_SIZE.  Nothing checked that a block is a page: fc->blkbits keeps
whatever blksize= asked for, and attr->blksize sets inode->i_blkbits per
inode.  iomap then goes back for the remainder of an edge block and
dirties what the write already sent.

Refuse the connection at FUSE_INIT when a writeback cache is negotiated
on a block that is not a page, and pin inode->i_blkbits afterwards.
st_blksize still reports what the server named.

Signed-off-by: Horst Birthelmer <hbirthelmer@ddn.com>
Readahead fills the page cache past the range fuse_cache_read_iter()
locked, so those folios get no revoke when a remote node writes them.
Request a read grant over the whole window in fuse_readahead() before
any folio is consumed, and skip the window when the request fails.

Signed-off-by: Horst Birthelmer <hbirthelmer@ddn.com>
A FUSE_DLM_WB_LOCK reply and a NOTIFY revoke run on different threads, so
a revoke can arrive before the grant is recorded, find nothing, and leave
a grant that is never taken back.  revoke_gen caught this with a
per-inode counter that cannot say which range was hit, so any revoke
re-requested every grant in flight.

Add enum fuse_dlm_range_state: REQUESTED or REVOKED on the new
cache->pending list while in flight, GRANTED in cache->ranges.  A revoke
marks the pending requests it overlaps, fuse_dlm_request_commit() drops a
marked grant, and unlinking the request and recording its grant is one
step under the cache lock.  Pending requests stay off the interval tree,
so no tree walker needs a state filter.

Signed-off-by: Horst Birthelmer <hbirthelmer@ddn.com>
struct fuse_dlm_range carried a lifecycle state and a mode, the mode
stored as FUSE_PCACHE_LK_READ/_WRITE (1 and 2) in a field typed enum
fuse_page_lock_mode, whose enumerators are 0 and 1.  The two are never
independent, and fuse_dlm_range_is_locked() relied on the value
ordering to let a write grant cover a read.

Replace GRANTED with READ and WRITE and drop the mode field and the
FUSE_PCACHE_LK_* values.  fuse_dlm_state_satisfies() replaces the
ordinal comparison.  fuse_dlm_request_begin() no longer takes a mode:
the one that reaches the tree is the one passed to the commit.

Signed-off-by: Horst Birthelmer <hbirthelmer@ddn.com>
A granted range does not say whether anything is cached under it, so a
revoke assumes the worst and drops with invalidate_inode_pages2_range(),
which launders dirty folios and waits for a FUSE_WRITE reply.

Add enum fuse_dlm_range_content: nothing cached, data the server has
seen, or data it has not.  It is an upper bound, raised in
fuse_dlm_range_touched() from the two points that authorise cached IO, so
it moves before the data lands.  Only fuse_dlm_ranges_flushed() lowers
it, under i_rwsem held exclusive; a mapped inode is skipped.

No caller acts on this yet.

Signed-off-by: Horst Birthelmer <hbirthelmer@ddn.com>
Grants are page aligned, buffered writes need not be, and
fuse_dlm_range_touched() marked whole tree nodes: a 4K write into a 1M
grant claimed the whole grant, so the record could not say which part of
a boundary page this client wrote.

Split at both ends of the marked region and mark only what it covers.
Both halves keep state and content, so grant coverage is unchanged and
fuse_dlm_try_merge() recoalesces.  fuse_get_dlm_lock() stops marking on
the write path, where its range is the page aligned request;
fuse_cache_write_iter() marks the exact range instead.

Signed-off-by: Horst Birthelmer <hbirthelmer@ddn.com>
Writeback sent every dirty folio whole, which is why an unaligned edge
cannot be cached: the untouched remainder of a boundary page would go to
the server with it.

fuse_dlm_dirty_run() walks forward while the recorded content stays the
same, so fuse_iomap_writeback_range() sends one written run at a time.
An unwritten run is reported as IOMAP_HOLE, which iomap skips, and a
folio with no written run has its writeback ended by iomap itself.

A range not covered by write grants throughout still goes whole: only a
write grant makes this client the one that dirties it.

Signed-off-by: Horst Birthelmer <hbirthelmer@ddn.com>
A data invalidation always dropped the range with
invalidate_inode_pages2_range(), which launders dirty folios and waits
for a FUSE_WRITE reply, even for a range holding nothing.

Ask instead, under the gate so nothing populates or dirties in between.
filemap_range_has_page() says whether anything is cached, and
fuse_dlm_range_may_be_dirty() whether laundering is needed; when it is
not, invalidate_mapping_pages() drops the same folios without waiting for
the server.

fuse_cache_write_iter() also marks the folio pagecache_isize_extended()
dirties, or the record would report it clean.

Signed-off-by: Horst Birthelmer <hbirthelmer@ddn.com>
fuse_dlm_unlock_range() removed the ranges it revoked, so a grant taken
away looked like a gap.  fuse_dlm_dirty_run() calls a gap UNKNOWN and
writeback sends the whole range, so a writer that passed
fuse_dlm_lock_is_held() before a revoke and dirtied after it had its
bytes sent for a range another node now holds.

Mark the range FUSE_DLM_RANGE_REVOKED instead; one with nothing cached
under it is still removed.  It covers nothing, so the IO paths request
again.  fuse_dlm_dirty_run() returns enum fuse_dlm_run so writeback can
tell the two apart and call fuse_dlm_regrant_range() before sending a
revoked run.

Splitting at the revoke bounds replaces the trim and punch-hole
arithmetic, so fuse_dlm_punch_hole() goes.

Signed-off-by: Horst Birthelmer <hbirthelmer@ddn.com>
wb_inval_rwsem fenced cached IO out for the whole of a NOTIFY invalidate,
so a writer could not dirty the page cache under a grant being revoked.
The record does that now: a write records its bytes before dirtying them,
a revoke marks the range instead of forgetting it, and writeback takes
the range again before sending anything marked that way.  A read never
needed the fence.

Gone with it: the re-validation and retry loops that existed only because
the gate had to be dropped around a FUSE_DLM_WB_LOCK round trip,
fuse_cache_wr_dlm_lock()'s unrecorded flag, and the percpu_rw_semaphore.
setattr and O_TRUNC lose it too, holding i_rwsem exclusive.

Signed-off-by: Horst Birthelmer <hbirthelmer@ddn.com>
fuse_do_readfolio() asks for page_zeroing, and fuse_copy_folio() answers
that by zeroing the whole folio whenever the request covers less than all
of it.  That is right for a whole folio read, where the zeroing fills a
short reply, and wrong for anything smaller: fuse_iomap_read_folio_range()
already asks for one block of a large folio at a time, so a
read-modify-write of one block wipes every other block of the folio while
their iomap uptodate bits still call them valid.

Add fuse_read_folio_range(), which asks without page_zeroing and zeroes
exactly what the reply left short, and use it for the partial reads.
fuse_do_readfolio() keeps the whole folio callers.

Signed-off-by: Horst Birthelmer <hbirthelmer@ddn.com>
fuse_dlm_dirty_run() walked the ranges overlapping the query and gave up
on the first gap, returning FUSE_DLM_RUN_UNKNOWN without saying how far
that answer held, so a run classified up to the gap was thrown away and
the caller sent the whole query range whole.

Treat a gap as a classification of its own and report how far it reaches,
the same as any other run.  A caller now always learns the extent of the
answer it got, and a record that covers part of the query is used for
that part instead of being discarded.

Signed-off-by: Horst Birthelmer <hbirthelmer@ddn.com>
A cached write covering part of a folio makes iomap read the rest in so
the folio can be called valid.  Under DLM that read buys nothing:
writeback already declines to send the server's own bytes back, an
expanding write pays a round trip for a range that holds nothing, and a
write-only handle has the READ refused.

Skip it.  fuse_iomap_read_folio_range() leaves the range alone,
fuse_iomap_put_folio() clears the uptodate flag under the folio lock and
records what the copy actually reached, and fuse_read_folio() fills the
record's gaps on the next read.

Only for folios of a single block: clearing the flag on a larger one
leaves iomap's per block bits set, which a read consults through
->is_partially_uptodate.  Such a folio can be dirty and invalid at once,
so fuse_read_folio() waits out writeback first.

Signed-off-by: Horst Birthelmer <hbirthelmer@ddn.com>
The record describes the inode, not one incarnation of a folio.  A folio
left invalid by a partial write is clean once writeback has sent what it
held, and reclaim may take it; the record still names those bytes, so
fuse_read_folio_merge() would decline to fetch them for the next folio at
that index.

Lower the record as writeback hands a run over, for an invalid folio
only.  The folio lock orders it against a rewrite.  fuse_dlm_split_at()
can no longer fail: it now decides which bytes exist.

Signed-off-by: Horst Birthelmer <hbirthelmer@ddn.com>
fuse_dlm_request_commit() reports -EAGAIN when a revoke overlapped a
request while it was on the wire, and __fuse_get_dlm_lock() went round
again with nothing stopping it.  A remote node revoking as fast as the
grants are handed out keeps that going for as long as it likes, and
writeback asks for a grant with a folio locked, so the loop is not
merely slow, it holds a folio hostage and the task is unkillable while it
does.

Give it a count and a signal check.  Both are generous: every pass is a
whole round trip, so reaching either means the range is genuinely being
fought over and the caller is better told than left spinning.

Signed-off-by: Horst Birthelmer <hbirthelmer@ddn.com>
fuse_do_setattr() revokes the grants past the new size and discards the
page cache there, but never tells the record.  A revoked range is kept
because it describes page cache dirtied before the grant went, so every
truncate leaves ranges describing folios that no longer exist.

Nothing frees them before the inode, and until then the inode reports
unwritten data and every later invalidate over that region launders.

Call fuse_dlm_ranges_dropped() from the first whole page above the new
size; the page holding the new end of the file survives, so its record
has to.

Signed-off-by: Horst Birthelmer <hbirthelmer@ddn.com>
hbirth and others added 14 commits August 26, 2026 21:37
The handler starts writeback so the bytes go while the grant still covers
them, then drops the range.  When the record says the range may be dirty
the drop launders, and laundering waits out the folios just put under
writeback: the handler waits for FUSE_WRITE replies it queued itself.

Wait here instead, before the revoke, so nothing classifies as revoked
and re-requests the range from inside the handler.  A server revoking
from a thread it also answers FUSE_WRITE on still deadlocks; the comment
now says so.

Signed-off-by: Horst Birthelmer <hbirthelmer@ddn.com>
The forced-direct-IO latch is checked once, so a buffered write that
passed the check just before a notify set the latch dirties the page
cache after the notify dropped the mapping.  The coherency gate used to
fence that.

What is left behind is invisible to the direct path: a direct read misses
the dirty folio, and a direct write lands underneath it, after which the
invalidate launders rather than drops and puts the stale folio on the
server on top.

Write the range back before re-routing.

Signed-off-by: Horst Birthelmer <hbirthelmer@ddn.com>
The kernel-doc of the DLM record says page offset throughout, but every
one of those arguments is a byte offset that happens to be page aligned,
and the same file computes real page indices a few lines from some of
them.  The code is right; the wording invites passing one for the other.

Say byte offset, and note on struct fuse_dlm_range that grants arrive
page aligned while the content bound is split to the exact bytes an
unaligned write covers, so the two are not the same thing.

Signed-off-by: Horst Birthelmer <hbirthelmer@ddn.com>
fuse_dlm_lock_range_locked() put every range overlapping the grant on
the upgrade list whole, without splitting at the grant bounds the way
fuse_dlm_unlock_range() does.  A range extending past the grant then had
its uncovered part upgraded with it: holding a read grant on [0, 8191]
and being granted write on [4096, 8191] recorded the whole node as held
for write, so fuse_dlm_lock_is_held() reported [0, 4095] covered, a
cached write there never sent FUSE_DLM_WB_LOCK, and the server went on
thinking this client held only a read there -- cluster exclusion broken
without any error to see.

The revoked case is worse.  Writeback re-grants only the run it is
about to send, but the commit flipped the whole revoked range back to
held, so revoked-dirty bytes outside the re-granted run reclassified as
FUSE_DLM_RUN_DIRTY and were sent without taking the range again --
exactly the lost update FUSE_DLM_RANGE_REVOKED exists to prevent.

Split at both grant bounds before walking, so upgrades apply only
inside the grant.  Both halves of a split keep state and content, and
fuse_dlm_try_merge() at the end recoalesces whatever stayed equal.

Signed-off-by: Allison Henderson <allison.henderson@ddn.com>
fuse_do_setattr() frees the record above the new size, but the
invalidate_inode_pages2() it runs empties the mapping below it too, and
the record there was left alone.  A dirty folio is laundered by that
invalidate, which puts its bytes on the server, but laundering lowers
the record only for a folio the page cache does not consider valid
(fuse_dlm_range_sent()), so an ordinary uptodate dirty folio leaves a
DIRTY range describing page cache that no longer exists.

The next unaligned write to such a page allocates a fresh folio, leaves
it unfilled, and records only its own bytes.  The stale DIRTY run over
the rest of the page then makes fuse_read_folio_merge() keep folio
bytes nobody wrote, and writeback classifies and sends them -- bad data
both to a local reader and to the server.  fsx reaches this with any
truncate-then-partial-write sequence.

Drop the record below the new size as well, and only when the drop
really emptied it: a busy folio that survives the invalidate still
needs its record, so ask filemap_range_has_page() first, the same rule
the NOTIFY invalidate path applies before its drop.

Signed-off-by: Allison Henderson <allison.henderson@ddn.com>
The atomic O_TRUNC branch of fuse_do_setattr() releases every DLM range
before dropping the cache, but the far more common path -- fuse_open()
with fc->atomic_o_trunc -- called truncate_pagecache() and left the
whole record standing.  Ranges recorded DIRTY then described folios
that no longer existed, and the next unaligned write to such a page
left a fresh folio unfilled while the stale run made
fuse_read_folio_merge() keep its unwritten bytes and writeback send
them: bad data from something as plain as 'echo x > file' followed by a
partial write.

Release the record before the truncate, exactly as fuse_do_setattr()
does; i_rwsem is held exclusive here (is_wb_truncate), so no cached
write is between recording and dirtying.

The FOPEN_KEEP_CACHE-less open one line below drops the mapping with
invalidate_inode_pages2() and has the same problem.  Drop the record
there too, but only when the invalidate really emptied the mapping,
under the same filemap_range_has_page() rule the NOTIFY path applies:
a busy folio that survived keeps its record.

Signed-off-by: Allison Henderson <allison.henderson@ddn.com>
FALLOC_FL_PUNCH_HOLE and FALLOC_FL_ZERO_RANGE flush the span, punch it
on the server, and drop it from the page cache with
truncate_pagecache_range() -- and told the record nothing.  The flush
lowers the record only for folios the page cache does not consider
valid, so an uptodate dirty folio inside the hole left a DIRTY range
describing page cache that no longer exists.

The next unaligned write into the hole allocates a fresh folio, leaves
it unfilled, and records its own bytes; the stale DIRTY run over the
rest of the page makes fuse_read_folio_merge() keep folio bytes nobody
wrote and writeback send them.  fsx exercises punch hole against
partial writes constantly, which is where the bad-data failures on this
branch come from.

Free the record over the whole pages inside the hole, under the same
filemap_range_has_page() rule the NOTIFY path applies before its drop.
The partial pages at the edges survive the truncate with their punched
part zeroed -- those zeroes are real bytes the folio really holds -- so
their record has to survive with them.

Signed-off-by: Allison Henderson <allison.henderson@ddn.com>
__fuse_copy_file_range() flushes the destination span, lets the server
copy, and then drops the copied pages with truncate_inode_pages_range()
as stale -- without telling the record.  As on the truncate and punch
hole paths, an uptodate dirty folio flushed and then dropped leaves a
DIRTY range describing page cache that no longer exists, and the next
partial write there keeps and flushes folio bytes nobody wrote.

Free the record over the dropped span, gated on
filemap_range_has_page() like the other drops: the copy runs under
i_rwsem but faults do not, and a folio a fault put back after the
truncate keeps its record.

Signed-off-by: Allison Henderson <allison.henderson@ddn.com>
fuse_attr_cache_mask() trusted the local i_size over the server's only
while a recorded write grant covered [attr->size, i_size).  Two paths
leave locally-extended data with no such record: a fault dirties pages
under a page-mkwrite lock that is never recorded, and a local truncate
revokes its own tail grants, after which cached writes extend the file
again.  On the next attribute refresh the check failed, the server's
smaller size was applied, and truncate_pagecache() discarded dirty
pages past it -- cached data destroyed by a GETATTR.  The invalidate
that follows then dropped the rest of the mapping with the record left
standing, arming the stale-DIRTY bad-data path on every page of the
file.  generic/075 hits this within a few hundred fsx operations; the
failure point moves with the attribute timeout, which is what made the
runs look nondeterministic.

Keep STATX_SIZE cached also while anything in [attr->size, i_size) is
dirty or under writeback: those bytes exist only here, and the server
cannot have a newer opinion about a size it has never seen.  A remote
truncate still lands, exactly as the design intends: its revoke
launders and drops the tail first, so nothing is dirty there by the
time the smaller size arrives.  The no-grant early return learns the
same rule, since a mapping dirtied only through page-mkwrite has no
recorded grant at all.

Signed-off-by: Allison Henderson <allison.henderson@ddn.com>
When an attribute reply is applied with the size not served from the
cache, fuse_change_attributes_i() truncates the page cache to the
server's size and, when the data may be stale, drops the whole mapping
with invalidate_inode_pages2() -- and told the record nothing.  This is
the same hole just closed on the truncate, O_TRUNC open, punch hole and
copy_file_range paths, reached from every GETATTR, and it is the drop
behind the generic/075 corruption: the server-side trace shows a folio
whose bytes were flushed correctly once, dropped here with its record
left DIRTY, repopulated by a later partial write that leaves the folio
unfilled, and then written back with the stale record naming the
unfilled head -- zeroes sent over data the server already had.

Free the record over what really went: unconditionally above the new
size, where truncate_pagecache() leaves nothing behind, and over the
rest only when the invalidate emptied the mapping, under the same
filemap_range_has_page() rule as the other drops, so a folio that
survived or was faulted back keeps its record.

Signed-off-by: Allison Henderson <allison.henderson@ddn.com>
The interior stage of fuse_dlm_buffered_write() returns @ToTal on error.
For a page aligned start the head chunk is empty, so @ToTal is zero and
an interior failure returns zero: no error and no bytes.
fuse_cache_write_iter() then falls back to @err, which still holds the
positive count from generic_write_checks(), and a write that failed
outright reports every byte as written.

Return the error while nothing has landed, a short write once something
has, in every chunk.

Signed-off-by: Horst Birthelmer <hbirthelmer@ddn.com>
fuse_iomap_read_folio_range() skipped the fill under DLM and left the
folio invalid, so a write covering part of a block need not read the
remainder back from a server that may hold nothing there.

A DLM buffered write is cut at the block size now, so its interior covers
whole blocks and the unaligned edges never reach iomap.  Nothing reaches
the skip.  It goes, with the invalid folio, its zero fill,
fuse_iomap_put_folio() and the cursor between fuse_iomap_begin() and
fuse_iomap_end().

Writeback's copy of the assumption goes too, and that one is a bug on its
own: iomap leaves a large folio partly valid whenever a write covers some
of its blocks and not others, so with no DLM such a folio has its dirty
blocks reported as a hole and dropped.

Signed-off-by: Horst Birthelmer <hbirthelmer@ddn.com>
Writeback asked the record what each dirty run was, because a boundary
folio could hold this client's bytes next to the server's.  Nothing
partly written is dirtied any more, so every run iomap reports dirty was
written whole by this client.  Call fuse_dlm_regrant_range() for the
whole run instead and let it early-exit on a grant still held;
fuse_dlm_dirty_run() and fuse_dlm_range_sent() go with the
classification.

fuse_read_folio_merge() asked the same question because reading a dirty
folio whole would lose what a write left in it.  iomap answers it through
->is_partially_uptodate, which fuse already publishes.  That also fixes a
mount with no DLM, where the merge was skipped for want of a record and
the folio read whole.

Signed-off-by: Horst Birthelmer <hbirthelmer@ddn.com>
struct fuse_dlm_range carried an upper bound on what the page cache under
it held.  Writeback no longer consults it, and the three remaining users
have exact page cache equivalents: fuse_dlm_range_may_be_dirty() is
filemap_range_needs_writeback(), the keep-or-free decision for a revoked
range is filemap_range_has_page(), and the revoked-and-dirty arm of
fuse_dlm_write_grant_exists() is covered by its caller's PAGECACHE_TAG
tests.

Remove enum fuse_dlm_range_content and fuse_dlm_range::content, and with
them fuse_dlm_range_touched(), fuse_dlm_range_written(),
fuse_dlm_ranges_flushed(), fuse_dlm_raise_dirty_shared(),
fuse_dlm_mark_populated(), fuse_dlm_touched_level() and
fuse_dlm_range_may_be_dirty().  fuse_iomap_end() has nothing left to do.

The record is written only on a grant and on a revoke now, so a cached
write does not take the per inode cache lock at all.

Signed-off-by: Horst Birthelmer <hbirthelmer@ddn.com>
@hbirth
hbirth force-pushed the redfs-ubuntu-hwe-6.17.0-16.16-24.04.1 branch from 9fb952d to 9bb6233 Compare August 26, 2026 19:49
With the writeback cache the kernel owns append positioning: a cached
O_APPEND write is placed at the local i_size, and writeback later sends
FUSE_WRITE requests with explicit offsets.  Those offsets are only
honoured if the server does not re-apply O_APPEND itself -- on Linux,
pwrite(2) on a descriptor opened O_APPEND appends regardless of the
offset argument.  libfuse's passthrough examples know this and strip
the flag, but only on FUSE_OPEN; a file created with FUSE_CREATE kept
an O_APPEND backing descriptor, and every writeback run landed at the
server's EOF instead of its offset.

That is not just a theoretical hazard.  Writeback legitimately covers
the same bytes twice -- a folio whose record still names sent bytes is
re-sent from its page start -- and with offsets honoured that re-send
is idempotent.  Appended instead, each flush's overlap with the
previous one is duplicated at EOF and the file grows: generic/069
fails with exactly this shape (an appended file 230356 bytes too long,
every byte of the excess a repeated flush overlap; server-side IO
traces show each pwrite landing at EOF, sum of pwrites equal to the
final file size).

Strip O_APPEND from the flags sent in FUSE_OPEN, FUSE_CREATE and the
compound open, the same way O_TRUNC is suppressed without
atomic_o_trunc.  The server cannot use the flag correctly under
writeback caching anyway: appending server-side would order writeback
runs by arrival, not by offset.  Non-writeback mounts are unchanged,
since there the server really does own append positioning.

Signed-off-by: Allison Henderson <allison.henderson@ddn.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