Skip to content

Commit 8edb105

Browse files
authored
Merge pull request #35 from cacheMon/claude/charming-brown-jbpfzy
Capture io_uring SQE fields and mirror async I/O into the fs trace
2 parents 7399781 + 4bb5372 commit 8edb105

5 files changed

Lines changed: 306 additions & 30 deletions

File tree

docs/traces/IO_URING_EVENTS.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,25 @@
44

55
**Kernel Probes Attached:**
66
- `__io_uring_enter` / `__sys_io_uring_enter` — io_uring_enter syscall
7+
- `io_prep_rw` (or per-op `io_prep_read{,v,_fixed}` / `io_prep_write{,v,_fixed}`) — SQE field capture
78
- `io_queue_sqe` / `io_submit_sqe` — SQE submission
89
- `io_req_complete_post` / `io_req_complete` — Completion
910
- `io_wq_submit_work` — Async worker execution
1011

1112
> **Note:** The `io_uring:io_uring_submit_sqe` and `io_uring:io_uring_complete` tracepoints are disabled by default due to incompatible struct field layouts across kernel versions. The kprobe-based implementations above provide cross-kernel compatibility.
1213
14+
### SQE field capture and file correlation
15+
16+
The SUBMIT kprobe on `io_queue_sqe` only receives the internal `struct io_kiocb`, whose layout is **not** ABI-stable across kernel releases, so reading `opcode`/`fd`/`len`/`offset`/`user_data` from it directly is unreliable. Instead these fields are captured at request-**prep** time:
17+
18+
- `trace_io_uring_prep_rw` attaches to the read/write prep handler (`io_prep_rw`), which is dispatched through the opcode table (`def->prep`) and therefore is not inlined. It receives the **UAPI `struct io_uring_sqe`** (PARM2), whose leading field offsets are stable for all io_uring kernels. A minimal mirror (`io_uring_sqe_min`) reads `opcode`, `flags`, `ioprio`, `fd`, `off`, `len`, `user_data` and `buf_index`.
19+
- The same probe reads `req->file` (the first member of `struct io_kiocb` on modern kernels) and, when it is a regular file on a real filesystem, records the backing **inode**, **device** and **superblock magic** via the same helpers used by the VFS probes.
20+
- These values are staged in the `io_uring_submit_map` (keyed by the `io_kiocb` pointer) and consumed by the SUBMIT, COMPLETE and WORKER probes.
21+
22+
If the prep symbol is unavailable on a given kernel, the SUBMIT probe falls back to reading `req->file` directly for inode/device/fs, and the SQE-only fields (`opcode`, `len`, `offset`, `user_data`) simply remain empty — graceful degradation rather than failure.
23+
24+
> **Note:** This captures SQE fields for the read/write opcode families (the bulk of filesystem I/O). Other opcodes (e.g. `OPENAT`, `STATX`) are not prepped through `io_prep_rw`, so their `opcode`/`fd`/`len`/`offset` columns may be empty.
25+
1326
## Data Captured
1427

1528
| # | Field | Type | Description |
@@ -52,6 +65,12 @@
5265
| 36 | CQ Tail | `u32` | Completion queue tail (optional) |
5366
| 37 | SQ Depth | `u32` | SQ backlog (sq_tail - sq_head) |
5467
| 38 | CQ Depth | `u32` | CQ backlog (cq_tail - cq_head) |
68+
| 39 | Inode | `u64` | Backing file inode for file-backed ops; empty otherwise |
69+
| 40 | Filename | `string` | Resolved file path (from the inode→path cache populated by OPEN events); empty if unresolved |
70+
| 41 | Device | `string` | Backing device as `major:minor` (from `super_block->s_dev`); empty otherwise |
71+
| 42 | FS Type | `string` | Source filesystem name from the superblock magic (e.g. `EXT2/3/4`, `XFS`, `BTRFS`); empty otherwise |
72+
73+
> Columns 39–42 are appended to the original 38-column schema, so parsers that read only the first 38 fields are unaffected.
5574
5675
## Event Types
5776

@@ -141,6 +160,16 @@ SUBMIT and COMPLETE events share the same `Req Ptr`, enabling latency calculatio
141160
latency_ns = complete_ts_ns - submit_ts_ns
142161
```
143162

163+
## Mirroring into the fs/VFS trace
164+
165+
io_uring read/write operations call `->read_iter`/`->write_iter` directly and **never pass through `vfs_read`/`vfs_write`**, so they are invisible to the VFS probes. To make async I/O visible alongside syscall I/O, each completed io_uring read/write is also emitted into the main **fs/VFS trace** (`fs/fs_*.csv`) using the standard VFS 22-column schema:
166+
167+
- **Mirrored opcodes:** `READV`, `READ_FIXED`, `READ``READ`; `WRITEV`, `WRITE_FIXED`, `WRITE``WRITE`.
168+
- **Trigger:** COMPLETE events only (so `bytes_completed`/`duration_ns` are known), and only when a backing inode was resolved.
169+
- **Columns:** filename/inode/device/fs_type come from the prep-time file capture; `size` is the SQE length, `bytes_completed`/`errno` from the CQE result, `duration_ns` from the submit→complete latency. The generic `flags` column carries the decoded **SQE flags** (`FIXED_FILE|ASYNC|IO_LINK…`) in place of the open-file `O_*` flags, which are not available on the io_uring path.
170+
171+
`fsync` is intentionally **not** mirrored: io_uring `FSYNC` calls `vfs_fsync` internally and is therefore already captured by the VFS fsync probe — mirroring it would double-count. The full async-specific detail (req_ptr, user_data, worker, queue depths) always remains in the dedicated io_uring CSV.
172+
144173
## Analysis Use Cases
145174

146175
This data enables:

docs/traces/VFS_EVENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
- `vfs_fallocate` — File space pre-allocation operations
2323
- `do_sendfile` / `__do_sendfile` — Efficient file-to-file transfer operations
2424

25+
> **io_uring-origin rows:** `READ`/`WRITE` operations issued via io_uring bypass `vfs_read`/`vfs_write` (they call `->read_iter`/`->write_iter` directly), so they are mirrored into this trace from the io_uring instrumentation rather than captured by a VFS probe. They use the same schema; their `flags` column carries io_uring SQE flags (`FIXED_FILE|ASYNC|…`) instead of `O_*` flags, and `ppid`/`container_id` are empty. See [IO_URING_EVENTS.md](IO_URING_EVENTS.md#mirroring-into-the-fsvfs-trace). Each such row also has a full-detail counterpart in the io_uring CSV.
26+
2527
## Filename Resolution
2628

2729
The `filename` field contains the best available path for the file at event time. Full absolute paths are resolved entirely inside the kernel at probe time before the process can exit, so even output from short-lived processes (e.g. `cat`, `ls`) contains correct paths.

src/tracer/IOTracer.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -800,6 +800,31 @@ def _print_event_io_uring(self, cpu, data, size):
800800
sq_depth = str(e.sq_depth) if e.sq_depth else ""
801801
cq_depth = str(e.cq_depth) if e.cq_depth else ""
802802

803+
# File correlation — the prep probe records the backing file's inode,
804+
# device and filesystem for file-backed ops. Resolve the path from the
805+
# inode→path cache populated by OPEN events (same strategy as VFS events)
806+
# so io_uring I/O carries the same file identity as the fs trace.
807+
inode_val = e.inode if getattr(e, "inode", 0) else ""
808+
dev_val = self._format_dev(e.dev) if getattr(e, "dev", 0) else ""
809+
fs_type_val = (
810+
self.flag_mapper.format_fs_type(e.fs_magic)
811+
if getattr(e, "fs_magic", 0) else ""
812+
)
813+
filename = ""
814+
if getattr(e, "inode", 0):
815+
cached = self.path_resolver.inode_to_path.get(e.inode)
816+
if cached:
817+
filename = cached
818+
if self.anonymous and filename:
819+
filename = hash_filename_in_path(Path(filename))
820+
821+
# Surface io_uring file READ/WRITE in the main fs/VFS trace stream so
822+
# async I/O appears alongside syscall reads/writes. Mirror only on
823+
# COMPLETE (result/latency known) and only read/write opcodes, which
824+
# bypass vfs_read/vfs_write and would otherwise be invisible there.
825+
if e.event_type == 2:
826+
self._mirror_io_uring_to_fs(e, comm, filename, ts, dev_val, fs_type_val)
827+
803828
# Build CSV row matching the unified schema from the guide
804829
output = format_csv_row(
805830
ts.strftime("%Y-%m-%d %H:%M:%S.%f"),
@@ -840,9 +865,76 @@ def _print_event_io_uring(self, cpu, data, size):
840865
cq_tail,
841866
sq_depth,
842867
cq_depth,
868+
inode_val,
869+
filename,
870+
dev_val,
871+
fs_type_val,
843872
)
844873
self.writer.append_io_uring_log(output)
845874

875+
def _mirror_io_uring_to_fs(self, e, comm, filename, ts, dev_val, fs_type_val):
876+
"""
877+
Emit an fs/VFS-shaped row for a completed io_uring file READ/WRITE.
878+
879+
io_uring read/write operations call ``->read_iter``/``->write_iter``
880+
directly and never pass through ``vfs_read``/``vfs_write``, so they are
881+
invisible to the VFS probes. To make async I/O visible alongside
882+
syscall I/O, this mirrors COMPLETE events for the read/write opcode
883+
families into the fs log using the same 22-column schema as
884+
``_print_event``.
885+
886+
fsync is intentionally not mirrored: io_uring FSYNC calls ``vfs_fsync``
887+
internally and is therefore already captured by the VFS fsync probe;
888+
mirroring it would double-count.
889+
890+
Args:
891+
e: The io_uring perf event.
892+
comm: Decoded process name.
893+
filename: Resolved file path (may be empty).
894+
ts: Event datetime (matches the fs-log timestamp format).
895+
dev_val: Pre-formatted ``major:minor`` device string.
896+
fs_type_val: Pre-formatted filesystem name.
897+
"""
898+
# IORING_OP_* read/write opcode families → unified fs operation name.
899+
op_map = {
900+
1: "READ", # READV
901+
4: "READ", # READ_FIXED
902+
22: "READ", # READ
903+
2: "WRITE", # WRITEV
904+
5: "WRITE", # WRITE_FIXED
905+
23: "WRITE", # WRITE
906+
}
907+
op_name = op_map.get(e.opcode)
908+
if not op_name or not getattr(e, "inode", 0):
909+
return
910+
911+
ret = e.result
912+
return_value = str(ret)
913+
errno_val = ""
914+
bytes_completed = ""
915+
if ret < 0:
916+
errno_val = self.flag_mapper.format_errno(-ret)
917+
else:
918+
bytes_completed = str(ret)
919+
duration_ns = str(e.latency_ns) if e.latency_ns else ""
920+
921+
offset_val = e.offset if e.offset else ""
922+
tid_val = e.tid if e.tid else ""
923+
size_val = e.len if e.len else 0
924+
# io_uring rows carry the SQE flags (FIXED_FILE|ASYNC|IO_LINK…) in the
925+
# generic flags column in place of the open-file O_* flags, which are
926+
# not available on the io_uring path.
927+
flags_val = self.flag_mapper.format_io_uring_sqe_flags(e.sqe_flags)
928+
cmdline = self._read_cmdline_cached(e.pid)
929+
930+
output = format_csv_row(
931+
ts, op_name, e.pid, comm, filename, size_val, e.inode,
932+
flags_val, offset_val, tid_val, "", "", "", cmdline,
933+
return_value, errno_val, bytes_completed, duration_ns,
934+
dev_val, "", "", fs_type_val
935+
)
936+
self.writer.append_fs_log(output)
937+
846938
def _cleanup(self, signum, frame):
847939
self.running = False
848940
self.probe_tracker.detach_kprobes()

src/tracer/KernelProbeTracker.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,33 @@ def attach_probes(self):
387387
if self.developer_mode:
388388
logger("warning", "io_uring_enter probe not available - ENTER events disabled")
389389

390+
# io_uring SQE field capture (opcode/fd/len/offset/user_data + backing
391+
# file). The SUBMIT kprobe on io_queue_sqe only has the io_kiocb, whose
392+
# layout is not ABI-stable; the read/write prep handler receives the
393+
# UAPI io_uring_sqe (stable offsets) and the io_kiocb, so we capture the
394+
# SQE fields there and stage them for the SUBMIT/COMPLETE probes. The
395+
# shared helper io_prep_rw covers all rw opcodes; fall back to the
396+
# per-op prep handlers when it is inlined/renamed on a given kernel.
397+
prep_attached = False
398+
for sym in (b'io_prep_rw', b'__io_prep_rw'):
399+
if BPF.get_kprobe_functions(sym):
400+
self.add_kprobe(sym.decode(), "trace_io_uring_prep_rw")
401+
prep_attached = True
402+
if self.developer_mode:
403+
logger("info", f"io_uring SQE capture enabled via {sym.decode()}")
404+
break
405+
if not prep_attached:
406+
for sym in (b'io_prep_readv', b'io_prep_writev',
407+
b'io_prep_read', b'io_prep_write',
408+
b'io_prep_read_fixed', b'io_prep_write_fixed'):
409+
if BPF.get_kprobe_functions(sym):
410+
self.add_kprobe(sym.decode(), "trace_io_uring_prep_rw")
411+
prep_attached = True
412+
if self.developer_mode and prep_attached:
413+
logger("info", "io_uring SQE capture enabled via per-op prep handlers")
414+
if not prep_attached and self.developer_mode:
415+
logger("warning", "io_uring SQE prep probe not available - opcode/fd/len/offset may be empty")
416+
390417
# io_uring SQE submission probe (kprobe fallback for SUBMIT events)
391418
# Note: TRACEPOINT_PROBE(io_uring, io_uring_submit_sqe) in BPF is preferred
392419
if BPF.get_kprobe_functions(b'io_queue_sqe'):

0 commit comments

Comments
 (0)