All of lore.kernel.org
 help / color / mirror / Atom feed
From: Lauri Gates <lauri.gates@gmail.com>
To: linux-xfs@vger.kernel.org
Cc: Carlos Maiolino <cem@kernel.org>
Subject: Concurrent FIEMAP on reflinked files serialises on the AGF buffer lock
Date: Mon,  2 Nov 2026 21:32:45 +0200	[thread overview]
Message-ID: <20261102193533.93690-1-lauri.gates@gmail.com> (raw)

Hi,

FIEMAP on files with XFS_DIFLAG2_REFLINK set does not scale across
threads. For every written extent, xfs_read_iomap_begin() calls
xfs_reflink_trim_around_shared() (IOMAP_REPORT), which goes to
xfs_reflink_find_shared() and reads the AGF of the extent's AG with
xfs_alloc_read_agf(pag, NULL, 0, &agbp). Concurrent callers queue on
that buffer's b_sema. A parallel du-style scan of a reflinked tree
stops scaling at roughly the AG count, and threads working in the same
AG run slower than one thread alone.

My question, in more detail at the end: does reporting the shared flag
for FIEMAP need the AGF buffer locked?

I ran into this adding a shared-block mode to dust, a du clone, which
FIEMAPs every file and counts FIEMAP_EXTENT_SHARED extents once.
Inodes without the reflink flag return early and are unaffected.

Wait stack, kernel 7.0.0-1014-gcp: bpftrace counted the scanner's
sched:sched_switch events into TASK_UNINTERRUPTIBLE, keyed by kernel
stack, for about 6 seconds of repeated scans. 730,556 switches had this
stack; the next most common had 47:

  down+88
  xfs_buf_lock+50
  xfs_buf_find_lock+106
  xfs_buf_lookup.constprop.0+318
  xfs_buf_get_map+249
  xfs_buf_read_map+83
  xfs_trans_read_buf_map+313
  xfs_read_agf+183
  xfs_alloc_read_agf+91
  xfs_reflink_find_shared+154
  xfs_reflink_trim_around_shared+235
  xfs_read_iomap_begin+356
  iomap_iter+304
  iomap_fiemap+409
  xfs_vn_fiemap+92
  do_vfs_ioctl+758
  __x64_sys_ioctl+131

7.2 on arm64 shows the same chain. The functions involved are
unchanged in v7.4-rc1 and xfs for-next; since v7.3,
xfs_buf_read_map() calls xfs_buf_find_lock() directly, which takes the
same lock.

Reproducer
----------

fiemap-mt.c and run.sh are below. For each AG count, run.sh makes an
8 GiB loop image (mkfs.xfs -d agcount=N -m reflink=1) and writes
100,000 files of 4-32 KiB into each of plain/ and src/. It then makes
a reflink copy of src/ with cp -a --reflink=always. fiemap-mt lists
the files first, then times open + FS_IOC_FIEMAP + close of each one
from N threads; the figures are medians of 5 warm-cache runs. Order
"seq" hands concurrent threads neighbouring files in one directory, so
they share an AG; "shuffle" uses a fixed-seed random order, so they
mostly hit different AGs.

Wall time, with voluntary context switches in brackets, reflinked src/:

GCP n2-standard-8 (8 vCPUs, x86_64), Ubuntu 24.04, 7.0.0-1014-gcp:

  order    AGs  1 thread        4 threads           8 threads
  shuffle    4  313.6 ms (1)    153.6 ms (19,859)   209.5 ms (38,629)
  shuffle   32  308.7 ms (1)     96.8 ms (1,670)     78.7 ms (3,751)
  seq        4  272.7 ms (1)    587.5 ms (98,954)   922.1 ms (98,868)
  seq       32  268.8 ms (1)    621.4 ms (99,220)   892.3 ms (99,022)

On this machine, with threads spread over AGs, 8 threads take 2.7x as
long on 4 AGs as on 32. With threads in one AG, 8 threads take 3.3x
to 3.4x as long as one thread, whatever the AG count. bpftrace during
these runs shows the same frames and offsets as above, from down+88 to
xfs_read_iomap_begin+356, where its 20-frame capture ends.

Same run on an arm64 VM (7.2.12, 13 vCPUs):

  order    AGs  1 thread        4 threads           13 threads
  shuffle    4  190.8 ms (1)    184.6 ms (15,761)   237.1 ms (28,167)
  shuffle   32  194.7 ms (1)     69.7 ms (1,389)    120.9 ms (6,291)
  seq        4   95.0 ms (1)    745.0 ms (74,991)   144.4 ms (32,586)
  seq       32  100.9 ms (1)    859.2 ms (80,052)   160.7 ms (33,328)

Here seq is worst at 4 threads (7.8x one thread) and recovers somewhat
at 13. Times are not monotonic in thread count on either machine; I
have not looked into why.

On plain/ (never reflinked) no configuration on either machine
exceeds 10 voluntary context switches.

The original workload, for scale: dust scanning the Linux 6.12 source
tree (86,618 files) plus a reflink copy of it, on the same GCP machine
type, at dust's default thread count, mean of 10 runs:

  kernel  4 AGs     32 AGs    dust's normal mode, no FIEMAP (either)
  7.0     506.3 ms  274.8 ms  105-106 ms
  5.15    476.6 ms  277.7 ms  104-106 ms

On 7.0, voluntary context switches fell from 67,089 with 4 AGs to
4,230 with 32. With 4 AGs, dust -T 8 ran slower than -T 4 (513 vs
419 ms). On ext4 the FIEMAP pass added nothing measurable (59.5 vs
60.6 ms, tree without a copy); on btrfs it cost 3.3x but with 131-300
voluntary context switches, so no lock queueing.

Question
--------

Does reporting the shared flag for FIEMAP need the AGF buffer locked?
On this path the refcount btree lookup runs with tp == NULL, under the
inode lock from xfs_ilock_data_map_shared(), and the FIEMAP result is
a snapshot that can be stale by the time userspace reads it.

I am not suggesting dropping the flag. Commit 5f9268ca53ac ("xfs:
don't bother looking at the refcount tree for reads") kept
IOMAP_REPORT on the refcount lookup deliberately, and Dave described
the shared flag as a field diagnostic for reflinked files:
https://lore.kernel.org/all/20210124214346.GC4626@dread.disaster.area/

What I would like to know is whether an unlocked or cached lookup is
acceptable here, for example building on 2aedf844be1d ("xfs: move
buffer locking out of xfs_find_get_buf", in v7.3), which prepares
lookups that do not lock the buffer. If it is, I can test patches,
or try writing one with guidance.

Thanks,
Lauri Gates

---- fiemap-mt.c ----
// fiemap-mt: dust-free reproducer for XFS FIEMAP contention on reflinked files.
//
//   fiemap-mt populate DIR NFILES      create NFILES small files (4-32 KiB, deterministic
//                                      content) in subdirectories of 1000
//   fiemap-mt run DIR THREADS [ORDER]  list regular files under DIR, then open + FIEMAP
//                                      each one from THREADS threads; print wall time,
//                                      voluntary/involuntary context switches, and
//                                      extent counts
//
// ORDER is `seq` (default: directory order, so concurrent threads work on
// neighbouring files, which XFS keeps in the same AG) or `shuffle` (a fixed-seed
// random order, so concurrent threads usually hit different AGs, closer to a
// parallel tree walker like dust).
//
// The file list is built before timing starts, so the timed section is only
// open/FS_IOC_FIEMAP/close, the calls dust --shared-blocks makes per file.
#define _GNU_SOURCE
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <linux/fiemap.h>
#include <linux/fs.h>
#include <pthread.h>
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/resource.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>

#define NEXT 64

static char **paths;
static size_t npaths, cap;
static atomic_size_t next_idx;
static atomic_long n_ext, n_shared_ext, n_shared_files, n_err;

static void die(const char *what) { perror(what); exit(1); }

static void add_path(const char *p) {
  if (npaths == cap) {
    cap = cap ? cap * 2 : 4096;
    paths = realloc(paths, cap * sizeof(*paths));
    if (!paths) die("realloc");
  }
  paths[npaths++] = strdup(p);
}

static void list_files(const char *dir) {
  DIR *d = opendir(dir);
  if (!d) die(dir);
  struct dirent *de;
  char p[4096];
  while ((de = readdir(d))) {
    if (!strcmp(de->d_name, ".") || !strcmp(de->d_name, "..")) continue;
    snprintf(p, sizeof(p), "%s/%s", dir, de->d_name);
    struct stat st;
    if (lstat(p, &st)) continue;
    if (S_ISDIR(st.st_mode)) list_files(p);
    else if (S_ISREG(st.st_mode) && st.st_blocks > 0) add_path(p);
  }
  closedir(d);
}

static void fiemap_file(const char *p) {
  int fd = open(p, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NOATIME);
  if (fd < 0) fd = open(p, O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
  if (fd < 0) { atomic_fetch_add(&n_err, 1); return; }
  char buf[sizeof(struct fiemap) + NEXT * sizeof(struct fiemap_extent)];
  struct fiemap *fm = (struct fiemap *)buf;
  uint64_t start = 0;
  long ext = 0, shared = 0;
  for (;;) {
    memset(fm, 0, sizeof(*fm));
    fm->fm_start = start;
    fm->fm_length = FIEMAP_MAX_OFFSET - start;
    fm->fm_extent_count = NEXT;
    if (ioctl(fd, FS_IOC_FIEMAP, fm) < 0) { atomic_fetch_add(&n_err, 1); break; }
    if (fm->fm_mapped_extents == 0) break;
    int last = 0;
    for (unsigned i = 0; i < fm->fm_mapped_extents; i++) {
      struct fiemap_extent *e = &fm->fm_extents[i];
      ext++;
      if (e->fe_flags & FIEMAP_EXTENT_SHARED) shared++;
      if (e->fe_flags & FIEMAP_EXTENT_LAST) last = 1;
      start = e->fe_logical + e->fe_length;
    }
    if (last || fm->fm_mapped_extents < NEXT) break;
  }
  close(fd);
  atomic_fetch_add(&n_ext, ext);
  atomic_fetch_add(&n_shared_ext, shared);
  if (shared) atomic_fetch_add(&n_shared_files, 1);
}

static void *worker(void *arg) {
  (void)arg;
  for (;;) {
    size_t i = atomic_fetch_add(&next_idx, 1);
    if (i >= npaths) return NULL;
    fiemap_file(paths[i]);
  }
}

static double now_ms(void) {
  struct timespec t;
  clock_gettime(CLOCK_MONOTONIC, &t);
  return t.tv_sec * 1e3 + t.tv_nsec / 1e6;
}

static void shuffle_paths(void) {
  uint64_t x = 0x2545F4914F6CDD1Dull;
  for (size_t i = npaths; i > 1; i--) {
    x ^= x << 13; x ^= x >> 7; x ^= x << 17;
    size_t j = x % i;
    char *t = paths[i - 1]; paths[i - 1] = paths[j]; paths[j] = t;
  }
}

static int cmd_run(const char *dir, int threads, const char *order) {
  list_files(dir);
  if (!strcmp(order, "shuffle")) shuffle_paths();
  else if (strcmp(order, "seq")) { fprintf(stderr, "ORDER must be seq or shuffle\n"); return 2; }
  pthread_t *tids = calloc(threads, sizeof(*tids));
  struct rusage r0, r1;
  getrusage(RUSAGE_SELF, &r0);
  double t0 = now_ms();
  for (int i = 0; i < threads; i++)
    if (pthread_create(&tids[i], NULL, worker, NULL)) die("pthread_create");
  for (int i = 0; i < threads; i++) pthread_join(tids[i], NULL);
  double t1 = now_ms();
  getrusage(RUSAGE_SELF, &r1);
  double sys = (r1.ru_stime.tv_sec - r0.ru_stime.tv_sec) * 1e3 +
               (r1.ru_stime.tv_usec - r0.ru_stime.tv_usec) / 1e3;
  printf("order=%s threads=%d files=%zu extents=%ld shared_ext=%ld shared_files=%ld errors=%ld "
         "wall_ms=%.1f sys_ms=%.1f vcsw=%ld ivcsw=%ld\n",
         order, threads, npaths, atomic_load(&n_ext), atomic_load(&n_shared_ext),
         atomic_load(&n_shared_files), atomic_load(&n_err), t1 - t0, sys,
         r1.ru_nvcsw - r0.ru_nvcsw, r1.ru_nivcsw - r0.ru_nivcsw);
  return atomic_load(&n_err) ? 1 : 0;
}

static int cmd_populate(const char *dir, long n) {
  static unsigned char buf[32768];
  uint64_t x = 0x9E3779B97F4A7C15ull;
  char p[4096];
  for (long i = 0; i < n; i++) {
    if (i % 1000 == 0) {
      snprintf(p, sizeof(p), "%s/d%04ld", dir, i / 1000);
      if (mkdir(p, 0755) && errno != EEXIST) die(p);
    }
    x ^= x << 13; x ^= x >> 7; x ^= x << 17;
    size_t len = 4096 * (1 + x % 8);  // 4-32 KiB
    for (size_t j = 0; j < len; j += 8) {
      x ^= x << 13; x ^= x >> 7; x ^= x << 17;
      memcpy(buf + j, &x, 8);
    }
    snprintf(p, sizeof(p), "%s/d%04ld/f%07ld", dir, i / 1000, i);
    int fd = open(p, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
    if (fd < 0) die(p);
    if (write(fd, buf, len) != (ssize_t)len) die("write");
    close(fd);
  }
  return 0;
}

int main(int argc, char **argv) {
  if (argc == 4 && !strcmp(argv[1], "populate")) return cmd_populate(argv[2], atol(argv[3]));
  if ((argc == 4 || argc == 5) && !strcmp(argv[1], "run"))
    return cmd_run(argv[2], atoi(argv[3]), argc == 5 ? argv[4] : "seq");
  fprintf(stderr, "usage: %s populate DIR NFILES | run DIR THREADS [seq|shuffle]\n", argv[0]);
  return 2;
}

---- run.sh ----
#!/usr/bin/env bash
# Dust-free reproducer for XFS FIEMAP contention on reflinked files.
# Run as root on any Linux host with xfsprogs, gcc and loop-device support
# (OrbStack machine, GCP VM, bare metal). Builds an XFS image per AG count,
# fills it with a never-reflinked tree and a tree that has a reflink copy, and
# times multithreaded open+FIEMAP over each.
#
#   sudo repro/run.sh                      # defaults below
#   sudo NFILES=50000 RUNS=3 AGCOUNTS="4 32" THREADS="1 4 8" ORDERS=shuffle repro/run.sh
set -euo pipefail

NFILES=${NFILES:-100000}
RUNS=${RUNS:-5}
AGCOUNTS=${AGCOUNTS:-"4 32"}
THREADS=${THREADS:-$(printf '%s\n' 1 4 8 "$(nproc)" | sort -nu | tr '\n' ' ')}
ORDERS=${ORDERS:-"seq shuffle"}
WORK=${WORK:-/var/tmp/xfs-fiemap-repro}
HERE=$(cd "$(dirname "$0")" && pwd)

[ "$(id -u)" = 0 ] || { echo "run as root" >&2; exit 1; }
mkdir -p "$WORK/mnt"
gcc -O2 -pthread -o "$WORK/fiemap-mt" "$HERE/fiemap-mt.c"
BIN=$WORK/fiemap-mt

cleanup() { mountpoint -q "$WORK/mnt" && umount "$WORK/mnt"; rm -f "$WORK/xfs.img"; }
trap cleanup EXIT

echo "kernel=$(uname -r) arch=$(uname -m) nproc=$(nproc) $(mkfs.xfs -V) nfiles=$NFILES runs=$RUNS"

# Median of RUNS runs by wall_ms; prints the whole median line.
median_run() {
  local dir=$1 t=$2 order=$3
  for _ in $(seq "$RUNS"); do "$BIN" run "$dir" "$t" "$order"; done |
    awk '{for (i = 1; i <= NF; i++) if ($i ~ /^wall_ms=/) {split($i, a, "="); print a[2], $0}}' |
    sort -n | awk -v m=$(( (RUNS + 1) / 2 )) 'NR == m {$1 = ""; sub(/^ /, ""); print}'
}

for ag in $AGCOUNTS; do
  cleanup
  truncate -s 8G "$WORK/xfs.img"
  mkfs.xfs -q -f -d agcount="$ag" -m reflink=1 "$WORK/xfs.img"
  mount -o loop "$WORK/xfs.img" "$WORK/mnt"
  echo "=== $(xfs_info "$WORK/mnt" | grep -o 'agcount=[0-9]*'), $(xfs_info "$WORK/mnt" | grep -o 'reflink=[0-9]')"
  M=$WORK/mnt
  mkdir "$M/plain" "$M/src"
  "$BIN" populate "$M/plain" "$NFILES"
  "$BIN" populate "$M/src" "$NFILES"
  sync
  cp -a --reflink=always "$M/src" "$M/copy"
  sync
  for tree in plain src; do
    "$BIN" run "$M/$tree" "$(nproc)" >/dev/null   # warm the inode, extent and buffer caches
    for order in $ORDERS; do
      for t in $THREADS; do
        echo "ag=$ag tree=$tree $(median_run "$M/$tree" "$t" "$order")"
      done
    done
  done
done

             reply	other threads:[~2026-11-02 19:36 UTC|newest]

Thread overview: 11+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-11-02 19:32 Lauri Gates [this message]
2026-11-03  5:25 ` Concurrent FIEMAP on reflinked files serialises on the AGF buffer lock Christoph Hellwig
2026-11-04 12:41   ` Lauri Gates
2026-11-04 21:11     ` Dave Chinner
2026-11-06 15:21     ` Christoph Hellwig
2026-11-04 21:04   ` Dave Chinner
2026-11-06 12:23     ` Lauri Gates
2026-11-06 15:20       ` Christoph Hellwig
2026-11-06 20:40         ` Lauri Gates
2026-11-12 20:05           ` Christoph Hellwig
2026-11-13  3:37           ` Eric Sandeen

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20261102193533.93690-1-lauri.gates@gmail.com \
    --to=lauri.gates@gmail.com \
    --cc=cem@kernel.org \
    --cc=linux-xfs@vger.kernel.org \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.