[PATCH 0/11] vfs: inode cache scalability improvements

Dave Chinner
2023-12-06T06:07:15Z

We all know that the global inode_hash_lock and the per-fs global
sb->s_inode_list_lock locks are contention points in filesystem workloads
that stream inodes through memory, so it's about time we addressed these
limitations.

The first part of the patchset address the sb->s_inode_list_lock.
This was done a long time ago by Waiman Long by converting the
global linked list to a per-cpu linked list - those infrastructure
patches are pretty much unchanged from when Waiman first wrote them,
and as such the still carry the RVB that Jan Kara gave for them. I
have no idea if the problem that Waiman was trying to solve still
exists, but that's largely irrelevant because there are other
problems that I can easily reproduce.

That is, once at ~16 threads trying to instantiate or tear down
inodes at the same time in a filesystem, the sb->s_inode_list_lock
becomes a single point of contention. Adding an inode to the inode
cache requires adding it to the sb->s_inodes list, and removing an
inode from the cache requires removing it from the sb->s_inodes
list. That's two exclusive lock operations per inode we cycle
through the inode cache.

This creates a hard limit on the number of inodes we can cycle
through memory in a single filesystem. It tops out at around
600-700,000 inodes per second on XFS, and at that point we see
catastrophic cacheline contention breakdown and nothing goes any
faster. We can easily burn hundreds of CPUs on the sb->s_inodes list
operations, yet we still can only get 600-700k inodes/s through the
cache.

Converting the sb->s_inodes list to a dlist gets rid of this single
contention point and makes the sb->s_inodes list operations
disappear from the profiles completely. Prior to this change, at 32
threads XFS could pull 12.8 million inodes into cache in ~20s
(that's ~600k inodes/s - sound familiar?). With this change, those
12.8 million inodes are pulled into cache in ~10s. That's double the
rate at which XFS can pull inodes into memory from the
filesystem....

I'm talking about XFS here, because none of the other filesystem
actually stress the sb->s_inode_list_lock at all. They all hit
catastrophic cacheline contention on the inode_hash_lock long before
they get anywhere near the sb->s_inodes limits. For ext4 and
bcachefs, the inode_hash_lock becomes a limiting factor at 8
threads. btrfs hits internal namespace tree contention limits at 2
threads, so it's not even stressing the inode_hash_lock unless
highly threaded workloads are manually sharded across subvolumes.

So to bring the rest of the filesystems up, we need to fix the
inode_hash_lock contention problems. This patchset replaces the
global inode_hash_lock with the same lock-per-chain implementation
that the dentry cache uses. i.e. hash-bl lists. This is more complex
than the dentry cache implementation, however, because we nest spin
locks inside the inode_hash_lock. This conversion means we nest spin
locks inside bit spin locks in the inode cache.

Whilst this doesn't sound particularly problematic, the issue arises
on CONFIG_PREEMPT_RT kernels, where spinlocks are converted to
sleeping locks. We can't place sleeping locks inside spinning bit
locks, and that's exactly what happens if we use hash-bl lists in
the inode cache and then turn on CONFIG_PREEMPT_RT.

The other downside to converting to hash-bl is that we lose lockdep
coverage of the inode hash table - lockdep does not track bit locks
at all.

Both of these issues can be solved the same way: whenever either of
these two config options are turned on, we change the hash-bl
implementation from using a bit spin lock on th elowest bit of the
chain head pointer to using as dedicated spinlock per chain. This
trades off performance and memory footprint for configurations where
correctness is more important than performance, but allows optimal
implementations of hash-bl lists when performance is the primary
concern.

In making this conversion, we make all hash-bl implementations safe
for PREEMPT_RT usage and gain lockdep coverage of all hash-bl lists.
It also pointed out that several hash-bl list users did not actually
initialise the hash list heads correctly - they elided the
initialisation and only got away with it because they allocated
zeroed memory and the hash list head would still work from empty.
This needed fixing for lockdep....

The result of this conversion is that inode cache lookup heavy
workloads such as filesystem traversals and inode creation/removal
no longer contend on the inode_hash_lock to stream inodes through
the inode cache. This results in big performance improvements at
thread counts of >= 8.

I've run this through fstests with lockdep enabled on ext4 and XFS
without discovering any issues except for dm-snapshot needing
lockdep nesting annotations for list-bl locks. I've run a bunch of
"will-it-scale" like tests across XFS, ext4, bcachefs and btrfs, and
the raw table results for 6.7-rc4 are below.

The tests runs a fixed number of files per thread, so as the thread
count increases we should see runtimes stay constant if scalability
is perfect. I'm not caring about operation rates, I'm not caring
about which filesystems are faster, all I'm looking at is whether
the scalability of individual filesytsems improves with the changes.

base: vanilla 6.7-rc4 kernel
scale: 6.7-rc4 plus this patchset

Filesystem Files Threads Create Walk chmod Unlink
base scale base scale base scale base scale
xfs 400000 1 11.217 10.477 11.621 11.570 14.980 14.797 18.314 18.248
xfs 800000 2 12.313 11.470 11.673 11.158 15.271 14.782 19.413 18.533
xfs 1600000 4 14.130 13.246 9.665 9.444 14.794 13.710 19.582 17.015
xfs 3200000 8 16.654 16.108 10.622 9.275 15.854 14.575 20.679 19.237
xfs 6400000 16 17.587 18.151 12.148 9.508 16.655 17.691 26.044 21.865
xfs 12800000 32 20.833 21.491 20.518 10.308 23.614 19.334 42.572 27.404

All of the operations that require directory traversal show
significant improvements at 16 or more threads on XFS. This is
entirely from the sb->s_inodes modifications.

Filesystem Files Threads Create Walk chmod Unlink
base scale base scale base scale base scale
ext4 400000 1 9.344 9.394 7.691 7.847 9.188 9.212 11.340 12.517
ext4 800000 2 10.445 10.375 7.923 7.358 10.158 10.114 14.366 14.093
ext4 1600000 4 11.008 10.948 8.152 7.530 11.140 11.150 18.093 17.153
ext4 3200000 8 23.348 12.134 13.090 7.871 15.437 12.824 30.806 31.968
ext4 6400000 16 17.343 29.112 24.602 9.540 31.034 22.057 60.538 57.636
ext4 12800000 32 40.125 44.638 49.536 19.314 63.183 38.905 138.688 138.632

Walk on ext4 shows major improvements at 8 threads and above, as
does the recursive chmod. This largely comes from the inode hash
lock removal, but the full scalability improvements are not realised
until the sb->s_inodes changes are added as well.

Note that unlink doesn't scale or improve because the mkfs.ext4
binary in debian unstable does not support the orphan file option
and so it is completely bottlenecked on orphan list scalability
issues.

Filesystem Files Threads Create Walk chmod Unlink
base scale base scale base scale base scale
bcachefs 400000 1 16.999 17.193 6.546 6.355 13.973 13.024 28.890 19.014
bcachefs 800000 2 20.133 19.597 8.003 7.276 22.042 20.070 28.959 29.141
bcachefs 1600000 4 22.836 23.764 9.097 8.506 58.827 56.108 38.955 37.435
bcachefs 3200000 8 27.932 27.545 11.752 10.015 192.802 185.834 64.402 77.188
bcachefs 6400000 16 32.389 32.021 24.614 13.989 409.873 408.098 243.757 249.220
bcachefs 12800000 32 39.809 40.221 49.179 25.982 879.721 821.497 501.357 485.781

bcachefs walk shows major improvements at 16 threads and above, but
chmod and unlink are drowned by internal contention problems.

Filesystem Files Threads Create Walk chmod Unlink
base scale base scale base scale base scale
btrfs 400000 1 10.307 10.228 12.597 12.104 14.744 14.030 24.171 24.273
btrfs 800000 2 15.956 14.018 19.693 17.180 24.859 20.872 59.338 48.725
btrfs 1600000 4 22.441 20.951 32.855 29.013 37.975 33.575 140.601 125.305
btrfs 3200000 8 34.157 32.693 55.066 56.726 66.676 64.661 343.379 325.816
btrfs 6400000 16 60.847 59.123 90.097 89.340 116.994 114.280 683.244 681.953
btrfs 12800000 32 122.525 118.510 118.036 125.761 206.122 212.102 1612.940 1629.689

There's little point in doing scalability testing on plain btrfs -
it is entirely bottlenecked on internal algorithms long before
anything in the VFS becomes a scalability limitation.

Filesystem Files Threads Create Walk chmod Unlink
base scale base scale base scale base scale
btrfs-svol 400000 1 10.417 9.830 12.011 12.154 14.894 14.913 24.157 23.447
btrfs-svol 800000 2 12.079 11.681 12.596 12.208 16.535 15.310 28.031 26.412
btrfs-svol 1600000 4 15.219 15.074 12.711 10.735 18.079 16.948 34.330 31.949
btrfs-svol 3200000 8 23.140 21.307 14.706 10.934 22.580 21.907 53.183 52.129
btrfs-svol 6400000 16 40.657 40.226 26.062 11.471 34.058 33.333 101.133 99.504
btrfs-svol 12800000 32 81.516 79.412 50.320 12.406 65.691 58.229 193.847 200.050

Once btrfs is running with a sharded namespace (i.e. subvol per
thread) we results very similar in nature to bcachefs - walk
improves dramatically at high thread counts, but nothing else
changes as all the scalability limitations are internal to the
filesystem.

I have tested to 64 threads, but there's not a lot extra to add. The
XFs walk was done in 14.1s, so scalability is falling off but I
haven't spent any time looking at it in detail because there's just
so much other internal stuff to fix up before the rest of this
benchmark scales to 64 threads on XFS....

Git tree containing this series can be pulled from:

https://git.kernel.org/pub/scm/linux/kernel/git/dgc/linux-xfs.git vfs-scale

-Dave.