Skip to content

perf(metrics): gather timeslice ragged values from a per-slice index - #58

Merged
Oseltamivir merged 1 commit into
SemiAnalysisAI:masterfrom
weireweire:fix/timeslice-ragged-bucketing
Oct 19, 2026
Merged

Oseltamivir merged 1 commit into
SemiAnalysisAI:masterfrom
weireweire:fix/timeslice-ragged-bucketing

Conversation

@weireweire

@weireweire weireweire commented Oct 19, 2026 •

Copy link
Copy Markdown

Summary

Make timeslice export linear in the number of stored list-metric values. Each slice now reads only its own inter_chunk_latency values from an index built once per export, instead of scanning every stored value. Exported results and timeslices are byte-for-byte unchanged.

Cause

  • export_results (src/aiperf/metrics/accumulator.py:767) computes timeslices whenever slice_duration is set. The agentx preset sets it to 1 s.
  • _compute_timeslices (accumulator.py:798) loops over every slice (:859) and calls _compute_results for each one (:875).
  • _compute_results -> _collect_scalars_and_arrays (:429) -> _collect_one_list_column (:508) -> RaggedSeries.get_values_for_mask (:543).
  • get_values_for_mask (src/aiperf/metrics/ragged_series.py:81) builds a boolean mask over all V stored values and gathers from it.
  • Total cost is O(T x V) for T slices. Both T and V grow with run length, so the cost grows with its square. Before perf: skip unused live metric computations #53 the same pass also ran on every live stats tick.

Fix

  • _build_slice_ragged_index assigns each value its record's slice, stable-sorts once per ragged column, and records each slice's range with searchsorted.
  • _collect_one_list_column takes a slice's values as values[order[start:end]]. The stable sort keeps storage order within a slice, so the array matches get_values_for_mask(full_mask) element for element, and sums and percentiles are bitwise equal.
  • All other paths, including the overall summary and the t-digest backend, still use the mask path.

Measurements

Saved records from a 3,600 s, concurrency-4096 AgentX run were replayed into MetricsAccumulator: 549,835 records, 161.5 M inter_chunk_latency values, 3,596 one-second slices, one CPU core.

stock this PR
export_results (3,596 slices) 1,717 s 141 s
export_results, first 20 K records (171 slices) 3.8 s 2.2 s

The exported results and all timeslices were compared through model_dump(). They are identical at 20 K records and at the full set. In the same run the client spent more than an hour between the end of profiling and the final export.

Tests

  • New TestTimesliceRaggedIndex (2 cases: unscoped, and phase-scoped with warmup records). It ingests 400 records with shuffled start times, empty lists, and starts on slice edges. It checks that the timeslice path no longer calls get_values_for_mask and that the export is byte-identical to the per-slice mask path.
  • tests/unit: 17,659 passed and 2 failed. Both failures also fail on master (test_completed_excludes_analyzer_metrics, test_every_numeric_field_has_bounds).
  • pre-commit run on the changed files passes, including check-ergonomics and check-ruff-baselined.

This complements #55: if timeslices become opt-in, runs that opt in still get a linear export.


Note

Medium Risk
Changes hot-path timeslice aggregation math for list metrics; behavior is intended to be identical but regressions would affect exported percentiles and sums on long AgentX runs.

Overview
Timeslice export for replay-capable ragged metrics (e.g. inter_chunk_latency) no longer scans all stored list values on every slice. Once per _compute_timeslices run, _build_slice_ragged_index buckets each value by its record’s time bin (stable sort + searchsorted ranges). While each slice is computed, _collect_one_list_column gathers that slice via values[order[start:end]] instead of RaggedSeries.get_values_for_mask, with transient _slice_ragged_index / _active_slice_bin cleared in a finally block. The per-slice loop moves into _compute_timeslice_bins; overall summary and non-replay backends still use the mask path.

Tests add TestTimesliceRaggedIndex: randomized ingest checks that timeslice export barely calls get_values_for_mask and matches byte-identical JSON to the legacy mask path (with and without phase scoping).

Reviewed by Cursor Bugbot for commit e2569e3. Bugbot is set up for automated code reviews on this repo. Configure here.

_compute_timeslices called _compute_results once per slice, and each call
gathered list-metric (ragged) values with get_values_for_mask, which scans
every stored value. Export cost was O(slices x values). Bucket each ragged
column's values by slice once with a stable sort, so a slice reads only its
own values, in the same storage order as the mask path. Exported results and
timeslices are unchanged.

Signed-off-by: weireweire <20922698+weireweire@users.noreply.github.com>
@github-actions github-actions Bot added the perf label Oct 19, 2026
@github-actions

Copy link
Copy Markdown

Try out this PR

Quick install:

pip install --upgrade --force-reinstall git+https://github.com/ai-dynamo/aiperf.git@e2569e3d128479944d49b7c454d7fcb6dfd671b9

Recommended with virtual environment (using uv):

uv venv --python 3.12 && source .venv/bin/activate
uv pip install --upgrade --force-reinstall git+https://github.com/ai-dynamo/aiperf.git@e2569e3d128479944d49b7c454d7fcb6dfd671b9

Last updated for commit: e2569e3 • Browse code

@Oseltamivir

Copy link
Copy Markdown

Thanks for the contribution!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants