Skip to content

Add I/O statistics: totals of what a run read and wrote - #1036

Merged
rapids-bot[bot] merged 8 commits into
rapidsai:mainfrom
madsbk:summary-monitor
Aug 22, 2026
Merged

Add I/O statistics: totals of what a run read and wrote#1036
rapids-bot[bot] merged 8 commits into
rapidsai:mainfrom
madsbk:summary-monitor

Conversation

@madsbk

@madsbk madsbk commented Aug 18, 2026

Copy link
Copy Markdown
Member

Builds on the observation facility from #1033. That PR gives a callback per user-facing I/O call, and this one gives the answer most people actually want from it, which is what a run did in total.

SummaryMonitor registers itself on construction and accumulates while it exists. Summary carries the operations, the bytes, the errors, and the time at least one operation was in flight, which is what distinguishes an I/O-bound run from a compute-bound one.

Using it

with kvikio.SummaryMonitor() as monitor:
    ...
print(monitor.get())

In C++ the constructor takes an optional callback, which runs on destruction, so a program can report its own I/O without touching the code that performs it.

kvikio::statistics::SummaryMonitor const monitor{
  [](kvikio::statistics::Summary const& summary) { std::cout << summary.report(); }};

An interval is the difference of two readings, so periodic reporting takes one reading per tick and differences it against the last. Taking the reading and the interval separately would leave a gap that an operation could fall into twice.

baseline = monitor.get()
while running:
    time.sleep(interval)
    now = monitor.get()
    report(now.since(baseline))
    baseline = now

The report

Printing a summary, or report(), gives a report meant for a person, always the same shape so two runs can be compared line by line. This is python/kvikio/examples/hello_world.py:

KvikIO I/O summary
  wall time            251.72 ms
  busy time            5.95 ms (2.36 % of the wall time)
  busy bandwidth       538.15 kB/s
  operations           5 (4 read, 1 write)
  time per operation   1.19 ms mean, 3.19 ms longest
  bytes                3.12 KiB of 3.12 KiB requested (2.34 KiB read, 800 B written)
  errors               0
  backend POSIX        3.12 KiB in 5 ops, 5.95 ms, 538.15 kB/s
  backend GDS          unused
  backend MMAP         unused
  backend REMOTE_HTTP  unused
  backend REMOTE_HDFS  unused

Five operations for one write and four reads, however many reads KvikIO issued underneath, since the observations are logical.

Busy time is the union of the operations' spans, so overlapping work counts once and the gaps between calls count as idle. Busy bandwidth divides by that rather than by the wall time, so a program that reads for 10 ms and then computes for 90 ms is not reported as ten times slower than its storage really is. The backend rows are the only place the report says whether a read reached cuFile or fell back to POSIX, which compatibility mode decides per call.

Overhead

On my local workstation, 32 cores, medians over two million observed operations, so an order of magnitude rather than a specification.

per operation
no monitor registered 4.8 ns
SummaryMonitor 64 to 79 ns

This includes everything, both the observation facility from #1033 stamping and dispatching each operation, and the accumulation this PR adds on top. So a monitor costs about 2 % of a 4 KiB pread() and 0.25 % of a 1 MiB one. Registering none is the 4.8 ns row, one relaxed atomic load per operation.

Eight threads doing nothing but emitting observations cost 320 ns each, every operation taking the monitor's lock to add itself to the totals. Real work in between makes it disappear.

Follow-ups

  • What KvikIO spends on itself, the bounce buffers it allocates, the connections it opens, the time inside the file system. The next PR adds those counters and surfaces them on Summary.
  • A record per operation, which a summary cannot give: a timeline monitor and a sampling monitor.
  • ObservationKind::PHYSICAL. Everything here is logical, one user-facing call being one operation. When physical operations arrive, Summary stays one type rather than splitting in two, since the fields mean the same thing at either level.

@madsbk madsbk self-assigned this Aug 18, 2026
@madsbk madsbk added improvement Improves an existing functionality non-breaking Introduces a non-breaking change labels Aug 18, 2026
@madsbk
madsbk force-pushed the summary-monitor branch 3 times, most recently from a28c446 to 2a27152 Compare August 19, 2026 06:42
Comment thread cpp/examples/basic_io.cpp

using namespace std;

class Timer {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can now use the new Monitor instead of this homemade Timer

@@ -0,0 +1,78 @@
/*

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@madsbk
madsbk force-pushed the summary-monitor branch 5 times, most recently from da11e39 to b0a5e4e Compare August 19, 2026 11:08
@madsbk
madsbk marked this pull request as ready for review August 19, 2026 12:41
@madsbk
madsbk requested review from a team as code owners August 19, 2026 12:41
@rapidsai rapidsai deleted a comment from copy-pr-bot Bot Aug 19, 2026
Comment thread python/kvikio/kvikio/_lib/statistics.pyx
Comment thread cpp/include/kvikio/detail/string_utils.hpp Outdated
Comment thread cpp/src/statistics/summary.cpp

@kingcrimsontianyu kingcrimsontianyu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great framework. This opens doors to many statistics we want to collect such as p50/p90/ p99 latency and probably TTFB (time to first byte).

@madsbk

madsbk commented Aug 22, 2026

Copy link
Copy Markdown
Member Author

/merge

@rapids-bot
rapids-bot Bot merged commit 9b720b1 into rapidsai:main Aug 22, 2026
67 checks passed
rapids-bot Bot pushed a commit to NVIDIA/cudf that referenced this pull request Aug 25, 2026
This PR enables KvikIO statistics on every rank and gathers them on the client. Depends on [rapidsai/kvikio#1036](rapidsai/kvikio#1036), which adds the monitor that does the counting.

Each rank turns on counting when the engine is configured with `statistics=True`, and `StreamingEngine.gather_io_summary()` brings back one `kvikio.Summary` per rank, keyed by rank index.

```python
options = StreamingOptions(statistics=True)
with SPMDEngine(
    rapidsmpf_options=options.to_rapidsmpf_options(),
    executor_options=options.to_executor_options(),
    engine_options=options.to_engine_options(),
) as engine:
    pl.scan_parquet(path).select(pl.col("a").sum()).collect(engine=engine)
    for rank, summary in engine.gather_io_summary().items():
        print(f"--- rank {rank} ---")
        print(summary)
```

### What a rank reports

KvikIO renders the report, so this PR formats nothing. A single-rank run of a parquet scan:

```
KvikIO I/O summary
  wall time            122.55 ms
  busy time            18.40 ms (15.02 % of the wall time)
  busy bandwidth       66.44 MB/s
  operations           12 (12 read, 0 write)
  mean duration        3.90 ms
  bytes                1.17 MiB of 1.17 MiB requested (1.17 MiB read, 0 B written)
  errors               0
  backend POSIX        1.17 MiB in 12 ops, 46.83 ms, 26.11 MB/s
  backend GDS          unused
  backend MMAP         unused
  backend REMOTE_HTTP  unused
  backend REMOTE_HDFS  unused
```

**Busy time** counts only the stretches with a read in flight, so **busy bandwidth** measures the storage rather than the query: this scan spent 15 % of its wall time reading, and dividing by the whole span would have reported it at a tenth of the rate the disk was really giving.

### In the benchmarks

The PDS runners record the per-rank summaries on each iteration's record when `--rapidsmpf-statistics` is passed, so I/O stays queryable across a whole sweep rather than being printed once and lost.

`print_results_file.py` (new file) reads a results file back and prints it, since nothing existed that could. Timings and I/O side by side, one row per rank per iteration:

```
$ python -m cudf_polars.streaming.benchmarks.print_results_file pdsh-output.json

==============================================================================
run       : b6274527-a5d4-4791-9aa0-2a7a4ee3d307  (2026-08-20T08:35:11+00:00)
engine    : cudf-polars  frontend=ray
dataset   : /datasets/tpch-rs/scale-10-duckdb/  scale=10
workers   : 2  iterations=3
==============================================================================

Timings
   query  iters        min        max       mean
       1      3    0.1364s    0.3168s    0.2166s
       3      3    0.1616s    0.2154s    0.1824s
   total                                 0.3990s

I/O per rank
   query  iter  rank      ops         read       busy   busy%    bandwidth  backends
       1     0     0      796   312.09 MiB     64.0ms    9.8%     5.11GB/s  POSIX
       1     0     1      793   310.21 MiB     75.4ms   11.5%     4.31GB/s  POSIX
       1     1     0      788   306.54 MiB     40.1ms   29.1%     8.01GB/s  POSIX
       1     1     1      793   310.21 MiB     54.3ms   39.5%     5.99GB/s  POSIX
       1     2     0      788   306.54 MiB     37.3ms   18.9%     8.61GB/s  POSIX
       1     2     1      793   310.21 MiB     49.7ms   25.1%     6.54GB/s  POSIX
       3     0     0     1485   553.21 MiB     91.2ms   42.0%     6.36GB/s  POSIX
       3     0     1      793   339.17 MiB     82.1ms   37.8%     4.33GB/s  POSIX
       3     1     0     1474   549.74 MiB     68.6ms   40.0%     8.41GB/s  POSIX
       3     1     1      793   339.17 MiB     59.5ms   34.7%     5.98GB/s  POSIX
       3     2     0     1474   549.74 MiB     67.1ms   41.1%     8.59GB/s  POSIX
       3     2     1      793   339.17 MiB     61.3ms   37.6%     5.80GB/s  POSIX

  widest read skew: 1.63x (query 3, iteration 0)
```

### Caveats worth knowing

- **Counting is per process.** With Ray and Dask each rank has a process to itself, so a summary covers only cudf-polars. With SPMD cudf-polars shares your script's process, so KvikIO operations your own code performs are counted too.
- **Not all I/O is observed.** Per KvikIO's `Monitor` docs the cuFile async API on a working GDS system and the batch API report nothing, and anything cudf-polars reads outside KvikIO is invisible.
- **Timestamps are per rank.** Each monitor takes its own clock anchor, so across hosts the start and end times carry whatever NTP skew exists. Ratios within a rank, `busy_fraction` and the bandwidths, are unaffected.

Authors:
  - Mads R. B. Kristensen (https://github.com/madsbk)

Approvers:
  - Peter Andreas Entschev (https://github.com/pentschev)
  - Tom Augspurger (https://github.com/TomAugspurger)

URL: #23738
@madsbk
madsbk deleted the summary-monitor branch August 27, 2026 11:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

improvement Improves an existing functionality non-breaking Introduces a non-breaking change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants