-
Notifications
You must be signed in to change notification settings - Fork 5
Statistics collection and processing
Opal collects statistics at four layers that feed into each other, from the finest to the coarsest granularity:
-
Per-request timestamps — every
LLMRequestcarries anLLMRequestStatsobject (opal/core/request.py) that records when the request hit each stage of its lifecycle (creation, router arrival, worker arrival, KVC lookup, GPU start/end, completion). -
Per-stage aggregation — a
StageStatisticsobject (opal/stats/stage_statistics.py) per workload stage collects one entry per finished request (latency, TTFT, queueing time, KVC hit tokens, tier breakdown, etc.) plus running counters (finished/queued/failed requests). -
Per-second sampling — the
Router's_per_second_statsprocess (opal/router/router.py) samples system-wide throughput, active worker count, and average GPU utilization once per simulated second, appending to the sameStageStatisticsobject. -
Final output — at the end of each stage (and at the end of the whole run),
StageStatisticsis rendered to a human-readable summary (printed to stdout and appended tosimulation.log), optionally plotted to PDF, and always serialized toopal_stats.json.
This page walks through each layer using the actual field and method names in the code. For the list of output files this produces, see the README's "What are simulation outputs" section — it is not repeated here.
Defined in opal/core/request.py. Every LLMRequest gets one LLMRequestStats instance (request.stats) at creation time. It stores a numbered sequence of raw timestamps:
_1_creation_time # request created at the workload generator
_2a_router_arrival_time # arrival at the router
_2b_worker_time # arrival at the vLLM worker
_3_start_processing_time # scheduler looks at the request (KVC lookup starts)
_4_request_ready_time # request is READY (KVC fetched, or no prefix match)
_5_gpu_start_time # added to a GPU batch
_6_prefill_done_time # prefill finished (note: mark_prefill_done() actually
# writes this into `_5_prefill_done_time`, a
# differently-named attribute set outside __init__ —
# see below)
_7_decode_done_time # decode finished / request fully done
_8_done_at_router # completion observed back at the router
plus:
-
__scheduler_timestamps— the raw list of every scheduler-step timestamp this request was touched at (appended viaadd_scheduler_timestamp()). The first__num_prefill_sched_stepsentries are prefill steps; the rest are decode steps. -
__kvc_hit_tokens— number of prefix tokens served from the KV cache (set_prefix_hit_tokens()/get_prefix_hit_tokens()). -
__kvc_tier_tokens— adict[str, int]mapping cache tier name (e.g."CPUMemory","LocalNVMe","DistributedFS", or"Cache Miss") to token count for this request (set_kvc_tier_tokens()/get_kvc_tier_tokens()).
Derived getters compute the metrics that matter downstream:
| Method | Meaning |
|---|---|
get_queue_time() |
_5_gpu_start_time - _1_creation_time — time spent anywhere before reaching the GPU. |
get_ttft() |
_5_prefill_done_time - _1_creation_time — time to first token. Set by mark_prefill_done(), which snapshots the last prefill entry out of __scheduler_timestamps. |
get_kvc_fetch_time() |
_4_request_ready_time - _3_start_processing_time — KV cache fetch/lookup latency. |
get_decode_times_including_ttft() |
[TTFT, itl_1, itl_2, ...] — TTFT as element 0, followed by the inter-token latency (delta between consecutive scheduler steps) for every decode step. |
get_total_worker_time() |
_7_decode_done_time - _2b_worker_time — end-to-end time inside the worker. |
get_gpu_time() |
last scheduler timestamp minus first — total time the request occupied the GPU scheduler (includes scheduling overhead). |
The worker that actually populates most of these fields during the scheduling loop is documented in vLLM Worker (see its "Statistics Collected" section for the mapping from scheduler phases to these timestamps — that page's field names such as worker_arrival_time, _3_start_processing_time, _4_request_ready_time, _5_gpu_start_time, _7_decode_done_time, scheduler_timestamps, and prefix_hit_tokens refer to this same LLMRequestStats object).
IORequest (also in request.py) has its own lightweight _Stats inner class (arrival_time, submission_time, completion_time, start_kvc_time, io_time) used for KV-cache I/O timing, separate from LLMRequestStats.
Defined in opal/stats/stage_statistics.py. The WorkloadOrchestrator (opal/workloads/workload_orchestrator.py) creates one StageStatistics instance per workload stage in self.stage_stats, and exposes the currently-running stage's instance via get_active_stage_stats(). Stage boundaries are marked with stage_time_start / stage_time_end, set when the orchestrator's run() loop starts/finishes each stage.
The Router (opal/router/router.py) drives most of the bookkeeping:
-
_accept_requests()incrementsget_active_stage_stats().queued_requestsas soon as a request arrives at the router — this is the "total requests seen" counter. -
_collect_completion()callsget_active_stage_stats().add_finished_request(request)once a request comes back on the results queue.
add_finished_request(request) reads request.stats (the LLMRequestStats from Layer 1) and appends one entry to each of these raw-value lists:
| Field | Source |
|---|---|
raw_latency_values |
via add_total_time_in_system({"E2E": stats.get_total_worker_time()}) — also bumps total_latency, finished_requests, and a bucketed latencies histogram (power-of-2 bins from 1s up to ~10,000,000s). |
raw_queuing_values |
stats.get_queue_time() |
raw_ttft_values |
stats.get_ttft() |
raw_kvc_io_time |
stats.get_kvc_fetch_time() |
raw_kvc_hit_tokens |
stats.get_prefix_hit_tokens() |
raw_gpu_time |
stats.get_gpu_time() |
raw_decode_values |
stats.get_decode_times_including_ttft() (list of lists, one per request) |
input_output_tokens_sz |
(request.input_length, request.output_length) tuples |
kvc_tier_tokens |
accumulated (defaultdict(int)) from stats.get_kvc_tier_tokens(), tier name -> running token total across all requests in the stage |
failed_requests exists as a counter field but is not incremented anywhere in the router/worker code as of this writing; print_summary_results() falls back to max(failed_requests, queued_requests - finished_requests) so a non-zero "Failed requests" figure can still show up in the summary even though nothing sets the field directly.
Three parallel lists are appended to once per simulated second (see next section): per_unit_req_done, per_unit_workers, per_unit_gpu_utilization, via add_per_unit_workdone(), add_per_unit_workers(), and add_per_unit_gpu_utilization() respectively.
-
calculate_user_metrics_in_ms()turnsraw_ttft_valuesandraw_decode_valuesinto(mean, median, p99)triplets for TTFT, TPOT, and ITL, converted to milliseconds. Internally_calculate_itl_tpot()splits each request's[TTFT, itl_1, itl_2, ...]array into a per-request TPOT (mean of that request's inter-token gaps) and a flat pool of every individual ITL (token-weighted) across all requests. -
get_average_latency()=total_latency / finished_requests. -
get_histogram()returns the bucketedlatenciesarray as a percentage offinished_requests. -
get_histogram_breakdown(num_bins)/_calculate_itl_tpotsupport a richer per-source latency breakdown, used byplot.plot_stacked_histogram(this particular breakdown path expectsself.latenciesto be a list of per-requestdictbreakdowns rather than the numpy histogram array that__init__actually creates it as — it is not exercised by the defaultadd_finished_requestcode path). -
print_summary_results(log_file=None)renders the vLLM-style benchmark block you see at the end of a run (Successful requests,Failed requests,Benchmark duration, throughput, TTFT/TPOT/ITL triplets, and — when any KV-cache tier tokens were recorded — aKVC Tier Token Breakdownsection with per-tier token counts and percentages). Iflog_fileis given, the same text is written there too.
to_dict() serializes every field above (numpy arrays converted via .tolist()) into a plain dict; from_dict() reconstructs a StageStatistics from that dict (calling __init__ first so bins/latencies get their default shape, then overwriting). raw_kvc_hit_tokens is read with .get(..., []) so old JSON files without that field still load. write_to_json(path) writes the dict out using a custom compact formatter (dump_json_compact_lists — one key per line, but lists/arrays kept on a single line for readability) rather than plain json.dump.
The Router runs a dedicated SimPy process, _per_second_stats() (see Router for the full list of router processes), that wakes up once every simulated second for as long as the simulation is running and pushes three samples into the currently active stage's StageStatistics:
-
stats.add_per_unit_workdone(self.cumulative_finished - last)— requests completed since the previous tick (this feedsthrp-request-sec.pdf). -
stats.add_per_unit_workers(len(self._active_workers))— how many workers are active right now (feedsthrp-workers-sec.pdf; flat if auto-scaling is disabled). -
stats.add_per_unit_gpu_utilization(utilization), whereutilization = mean(w.gpu_busy_time for w in active_workers) * 100 / now— average, since simulation start, of the fraction of time each active worker's GPU has been busy (feedsgpu-utilization-per-sec.pdf).
sample_workdone_per_K(k) (on StageStatistics, delegating to opal.utils.util.sample_series_K) can downsample any of these per-second series by averaging every k consecutive samples — used by the plotting code's per_K parameter to reduce noise on long runs.
At the end of a run, OpalSimulator._process_per_stage() (opal/core/opal.py) iterates workload_orchestrator.stage_stats and, per stage:
- Prints a
===== stage_N =====header and callsStageStatistics.print_summary_results(log_file=...), writing the vLLM-style benchmark block both to stdout and tosimulation.log(this is the mechanism behind "Write stage summary statistics to simulation.log"). - If
-g/--graphswas passed, callssimend_plot(stats, config, stage_dir)(opal/stats/plot.py), which produces the CDF, throughput, worker-count, GPU-utilization, and histogram PDFs for that stage. - Separately,
OpalSimulatorEnvironment.write_simulation_data()(opal/core/environment.py) — invoked fromOpalSimulator.run()whensimulation.save_simulation_dataistrue(see Configuration Simulation) — writes each stage's fullStageStatistics.to_dict()tostage_N/opal_stats.json.
The exact set of per-stage files (cdf-latencies.pdf, gpu-utilization-per-sec.pdf, histo-latencies.pdf, thrp-request-sec.pdf, thrp-workers-sec.pdf, opal_stats.json) and top-level files (sim_config.json, simulation.log) is documented in the README's "What are simulation outputs" and "Logging" sections — refer there for the canonical list; this page only explains how each file's content is produced.
Because StageStatistics.to_dict() / from_dict() are symmetric, the simplest way to reload a stage's stats outside of a live simulation is:
import json
from opal.stats.stage_statistics import StageStatistics
with open("simulation-runs/sim-.../stage_0/opal_stats.json") as f:
data = json.load(f)
stats = StageStatistics.from_dict(data)
print(stats.get_average_latency())
print(stats.calculate_user_metrics_in_ms()) # (ttft_mean, ttft_median, ttft_p99, tpot_mean, ...)tests/test_stage_statistics.py exercises this same machinery directly (without going through a full simulation run) and is a good second reference for exact field behavior — e.g. it confirms that raw_kvc_hit_tokens defaults to [] when reloading an older JSON file that predates that field, and that print_summary_results() works whether or not a log_file is supplied.
-
vLLM Worker — where most
LLMRequestStatstimestamps are actually set during scheduling. -
Router — full list of router-owned SimPy processes, including
_per_second_stats. -
Configuration Simulation — the
simulation.save_simulation_datakey that gates whetheropal_stats.jsongets written at all.