Skip to content

mds: fetch only the wanted dentry when the cache has no room for the dirfrag - #73744

Open
xiubli wants to merge 2 commits into
ceph:mainfrom
xiubli:wip-mds-dir-prefetch-cache-full
Open

xiubli wants to merge 2 commits into
ceph:mainfrom
xiubli:wip-mds-dir-prefetch-cache-full

Conversation

@xiubli

@xiubli xiubli commented Nov 10, 2026

Copy link
Copy Markdown
Member

With mds_dir_prefetch (the default), a lookup that misses a dentry makes CDir::fetch() load the whole dirfrag, not only the wanted dentry. That pays off while the cache can keep the dirfrag until its other entries are looked up.

It stops paying off once the tree being walked is larger than the cache:

  • The cache sits at its trim target, so each dirfrag loaded for one dentry pushes other entries out.
  • Its own entries are trimmed again before most of them are used.
  • Every lookup that misses then reads a whole dirfrag from the metadata pool and puts thousands of inodes through the cache.

In mdtest stat phases over 12M files, with 2 active MDS and a 16 GiB cache, each rank held about 5.3M inodes. Per rank, in 10 s:

┌─────────────────────────────────────────────────┬─────────┐
│                                                 │         │
├─────────────────────────────────────────────────┼─────────┤
│ lookups replied (mds.reply)                     │ 3,071   │
├─────────────────────────────────────────────────┼─────────┤
│ whole dirfrags fetched (mds.dir_fetch_complete) │ 275     │
├─────────────────────────────────────────────────┼─────────┤
│ inodes trimmed (mds.inodes_expired)             │ 626,322 │
└─────────────────────────────────────────────────┴─────────┘

That is about 300 lookups/s per rank, with the metadata pool read at about 34 MiB/s. The same test with 4.8M files, which fit in the cache, runs at 50–66k stat/s. Two heads of #72265 that are a week apart behave the same, and the code that does it is in main.

Fix (mds: fetch only the wanted dentry when the cache has no room for the dirfrag)

MDCache::cache_has_room_for(n) tells whether n more entries fit. It says no when:

  • the cache is over its trim target, or
  • trimming has lately dropped at least n entries (the decaying trim_counter). Trimming then keeps the cache just below its target, and a loaded dirfrag only pushes other entries out again. This is what happens in mdtest delete phases, where unlinks keep making a little room, or
  • the dirfrag would take the cache over its target. An entry is taken to be as large as a cached entry on average. While the cache holds fewer entries than the dirfrag, that average says little, and the whole dirfrag is loaded only while the cache is less than half full.

On a lookup miss, CDir::fetch() loads only the wanted dentry (fetch_keys()) when the dirfrag does not fit. The number of entries comes from the dirfrag's fragstat, or from the directory's dirstat when a dirfrag that trimming closed comes back without its fnode.

Nothing changes while the cache has room. Readdir, and lookups with mds_dir_prefetch off, are not affected.

Test (qa/cephfs: test lookups that miss when the MDS cache is full)

TestDirPrefetchCacheFull in test_misc.py creates 1000 files in dir and drops them from the MDS cache:

  • test_lookup_prefetches_dirfrag: with room in the cache, a lookup that misses loads the whole dirfrag.
  • test_lookup_fetches_key_when_cache_full: with mds_cache_memory_limit at 8 MiB, filled by looking up 5000 files of another directory, lookups in dir load only their dentry (no complete fetch, one keyed fetch per lookup). Readdir still lists every file, and every file can be looked up.

Testing

qa on vstart:

┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────┬─────────────────────────┐
│                                                                                                                                      │                     base                      │         this PR         │
├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┼─────────────────────────┤
│ TestDirPrefetchCacheFull                                                                                                             │ test_lookup_fetches_key_when_cache_full fails │ passes, 3 runs out of 3 │
├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────┼─────────────────────────┤
│ TestCacheDrop, TestStrays (test_stale_snap_remote_dentry_purging_stray, test_dirfrag_limit, test_snapshot_remove, test_dir_deletion) │                                               │ pass                    │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────┴─────────────────────────┘

IO500 with a fixed n and no stonewall, 2 active MDS, 48 tasks over 24 kernel mounts, one run per column, in kIOPS. The builds were main with #72265 and #72900, since the setup uses ceph.dir.pin.distributed.tree on the mdtest directories.

1 GiB cache, 1.34M files (about twice what the cache holds):

┌────────────────────┬─────────────────┬────────────────────────┬─────────┐
│                    │     default     │ mds_dir_prefetch false │ this PR │
├────────────────────┼─────────────────┼────────────────────────┼─────────┤
│ mdtest-easy-stat   │ ~0.07 (stopped) │ 16.5                   │ 15.6    │
├────────────────────┼─────────────────┼────────────────────────┼─────────┤
│ mdtest-hard-stat   │                 │ 30.2                   │ 64.9    │
├────────────────────┼─────────────────┼────────────────────────┼─────────┤
│ mdtest-easy-delete │                 │ 6.58                   │ 6.69    │
├────────────────────┼─────────────────┼────────────────────────┼─────────┤
│ mdtest-hard-read   │                 │ 32.8                   │ 28.5    │
├────────────────────┼─────────────────┼────────────────────────┼─────────┤
│ mdtest-hard-delete │                 │ 9.74                   │ 9.70    │
└────────────────────┴─────────────────┴────────────────────────┴─────────┘

With the default, each rank fetched about 23 whole dirfrags and trimmed about 47k inodes per second during easy-stat, replying to about 35 lookups per second. With this PR, no whole dirfrag was fetched during the stat phases. hard-stat varied between 30 and 66 from run to run with both settings.

16 GiB cache:

┌────────────────────────────────────────────────────────────┬─────────────────────────────────┬────────────────────┐
│                                                            │             default             │      this PR       │
├────────────────────────────────────────────────────────────┼─────────────────────────────────┼────────────────────┤
│ 12M files (above the cache): mdtest-easy-stat              │ ~0.3–0.66 (stopped after hours) │ 24.1               │
├────────────────────────────────────────────────────────────┼─────────────────────────────────┼────────────────────┤
│ 12M files: mdtest-hard-stat                                │                                 │ 17.1               │
├────────────────────────────────────────────────────────────┼─────────────────────────────────┼────────────────────┤
│ 12M files: mdtest-easy-delete / hard-read / hard-delete    │                                 │ 5.84 / 20.5 / 7.08 │
├────────────────────────────────────────────────────────────┼─────────────────────────────────┼────────────────────┤
│ 4.8M files (below the cache): mdtest-easy-stat / hard-stat │ 66.1 / 62.3                     │ 66.5 / 63.0        │
├────────────────────────────────────────────────────────────┼─────────────────────────────────┼────────────────────┤
│ 4.8M files: mdtest-easy-delete / hard-read / hard-delete   │ 7.11 / 23.4 / 8.31              │ 7.20 / 25.1 / 8.55 │
└────────────────────────────────────────────────────────────┴─────────────────────────────────┴────────────────────┘

Above the cache, stat lookups that miss are now bound by keyed omap reads, about 3.9k to 12k per second. Below the cache nothing changes.

The 4.8M-file runs of this PR used a revision without the trim_counter check, which cannot take effect while nothing is trimmed. All 16 GiB runs, and the default column of the 1 GiB run, were measured on a #72265 revision that predates its fix for fragments handed back when they empty. That fix matters only in delete phases.

Fixes: https://tracker.ceph.com/issues/82988

Contribution Guidelines

  • To sign and title your commits, please refer to Submitting Patches to Ceph.

  • If you are submitting a fix for a stable branch (e.g. "quincy"), please refer to Submitting Patches to Ceph - Backports for the proper workflow.

  • When filling out the below checklist, you may click boxes directly in the GitHub web UI. When entering or editing the entire PR message in the GitHub web UI editor, you may also select a checklist item by adding an x between the brackets: [x]. Spaces and capitalization matter when checking off items this way.

Checklist

  • Tracker (select at least one)
    • References tracker ticket
    • Very recent bug; references commit where it was introduced
    • New feature (ticket optional)
    • Doc update (no ticket needed)
    • Code cleanup (no ticket needed)
  • Component impact
    • Affects Dashboard, opened tracker ticket
    • Affects Orchestrator, opened tracker ticket
    • No impact that needs to be tracked
  • Documentation (select at least one)
    • Updates relevant documentation
    • No doc update is appropriate
  • Tests (select at least one)
Show available Jenkins commands

You must only issue one Jenkins command per-comment. Jenkins does not understand
comments with more than one command.

…dirfrag

With mds_dir_prefetch, the default, a lookup that misses a dentry loads
the whole dirfrag.  That pays off when the cache can keep the dirfrag
until its other entries are looked up.

Once a directory tree is larger than the cache, it does not.  The
cache sits at its trim target, so each loaded dirfrag pushes other
entries out, and its own entries are trimmed again before most of them
are used.  Every lookup that misses then reads a whole dirfrag from the
metadata pool and puts thousands of inodes through the cache.

In mdtest stat phases over 12M files with 2 active MDS and a 16 GiB
cache, a rank fetched 275 whole dirfrags and trimmed 626k inodes per
10 s while replying to only 3k lookups.

Load only the wanted dentry when the dirfrag would not fit:

- the cache is over its trim target, or
- it has lately trimmed at least as many entries as the dirfrag holds.
  Trimming then keeps the cache just below its target, and a loaded
  dirfrag only pushes other entries out again, as in mdtest delete
  phases, or
- the dirfrag would take the cache over its target.  An entry is taken
  to be as large as a cached entry on average.  While the cache holds
  fewer entries than the dirfrag, that average says little, and the
  whole dirfrag is loaded only while the cache is less than half full.

The number of entries comes from the dirfrag's fragstat, or from the
directory's dirstat when a dirfrag closed by trimming comes back
without its fnode.

Fixes: https://tracker.ceph.com/issues/82988
Reported-by: Anthony D'Atri <anthony.datri@gmail.com>
Assisted-by: LLM
Signed-off-by: Xiubo Li <xiubo.li@clyso.com>
With room in the cache, a lookup that misses loads the whole dirfrag.
With the cache over its trim target, it loads only the wanted dentry,
and readdir still loads the whole dirfrag.

Fixes: https://tracker.ceph.com/issues/82988
Assisted-by: LLM
Signed-off-by: Xiubo Li <xiubo.li@clyso.com>
@xiubli
xiubli requested a review from a team as a code owner November 10, 2026 21:26
@xiubli
xiubli requested a review from anthonyeleven November 10, 2026 21:26
@github-actions

Copy link
Copy Markdown

Config Diff Tool Output

! changed: mds_dir_prefetch: old:  (mds.yaml.in)
! changed: mds_dir_prefetch: new: When a lookup misses a dentry, load the whole dirfrag instead of only that dentry. When the MDS cache has no room for the whole dirfrag, only the dentry is loaded. (mds.yaml.in)

The above configuration changes are found in the PR. Please update the relevant release documentation if necessary.
Ignore this comment if docs are already updated. To make the "Check ceph config changes" CI check pass, please comment /config check ok and re-run the test.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant