mirror of
https://github.com/trapexit/mergerfs.git
synced 2026-10-26 19:01:56 -04:00
std::filesystem::path is wrong for mergerfs's hot path:
- `branch->path / fusepath_` heap-allocates per FUSE op (3 allocs each
on libstdc++; the SSO buffer is 15 bytes and any joined path exceeds it).
- The implicit `operator string_type()` adds another std::string when
passing to `fs::*` wrappers that take `const std::string&`.
- Per-branch loops rebuild fullpath from scratch every iteration.
The new fs::relpath is a fixed 8192-byte (2 * PATH_MAX) inline buffer,
NUL-terminated, never heap-allocates. The rel data is anchored at offset
REL_OFFSET = 4096; the 4096 bytes before the anchor form the "prefix
area" so a long base/prefix can be written there in place via
set_prefix() without disturbing the rel. The remaining ~4096 bytes
after the rel are available for trailing append (filenames in
readdir-style loops, etc.).
set_prefix's cost is O(prefix size) -- only the actually-used bytes
are touched -- so the buffer size is a stack-footprint question, not a
hot-path cost. FUSE worker threads use the pthread default stack
(~8 MB on Linux); a 4-relpath frame in fuse_link/fuse_rename is 32 KB,
~0.4% of the stack budget.
The per-branch hot loop:
fs::relpath fullpath = fusepath_; // one copy of the rel
for (auto branch : branches) {
fullpath.set_prefix(branch->path); // writes only [start, 4096)
fs::syscall(fullpath);
}
set_prefix only rewrites the prefix area; the rel stays anchored. A
cached _rel_size means no recomputation across iterations.
Microbenchmark (8 branches, 30-char fusepath, -O3, 4 M path builds):
variant ns/build allocs/build
-------------------------------------- -------- ------------
origin/master (std::filesystem::path) 158 3.00
fs::relpath assign_concat (single-shot) 9.1 0
fs::relpath set_prefix (loop-optimized) 4.5 0
35x faster, zero allocations in the hot path.
Contract: paths flow through fs::relpath in canonical form -- prefix
without trailing '/', rel without leading '/'. No sanitization;
violations yield double slashes. Mergerfs satisfies this by
construction:
- libfuse's try_get_path() walks leaf-to-root via prepend(), then
calls consolidate_to_rel(strip=true) which drops the leading '/'
that prepend produces.
- branch paths are realpath-canonicalized at config load so they
never have a trailing slash.
Mergerfs has exactly one path-manipulation type: a relative fusepath.
Branch paths and other absolute paths are stored as std::string -- they
are spliced into the prefix area of an fs::relpath at syscall time but
are never themselves mutated as path objects.
Major changes:
- vendored/libfuse/{include/fs_path.hpp,lib/fs_path.cpp}: new
fs::relpath class (8 KB inline, REL anchor at 4096, set_prefix /
clear_prefix / consolidate_to_rel, _rel_size cache, self-aliasing-
safe assign_concat, sticky ENAMETOOLONG via errnum()).
- 30+ FUSE-op call sites converted from `fullpath = branch / fusepath`
to the `set_prefix` loop pattern (fuse_unlink, fuse_chmod,
fuse_chown, fuse_listxattr, fuse_truncate, fuse_utimens,
fuse_setxattr, fuse_rmdir, fuse_removexattr, fuse_link's
preserve-path and create-path loops, fuse_rename's create-path
and preserve-path loops, fuse_statfs FULL mode, fs::findallfiles,
fs::findonfs).
- fs::* syscall wrappers that previously only took
`const std::string&` got `const char*` overloads so fs::relpath's
implicit `operator const char*()` lands cleanly (fs::link,
fs::lutimens, fs::umount_lazy, fs::open_dir_ro, fs::readlink,
fs::lremovexattr, fs::lstatvfs, fs::info, fs::statvfs_cache).
- fs::exists gained a (fs::relpath&, basepath, ...) overload that
calls set_prefix internally so per-branch policy fan-out loops
share a single fullpath, rewriting only the prefix area on each
iteration.
- All policies (epall, epff, eplfs, eplus, epmfs, eppfrd, ff, lup,
msplfs, msplus, mspmfs, msppfrd, newest) refactored to hoist the
fs::relpath fullpath construction out of the per-branch loop and
use the set_prefix-based fs::exists overload.
- fs::inode::ReaddirCalc::_fusepath migrated from std::string to
fs::relpath with the same resize/append API.
- std::filesystem removed entirely from the project: branches.cpp
and option_parser.cpp's exists/is_directory/equivalent calls now
use direct ::stat(); mergerfs_fsck.cpp's recursive_directory_iterator
was replaced with an opendir-based walk.
Tests (tests/tests.cpp): 27 new fs::relpath test cases covering
construction, set_prefix in/out of REL anchor, prefix()/rel()
accessors, clear_prefix, concat canonical form, self-aliasing
(`p /= "x"`), operator/=, resize zero-fill, swap, copy/move,
equality, implicit conversions, lexically_relative, remove_filename,
_rel_size invariant across every mutator, capacity bounds.
Full suite: 213/213 pass.
378 lines
16 KiB
C++
378 lines
16 KiB
C++
/*
|
|
ISC License
|
|
|
|
Copyright (c) 2026, Antonio SJ Musumeci <trapexit@spawn.link>
|
|
|
|
Permission to use, copy, modify, and/or distribute this software for any
|
|
purpose with or without fee is hereby granted, provided that the above
|
|
copyright notice and this permission notice appear in all copies.
|
|
|
|
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
*/
|
|
|
|
#pragma once
|
|
|
|
#include <cassert>
|
|
#include <cstddef>
|
|
#include <cstdint>
|
|
#include <cstring>
|
|
#include <string>
|
|
#include <string_view>
|
|
|
|
|
|
namespace fs
|
|
{
|
|
// relpath: a relative path (mergerfs's "fusepath") backed by a fixed
|
|
// 8192-byte inline buffer, NUL-terminated, no heap.
|
|
//
|
|
// Layout: rel data is anchored at _rel_start, which defaults to
|
|
// REL_OFFSET (4096) on construction, clear(), assign(), and
|
|
// consolidate_to_rel(). The bytes before _rel_start form the
|
|
// "prefix area" -- a long base/prefix can be written there in place
|
|
// via set_prefix() without touching the rel. The remaining bytes
|
|
// after the rel are available for appending (filenames in
|
|
// readdir-style loops, etc.).
|
|
//
|
|
// Buffer is sized at 2 * PATH_MAX. The fast path -- a prefix that
|
|
// fits in REL_OFFSET bytes -- writes only the prefix area; rel
|
|
// stays anchored at REL_OFFSET. If a caller hands in a prefix
|
|
// larger than REL_OFFSET (contract violation or future-larger
|
|
// PATH_MAX), set_prefix shifts the rel rightward to make room and
|
|
// updates _rel_start, so the class's effective capacity is the
|
|
// full BUF_SIZE - 1 rather than artificially capping at REL_OFFSET.
|
|
// set_prefix's cost is O(prefix size + shift size), independent of
|
|
// BUF_SIZE -- only the actually-used bytes are touched -- so the
|
|
// buffer size is a stack-footprint question, not a hot-path cost.
|
|
// FUSE worker threads use the pthread default stack (~8 MB on
|
|
// Linux); a 4-relpath frame in fuse_link/fuse_rename is 32 KB,
|
|
// ~0.4% of the stack budget.
|
|
//
|
|
// Mergerfs has exactly one path-manipulation type: a relative
|
|
// fusepath. Branch paths and other absolute paths are stored as
|
|
// std::string -- they are spliced into the prefix area of an
|
|
// fs::relpath at syscall time but are never themselves mutated as
|
|
// path objects.
|
|
//
|
|
// Canonical mergerfs form -- prefix has no trailing '/', rel has
|
|
// no leading '/'. set_prefix() inserts exactly one '/' between a
|
|
// non-empty prefix and a non-empty rel. No sanitization; violating
|
|
// the contract yields double slashes. Mergerfs satisfies the
|
|
// contract by construction:
|
|
// - libfuse's try_get_path() walks leaf-to-root via prepend(),
|
|
// then calls consolidate_to_rel(strip=true) which drops the
|
|
// leading '/' that prepend produces.
|
|
// - branch paths are realpath-canonicalized at config load so
|
|
// they have no trailing '/'.
|
|
class relpath final
|
|
{
|
|
public:
|
|
static constexpr std::size_t BUF_SIZE = 8192;
|
|
static constexpr std::size_t REL_OFFSET = 4096;
|
|
|
|
public:
|
|
relpath() noexcept;
|
|
relpath(const char *s);
|
|
relpath(std::string_view s);
|
|
relpath(const std::string &s);
|
|
relpath(const relpath&);
|
|
relpath(relpath&&) noexcept;
|
|
~relpath() = default;
|
|
|
|
public:
|
|
relpath& operator=(const relpath&);
|
|
relpath& operator=(relpath&&) noexcept;
|
|
relpath& operator=(const char *s);
|
|
relpath& operator=(std::string_view s);
|
|
relpath& operator=(const std::string &s);
|
|
|
|
public:
|
|
// Replace value entirely. The input is stored verbatim -- no
|
|
// leading-'/' stripping. The caller is responsible for canonical
|
|
// rel form (no leading '/') if the result is going to be re-
|
|
// prefixed via set_prefix(); mergerfs's pipeline guarantees this
|
|
// via libfuse's consolidate_to_rel(strip=true) step. Storing
|
|
// verbatim means an fs::relpath can also hold a fully-built
|
|
// absolute fullpath (`branch.path / fusepath`) without losing the
|
|
// leading '/'.
|
|
relpath& assign(std::string_view s);
|
|
|
|
// The hot-path primitive: replace the prefix in place. The rel
|
|
// portion is undisturbed; only [_start, _rel_start) is touched
|
|
// when the prefix fits in front of the current rel anchor.
|
|
//
|
|
// Always inserts exactly one '/' between non-empty prefix and rel.
|
|
// No sanitization: prefix must have no trailing '/' (canonical
|
|
// mergerfs branch path form). Empty prefix drops the prefix and
|
|
// restores bare-rel state.
|
|
//
|
|
// Each call is a fresh prefix assignment. If the prefix is
|
|
// larger than the current prefix area, the rel data is shifted
|
|
// rightward to enlarge the prefix area; this fallback engages
|
|
// only when p_size + sep > _rel_start, which is unreachable
|
|
// under Linux PATH_MAX (the fast path always fires).
|
|
//
|
|
// Capacity contract: prefix + sep + rel must fit in BUF_SIZE - 1.
|
|
// BUF_SIZE = 2 * PATH_MAX, so any prefix and rel that came from
|
|
// the kernel necessarily fit. Exceeding the capacity is a
|
|
// programming error; the syscall layer (Linux PATH_MAX) returns
|
|
// ENAMETOOLONG for any oversized path long before the path can
|
|
// be assembled here.
|
|
void set_prefix(std::string_view prefix);
|
|
|
|
// Restore to just the rel portion (start = _rel_start, no prefix).
|
|
void clear_prefix() noexcept
|
|
{
|
|
_start = _rel_start;
|
|
_size = _rel_size;
|
|
_buf[_start + _size] = '\0';
|
|
}
|
|
|
|
// Prepend "/component" to the path, growing leftward into the
|
|
// prefix area. Used for libfuse's path-building pattern: walk
|
|
// leaf-to-root, prepending each ancestor's name. The component
|
|
// must be a bare name (no leading '/'); the slash separator is
|
|
// inserted automatically.
|
|
//
|
|
// Capacity contract: the cumulative prepend chain must fit in
|
|
// REL_OFFSET bytes (PATH_MAX); the kernel's own PATH_MAX guarantees
|
|
// this for any path that could be delivered to a FUSE op.
|
|
void prepend(std::string_view component) noexcept
|
|
{
|
|
const std::size_t total = component.size() + 1; // '/' + component
|
|
// Contract guard: the cumulative prepend chain must not underflow
|
|
// _start (a uint16_t). Silent underflow would land somewhere in
|
|
// the upper half of _buf and corrupt unrelated data.
|
|
assert(_start >= total);
|
|
_start -= total;
|
|
_buf[_start] = '/';
|
|
std::memcpy(_buf + _start + 1,component.data(),component.size());
|
|
_size += total;
|
|
// _rel_size unchanged: the prefix area grew; the rel anchor stays.
|
|
}
|
|
|
|
// Move current data to the REL anchor, optionally stripping a
|
|
// single leading '/' (the one libfuse's prepend chain produces).
|
|
// After this call:
|
|
// _start == REL_OFFSET
|
|
// _rel_start == REL_OFFSET
|
|
// _rel_size == _size
|
|
// so set_prefix from a caller works as expected. This is the
|
|
// bridge between the prepend-style backward build (data in the
|
|
// prefix area) and the set_prefix-style forward use (rel anchored
|
|
// at REL_OFFSET).
|
|
//
|
|
// When strip_leading_slash is true and the first byte is '/', it
|
|
// is dropped. The libfuse path-build chain produces this slash
|
|
// via prepend; the canonical mergerfs rel form does not include
|
|
// it. If the first byte is not '/' (e.g. a zero-prepend root
|
|
// case), nothing is stripped.
|
|
void consolidate_to_rel(const bool strip_leading_slash = false) noexcept
|
|
{
|
|
if(strip_leading_slash && _size > 0 && _buf[_start] == '/')
|
|
{
|
|
_start += 1;
|
|
_size -= 1;
|
|
}
|
|
if((_start != REL_OFFSET) && (_size > 0))
|
|
std::memmove(_buf + REL_OFFSET,_buf + _start,_size);
|
|
_start = REL_OFFSET;
|
|
_rel_start = REL_OFFSET;
|
|
_rel_size = _size;
|
|
_buf[REL_OFFSET + _size] = '\0';
|
|
}
|
|
|
|
// View into the prefix area (i.e. [_start, REL_OFFSET)).
|
|
//
|
|
// NOTE: prefix() includes the trailing '/' that set_prefix
|
|
// inserts between prefix and rel. set_prefix("/foo") produces a
|
|
// path whose prefix() is "/foo/" -- the input doesn't have a
|
|
// trailing slash but the view does. This is asymmetric but
|
|
// matches the in-buffer representation and avoids a copy.
|
|
//
|
|
// Lifetime caveat applies: see the LIFETIME block on the
|
|
// structural queries below.
|
|
std::string_view prefix() const noexcept;
|
|
|
|
// View into the rel portion: [REL_OFFSET, REL_OFFSET + _rel_size).
|
|
std::string_view rel() const noexcept;
|
|
|
|
public:
|
|
// Inserts a single '/' between non-empty base and rel (no
|
|
// sanitization -- same contract as set_prefix: base no trailing
|
|
// '/', rel no leading '/'). The result is a relpath, so it can
|
|
// be re-prefixed in place by a downstream set_prefix().
|
|
static relpath concat(std::string_view base, std::string_view rel);
|
|
relpath& assign_concat(std::string_view base, std::string_view rel);
|
|
|
|
relpath operator/(std::string_view rhs) const;
|
|
relpath& operator/=(std::string_view rhs);
|
|
|
|
public:
|
|
// Structural queries -- return string_view into _buf, zero
|
|
// allocation.
|
|
//
|
|
// LIFETIME: all of native/filename/parent_path/extension/stem/
|
|
// prefix/rel return views into this path's internal buffer. ANY
|
|
// non-const operation on *this (set_prefix, clear_prefix, assign,
|
|
// assign_concat, append, resize, remove_filename, clear, swap,
|
|
// operator=, operator/=) may invalidate previously-returned views
|
|
// -- using one after a mutation is undefined behavior. Same rule
|
|
// as std::string -> std::string_view, but easier to hit here
|
|
// because set_prefix is the natural per-iteration mutation in the
|
|
// per-branch loop pattern.
|
|
std::string_view native() const noexcept { return {c_str(),_size}; }
|
|
std::string_view filename() const noexcept;
|
|
std::string_view parent_path() const noexcept;
|
|
std::string_view extension() const noexcept;
|
|
std::string_view stem() const noexcept;
|
|
|
|
// Truncate to the directory form: drops the final component but
|
|
// retains the trailing '/'. For example, "foo/bar" -> "foo/",
|
|
// "foo/" stays "foo/". Use parent_path() if you want the
|
|
// dirname without the trailing slash.
|
|
relpath& remove_filename();
|
|
|
|
// Lexical relativization. Both *this and base must be in the
|
|
// same form (both with or both without leading '/'). Result is a
|
|
// relpath (in canonical form, no leading '/'); on shape mismatch
|
|
// or an unresolvable ".." in base, returns an empty relpath. A
|
|
// successful relativization never produces an empty result (it
|
|
// is "." for equal paths), so callers can use .empty() to detect
|
|
// failure.
|
|
relpath lexically_relative(std::string_view base) const;
|
|
|
|
public:
|
|
const char* c_str() const noexcept { return _buf + _start; }
|
|
const char* data() const noexcept { return _buf + _start; }
|
|
std::size_t size() const noexcept { return _size; }
|
|
// Practical capacity for a freshly-assigned bare-rel path: the
|
|
// rel area is REL_OFFSET-wide and holds the trailing NUL, leaving
|
|
// (BUF_SIZE - REL_OFFSET - 1) bytes (= 4095) for the rel content.
|
|
// A path that has both a prefix and a rel can total up to
|
|
// BUF_SIZE - 1, but assigning more than capacity() to a bare-rel
|
|
// overflows.
|
|
std::size_t capacity() const noexcept { return BUF_SIZE - REL_OFFSET - 1; }
|
|
bool empty() const noexcept { return _size == 0; }
|
|
|
|
void clear() noexcept;
|
|
// Capacity is fixed at construction; reserve() exists for
|
|
// std::string API compatibility only. Anything exceeding
|
|
// capacity() is a programming error.
|
|
void reserve(std::size_t /*n*/) noexcept {}
|
|
void swap(relpath &o) noexcept;
|
|
|
|
// std::string-style mutators. These work from the END of the
|
|
// current data. Growing zero-fills the new bytes to match
|
|
// std::string semantics.
|
|
void resize(std::size_t n);
|
|
void append(const char*,std::size_t);
|
|
void append(std::string_view);
|
|
|
|
std::string string() const { return std::string(c_str(),_size); }
|
|
|
|
public:
|
|
// Implicit conversions are intentional: they let an fs::relpath
|
|
// flow into fs::* syscall wrappers that take const char* /
|
|
// std::string_view without an explicit .c_str() / .native() at
|
|
// every call site. The lifetime caveat from native() applies to
|
|
// both -- the returned pointer / view becomes invalid on any
|
|
// non-const op.
|
|
//
|
|
// FOOTGUN: `std::string s = some_relpath;` silently allocates --
|
|
// it picks `string(const char*)` via the implicit conversion.
|
|
// When you actually want a std::string copy use `.string()`
|
|
// explicitly so the cost is visible at the call site.
|
|
operator const char*() const noexcept { return c_str(); }
|
|
operator std::string_view() const noexcept { return native(); }
|
|
|
|
bool operator==(const relpath &o) const noexcept
|
|
{ return native() == o.native(); }
|
|
bool operator==(std::string_view s) const noexcept
|
|
{ return native() == s; }
|
|
bool operator==(const char *s) const noexcept
|
|
{ return s ? native() == std::string_view(s) : _size == 0; }
|
|
|
|
bool operator!=(const relpath &o) const noexcept { return !(*this == o); }
|
|
bool operator!=(std::string_view s) const noexcept { return !(*this == s); }
|
|
bool operator!=(const char *s) const noexcept { return !(*this == s); }
|
|
|
|
bool operator<(const relpath &o) const noexcept
|
|
{ return native() < o.native(); }
|
|
bool operator<(std::string_view s) const noexcept
|
|
{ return native() < s; }
|
|
|
|
private:
|
|
void _init(std::string_view s);
|
|
bool _aliases(std::string_view s) const noexcept;
|
|
|
|
private:
|
|
// Invariants:
|
|
// _buf[_start + _size] == '\0'
|
|
// data lives at [_start, _start + _size).
|
|
// _start is the offset where the (optionally prefixed) data
|
|
// begins; _start <= _rel_start.
|
|
// The rel portion is at [_rel_start, _rel_start + _rel_size).
|
|
// _size == (_rel_start - _start) + _rel_size.
|
|
// _rel_start >= REL_OFFSET; defaults to REL_OFFSET and only
|
|
// grows when set_prefix needs more prefix room than
|
|
// REL_OFFSET provides (oversized-prefix fallback).
|
|
//
|
|
// _rel_size is cached so set_prefix / clear_prefix / rel() can
|
|
// read it directly instead of recomputing (_start + _size) -
|
|
// _rel_start. It is maintained on every mutation (assign /
|
|
// assign_concat / consolidate_to_rel set it; set_prefix and
|
|
// clear_prefix leave it; resize/append adjust it).
|
|
alignas(8) char _buf[BUF_SIZE];
|
|
std::uint16_t _start;
|
|
std::uint16_t _size;
|
|
std::uint16_t _rel_start;
|
|
std::uint16_t _rel_size;
|
|
};
|
|
|
|
inline
|
|
relpath
|
|
operator/(std::string_view a_,
|
|
std::string_view b_)
|
|
{
|
|
return relpath::concat(a_,b_);
|
|
}
|
|
}
|
|
|
|
namespace std
|
|
{
|
|
template<>
|
|
struct hash<fs::relpath>
|
|
{
|
|
std::size_t
|
|
operator()(const fs::relpath &p_) const noexcept
|
|
{
|
|
return std::hash<std::string_view>{}(p_.native());
|
|
}
|
|
};
|
|
}
|
|
|
|
#include <ostream>
|
|
namespace fs
|
|
{
|
|
inline
|
|
std::ostream&
|
|
operator<<(std::ostream &os_,
|
|
const relpath &p_)
|
|
{
|
|
return os_ << p_.native();
|
|
}
|
|
}
|
|
|
|
// Note: there is no fmt::formatter specialization here. The previous
|
|
// fs::path version included one because it was used directly in
|
|
// SysLog::warning("... {}", path) calls. After the path/string split,
|
|
// every formatted "path" is std::string (branches, mountpoint, etc.)
|
|
// or a structural-query string_view -- both fmt formats natively.
|
|
// Keeping fmt out of this header avoids pulling fmt/format.h into
|
|
// every translation unit that includes it.
|