feat(perf): add workload_trace dataset plugin for production traffic replay#1494
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces the workload_trace dataset plugin, enabling the replay of recorded production traffic traces with their original arrival patterns in open-loop mode. It updates the argument parser, HTTP client, and open-loop strategy to handle per-request metadata, such as arrival offsets, custom headers, and request IDs. Feedback on the changes suggests several robustness and performance improvements, including replacing Pydantic models with plain dictionaries for trace records to reduce overhead, handling stringified epoch timestamps during normalization, preserving the list type of query_parameters.number, and adding defensive checks to prevent potential KeyError or AttributeError exceptions.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| def _compute_schedule(self) -> np.ndarray: | ||
| if not self._records: | ||
| return np.array([]) | ||
| t0 = self._records[0].timestamp | ||
| return np.array([(r.timestamp - t0) / self._config.speed for r in self._records]) |
There was a problem hiding this comment.
Update _compute_schedule to use dictionary access instead of object attribute access, matching the optimized dict-based record storage.
| def _compute_schedule(self) -> np.ndarray: | |
| if not self._records: | |
| return np.array([]) | |
| t0 = self._records[0].timestamp | |
| return np.array([(r.timestamp - t0) / self._config.speed for r in self._records]) | |
| def _compute_schedule(self) -> np.ndarray: | |
| if not self._records: | |
| return np.array([]) | |
| t0 = self._records[0]['timestamp'] | |
| return np.array([(r['timestamp'] - t0) / self._config.speed for r in self._records]) |
| def build_messages(self) -> Iterator[Dict[str, Any]]: | ||
| match_output = self._config.match_output_length | ||
| for i, record in enumerate(self._records): | ||
| body = dict(record.body) | ||
| self._rewrite_model(body) | ||
|
|
||
| if match_output and record.completion_tokens is not None: | ||
| body['max_tokens'] = record.completion_tokens | ||
| body['ignore_eos'] = True | ||
|
|
||
| body[BODY_META_ARRIVAL_OFFSET] = float(self._schedule[i]) | ||
| if i < self._warmup_count: | ||
| body[BODY_META_IS_WARMUP] = True | ||
| if record.headers: | ||
| body[BODY_META_HEADERS] = dict(record.headers) | ||
| if record.request_id: | ||
| body[BODY_META_REQUEST_ID] = record.request_id | ||
|
|
||
| yield body |
There was a problem hiding this comment.
Update build_messages to use dictionary access instead of object attribute access, matching the optimized dict-based record storage.
def build_messages(self) -> Iterator[Dict[str, Any]]:
match_output = self._config.match_output_length
for i, record in enumerate(self._records):
body = dict(record['body'])
self._rewrite_model(body)
completion_tokens = record.get('completion_tokens')
if match_output and completion_tokens is not None:
body['max_tokens'] = completion_tokens
body['ignore_eos'] = True
body[BODY_META_ARRIVAL_OFFSET] = float(self._schedule[i])
if i < self._warmup_count:
body[BODY_META_IS_WARMUP] = True
headers = record.get('headers')
if headers:
body[BODY_META_HEADERS] = dict(headers)
request_id = record.get('request_id')
if request_id:
body[BODY_META_REQUEST_ID] = request_id
yield body| Returns: | ||
| (headers, request_id) | ||
| """ | ||
| extra_headers = body.pop(BODY_META_HEADERS, None) |
There was a problem hiding this comment.
a7e9084 to
4d91f4b
Compare
There was a problem hiding this comment.
Pull request overview
Adds a new workload_trace perf dataset plugin to replay recorded JSONL production traffic with preserved request bodies and a trace-derived open-loop arrival schedule, enabling more realistic performance benchmarking than synthetic generators.
Changes:
- Introduces
workload_tracedataset plugin with timestamp parsing, model rewriting options, warmup marking, and optional output-length matching. - Adds “sideband” body-meta keys and a common
extract_body_meta()path to strip them and apply per-request headers / request IDs during HTTP dispatch. - Updates open-loop strategy to support trace-provided arrival offsets and defers
--durationaccounting until after warmup for trace-offset replay.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
evalscope/perf/plugin/datasets/workload_trace.py |
Implements JSONL trace loading/validation, schedule computation, and message construction with body-meta sidebands. |
evalscope/perf/utils/body_meta.py |
Defines reserved __evalscope_* sideband keys used across the perf pipeline. |
evalscope/perf/plugin/api/base.py |
Adds extract_body_meta() to strip sideband keys and merge per-request headers; returns request correlation ID. |
evalscope/perf/core/http_client.py |
Invokes extract_body_meta() before request processing and propagates request IDs into result objects. |
evalscope/perf/core/strategies/open_loop.py |
Adds a dispatch path that uses trace-provided arrival offsets and handles warmup/deadline behavior for replay. |
evalscope/perf/plugin/api/openai_api.py |
Avoids writing model=None into the outgoing payload when the model is carried by the dataset body. |
evalscope/perf/plugin/datasets/base.py |
Adds dataset capability flags (provides_arrival_schedule, requires_model) for validation and scheduling behavior. |
evalscope/perf/plugin/datasets/__init__.py |
Registers the new dataset plugin import. |
evalscope/perf/benchmark.py |
Re-reads counts after dataset plugin construction so plugins can adjust number/warmup parameters. |
evalscope/perf/arguments.py |
Makes --model/--number optional at CLI level and validates them based on dataset capabilities; relaxes open-loop --rate validation for schedule-providing datasets. |
tests/perf/test_workload_trace.py |
Adds unit coverage for loading, rewriting, timestamp parsing, warmup marking, and match-output-length behavior. |
tests/perf/test_workload_trace_e2e.py |
Adds end-to-end tests with an in-process aiohttp server to validate timing and body/header propagation through the full pipeline. |
| if actual_deadline is not None: | ||
| sleep_s = min(sleep_s, actual_deadline - time.perf_counter()) | ||
| if sleep_s > 0: | ||
| await asyncio.sleep(sleep_s) | ||
| task = asyncio.create_task( | ||
| _send_request_open_loop(request, is_warmup or is_warmup_req, self.queue, self.client) | ||
| ) |
There was a problem hiding this comment.
I think it's fine for --duration as a soft deadline, but happy to harden if needed.
| def _load_and_validate(self) -> None: | ||
| errors: List[str] = [] | ||
| path = self.query_parameters.dataset_path | ||
|
|
| try: | ||
| self._records.append(TraceRecord(**data)) | ||
| except Exception as e: | ||
| errors.append(f'line {line_num}: {e}') |
| for i in range(1, len(self._records)): | ||
| if self._records[i].timestamp < self._records[i - 1].timestamp: | ||
| raise ValueError( | ||
| f'Timestamps must be monotonically non-decreasing: ' | ||
| f'record {i + 1} (t={self._records[i].timestamp}) < ' | ||
| f'record {i} (t={self._records[i - 1].timestamp})' | ||
| ) |
…replay Add a workload_trace dataset plugin that replays recorded production traffic traces with their original arrival pattern in open-loop mode. JSONL format: each line has body (complete request), timestamp, optional headers, request_id, and completion_tokens. Features: - Stateless open-loop replay with configurable speed multiplier - Model rewriting via model_override / model_mapping in --dataset-args - Per-request headers with fill semantics (CLI headers take precedence) - Per-request request_id propagated to results for correlation - ISO-8601 and epoch timestamp formats (including stringified epochs) - Strict validation with line-number error reporting - --model and --number optional (requires_model class attribute) - --rate validation bypass (provides_arrival_schedule class attribute) - Deferred duration deadline (warmup doesn't consume benchmark time) - Integrates with unified --dataset-args framework (args_schema) Implementation uses __evalscope_* prefixed sideband keys in the body dict, stripped by extract_body_meta() before reaching API plugins. 48 unit tests + 7 E2E tests.
When --dataset-args '{"match_output_length": true}' is set and a trace
record contains a completion_tokens field, the plugin overwrites
body['max_tokens'] with the recorded value and sets ignore_eos=True,
forcing the model to produce the same output length as the original
request.
4d91f4b to
1b5ee5e
Compare
…sort - Strip hop-by-hop headers (Host, Connection, etc.) from trace data before merging with CLI headers. - Add dedicated request_id field to BenchmarkData and DB schema; do not reuse trace_id (reserved for multi-turn conversation grouping). - Log when --model is used as model_override fallback. - Fix docstring in extract_body_meta.
1b5ee5e to
4d9d9ee
Compare
3348cf3 to
87cbe71
Compare
…nference - workload_trace: --model no longer rewrites trace bodies (issue modelscope#1489); warn instead, preserving each request's own model and multi-model routing - guard model=None across API plugins (dashscope/responses/embedding/rerank/custom) - gate per-request update_gpu_usage() to --api local (in-process inference); avoids first-request 'import torch' blocking the event loop, which distorted open-loop trace arrival timing for compact traces - docs: add workload_trace dataset mode + dataset-args + examples (zh/en) - test: assert --model does not rewrite trace body
Yunnglin
left a comment
There was a problem hiding this comment.
Pushed a few review-driven refinements on top of the latest commit:
--modelno longer rewrites trace bodies (per #1489). The previous fallback silently turned--model Xintomodel_override, collapsing multi-model traces into one. It now just logs a warning and each request keeps its ownmodel; use--dataset-argsmodel_override/model_mappingfor explicit rewriting.- Guarded
model=Nonein the remaining API plugins (dashscope / responses / embedding / rerank / custom). Onlyopenai_apihad the guard, so the others would writemodel: nullwhen--modelis omitted. - Gated per-request
update_gpu_usage()to--api local. For remote APIs andlocal_vllmthe client process holds no CUDA memory (measurement is always 0), and the first-requestimport torch(~3.5–6s) blocked the event loop — which distorted open-loop trace arrival timing for compact traces. Verified: a 3-request trace with 0.5s/0.8s gaps now replays within ~40ms instead of bursting. - Docs + test: added the
workload_tracedataset mode,dataset-args, and usage examples (zh/en); added a test asserting--modeldoes not rewrite the body.
E2E-verified against a mock OpenAI server: multi-model routing preserved, per-request headers applied, request_id propagated to the DB, and no __evalscope_* sideband keys leak into outgoing requests.
Summary
Add a
workload_tracedataset plugin that replays recorded production traffic with original timing, headers, and request bodies — enabling performance benchmarking against real-world workload patterns.Closes #1489
Motivation
Existing perf datasets (
openqa,share_gpt,random, etc.) generate synthetic traffic that may not reflect production load characteristics. Real production workloads have bursty arrival patterns, heterogeneous request shapes, and model-specific routing. This plugin bridges that gap by replaying captured JSONL traces verbatim.Changes
New files
evalscope/perf/plugin/datasets/workload_trace.pyevalscope/perf/utils/body_meta.pytests/perf/test_workload_trace.pytests/perf/test_workload_trace_e2e.pyModified files
arguments.py--dataset-argsCLI arg;--model/--numberoptional for trace datasets;requires_modelvalidationbenchmark.pytotal_count/warmup_countafter plugin construction (plugins may adjust them); remove unusedimport numpyplugin/api/base.pyextract_body_meta()— pops sideband keys beforeprocess_request; merges per-request headers with fill semantics (CLI headers win)plugin/api/openai_api.pymodel=Nonewhen dataset carries model in bodyplugin/datasets/base.pyprovides_arrival_schedule/requires_modelclass attributesplugin/datasets/__init__.pycore/strategies/open_loop.pycore/http_client.pyextract_body_metabefore delegating to API pluginTrace format
Each line in the JSONL file:
{"body": {"model": "qwen-plus", "messages": [...]}, "timestamp": 1700000000.0} {"body": {"model": "qwen-max", "messages": [...]}, "timestamp": 1700000001.5, "headers": {"X-Tag": "exp"}, "request_id": "req-42", "completion_tokens": 256}body(dict or JSON string): complete request body, sent as-is.timestamp(number or ISO-8601): arrival time; only relative deltas matter.headers(optional): per-request HTTP headers (merged with fill semantics — CLI headers take precedence).request_id(optional): propagated to results for correlation.completion_tokens(optional): used bymatch_output_length.Usage
Dataset args (
--dataset-args)speed1.0model_overrideNonemodel_mappingNonematch_output_lengthfalsemax_tokensfromcompletion_tokensand enableignore_eos(requires vLLM or compatible)Design decisions
__evalscope_*prefixed) is embedded in the body dict and stripped byextract_body_metabefore reaching API plugins. This avoids changing the plugin interface.{**trace_headers, **cli_headers}— CLI-set headers (e.g.Authorizationfrom--api-key) always take precedence over trace headers, so explicit CLI configuration is never silently overridden by trace data.--model/--numberoptional: Trace data carries its own model and count. Newrequires_modelclass attribute onDatasetPluginBasecontrols validation.--durationcountdown starts after warmup completes, so warmup doesn't consume benchmark time.Testing
Coverage includes: loading/validation, timestamp parsing (epoch + ISO-8601), body-as-string, model rewriting, speed scaling, warmup,
--numbertruncation,match_output_length, header fill semantics, reserved key rejection, E2E body passthrough, and E2E warmup exclusion.