XFS logging
Overview
XFS uses Write-Ahead Logging (WAL) to guarantee filesystem metadata consistency. Every metadata change is recorded in the log before being applied to the on-disk structures. If a crash occurs, the log is replayed to bring the filesystem back to a consistent state.
The logging subsystem has two major layers that work together:
- The circular on-disk log — a fixed-size ring buffer of 512-byte blocks stored in a dedicated log device (or the end of the data device).
- The Committed Item List (CIL) / Delayed Logging layer — an in-memory aggregation layer that batches and de-duplicates log writes before flushing to the on-disk log.
Key Data Structures
struct xlog — The Log Manager
Defined in xfs_log_priv.h, this is the central control structure for the entire
logging subsystem.
struct xlog {
struct xfs_mount *l_mp; // owning filesystem mount
struct xfs_ail *l_ailp; // Active Item List
struct xfs_cil *l_cilp; // Committed Item List (delayed logging)
struct xlog_grant_head l_reserve_head; // logical reservation accounting
struct xlog_grant_head l_write_head; // physical space accounting
atomic64_t l_tail_lsn; // LSN of oldest unpersisted transaction
struct xlog_in_core *l_iclog; // head of the iclog ring
spinlock_t l_icloglock; // protects the iclog state machine
};
The two grant_head fields are the heart of log space management and are explained
in detail in Log Space Accounting.
struct xlog_in_core — The In-Core Log Buffer (iclog)
Each iclog is a chunk of memory that absorbs formatted log records before they are written to disk. They form a circular ring buffer of typically 4 to 8 buffers, each up to 256 KB.
struct xlog_in_core {
enum xlog_iclog_state ic_state; // state machine position
atomic_t ic_refcnt; // reference count
wait_queue_head_t ic_force_wait; // waiters on forced flush
struct xlog_in_core *ic_next; // next in ring
u32 ic_offset; // current write cursor
u32 ic_size; // total buffer size
void *ic_datap; // pointer to buffer data
struct list_head ic_callbacks; // CIL checkpoint callbacks
};
Iclog state machine (xfs_log.c):
ACTIVE → WANT_SYNC → SYNCING → DONE_SYNC → CALLBACK → DIRTY → ACTIVE
| State | Meaning |
|---|---|
ACTIVE | Accepting new log records |
WANT_SYNC | Full or flushed, waiting for all writers to finish |
SYNCING | I/O submitted to disk |
DONE_SYNC | I/O complete, callbacks pending |
CALLBACK | Running CIL checkpoint callbacks (AIL insertion) |
DIRTY | Buffer spent; being recycled back to ACTIVE |
struct xfs_cil and struct xfs_cil_ctx — The Delayed Logging Layer
xfs_cil_ctx (xfs_log_priv.h) is the container for a single CIL checkpoint —
a batch of log items accumulated since the last checkpoint flush.
struct xfs_cil_ctx {
xfs_csn_t sequence; // monotonically increasing checkpoint number
xfs_lsn_t start_lsn; // LSN of first log record
xfs_lsn_t commit_lsn; // LSN of commit record
struct list_head lv_chain; // chain of formatted shadow buffers
atomic_t space_used; // bytes accumulated so far
struct xlog_ticket *ticket; // log reservation ticket
};
xfs_cil (xfs_log_priv.h) owns the current context and drives the push:
struct xfs_cil {
struct xlog *xc_log;
struct rw_semaphore xc_ctx_lock; // write-locked during push only
struct xfs_cil_ctx *xc_ctx; // current live context
spinlock_t xc_push_lock; // protects ordering list
wait_queue_head_t xc_commit_wait;
void __percpu *xc_pcp; // per-CPU item lists
};
struct xfs_log_vec — The Shadow Buffer
When a transaction commits, each log item’s in-memory state is formatted into a
shadow buffer (a log_vec) and decoupled from the live object. This is the
central innovation of delayed logging.
struct xfs_log_vec {
struct list_head lv_list; // CIL chain
uint32_t lv_order_id; // intra-checkpoint ordering
int lv_niovecs; // number of iovecs
struct xfs_log_iovec *lv_iovecp; // formatted region descriptors
struct xfs_log_item *lv_item; // back-pointer to log item
char *lv_buf; // shadow buffer memory
int lv_bytes; // bytes used
};
After formatting, the log item is unlocked immediately. The shadow buffer holds all data required for the eventual log write.
Log Space Accounting: The Dual-Grant-Head Model
XFS tracks log space with two independent accounting heads, both defined as
xlog_grant_head (xfs_log_priv.h):
| Head | What it tracks | Can overcommit? |
|---|---|---|
l_reserve_head | Logical reservation (space promised to transactions) | Yes — future commits block, but existing ones proceed |
l_write_head | Physical bytes actually written to the log | No — hard limit, never advances past the tail |
Why two heads? Rolling transactions (e.g., directory operations that span many buffer modifications) need to reserve space upfront without exhausting the physical log. The reserve head allows logical overcommitment so a rolling transaction can keep rolling, while the write head enforces the actual circular buffer boundary.
CIL space limits (xfs_log_priv.h):
// Background push triggered at ~12.5% of total log size
#define XLOG_CIL_SPACE_LIMIT(log) min_t(int, (log)->l_logsize >> 3, ...)
// Transaction commits throttled (sleeping wait) at 25% of log size
#define XLOG_CIL_BLOCKING_SPACE_LIMIT(log) (XLOG_CIL_SPACE_LIMIT(log) * 2)
LSN Encoding
Log Sequence Numbers are 64-bit values:
LSN = (cycle << 32) | block_offset
- cycle: how many times the log has wrapped around.
- block_offset: 512-byte block offset within the log.
Macros CYCLE_LSN() and BLOCK_LSN() extract these fields throughout the code.
Transaction Lifecycle with Delayed Logging
This is the full path from a filesystem operation to a durable on-disk record.
Phase 1: Transaction Allocation and Reservation
xfs_trans_alloc()
→ xlog_ticket_alloc()
→ xlog_grant_head_check() ← may sleep here if log is full
A ticket (reservation) is allocated holding the worst-case byte count for this transaction type. The reservation is calculated at mount time from geometric properties (tree depth, block size, etc.) and accounts for recursive modifications.
Phase 2: Item Modification
The caller modifies in-memory metadata (inode, buffer, dquot). Items are logged
via xfs_trans_log_inode(), xfs_trans_log_buf(), etc., which mark items dirty on
the transaction.
Phase 3: Transaction Commit → CIL Insertion
xfs_trans_commit() → xlog_cil_commit():
- Shadow buffer allocation (
xlog_cil_alloc_shadow_bufs()): outsidexc_ctx_lock, allocate memory sized for each item’s formatted representation. - Lock acquisition: acquire
xc_ctx_lockas a reader (allows concurrent commits). - Format into shadow buffers: call each item’s
iop_format()callback, writing item state into the shadow buffer. - Pin on first insertion: if the item has not been in the CIL before, call
iop_pin(). Re-logging (subsequent modifications before a checkpoint) does not add additional pins — the existing pin is reused and the old shadow buffer is discarded. - Add to per-CPU list: attach the
log_vecto the per-CPU CIL pending list. - Unlock item: the live object is immediately available for further modification.
- Release
xc_ctx_lock.
Relogging
Relogging is the critical property that prevents log tail pinning. Each re-commit of
an item supersedes the previous version. Only the latest aggregate snapshot is
written to the on-disk log (xfs-delayed-logging-design.rst):
Transaction What is logged LSN
A A X
B A+B X+n ← A's slot at X is now stale
C A+B+C X+n+m
Phase 4: CIL Background Push
The CIL push worker (xlog_cil_push_work(), xfs_log_cil.c) is triggered when:
- CIL space exceeds
XLOG_CIL_SPACE_LIMIT(background push), or - A caller explicitly issues
xfs_log_force()orfsync().
Push sequence:
1. Acquire xc_ctx_lock as WRITER (excludes all new commits)
2. Swap context: xc_ctx points to a new empty context
3. Release xc_ctx_lock ← concurrent commits resume on new context
4. Sort and aggregate log_vecs from the old context
5. Write start record and all item vectors to iclogs
6. Order commit record among concurrent checkpoints (xlog_cil_order_write)
7. Write commit record to iclog; submit iclog for I/O
8. On I/O completion: insert items into AIL, call iop_committed, unpin items
Step 6 — checkpoint ordering — ensures commit records appear in the log in strictly ascending checkpoint sequence order, regardless of when individual iclogs complete I/O.
Phase 5: Iclog I/O and AIL Insertion
When an iclog transitions from SYNCING to DONE_SYNC:
xlog_state_iodone_process_iclog()runs callbacks.xlog_cil_process_committed()inserts log items into the Active Item List (AIL) at their commit LSN.- Items are unpinned (
iop_unpin()), making them eligible for writeback.
Phase 6: AIL Writeback and Log Tail Advancement
Once items are inserted into the AIL the xfsaild kernel thread takes over. The
full mechanism is described in The Active Item List and Log Tail Pushing.
Log space is only reclaimed when the tail advances. This makes the log a true circular buffer.
The Active Item List and Log Tail Pushing
The AIL is the bridge between the log and the on-disk metadata. Its sole purpose is to track every log item that has been committed to the log but not yet written to its final on-disk location, and to push those items to disk so the log tail can advance and log space can be reclaimed.
Data Structures
struct xfs_ail (xfs_trans_priv.h)
struct xfs_ail {
struct xlog *ail_log; // log being managed
struct task_struct *ail_task; // xfsaild kthread
struct list_head ail_head; // LSN-ordered item list
struct list_head ail_cursors; // active traversal cursors
spinlock_t ail_lock; // protects all AIL state
xfs_lsn_t ail_last_pushed_lsn;// LSN of last successfully pushed item
xfs_lsn_t ail_head_lsn; // log head LSN at AIL init
int ail_log_flush; // counter: force CIL push when set
unsigned long ail_opstate; // XFS_AIL_OPSTATE_PUSH_ALL flag
struct list_head ail_buf_list; // buffers queued for delwri submission
wait_queue_head_t ail_empty; // waiters for AIL to drain completely
xfs_lsn_t ail_target; // LSN we are currently pushing toward
};
The list at ail_head is kept in strict ascending LSN order. The item at the front
(minimum LSN) defines the log tail: that is the oldest record in the log that has
not yet been written to disk.
struct xfs_ail_cursor (xfs_trans_priv.h)
struct xfs_ail_cursor {
struct list_head list; // registered in ailp->ail_cursors
struct xfs_log_item *item; // current position (low bit = invalidated)
};
Cursors allow xfsaild to walk the AIL safely even when items are deleted
concurrently. When an item is removed, every cursor pointing at it has its low
pointer bit set. The next call to xfs_trans_ail_cursor_next() detects this and
restarts the traversal from the new minimum.
The xfsaild Daemon Main Loop (xfs_trans_ail.c:653)
xfsaild is a single per-filesystem kthread. Its loop has three states:
┌──────────────────────────────────────────────────────┐
│ Set TASK_KILLABLE or TASK_INTERRUPTIBLE │
│ (KILLABLE if tout ≤ 20ms for fast wakeup) │
├──────────────────────────────────────────────────────┤
│ Check kthread_should_stop() → drain ail_buf_list │
│ and exit on shutdown │
├──────────────────────────────────────────────────────┤
│ If AIL empty AND ail_buf_list empty → schedule() │
│ (full idle: no timeout, wait for wakeup) │
├──────────────────────────────────────────────────────┤
│ If tout > 0 → msleep(tout) │
├──────────────────────────────────────────────────────┤
│ tout = xfsaild_push(ailp) ← core work │
└──────────────────────────────────────────────────────┘
The thread is woken by:
xfs_ail_push()— called fromxlog_assign_tail_lsn()when the log approaches full.xfs_ail_push_all()— called by umount and log quiesce to drain the AIL completely.xfs_trans_ail_update_bulk()— any new insertion into the AIL.
Push Target Calculation (xfs_trans_ail.c:405)
Before scanning items, xfsaild_push() calls xfs_ail_calc_push_target() to
decide how far to push. The logic in order of priority:
-
Push-all flag set (
XFS_AIL_OPSTATE_PUSH_ALL) orail_emptyhas waiters: returnmax_lsn(the current log head). Push everything. -
Log already has ≥ 25% free space:
free_bytes = l_logsize − (head_lsn − min_lsn) if free_bytes ≥ l_logsize / 4 → keep current ail_targetNo pushing needed; keep the existing target.
-
Log has < 25% free space: advance the target by 25% of the log size from the current tail:
target_block = BLOCK_LSN(min_lsn) + (l_logBBsize >> 2); // wrap cycle if needed target_lsn = xlog_assign_lsn(target_cycle, target_block);The target is clamped to
max_lsnand never lowered below the existingail_target.
Design intent: one push round reclaims exactly 25% of the log, ensuring a predictable amount of free space without over-flushing.
The xfsaild_push() Loop (xfs_trans_ail.c:535)
This is the core of the push. It runs under ail_lock for the traversal, briefly
dropping it during I/O operations.
Step 1: CIL Pre-flush Optimization
if (ailp->ail_log_flush && ailp->ail_last_pushed_lsn == 0 &&
(!list_empty_careful(&ailp->ail_buf_list) || xfs_ail_min_lsn(ailp))) {
ailp->ail_log_flush = 0;
xlog_cil_flush(ailp->ail_log);
}
When the AIL has items but the push cursor is stuck at the beginning
(ail_last_pushed_lsn == 0), it means items are pinned by in-flight CIL
transactions. Rather than spinning on pinned items, xfsaild forces a synchronous
CIL flush. This breaks the potential circular wait:
CIL holds pins → AIL cannot advance → log fills → CIL cannot commit → deadlock
Step 2: Cursor Initialization and Target Update
WRITE_ONCE(ailp->ail_target, xfs_ail_calc_push_target(ailp));
lip = xfs_trans_ail_cursor_first(ailp, &cur, ailp->ail_last_pushed_lsn);
The cursor starts from ail_last_pushed_lsn so that a push that hit the item limit
in one round can continue from where it left off in the next.
Step 3: Item Traversal
while (XFS_LSN_CMP(lip->li_lsn, ailp->ail_target) <= 0) {
if (test_bit(XFS_LI_FLUSHING, &lip->li_flags))
goto next_item; // skip: already in-flight
xfsaild_process_logitem(ailp, lip, &stuck, &flushing);
count++;
if (stuck > 100)
break; // backoff: too many blocked items
if (lip->li_lsn != lsn && count > 1000)
break; // per-LSN limit: avoid infinite loop
}
Two hard limits prevent the push loop from monopolizing the CPU:
stuck > 100: if more than 100 consecutive items are pinned or locked, abort and sleep. Continuing would just burn CPU with no progress.count > 1000at a new LSN: prevents unbounded iteration when many items share the same commit LSN.
Step 4: Async Buffer Submission
if (xfs_buf_delwri_submit_nowait(&ailp->ail_buf_list))
ailp->ail_log_flush++;
All buffers queued during the traversal are submitted in a single batched write.
submit_nowait returns non-zero if the submission was not possible (e.g. I/O error
or congestion), which sets ail_log_flush to trigger a CIL flush on the next round.
Step 5: Timeout Selection
The return value controls how long xfsaild sleeps before the next round:
| Condition | tout | Meaning |
|---|---|---|
| Reached target, or AIL empty | 50 ms | Wait for in-flight I/O to complete; reset cursor to 0 |
| >90% of items were stuck/flushing | 20 ms | Back off; next round may issue a log force; reset cursor to 0 |
| More items remain below target | 0 ms | Return immediately; continue from ail_last_pushed_lsn |
The cursor reset (ail_last_pushed_lsn = 0) on the first two cases ensures the
next wakeup re-evaluates the entire AIL from the minimum, picking up items that may
have been unpinned during the sleep.
Per-Item Push: xfsaild_process_logitem() (xfs_trans_ail.c:468)
For each item in the traversal, xfsaild_push_item() dispatches to the item’s
iop_push callback and interprets the return code:
| Return code | Meaning | Action |
|---|---|---|
XFS_ITEM_SUCCESS | Queued for I/O | Update ail_last_pushed_lsn |
XFS_ITEM_FLUSHING | Already being written | Increment flushing; update ail_last_pushed_lsn |
XFS_ITEM_PINNED | Held by an in-memory transaction | Increment stuck; set ail_log_flush |
XFS_ITEM_LOCKED | Could not acquire buffer lock | Increment stuck |
XFS_ITEM_FAILED | Previous I/O failed | Resubmit via xfsaild_resubmit_item() |
Items with XFS_LI_FAILED set are handled by xfsaild_resubmit_item() which
re-queues the backing buffer directly to ail_buf_list without calling iop_push
again, allowing the I/O to be retried on the next submission round.
Inode Item Push: xfs_inode_item_push() (xfs_inode_item.c:739)
Inode items use cluster flushing to amortize I/O overhead. A cluster is a group of inodes that share a single filesystem buffer (typically a 4 KB or 16 KB block).
1. Check preconditions (return PINNED or FLUSHING if not ready):
- inode stale (being freed)? → PINNED
- ipincount > 0? → PINNED
- cluster buffer pinned? → PINNED
- XFS_IFLUSHING flag set? → FLUSHING
- xfs_buf_trylock() fails? → LOCKED
2. Release ail_lock ← avoids holding spinlock during I/O
3. xfs_iflush_cluster(bp) ← formats ALL inodes in the cluster into bp
4. xfs_buf_delwri_queue(bp, &ailp->ail_buf_list)
5. Reacquire ail_lock
xfs_iflush_cluster() walks all inodes mapped to the same buffer and formats each
one’s in-memory xfs_dinode into the buffer in one pass. This means that when
xfsaild pushes one inode item, it potentially writes dozens of inodes with a
single I/O, which is critical for performance on inode-dense workloads.
The AIL lock is dropped during xfs_iflush_cluster(). Cursors handle any
concurrent deletions that occur during this window.
Buffer Item Push: xfs_buf_item_push() (xfs_buf_item.c:565)
Buffer items (btree blocks, superblock, AGF/AGI headers, etc.) have a simpler push path:
1. xfs_buf_ispinned(bp)? → PINNED (transaction holds a log reference)
2. xfs_buf_trylock(bp) fails?→ LOCKED (re-check pin after trylock failure)
3. Log a warning if XBF_WRITE_FAIL is set (previous write error)
4. xfs_buf_delwri_queue(bp, &ailp->ail_buf_list)
5. xfs_buf_unlock(bp)
Unlike inodes, each buffer item maps 1:1 to a buffer, so no clustering is needed.
The trylock avoids blocking — if the buffer is locked by another writer, xfsaild
moves on and returns to it on the next round.
Tail Advancement: __xfs_ail_assign_tail_lsn() (xfs_trans_ail.c:753)
When xfs_ail_delete() removes an item from the AIL, it calls
xfs_ail_update_finish(), which calls __xfs_ail_assign_tail_lsn():
tail_lsn = __xfs_ail_min_lsn(ailp); // LSN of first item in AIL
if (!tail_lsn)
tail_lsn = ailp->ail_head_lsn; // AIL empty: tail = current head
WRITE_ONCE(log->l_tail_space,
xlog_lsn_sub(log, ailp->ail_head_lsn, tail_lsn));
atomic64_set(&log->l_tail_lsn, tail_lsn);
After updating the tail, xfs_ail_update_finish() calls xfs_log_space_wake(),
which wakes all threads sleeping on l_reserve_head or l_write_head. This
directly unblocks stalled transaction allocations.
The tail can only move forward. It is the minimum LSN of all items still in the AIL. The log space available to new transactions is:
available = l_logsize − (l_tail_space)
= l_logsize − (head_lsn − tail_lsn)
Every item flushed to disk shrinks l_tail_space, freeing space for the next wave
of transactions.
AIL Locking Summary
| Lock | Scope | Notes |
|---|---|---|
ail_lock (spinlock) | All AIL list/state access | Dropped during I/O in inode push |
xfs_buf.b_lock | Individual buffer state | Acquired via trylock only; never spins |
i_pincount / b_pin_count | Pin reference counts | Atomic; checked before attempting push |
The key design rule: ail_lock is never held while waiting for I/O. It is
dropped before xfs_iflush_cluster() and reacquired immediately after, with cursors
protecting traversal safety across the gap.
On-Disk Log Format
Log Record Header (xfs_log_format.h)
struct xlog_rec_header {
__be32 h_magicno; // 0xFEEDbabe
__be32 h_cycle; // wrap count
__be32 h_version; // log version (1 or 2)
__be32 h_len; // data length in bytes
__be64 h_lsn; // this record's LSN
__be64 h_tail_lsn; // oldest uncommitted LSN at write time
__le32 h_crc; // CRC-32c of entire record
__be32 h_num_logops; // count of operations in this record
__be32 h_cycle_data[]; // cycle number embedded in each 512-byte block
uuid_t h_fs_uuid; // filesystem UUID
};
Operation Header
struct xlog_op_header {
__be32 oh_tid; // transaction ID (for grouping ops)
__be32 oh_len; // payload length
__u8 oh_clientid; // XFS_TRANSACTION = 0x69
__u8 oh_flags; // START_TRANS | COMMIT_TRANS | CONTINUE_TRANS
};
Log Item Types
| Type | Value | Description |
|---|---|---|
XFS_LI_INODE | 0x123b | Inode core and data fork |
XFS_LI_BUF | 0x123c | Raw buffer (btree blocks, superblock, etc.) |
XFS_LI_DQUOT | 0x123d | Quota record |
XFS_LI_EFI/EFD | 0x1236/7 | Extent free intent/done |
XFS_LI_RUI/RUD | 0x123a/9 | Rmap update intent/done |
XFS_LI_CUI/CUD | 0x123f/g | Refcount update intent/done |
XFS_LI_BUI/BUD | intent pairs | BMBT update intent/done |
XFS_LI_ATTRI/ATTRD | intent pairs | Xattr update intent/done |
Intent/Done pairs implement a two-phase commit protocol for complex operations that span multiple sub-transactions (e.g., freeing extents requires updating the free space B-tree and the reverse-mapping B-tree). If the filesystem crashes between writing the Intent and the Done record, recovery re-executes the operation from the Intent.
B-tree Splits
A B-tree split is the most log-intensive operation in the XFS metadata path. A single record insertion can trigger a cascade of splits from leaf to root, each allocating a new block and logging multiple buffers. Because reservation sizes are calculated from the worst-case split depth, understanding splits is essential for understanding why XFS log reservations are as large as they are.
When a Split Occurs
XFS B-trees are full B+ trees: every block is kept as full as possible during
insertion. When an insertion targets a block that is already at maximum capacity,
the kernel first tries two cheaper alternatives before resorting to a split
(xfs_btree_make_block_unfull(), xfs_btree.c):
- Left shift (
xfs_btree_lshift()): move the leftmost record to the left sibling if it has space. - Right shift (
xfs_btree_rshift()): move the rightmost record to the right sibling if it has space. - Split (
xfs_btree_split()): only if both siblings are also full.
A split always produces exactly one new block at the current level and returns one new key/pointer pair to the caller, which must then insert that pair into the parent level — potentially triggering another split.
On-Disk Block Format
Every XFS B-tree block on disk begins with struct xfs_btree_block
(libxfs/xfs_btree_format.h):
struct xfs_btree_block {
__be32 bb_magic; // per-btree magic (e.g. XFS_BNOBT_MAGIC)
__be16 bb_level; // 0 = leaf, 1+ = internal node
__be16 bb_numrecs; // number of records/keys currently stored
union {
struct xfs_btree_block_shdr s; // AG-rooted trees (32-bit sibling ptrs)
struct xfs_btree_block_lhdr l; // inode-rooted trees (64-bit sibling ptrs)
} bb_u;
};
Both header variants contain:
| Field | Purpose |
|---|---|
bb_leftsib | Block number of left sibling (or NULLAGBLOCK/NULLFSBLOCK) |
bb_rightsib | Block number of right sibling |
bb_blkno | Physical block address of this block |
bb_lsn | LSN of the last transaction that modified this block |
bb_uuid | Filesystem UUID (guards against cross-filesystem recovery) |
bb_owner | AG number (AG-rooted) or inode number (inode-rooted) |
bb_crc | CRC-32c of the block (recalculated on recovery, not logged) |
Following the header, a block contains either:
- Leaf: a flat array of fixed-size records.
- Internal node: an array of keys followed by an array of
n+1child pointers.
All integer fields are big-endian on disk.
The Split Mechanism: __xfs_btree_split()
The core implementation lives in __xfs_btree_split() (xfs_btree.c). For BMBT
(block map B-tree) splits where no AGF lock is held, a worker-thread wrapper
xfs_btree_split() offloads the call to avoid unbounded kernel stack growth during
recursive allocation; all other tree types call __xfs_btree_split() directly.
Step 1: Allocate the New Right Block
xfs_btree_alloc_block(cur, &lptr, &rptr, stat)
xfs_btree_get_buf_block(cur, &rptr, &right, &rbp)
xfs_btree_init_block_cur(cur, rbp, level, 0)
Block allocation is type-specific:
| B-tree | Source of new block | Side effect logged |
|---|---|---|
| BNOBT / CNTBT | xfs_alloc_get_freelist() — AG free list | AGF header (XFS_AGF_FLFIRST, XFS_AGF_FLCOUNT) |
| INOBT / FINOBT | xfs_alloc_vextent_near_bno() — AG free space | AGF + AGI block counter |
| RMAPBT | xfs_alloc_get_freelist() — AGFL | AGF agf_rmap_blocks, space reservation |
| BMBT | xfs_alloc_vextent_near_bno() — data AG | Inode fork block count |
Every one of these block sources modifies an AG header (AGF or AGI), which is itself
logged as a XFS_LI_BUF item. A split thus always generates at least two logged
buffers before any tree data is touched.
Step 2: Divide Records Between Left and Right
lrecs = xfs_btree_get_numrecs(left)
rrecs = lrecs / 2
if (lrecs is odd && cursor position <= rrecs + 1)
rrecs++ // tilt balance toward right when cursor is nearby
src_index = lrecs - rrecs + 1
xfs_btree_set_numrecs(left, lrecs - rrecs)
xfs_btree_set_numrecs(right, rrecs)
The split point is chosen so that both blocks end up roughly half full. The odd- record tilt biases records toward the block the cursor is about to insert into, minimising the chance of an immediate follow-up split.
For leaf blocks: xfs_btree_copy_recs() copies the upper half of records into
the right block.
For internal nodes: xfs_btree_copy_keys() and xfs_btree_copy_ptrs() copy
the upper half of keys and their associated child pointers.
The key at src_index (the lowest key of the right block) is extracted and returned
to the caller as the split key — the value that must be inserted into the parent
level as the separator between left and right.
Step 3: Log Every Modified Buffer
This is the critical point at which the split becomes durable. The following log operations happen in order:
| What | Fields logged | xfs_btree_log_block() flags |
|---|---|---|
| Right block — all header fields | magic, level, numrecs, both sibling ptrs, blkno, LSN, UUID, owner | XFS_BB_ALL_BITS (excludes bb_crc) |
| Right block — data (leaf) | records 1..rrecs | via xfs_btree_log_recs() |
| Right block — data (node) | keys 1..rrecs, ptrs 1..rrecs | via xfs_btree_log_keys() + xfs_btree_log_ptrs() |
| Left block — changed header | bb_numrecs, bb_rightsib | XFS_BB_NUMRECS | XFS_BB_RIGHTSIB |
| Right-right sibling (if exists) | bb_leftsib | XFS_BB_LEFTSIB |
xfs_btree_log_block() converts the field bitmask to a byte range and calls
xfs_trans_log_buf(), which marks that range dirty in the transaction’s log vector.
xfs_trans_buf_set_type() is called first to stamp the buffer as
XFS_BLFT_BTREE_BUF, which recovery uses to distinguish B-tree blocks from other
buffer types.
The CRC (bb_crc) is deliberately not logged. It is recalculated from the block
contents during recovery using xfs_btree_reada_bufs(), ensuring the stored CRC
always matches what is actually on disk after replay.
Step 4: Update Sibling Chain
Before logging, the sibling doubly-linked list is repaired:
Before split:
[left] ↔ [right-right]
After split:
[left] ↔ [right (new)] ↔ [right-right]
Three pointer writes are needed:
left->bb_rightsib = right(logged as part of left block header)right->bb_leftsib = left(logged as part of right block header,XFS_BB_ALL_BITS)right->bb_rightsib = right-right(logged as part of right block header)right-right->bb_leftsib = right(logged separately:XFS_BB_LEFTSIBonly)
The right-right block read uses xfs_btree_read_buf_block(), which may issue a
synchronous read if the block is not already in the buffer cache. On cold-cache
workloads this is a significant latency source.
Upward Propagation: Recursive Splits
After __xfs_btree_split() returns, the caller (xfs_btree_insrec()) must insert
the split key and right-block pointer into the parent level. If the parent is
also full, it too must split. xfs_btree_insert() drives this loop:
do {
error = xfs_btree_insrec(cur, level, &nptr, &rec, &key, &ncur, &i);
// nptr is non-null if a split occurred at this level
level++;
} while (!xfs_btree_ptr_is_null(cur, &nptr));
Each iteration may allocate one block and log three to five buffers. The loop terminates only when a level has room for the new key/pointer without splitting.
Worst-case depth: on a filesystem with a large allocation group and all optional B-trees enabled, a fully-populated RMAPBT can reach five levels. A single extent allocation that triggers a split at every level logs five new blocks plus five parent block updates plus five AG header updates — thirty or more buffer log items for one allocation.
The per-transaction log reservation must cover this worst case upfront, which is why reservation sizes are computed from tree height and block size at mount time rather than at runtime.
Root Split: Growing the Tree
When the split reaches the root, there is no parent to absorb the new key. The tree must grow one level taller.
AG-Rooted Trees (BNOBT, CNTBT, INOBT, FINOBT, RMAPBT)
xfs_btree_new_root() (xfs_btree.c):
1. Allocate a new block → becomes the new root
2. xfs_btree_set_root(cur, &nptr, +1)
→ update AG header (AGF or AGI) root pointer and level field
→ log AGF/AGI with XFS_AGF_ROOTS | XFS_AGF_LEVELS
3. Initialize new root block (level = old_height, numrecs = 2)
4. Log new root block: XFS_BB_ALL_BITS
5. Copy lowest key of each child into new root keys
6. Log keys: xfs_btree_log_keys(cur, nbp, 1, 2)
7. Write left-child and right-child pointers into new root
8. Log ptrs: xfs_btree_log_ptrs(cur, nbp, 1, 2)
9. Advance cursor: bc_nlevels++
The AG header (AGF or AGI) records the new root block number and the new tree height. On the next mount, XFS reads those fields to reconstruct the cursor starting position without scanning the tree.
Inode-Rooted Trees (BMBT)
xfs_btree_new_iroot() (xfs_btree.c) handles the bmap B-tree, where the root
lives directly inside the inode fork rather than in a separate block:
1. Allocate a new block → receives a copy of current inode-root contents
2. memcpy(new_block, inode_root_data)
Fix bb_blkno in new block to match its physical address
3. Compress the inode fork to hold only the new root (one key + one pointer)
4. Log new child block: XFS_BB_ALL_BITS + records/keys/ptrs
5. Log inode: XFS_ILOG_CORE | xfs_ilog_fbroot(whichfork)
(the inode fork data region is now a single-entry root node)
6. bc_nlevels++
The inode fork has a fixed size defined by its di_forkoff. Once the in-inode root
cannot hold another key/pointer pair even after a split, the inode root gains another
level outward, eventually consuming the entire fork and forcing a fork conversion.
Cursor Tracking Across a Split
The xfs_btree_cur maintains one xfs_btree_level entry per tree level, each
holding a (buffer, position) pair:
struct xfs_btree_level {
struct xfs_buf *bp; // buffer holding the block at this level
uint16_t ptr; // 1-based index of current key/record
};
After __xfs_btree_split() divides the block, the cursor position may have moved
to the right block:
if (cur->bc_levels[level].ptr > lrecs + 1) {
xfs_btree_setbuf(cur, level, rbp); // switch to right block
cur->bc_levels[level].ptr -= lrecs; // adjust position
}
If there are more levels above, a second cursor is duplicated
(xfs_btree_dup_cursor()). One cursor tracks the left child; the duplicated cursor’s
parent-level pointer is incremented by one to track the right child. The insertion
loop in xfs_btree_insert() manages which cursor to use at each level and deletes
the spare when the split chain resolves.
What Gets Logged Per Split Level
Summing the buffer log items produced by one complete split at a single level:
| Buffer | Log items | Condition |
|---|---|---|
| AG header (AGF or AGI) | XFS_LI_BUF | Always — block allocation modifies AG header |
| New right block header | XFS_LI_BUF (XFS_BB_ALL_BITS) | Always |
| New right block data (recs/keys/ptrs) | XFS_LI_BUF | Always |
| Left block header | XFS_LI_BUF (XFS_BB_NUMRECS | XFS_BB_RIGHTSIB) | Always |
| Right-right sibling header | XFS_LI_BUF (XFS_BB_LEFTSIB) | Only if right-right exists |
That is four or five XFS_LI_BUF items per split level, plus the AG header
items from block allocation, which may themselves modify additional blocks (e.g.,
the AGFL block list used by BNOBT/RMAPBT to store free blocks). In a five-level tree
where every level splits, a single insertion can log twenty or more distinct buffers
before the transaction commits.
Crash Recovery of a Split
Because every buffer modified during a split is logged before the transaction commits, crash recovery is straightforward:
- Crash before commit: no log records for this transaction are durable. The pre-split blocks are unmodified. The newly allocated block may appear in the block allocation structures but will be reclaimed by the space recovery pass.
- Crash after commit: all log records are durable. Recovery replays each buffer item in order, restoring every block to its post-split state. The B-tree is structurally consistent at the end of replay.
There are no intent records for splits. A split is not a deferred operation: it is atomic within the transaction that triggered it. Either all split records are committed together, or none of them are.
Crash Recovery
xfs_log_recover.c implements recovery in two passes.
Pass 1: Log Scanning
xlog_recover() walks the log from tail_lsn forward:
- Reads each log record header.
- Validates magic number and CRC.
- Groups operation headers by transaction ID.
- Builds an in-memory map of all transactions present in the log.
Pass 2: Replay
xlog_recover_commit_trans() replays each complete transaction:
- For each log item, call
xlog_recover_commit_buffer/inode/dquot(). - Overwrite on-disk metadata with the logged versions.
- For intent items (EFI, RUI, etc.), reconstruct the pending operation and schedule it for Phase 2 completion via deferred operations.
xlog_recover_finish() processes all deferred operations, completing any
partially-done multi-step operations (extent frees, rmap updates, etc.).
Invariant enforced by design: a CIL checkpoint must be smaller than half the total log size. This guarantees that at least one full checkpoint is always present in the log, making partial-write crashes safe.
Locking Hierarchy
Violating this order causes deadlock.
1. xfs_mount.m_sb_lock (filesystem-wide, rarely held)
2. xfs_buf.b_lock (individual buffer locks)
3. xfs_inode.i_lock (inode lock)
4. xlog.l_icloglock (spinlock, iclog state machine)
5. xfs_cil.xc_ctx_lock (rwsem, CIL context switch)
6. xfs_cil.xc_push_lock (spinlock, checkpoint ordering list)
7. xfs_ail.ail_lock (spinlock, AIL list)
xc_ctx_lock is a sleeping rwsem, not a spinlock, specifically to avoid holding a
spinlock during log I/O submission, which can sleep.
Performance Characteristics and Bottlenecks
Batching Efficiency (CIL)
The CIL’s primary value is write amplification reduction. Without delayed
logging, each fsync or log force flushes all dirty items individually. With CIL,
items modified 100 times between two checkpoints are written to disk once, at
their final state. In workloads with heavy relogging (directory updates, quota
updates), this can reduce log I/O by an order of magnitude.
Bottleneck: If the CIL is too small (< 8 MB on a busy filesystem), background pushes fire too frequently, destroying the batching benefit and driving up log I/O.
Per-CPU CIL Aggregation
Transaction commits add items to per-CPU pending lists (xc_pcp) to eliminate
contention on a single lock. Space accounting uses per-CPU counters until the soft
limit is approached, at which point it transitions to atomic operations and wakes the
push worker.
Bottleneck: On workloads with very many small transactions (e.g., millions of small file creates), the per-CPU-to-atomic transition point creates a serialization spike. Threads pile up in
xlog_cil_commit()contending onxc_ctx_lockwrite acquisition during the context switch.
Grant Head Waiters
When the log is full (write head nearly meets the tail), new transactions block in
xlog_grant_head_wait() on a FIFO wait queue.
Bottleneck — log tail pinning: The tail can only advance when the AIL empties items. The AIL can only empty items when their buffers are written to disk. If the storage device is slow, the log fills up and all new transactions stall. This is the primary throughput bottleneck on write-heavy workloads on slow devices.
Bottleneck — reservation overestimation: Reservations are computed for the worst case (maximum B-tree depth). On a mostly-empty filesystem, actual usage is much less, but the reservation holds the full amount until released. This reduces parallelism on small logs.
Iclog Contention (l_icloglock)
Every thread completing a CIL commit must briefly hold l_icloglock to copy its log
vectors into the current iclog and advance the write cursor. On many-core systems
(32+ CPUs), this spinlock becomes a serialization point under high log bandwidth.
Bottleneck: Large CIL checkpoints writing megabytes of log data while holding
l_icloglockfor each 32 KB iclog block starve concurrent threads trying to start new transactions.
AIL Push Rate and Tail Stall
xfsaild is a single-threaded daemon. On systems with many concurrent metadata
writers, it must push items fast enough to keep the tail advancing ahead of the
write head.
Bottleneck — device throughput: If the block device cannot sustain the required writeback rate,
xfsaildbuilds up a backlog, the AIL grows, the tail stalls, the write head catches the tail, and transaction allocation blocks — a global freeze. The only remedy is faster storage, a larger log, or reducing metadata write amplification.
Bottleneck — pinned items: Items held by long-running or stalled transactions cannot be pushed regardless of device speed. When
xfsaildencounters more than 100 pinned items in a row it backs off (20–50 ms sleep). If the items remain pinned across many rounds,ail_log_flushaccumulates and each new push round opens with a forced CIL flush to try to unpin them. A transaction that holds its locks too long effectively pins the log tail and starves all other writers.
Bottleneck — buffer lock contention:
xfsaildusestrylockon all buffers and returnsXFS_ITEM_LOCKEDimmediately if the lock is unavailable. Under heavy concurrent writeback, many buffers may be locked by page writeback or other kernel paths. Items returningLOCKEDcount against thestuckthreshold (100 items), triggering the backoff before the target LSN is reached.
Bottleneck — single-threaded design:
xfsaildprocesses the AIL serially. Each iteration submits buffers viaxfs_buf_delwri_submit_nowait(), which is asynchronous, but the traversal itself is sequential. On workloads that produce millions of small dirty metadata items, the daemon can spend more time traversing the list than the device spends doing I/O. There is no parallelism within a single push round.
Bottleneck — cluster flush overhead: Inode pushes call
xfs_iflush_cluster()which formats all inodes in a buffer cluster. While this amortizes I/O, it also meansxfsailddrops and reacquiresail_lockfor every inode buffer, and any cursor invalidation during that window forces a restart of the traversal from the AIL minimum. On a filesystem with millions of recently-modified inodes spread across many clusters, this restart overhead can significantly slow the effective push rate.
Observable symptoms of a stalled tail:
xfs_log_forcelatency increases (callers sleeping onl_write_head).xfs_buf_delwri_submit_nowaitreturns non-zero repeatedly (setsail_log_flusheach time), causing redundant CIL flushes./proc/fs/xfs/statcountersxs_push_ail_pinnedandxs_push_ail_lockedgrow faster thanxs_push_ail_success.xfsaildwakes withtout=20continuously (>90% contention threshold crossed).
Tuning levers:
- Larger log: more space between head and tail gives
xfsaildmore time before the write head catches the tail. - Dedicated log device (separate fast NVMe): isolates log writes from data writeback, reducing contention on the device queue.
vm.dirty_ratio/vm.dirty_background_ratio: reducing the dirty page ratio limits how many buffers can be in-flight at once, reducing lock contention seen byxfsaild.
Checkpoint Ordering Serialization
xlog_cil_order_write() (xfs_log_cil.c) ensures commit records are written in
sequence order. When two concurrent checkpoints race, the higher-sequence one must
wait for the lower-sequence one to establish its commit_lsn before writing its own
commit record.
Bottleneck: Under extreme concurrency with many small checkpoints firing in rapid succession, checkpoint ordering serialization limits the rate at which new commit LSNs can be established, capping throughput in the log-write path.
Recovery Time
Recovery time is proportional to the amount of data between tail_lsn and
head_lsn at the time of crash. A larger log retains more history, meaning more
data to replay. On systems with very large logs (hundreds of GB) and high write rates
before the crash, recovery can take minutes.
Bottlenecks and Write Amplification
Write amplification in XFS logging occurs at several independent layers. Each layer multiplies the number of actual device writes relative to the application-level operation that triggered them. Understanding which layer is responsible for observed I/O load is essential for diagnosis.
Write Amplification Taxonomy
Layer 1: Fundamental WAL Amplification
Every metadata modification is written twice: once sequentially to the log, and
once in-place to the metadata location on disk. This is the irreducible cost of
crash consistency via WAL. A single mkdir that modifies an inode, a directory
block, and two AGF entries produces at minimum four log writes and four eventual
on-disk writes — eight device writes for four logical changes.
Application write
└─ metadata change
├─ → log write (sequential, via iclog)
└─ → on-disk write (random, via AIL writeback)
The log write is sequential and cheap per-byte. The on-disk write is random and expensive per-operation. For metadata-heavy workloads on rotational storage the random on-disk writes dominate; on NVMe the log bandwidth is more often the limit.
Layer 2: Relogging Amplification (Pre-CIL)
Before delayed logging, every transaction that modified an already-logged item wrote the item to the log again in full, even if the change was a single byte. A hot inode touched by 1 000 transactions before being flushed to disk would appear 1 000 times in the log. Log space consumption was proportional to transaction count, not to the number of distinct objects.
CIL eliminates this at the log level: the item is formatted once per checkpoint regardless of how many transactions modified it within that checkpoint window. The reduction in log write amplification depends entirely on the relogging rate. On workloads with heavy relogging (directory entry updates, quota tracking, allocation group headers), CIL can reduce log write volume by one to two orders of magnitude.
Layer 3: Shadow Buffer Copy Amplification
CIL introduces one additional in-memory copy per commit. Each item is formatted from
its live in-memory representation into a shadow buffer (xfs_log_vec.lv_buf)
before being added to the CIL. This decouples the item from the log write so the
item can be unlocked immediately, but it means every committed item is represented in
memory at least twice: once as the live object (inode, buffer) and once as the
formatted shadow. The shadow is later copied into the iclog when the CIL pushes.
Memory path for a single logged inode:
xfs_inode (in memory)
→ iop_format() → lv_buf (shadow buffer, CIL holds it)
→ xlog_write() → iclog data buffer (ring)
→ disk
Three copies before the data reaches the log device. This amplification is intentional: it removes the need to hold any lock on the live object during log I/O, enabling the parallelism that makes delayed logging viable.
Layer 4: Metadata Cascade Amplification (B-tree Fan-out)
A single application-visible operation triggers a cascade of internal metadata changes, each of which must be logged independently. The worst case occurs during extent allocation on a filesystem with all optional B-trees enabled:
| Operation step | Items logged |
|---|---|
| Inode size/extent count update | XFS_LI_INODE |
| BMBT (extent map B-tree) block | XFS_LI_BUF × (tree height) |
| AGF header update | XFS_LI_BUF |
| Free space B-tree by block (BNOBT) | XFS_LI_BUF × (split depth) |
| Free space B-tree by size (CNTBT) | XFS_LI_BUF × (split depth) |
| Rmap B-tree (RMAPBT, if enabled) | XFS_LI_BUF × (split depth) |
| Refcount B-tree (REFCBT, if enabled) | XFS_LI_BUF × (split depth) |
| AGI header (if inode allocation) | XFS_LI_BUF |
| Inode B-tree (INOBT/FINOBT) | XFS_LI_BUF × (split depth) |
A single fallocate call on a filesystem with rmapbt and refcountbt enabled can
log 20–40 buffer items. Each B-tree split creates a new block that must also be
logged. The reservation system accounts for this worst case, which is why per-
transaction reservations are large relative to the actual bytes changed.
Layer 5: Intent/Done Record Overhead
Multi-step operations (extent free, rmap update, refcount update, attribute write) write a pair of log records — an Intent before the operation and a Done after. This ensures recovery can detect and complete partial operations. The overhead is two additional log records per complex sub-operation:
EFI (Extent Free Intent) ← written before freeing extent
→ btree updates (AGF, BNOBT, CNTBT, RMAPBT, REFCBT)
EFD (Extent Free Done) ← written after btree updates complete
On workloads that perform many small file deletions (e.g. log rotation, build artifact cleanup), Intent/Done pairs can account for a significant fraction of log traffic. A delete of a 100-extent file generates 100 EFI/EFD pairs plus all associated B-tree buffer logs.
Layer 6: Iclog Block Padding
Log records are written in units of 512-byte blocks and padded to the next block boundary. Small transactions that log only a few hundred bytes waste the remainder of the block. On workloads with many small transactions the padding overhead can reach 30–50% of raw log bandwidth, effectively shrinking the usable log size.
The CIL largely mitigates this by batching many small transactions into a single large checkpoint record. Padding waste is then amortized across the checkpoint rather than per-transaction.
Bottleneck Catalog
Each bottleneck is described with its root cause, how it manifests in observable metrics, and what can be done to mitigate it.
B1: Log Full — Grant Head Stall
Root cause: The write head has caught up to the tail. No physical log space
remains for new transactions. All calls to xlog_grant_head_check() block on the
l_write_head FIFO wait queue.
Cause chain:
Device too slow → AIL drain lags → tail does not advance
→ write head catches tail → xlog_grant_head_wait() blocks all writers
Symptoms:
- All application threads stall in
xfs_log_reserve()simultaneously — a global filesystem freeze from the application’s perspective. dmesgmay showXFS: xlog_grant_log_space: sleepif debug logging enabled.iostatshows log device at 100% utilisation with very low metadata device I/O (metadata writes are blocked waiting for log space)./proc/fs/xfs/stat:xs_trans_ailstalled;xs_push_ail_successnear zero.
Mitigations:
- Increase log size (
mkfs.xfs -l size=...or external log device). - Move the log to a dedicated faster device (NVMe vs. HDD).
- Reduce B-tree fan-out amplification by enabling
bigtime,nrext64, or choosing a larger block size to pack more records per B-tree node. - Reduce the number of enabled optional B-trees if rmap/reflink are not required.
B2: CIL Context Switch Contention
Root cause: The CIL push worker acquires xc_ctx_lock as a writer to swap
the live context. During this window, all concurrent xlog_cil_commit() calls block
waiting for the read lock. On many-core systems with high transaction rates the
context switch becomes a serialisation barrier.
Symptoms:
- CPU profiles show many threads spinning or sleeping in
xlog_cil_commit(). - Short bursts of very high lock wait time correlate with checkpoint boundaries.
- Transaction commit latency has a periodic spike pattern matching the CIL push interval (every few hundred milliseconds under load).
Mitigations:
- The CIL is already tuned to minimise the write-lock hold time (context is swapped
then lock released immediately). The main lever is reducing push frequency by
ensuring the log is large enough that the CIL soft limit (
XLOG_CIL_SPACE_LIMIT, ~12.5% of log) is not hit too often. - Workloads that issue many synchronous
fsynccalls force CIL pushes on every call. Batchingfsync(e.g. usingsync_file_rangeor application-level buffering) reduces push frequency.
B3: Iclog Spinlock Serialisation (l_icloglock)
Root cause: Every thread writing to an iclog must hold l_icloglock for the
duration of the copy. The lock is a raw spinlock. On systems with 32+ CPUs all
running concurrent CIL push workers, the spinlock degrades to a bottleneck.
Symptoms:
perforftraceshows high time in_raw_spin_lockcalled fromxlog_write_iclog()orxlog_state_get_iclog_space().- Log write bandwidth plateaus below the device’s sequential write capacity.
- Adding more CPUs does not improve log throughput.
Mitigations:
- Use larger iclogs (
mkfs.xfs -l version=2,size=...,su=262144sets 256 KB iclogs). Larger iclogs mean fewer lock acquisitions per unit of log data. - Reduce the number of concurrent CIL pushes by ensuring workload transactions are large enough to batch well before hitting the CIL limit.
B4: Reservation Overestimation on Small Logs
Root cause: Each transaction type holds a worst-case reservation for the entire duration of the transaction, even if the actual log usage is a fraction of that. On a small log (< 256 MB), the sum of all in-flight reservations can exhaust the reserve head even when the physical log has space, causing false stalls.
Symptoms:
xlog_grant_head_check()blocks onl_reserve_headeven thoughl_write_headhas available space.- Log device utilisation is low but transaction latency is high.
- Reducing concurrent writer count relieves the stall.
Mitigations:
- Increase log size. Reservations are a fixed fraction of log size; a larger log accommodates more concurrent in-flight transactions.
- Avoid small logs on high-concurrency filesystems. The minimum practical log size for a busy filesystem is typically 512 MB; 1–2 GB is common on production systems.
B5: AIL Tail Stall — Pinned Items
Root cause: Items in the AIL that are still pinned by in-flight CIL transactions
cannot be flushed. If the CIL checkpoint does not complete quickly enough (e.g.,
because log I/O is slow), the tail cannot advance. xfsaild counts pinned items
against the stuck threshold and backs off after 100 consecutive pinned items.
Symptoms:
/proc/fs/xfs/stat:xs_push_ail_pinneddominates overxs_push_ail_success.xfsaildsleep time is consistently 20–50 ms (backoff mode).ail_log_flushcounter increments rapidly, causing redundant CIL flushes.- Log I/O latency is high (slow log device or iclog congestion).
Mitigations:
- Faster log device reduces the time between CIL commit and iclog I/O completion, unpinning items sooner.
- If the workload uses explicit
fsync, confirm that it is not being called at a rate that prevents the CIL from batching effectively.
B6: AIL Tail Stall — Buffer Lock Contention
Root cause: xfsaild uses trylock on all buffers. Under heavy concurrent
writeback from the page cache or other kernel paths, many metadata buffers are
already locked when xfsaild tries to acquire them. Each failure increments the
stuck counter toward the 100-item backoff threshold.
Symptoms:
/proc/fs/xfs/stat:xs_push_ail_lockedgrows alongsidexs_push_ail_pinned.- High
iowaiton the metadata device during writeback storms. xfsaildalternates between 0 ms (making progress) and 20 ms (backoff) with no clear pattern.
Mitigations:
- Reduce concurrent writeback pressure via
vm.dirty_background_ratioandvm.dirty_ratio. - On NVMe, increase the
nr_requestsqueue depth to absorb more concurrent I/Os without serialising at the block layer.
B7: AIL Single-Threaded Traversal
Root cause: xfsaild is one thread per filesystem. The AIL traversal loop is
sequential. On workloads that accumulate millions of dirty metadata items (e.g. large
rsync, git clone of a large repository, database checkpoint), the traversal itself
consumes significant CPU time before items are submitted.
Symptoms:
xfsaildCPU usage is consistently high (near 100% of one core).- Log device I/O queue is not saturated —
xfsaildis the bottleneck, not the device. - Cursor restarts are frequent:
xfsaildrepeatedly restarts from the AIL minimum due to concurrent deletions during inode cluster flushes.
Mitigations:
- There is no kernel-level tuning knob to parallelise
xfsaild. The mitigation is to reduce the number of items in the AIL at any one time by ensuring the CIL pushes frequently and items are promptly written to disk. - Increasing
vm.dirty_expire_centisecsdelays the page cache writeback that competes withxfsaild, reducing cursor invalidation interference.
B8: Checkpoint Ordering Stall
Root cause: xlog_cil_order_write() enforces that commit records appear in
strictly ascending checkpoint sequence order. A slow checkpoint (due to large
checkpoint size or log I/O contention) blocks all higher-sequence checkpoints from
writing their commit records, even if their data has already been written to iclogs.
Symptoms:
- Multiple CIL push workers are stalled in
xlog_cil_order_write()waiting onxc_commit_wait. - Log device appears idle despite pending checkpoint data.
- Checkpoint commit latency increases proportionally to checkpoint I/O latency.
Mitigations:
- Reduce checkpoint size by reducing the CIL soft limit (not directly tunable at runtime; requires log size adjustment since the limit is a fraction of log size).
- Faster log device reduces per-checkpoint I/O time, shortening the ordering wait.
B9: Write Amplification from Optional B-Trees
Root cause: Enabling rmapbt (reverse mapping) and reflink (reference
counting) adds two additional B-trees that must be updated on every extent
allocation, deallocation, and CoW operation. Each B-tree update is a separate logged
buffer item. On workloads with high extent churn, these trees double or triple the
number of buffer items logged per operation.
Symptoms:
- Log write bandwidth is significantly higher after enabling reflink or rmapbt compared to a plain filesystem.
- Per-transaction reservation sizes are larger (visible via
xfs_logprint). - Extent allocation operations are slower under concurrency due to higher per- transaction lock hold times.
Mitigations:
- Do not enable
rmapbtorreflinkif the workload does not require them. These features cannot be disabled aftermkfs. - Use a larger block size to increase B-tree node fanout, reducing tree height and therefore split frequency.
B10: Recovery Time from Large Logs
Root cause: Recovery replays every log record between tail_lsn and head_lsn
at crash time. A larger log retains more history. A filesystem that was writing
heavily immediately before the crash will have a full or nearly-full log to replay.
Symptoms:
- Mount time is minutes rather than seconds after an unclean shutdown.
- Recovery I/O is visible on the log device during mount.
dmesgshowsXFS: starting recoveryfollowed by a long gap beforeXFS: Ending recovery.
Mitigations:
- Use
barrier=1(default) to ensure log records are committed before the device acknowledges the write, keeping the recovery window bounded. - A dedicated log device with lower write latency reduces the time to write
checkpoints, keeping
tail_lsncloser tohead_lsnat any given moment (less to replay). - Do not artificially inflate log size beyond what is needed. A log larger than necessary does not improve steady-state performance and increases worst-case recovery time.
Amplification Summary
| Layer | What is amplified | CIL mitigation |
|---|---|---|
| Fundamental WAL | Every metadata write appears twice (log + disk) | None — inherent to WAL |
| Relogging | Hot items logged once per transaction | Yes — once per checkpoint |
| Shadow buffer copies | 3 in-memory copies before disk | Unavoidable cost of lock-free commit |
| B-tree cascade | 10–40 buffer items per file operation | Partial — items are batched per checkpoint |
| Intent/Done pairs | 2 log records per multi-step operation | Partial — both records batched in checkpoint |
| Iclog padding | Up to 512 bytes wasted per transaction | Yes — padding amortised across checkpoint |
| Optional B-trees | 2× log traffic with rmapbt + reflink | None — structural overhead |
Summary of Critical Paths
| Path | Key bottleneck |
|---|---|
| Transaction allocation | l_reserve_head FIFO wait when log is full |
| CIL commit | xc_ctx_lock contention during context switch |
| CIL push → iclog write | l_icloglock on every 32 KB iclog block |
| Iclog I/O completion | Block device latency |
| AIL push — device throughput | xfsaild delwri queue depth vs. device bandwidth |
| AIL push — pinned items | Long-running transactions pin tail; ail_log_flush triggers CIL force |
| AIL push — buffer contention | trylock failures accumulate; 100-item stuck threshold triggers backoff |
| AIL push — single-threaded | Sequential traversal; cursor restarts on concurrent deletion |
| AIL cluster flush | ail_lock drop/reacquire per inode cluster; cursor restart on remove |
| Log tail advance | __xfs_ail_assign_tail_lsn() wakes grant head waiters; stalls if AIL never drains |
| Recovery | Log size × write rate at crash time |
Understanding these seven paths and their limiting factors is the foundation for diagnosing and resolving XFS performance problems on write-intensive workloads.
Source References
| File | Purpose |
|---|---|
fs/xfs/xfs_log.c | Main log manager, iclog state machine |
fs/xfs/xfs_log_cil.c | Delayed logging, CIL push worker |
fs/xfs/xfs_trans_ail.c | AIL daemon (xfsaild), push loop, tail assignment, cursor management |
fs/xfs/xfs_trans_priv.h | xfs_ail and xfs_ail_cursor structure definitions |
fs/xfs/xfs_inode_item.c | xfs_inode_item_push(), cluster flush dispatch |
fs/xfs/xfs_buf_item.c | xfs_buf_item_push(), buffer trylock and delwri queue |
fs/xfs/xfs_log_recover.c | Crash recovery, two-pass replay |
fs/xfs/xfs_log_priv.h | Internal structures (xlog, xlog_in_core, xfs_cil) |
fs/xfs/xfs_log.h | Public logging API |
fs/xfs/libxfs/xfs_log_format.h | On-disk format (xlog_rec_header, item types) |
Documentation/filesystems/xfs/xfs-delayed-logging-design.rst | Authoritative design document |