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:

  1. 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).
  2. 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
StateMeaning
ACTIVEAccepting new log records
WANT_SYNCFull or flushed, waiting for all writers to finish
SYNCINGI/O submitted to disk
DONE_SYNCI/O complete, callbacks pending
CALLBACKRunning CIL checkpoint callbacks (AIL insertion)
DIRTYBuffer 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):

HeadWhat it tracksCan overcommit?
l_reserve_headLogical reservation (space promised to transactions)Yes — future commits block, but existing ones proceed
l_write_headPhysical bytes actually written to the logNo — 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():

  1. Shadow buffer allocation (xlog_cil_alloc_shadow_bufs()): outside xc_ctx_lock, allocate memory sized for each item’s formatted representation.
  2. Lock acquisition: acquire xc_ctx_lock as a reader (allows concurrent commits).
  3. Format into shadow buffers: call each item’s iop_format() callback, writing item state into the shadow buffer.
  4. 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.
  5. Add to per-CPU list: attach the log_vec to the per-CPU CIL pending list.
  6. Unlock item: the live object is immediately available for further modification.
  7. 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() or fsync().

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:

  1. xlog_state_iodone_process_iclog() runs callbacks.
  2. xlog_cil_process_committed() inserts log items into the Active Item List (AIL) at their commit LSN.
  3. 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 from xlog_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:

  1. Push-all flag set (XFS_AIL_OPSTATE_PUSH_ALL) or ail_empty has waiters: return max_lsn (the current log head). Push everything.

  2. Log already has ≥ 25% free space:

    free_bytes = l_logsize − (head_lsn − min_lsn)
    if free_bytes ≥ l_logsize / 4 → keep current ail_target
    

    No pushing needed; keep the existing target.

  3. 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_lsn and never lowered below the existing ail_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 > 1000 at 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:

ConditiontoutMeaning
Reached target, or AIL empty50 msWait for in-flight I/O to complete; reset cursor to 0
>90% of items were stuck/flushing20 msBack off; next round may issue a log force; reset cursor to 0
More items remain below target0 msReturn 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 codeMeaningAction
XFS_ITEM_SUCCESSQueued for I/OUpdate ail_last_pushed_lsn
XFS_ITEM_FLUSHINGAlready being writtenIncrement flushing; update ail_last_pushed_lsn
XFS_ITEM_PINNEDHeld by an in-memory transactionIncrement stuck; set ail_log_flush
XFS_ITEM_LOCKEDCould not acquire buffer lockIncrement stuck
XFS_ITEM_FAILEDPrevious I/O failedResubmit 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

LockScopeNotes
ail_lock (spinlock)All AIL list/state accessDropped during I/O in inode push
xfs_buf.b_lockIndividual buffer stateAcquired via trylock only; never spins
i_pincount / b_pin_countPin reference countsAtomic; 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

TypeValueDescription
XFS_LI_INODE0x123bInode core and data fork
XFS_LI_BUF0x123cRaw buffer (btree blocks, superblock, etc.)
XFS_LI_DQUOT0x123dQuota record
XFS_LI_EFI/EFD0x1236/7Extent free intent/done
XFS_LI_RUI/RUD0x123a/9Rmap update intent/done
XFS_LI_CUI/CUD0x123f/gRefcount update intent/done
XFS_LI_BUI/BUDintent pairsBMBT update intent/done
XFS_LI_ATTRI/ATTRDintent pairsXattr 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):

  1. Left shift (xfs_btree_lshift()): move the leftmost record to the left sibling if it has space.
  2. Right shift (xfs_btree_rshift()): move the rightmost record to the right sibling if it has space.
  3. 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:

FieldPurpose
bb_leftsibBlock number of left sibling (or NULLAGBLOCK/NULLFSBLOCK)
bb_rightsibBlock number of right sibling
bb_blknoPhysical block address of this block
bb_lsnLSN of the last transaction that modified this block
bb_uuidFilesystem UUID (guards against cross-filesystem recovery)
bb_ownerAG number (AG-rooted) or inode number (inode-rooted)
bb_crcCRC-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+1 child 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-treeSource of new blockSide effect logged
BNOBT / CNTBTxfs_alloc_get_freelist() — AG free listAGF header (XFS_AGF_FLFIRST, XFS_AGF_FLCOUNT)
INOBT / FINOBTxfs_alloc_vextent_near_bno() — AG free spaceAGF + AGI block counter
RMAPBTxfs_alloc_get_freelist() — AGFLAGF agf_rmap_blocks, space reservation
BMBTxfs_alloc_vextent_near_bno() — data AGInode 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:

WhatFields loggedxfs_btree_log_block() flags
Right block — all header fieldsmagic, level, numrecs, both sibling ptrs, blkno, LSN, UUID, ownerXFS_BB_ALL_BITS (excludes bb_crc)
Right block — data (leaf)records 1..rrecsvia xfs_btree_log_recs()
Right block — data (node)keys 1..rrecs, ptrs 1..rrecsvia xfs_btree_log_keys() + xfs_btree_log_ptrs()
Left block — changed headerbb_numrecs, bb_rightsibXFS_BB_NUMRECS | XFS_BB_RIGHTSIB
Right-right sibling (if exists)bb_leftsibXFS_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_LEFTSIB only)

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:

BufferLog itemsCondition
AG header (AGF or AGI)XFS_LI_BUFAlways — block allocation modifies AG header
New right block headerXFS_LI_BUF (XFS_BB_ALL_BITS)Always
New right block data (recs/keys/ptrs)XFS_LI_BUFAlways
Left block headerXFS_LI_BUF (XFS_BB_NUMRECS | XFS_BB_RIGHTSIB)Always
Right-right sibling headerXFS_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:

  1. For each log item, call xlog_recover_commit_buffer/inode/dquot().
  2. Overwrite on-disk metadata with the logged versions.
  3. 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 on xc_ctx_lock write 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_icloglock for 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, xfsaild builds 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 xfsaild encounters 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_flush accumulates 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: xfsaild uses trylock on all buffers and returns XFS_ITEM_LOCKED immediately if the lock is unavailable. Under heavy concurrent writeback, many buffers may be locked by page writeback or other kernel paths. Items returning LOCKED count against the stuck threshold (100 items), triggering the backoff before the target LSN is reached.

Bottleneck — single-threaded design: xfsaild processes the AIL serially. Each iteration submits buffers via xfs_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 means xfsaild drops and reacquires ail_lock for 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_force latency increases (callers sleeping on l_write_head).
  • xfs_buf_delwri_submit_nowait returns non-zero repeatedly (sets ail_log_flush each time), causing redundant CIL flushes.
  • /proc/fs/xfs/stat counters xs_push_ail_pinned and xs_push_ail_locked grow faster than xs_push_ail_success.
  • xfsaild wakes with tout=20 continuously (>90% contention threshold crossed).

Tuning levers:

  • Larger log: more space between head and tail gives xfsaild more 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 by xfsaild.

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 stepItems logged
Inode size/extent count updateXFS_LI_INODE
BMBT (extent map B-tree) blockXFS_LI_BUF × (tree height)
AGF header updateXFS_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.
  • dmesg may show XFS: xlog_grant_log_space: sleep if debug logging enabled.
  • iostat shows 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_ail stalled; xs_push_ail_success near 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 fsync calls force CIL pushes on every call. Batching fsync (e.g. using sync_file_range or 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:

  • perf or ftrace shows high time in _raw_spin_lock called from xlog_write_iclog() or xlog_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=262144 sets 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 on l_reserve_head even though l_write_head has 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_pinned dominates over xs_push_ail_success.
  • xfsaild sleep time is consistently 20–50 ms (backoff mode).
  • ail_log_flush counter 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_locked grows alongside xs_push_ail_pinned.
  • High iowait on the metadata device during writeback storms.
  • xfsaild alternates between 0 ms (making progress) and 20 ms (backoff) with no clear pattern.

Mitigations:

  • Reduce concurrent writeback pressure via vm.dirty_background_ratio and vm.dirty_ratio.
  • On NVMe, increase the nr_requests queue 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:

  • xfsaild CPU usage is consistently high (near 100% of one core).
  • Log device I/O queue is not saturated — xfsaild is the bottleneck, not the device.
  • Cursor restarts are frequent: xfsaild repeatedly 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_centisecs delays the page cache writeback that competes with xfsaild, 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 on xc_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 rmapbt or reflink if the workload does not require them. These features cannot be disabled after mkfs.
  • 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.
  • dmesg shows XFS: starting recovery followed by a long gap before XFS: 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_lsn closer to head_lsn at 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

LayerWhat is amplifiedCIL mitigation
Fundamental WALEvery metadata write appears twice (log + disk)None — inherent to WAL
ReloggingHot items logged once per transactionYes — once per checkpoint
Shadow buffer copies3 in-memory copies before diskUnavoidable cost of lock-free commit
B-tree cascade10–40 buffer items per file operationPartial — items are batched per checkpoint
Intent/Done pairs2 log records per multi-step operationPartial — both records batched in checkpoint
Iclog paddingUp to 512 bytes wasted per transactionYes — padding amortised across checkpoint
Optional B-trees2× log traffic with rmapbt + reflinkNone — structural overhead

Summary of Critical Paths

PathKey bottleneck
Transaction allocationl_reserve_head FIFO wait when log is full
CIL commitxc_ctx_lock contention during context switch
CIL push → iclog writel_icloglock on every 32 KB iclog block
Iclog I/O completionBlock device latency
AIL push — device throughputxfsaild delwri queue depth vs. device bandwidth
AIL push — pinned itemsLong-running transactions pin tail; ail_log_flush triggers CIL force
AIL push — buffer contentiontrylock failures accumulate; 100-item stuck threshold triggers backoff
AIL push — single-threadedSequential traversal; cursor restarts on concurrent deletion
AIL cluster flushail_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
RecoveryLog 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

FilePurpose
fs/xfs/xfs_log.cMain log manager, iclog state machine
fs/xfs/xfs_log_cil.cDelayed logging, CIL push worker
fs/xfs/xfs_trans_ail.cAIL daemon (xfsaild), push loop, tail assignment, cursor management
fs/xfs/xfs_trans_priv.hxfs_ail and xfs_ail_cursor structure definitions
fs/xfs/xfs_inode_item.cxfs_inode_item_push(), cluster flush dispatch
fs/xfs/xfs_buf_item.cxfs_buf_item_push(), buffer trylock and delwri queue
fs/xfs/xfs_log_recover.cCrash recovery, two-pass replay
fs/xfs/xfs_log_priv.hInternal structures (xlog, xlog_in_core, xfs_cil)
fs/xfs/xfs_log.hPublic logging API
fs/xfs/libxfs/xfs_log_format.hOn-disk format (xlog_rec_header, item types)
Documentation/filesystems/xfs/xfs-delayed-logging-design.rstAuthoritative design document