Catching Slow File I/O with a Filtered Extended Events Session

Duration speed-gate catching a slow file I/O operation among fast SQL Server I/O packets

sys.dm_io_virtual_file_stats will tell you that one of your data files has an average read latency of 45 milliseconds, which is bad, but it is an average since the file was opened. It cannot tell you whether that 45 ms is a steady drizzle of slightly-slow reads or a flat-calm file that froze for two seconds at 3 a.m. during a backup. Averages hide exactly the spikes you are being paged about. To see the individual slow I/Os, with the file, the size, and the offset, you need to watch each operation as it completes.

SQL Server raises an event for every completed file read and every completed file write, and you can capture them with Extended Events. The catch is volume: a busy instance does tens of thousands of these per second, and capturing all of them would cost more than the problem. This is the fifth and final post in the Extended Events for the working DBA series, and it is built on the technique that makes high-frequency events usable: filter at the session, so SQL Server only ever materialises the events you care about.

The Predicate Earns Its Keep

file_read_completed and file_write_completed each carry a duration field, in microseconds, for how long the I/O took.[1] Put a WHERE clause on that duration and the session discards every fast, healthy I/O before it is ever serialised, keeping only the outliers. This is the same lesson as the error session in the third post: the predicate is what makes a firehose event safe to turn on. Here the stakes are higher, because without the filter you would be capturing essentially all storage activity on the instance.

The threshold is a number of microseconds. A value of 100000 (100 ms) captures genuinely slow I/O without much noise; lower it toward 20000 (20 ms) on storage you expect to be fast, such as NVMe, to catch latency that still matters there. Set collect_path so each event records which file it touched, and attach the database name as an action so you do not have to resolve it later.

Two of the WITH options differ deliberately from the earlier posts. EVENT_RETENTION_MODE is ALLOW_MULTIPLE_EVENT_LOSS rather than single-event loss, because for sampling slow I/O it is far better to drop a few events under pressure than to let the session slow down the very workload you are measuring. And MAX_DISPATCH_LATENCY is a relaxed 30 seconds, since nothing here is time-critical to the second. This is a sampling tool, not an audit.

Shredding to Name the File

The ring buffer now holds only slow I/Os. Shred them as in the earlier posts, but with one addition that turns raw ids into something readable: join the database_id and file_id from each event to sys.databases and sys.master_files, so the output names the database and the physical file rather than making you look them up.[2]

Note the unit conversion. The captured duration is microseconds; dividing by 1000 gives milliseconds, the unit everyone actually talks about when discussing storage latency. Sorting by the raw microsecond value puts the worst offenders on top:

That is a different and more actionable picture than an average. A 1.8-second read against the SalesDB data file names the database, the file, and the moment, so you can line it up against what else was running, a backup, an index rebuild, a noisy neighbour on shared storage. The tempdb write near the top points at spill or version-store pressure. None of this is visible in a latency average; all of it falls out of capturing the individual slow operations.

Turn It Off When You Are Done

Unlike the deadlock, blocked process, and error sessions, which are cheap enough to leave running permanently, this one is a diagnostic you turn on to investigate a specific complaint and turn off afterward. Even filtered, file I/O events are high frequency, and there is no reason to pay for the session once you have your answer.

Caveats

  • Duration is microseconds. Both the predicate and the captured value are in microseconds. Filtering on > 100 when you meant 100 ms would capture essentially everything. 100 ms is 100000.
  • The filter is not optional. Without the duration predicate this session captures all file I/O on the instance. Never run these events unfiltered on a busy server.
  • This is a sampling tool. ALLOW_MULTIPLE_EVENT_LOSS means events can be dropped under load by design. Do not use this to audit every I/O; use it to find the slow ones.
  • It is a session-on, session-off diagnostic. Stop and drop it when the investigation is over. The cheap, always-on sessions in this series are the deadlock, blocked process, and error ones, not this.
  • Pair it with the file stats DMV. sys.dm_io_virtual_file_stats gives you the averages and totals; this session gives you the individual spikes. Use them together: the DMV to spot which file is slow on average, this session to see the shape of the slowness.

The Takeaway, and the Series

File I/O latency is the case where the filter is not a nicety but the whole point. Capture file_read_completed and file_write_completed with a duration predicate, shred the ring buffer, and join to sys.master_files, and a vague “storage was slow” turns into a ranked list of the exact operations that stalled, named down to the file and the millisecond. Turn it on to investigate, turn it off when you are done.

That closes out the series. Across five posts the same handful of moves kept coming back: define a session that captures exactly the event you need, filter at the source so you keep signal and discard noise, persist the ring buffer into a table when you want history, and shred the XML into columns you can group and rank. The events change, deadlocks, blocking, errors, the system_health firehose, file I/O, but the pattern does not. Once it is muscle memory, Extended Events stops being the intimidating replacement for Profiler and becomes the first thing you reach for.

Which of these will you set up first, or what would you add to the list? I would like to hear how you use Extended Events. Find me on Bluesky or LinkedIn.

For more on shredding a ring buffer target specifically, see Reading the ring buffer target, which goes through the XML in more detail than there was room for here.

More in this series

References

  1. Extended Events – Microsoft Learn. The file_read_completed and file_write_completed events and the duration, size, and path fields they carry.
  2. sys.master_files (Transact-SQL) – Microsoft Learn. Joining database_id and file_id to name the physical file behind an I/O event.