A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 2, 14:45 UTC
[Feature] Add support for timed trace replay in `vllm bench serve` to replay Moonshot and Alibaba workload traces (#39795)
Signed-off-by: Animesh Trivedi <Animesh.Trivedi@ibm.com>
Animesh Trivedi committed
May 28, 2026 at 12:31 UTC
bfb9ebc211d3b4abaa3fc2c87315ff7801d93d5b
3 files changed
+351
-53
docs/benchmarking/cli.md
+35
@@ -918,6 +918,41 @@ vllm bench serve \
918
919
</details>
920
921
+### Replay Timed Traces
922
+
923
+<details class="admonition abstract" markdown="1">
924
+<summary>Show more</summary>
925
+
926
+Example of how to run traces which have timing information
927
+with them.
928
+
929
+#### Running MoonshotAI traces
930
+
931
+Start the server:
932
+
933
+```bash
934
+vllm serve Qwen/Qwen3.5-2B \
935
+--host 127.0.0.1 --port 8000
936
+```
937
+
938
+Run the benchmark:
939
+
940
+```bash
941
+# Download an example trace
942
+# curl -L -o conversation_trace.jsonl \
943
+#https://raw.githubusercontent.com/kvcache-ai/Mooncake/main/FAST25-release/traces/conversation_trace.jsonl
944
+
945
+vllm bench serve --model Qwen/Qwen3.5-2B \
946
+--dataset-name=timed_trace --num-prompts 100 --host 127.0.0.1 \
947
+--port 8000 --dataset-path ./conversation_trace.jsonl \
948
+--ignore-eos --self-timed --timed-trace-chunk-hash-size 512 \
949
+--timed-trace-sec-multiplier 0.001
950
+```
951
+
952
+This will replay the first 100 lines from the trace file `conversation.jsonl`.
953
+
954
+</details>
955
+
956
### 🧪 Hashing Benchmarks
957
958
<details class="admonition abstract" markdown="1">
vllm/benchmarks/datasets/datasets.py
+219
@@ -83,6 +83,7 @@ class SampleRequest:
83
multi_modal_data: MultiModalDataDict | dict | list[dict] | None = None
84
lora_request: LoRARequest | None = None
85
request_id: str | None = None
86
+ timestamp: float | None = None
87
88
89
# -----------------------------------------------------------------------------
@@ -1399,6 +1400,168 @@ class ShareGPTDataset(BenchmarkDataset):
1400
return samples
1401
1402
1403
+class TimedTrace(BenchmarkDataset):
1404
+ """
1405
+ Implements a base class to replay various timed traces.
1406
+ Loads data from a JSON file and generates sample requests
1407
+ based on the timing information in the traces.
1408
+ """
1409
+
1410
+ def __init__(self, **kwargs) -> None:
1411
+ super().__init__(**kwargs)
1412
+ random.seed(self.random_seed)
1413
+ np.random.seed(self.random_seed)
1414
+
1415
+ # Set parameters with defaults from timed_trace_group arguments
1416
+ self.chunk_size = int(kwargs.get("timed_trace_chunk_hash_size", 16))
1417
+ self.sec_multiplier = float(kwargs.get("timed_trace_sec_multiplier", 1))
1418
+ self.label_ts = str(kwargs.get("timed_trace_label_timestamp"))
1419
+ self.label_input_length = str(kwargs.get("timed_trace_label_input_length"))
1420
+ self.label_output_length = str(kwargs.get("timed_trace_label_output_length"))
1421
+ self.label_hash_ids = str(kwargs.get("timed_trace_label_hash_ids"))
1422
+ print(
1423
+ f"timed-trace: chunk_size: {self.chunk_size}, "
1424
+ f"sec_multiplier: {self.sec_multiplier}, "
1425
+ f'label_ts: "{self.label_ts}", '
1426
+ f'label_input_length: "{self.label_input_length}", '
1427
+ f'label_output_length: "{self.label_output_length}", '
1428
+ f'label_hash_ids: "{self.label_hash_ids}"'
1429
+ )
1430
+ self._expanded_generated_prompts = {}
1431
+ self.load_data()
1432
+
1433
+ def load_data(self) -> None:
1434
+ # check if the file is there
1435
+ if self.dataset_path is None:
1436
+ raise ValueError("dataset_path must be provided for loading data.")
1437
+
1438
+ # load and we will do transformation once we have the Tokenizer available
1439
+ # this is jsonl data format
1440
+ with open(self.dataset_path) as f:
1441
+ self.data = f.readlines()
1442
+
1443
+ def _sample_token(
1444
+ self, num_tokens: int, tokenizer: TokenizerLike, seed: int | None = None
1445
+ ) -> list[int]:
1446
+ # Initialize vocab only if it doesn't exist yet
1447
+ if not hasattr(self, "vocab"):
1448
+ self.vocab = tokenizer.get_vocab()
1449
+ # Remove the special tokens.
1450
+ self.vocab = {
1451
+ k: v
1452
+ for k, v in self.vocab.items()
1453
+ if v not in tokenizer.all_special_ids
1454
+ }
1455
+ # Create a sorted list of vocab values for deterministic sampling
1456
+ self.vocab_values_sorted = sorted(self.vocab.values())
1457
+
1458
+ # Use the provided seed if given, otherwise use global random state
1459
+ if seed is not None:
1460
+ rng = random.Random(seed)
1461
+ sampled_token_ids = rng.choices(self.vocab_values_sorted, k=num_tokens)
1462
+ else:
1463
+ sampled_token_ids = random.choices(self.vocab_values_sorted, k=num_tokens)
1464
+
1465
+ return sampled_token_ids
1466
+
1467
+ def _expand_prompt(
1468
+ self,
1469
+ chunked_hashes: list[int],
1470
+ target_input_size: int,
1471
+ tokenizer: TokenizerLike,
1472
+ ) -> list[int]:
1473
+ raw_tokenized_prompt = []
1474
+ for h in chunked_hashes:
1475
+ # Calculate how many tokens to expand for this chunk
1476
+ expanded_size = (
1477
+ self.chunk_size
1478
+ if target_input_size >= self.chunk_size
1479
+ else target_input_size
1480
+ )
1481
+
1482
+ # Cache key includes size for partial chunks at the end
1483
+ key = f"{h}:{expanded_size}"
1484
+
1485
+ if key not in self._expanded_generated_prompts:
1486
+ # Convert key to a deterministic seed
1487
+ key_seed = hash(key) & 0xFFFFFFFF # Convert to 32-bit int
1488
+ self._expanded_generated_prompts[key] = self._sample_token(
1489
+ expanded_size, tokenizer, seed=key_seed
1490
+ )
1491
+ # once inserted get the tokenized prompt and append to the list
1492
+ raw_tokenized_prompt.extend(self._expanded_generated_prompts[key])
1493
+ target_input_size -= expanded_size
1494
+
1495
+ if target_input_size <= 0:
1496
+ break
1497
+
1498
+ return raw_tokenized_prompt
1499
+
1500
+ def sample(
1501
+ self,
1502
+ tokenizer: TokenizerLike,
1503
+ num_requests: int,
1504
+ request_id_prefix: str = "",
1505
+ **kwargs,
1506
+ ) -> list:
1507
+ samples: list = []
1508
+ assert tokenizer is not None, "Tokenizer must be provided, now is Null"
1509
+
1510
+ for ind, entry in enumerate(self.data):
1511
+ if len(samples) >= num_requests:
1512
+ break
1513
+
1514
+ # now we create the SampleRequest with timing info
1515
+ entry = json.loads(entry.strip())
1516
+ input_length = entry.get(self.label_input_length)
1517
+ if input_length is None:
1518
+ raise ValueError(
1519
+ f"Input length field '{self.label_input_length}' "
1520
+ f"not found in trace entry. "
1521
+ f"Available fields: {list(entry.keys())}. "
1522
+ f"Use --label-input-length to specify the correct "
1523
+ f"field name."
1524
+ )
1525
+ new_output_len = entry.get(self.label_output_length)
1526
+ if new_output_len is None:
1527
+ raise ValueError(
1528
+ f"Output length field '{self.label_output_length}' "
1529
+ f"not found in trace entry. "
1530
+ f"Available fields: {list(entry.keys())}. "
1531
+ f"Use --label-output-length to specify the correct "
1532
+ f"field name."
1533
+ )
1534
+ prompt_ids = self._expand_prompt(
1535
+ entry.get(self.label_hash_ids, []), input_length, tokenizer
1536
+ )
1537
+ prompt = tokenizer.decode(prompt_ids)
1538
+
1539
+ # Get timestamp with proper error handling
1540
+ ts_value = entry.get(self.label_ts)
1541
+ if ts_value is None:
1542
+ raise ValueError(
1543
+ f"Timestamp field '{self.label_ts}' not found in trace entry. "
1544
+ f"Available fields: {list(entry.keys())}. "
1545
+ f"Use --label-timestamp to specify the correct field name."
1546
+ )
1547
+ timestamp = float(ts_value) * self.sec_multiplier
1548
+
1549
+ prompt_len = len(prompt_ids)
1550
+
1551
+ samples.append(
1552
+ SampleRequest(
1553
+ prompt=prompt,
1554
+ prompt_len=prompt_len,
1555
+ expected_output_len=new_output_len,
1556
+ lora_request=None,
1557
+ multi_modal_data=None,
1558
+ request_id=request_id_prefix + str(ind),
1559
+ timestamp=timestamp,
1560
+ )
1561
+ )
1562
+ return samples
1563
+
1564
+
1565
def add_dataset_parser(parser: FlexibleArgumentParser):
1566
parser.add_argument(
1567
"--trust-remote-code",
@@ -1431,6 +1594,7 @@ def add_dataset_parser(parser: FlexibleArgumentParser):
1594
"prefix_repetition",
1595
"spec_bench",
1596
"speed_bench",
1597
+ "timed_trace",
1598
],
1599
help="Name of the dataset to benchmark on.",
1600
)
@@ -1521,6 +1685,53 @@ def add_dataset_parser(parser: FlexibleArgumentParser):
1685
"from the ShareGPT dataset.",
1686
)
1687
1688
+ timed_trace_group = parser.add_argument_group("timed-trace dataset options")
1689
+ timed_trace_group.add_argument(
1690
+ "--timed-trace-chunk-hash-size",
1691
+ type=int,
1692
+ default=16,
1693
+ help=(
1694
+ "Each hash tokens, if present, represent how many token "
1695
+ "hashes. For example in the Moonshot traces it is 512, while "
1696
+ "the Qwen/Alibaba has 16."
1697
+ ),
1698
+ )
1699
+ timed_trace_group.add_argument(
1700
+ "--timed-trace-sec-multiplier",
1701
+ type=float,
1702
+ default=1,
1703
+ help=(
1704
+ "What multiplier to use when converting timestamps to "
1705
+ "seconds. We will multiply timestamps by this. For example"
1706
+ "if the timestamps are in milliseconds, then pass 0.001."
1707
+ "If they are already in seconds, then the default 1 is sufficient."
1708
+ ),
1709
+ )
1710
+ timed_trace_group.add_argument(
1711
+ "--timed-trace-label-timestamp",
1712
+ type=str,
1713
+ default="timestamp",
1714
+ help="What json label to use to index the timestamp in the trace.",
1715
+ )
1716
+ timed_trace_group.add_argument(
1717
+ "--timed-trace-label-input-length",
1718
+ type=str,
1719
+ default="input_length",
1720
+ help=("What json label to use to index the input length field in the trace."),
1721
+ )
1722
+ timed_trace_group.add_argument(
1723
+ "--timed-trace-label-output-length",
1724
+ type=str,
1725
+ default="output_length",
1726
+ help=("What json label to use to index the output length field in the trace."),
1727
+ )
1728
+ timed_trace_group.add_argument(
1729
+ "--timed-trace-label-hash-ids",
1730
+ type=str,
1731
+ default="hash_ids",
1732
+ help=("What json label to use to index the hash ids for the input prompts."),
1733
+ )
1734
+
1735
blazedit_group = parser.add_argument_group("blazedit dataset options")
1736
blazedit_group.add_argument(
1737
"--blazedit-min-distance",
@@ -2051,6 +2262,14 @@ def get_samples(args, tokenizer: TokenizerLike) -> list[SampleRequest]:
2262
**hf_kwargs,
2263
)
2264
2265
+ elif args.dataset_name == "timed_trace":
2266
+ dataloader = TimedTrace(**vars(args))
2267
+ input_requests = dataloader.sample(
2268
+ num_requests=args.num_prompts,
2269
+ tokenizer=tokenizer,
2270
+ request_id_prefix=args.request_id_prefix,
2271
+ )
2272
+
2273
else:
2274
# For datasets that follow a similar structure, use a mapping.
2275
dataset_mapping = {
vllm/benchmarks/serve.py
+97
-53
@@ -251,6 +251,7 @@ async def get_request(
251
ramp_up_strategy: Literal["linear", "exponential"] | None = None,
252
ramp_up_start_rps: int | None = None,
253
ramp_up_end_rps: int | None = None,
254
+ self_timed: bool = False,
255
) -> AsyncGenerator[tuple[SampleRequest, float], None]:
256
"""
257
Asynchronously generates requests at a specified rate
@@ -290,49 +291,59 @@ async def get_request(
291
# Precompute delays among requests to minimize request send laggings
292
request_rates = []
293
delay_ts = []
293
- for request_index, request in enumerate(input_requests):
294
- current_request_rate = _get_current_request_rate(
295
- ramp_up_strategy,
296
- ramp_up_start_rps,
297
- ramp_up_end_rps,
298
- request_index,
299
- total_requests,
300
- request_rate,
301
- )
302
- assert current_request_rate > 0.0, (
303
- f"Obtained non-positive request rate {current_request_rate}."
304
- )
305
- request_rates.append(current_request_rate)
306
- if current_request_rate == float("inf"):
307
- delay_ts.append(0)
308
- elif burstiness == float("inf"):
309
- # when burstiness tends to infinity, the delay time becomes constant
310
- # and tends to the inverse of the request rate
311
- delay_ts.append(1.0 / current_request_rate)
312
- else:
313
- theta = 1.0 / (current_request_rate * burstiness)
314
-
315
- # Sample the request interval from the gamma distribution.
316
- # If burstiness is 1, it follows exponential distribution.
317
- delay_ts.append(np.random.gamma(shape=burstiness, scale=theta))
318
-
319
- # Calculate the cumulative delay time from the first sent out requests.
320
- for i in range(1, len(delay_ts)):
321
- delay_ts[i] += delay_ts[i - 1]
322
- if ramp_up_strategy is None and delay_ts[-1] != 0:
323
- # When ramp_up_strategy is not set, we assume the request rate is fixed
324
- # and all requests should be sent in target_total_delay_s, the following
325
- # logic would re-scale delay time to ensure the final delay_ts
326
- # align with target_total_delay_s.
327
- #
328
- # NOTE: If we simply accumulate the random delta values
329
- # from the gamma distribution, their sum would have 1-2% gap
330
- # from target_total_delay_s. The purpose of the following logic is to
331
- # close the gap for stabilizing the throughput data
332
- # from different random seeds.
333
- target_total_delay_s = total_requests / request_rate
334
- normalize_factor = target_total_delay_s / delay_ts[-1]
335
- delay_ts = [delay * normalize_factor for delay in delay_ts]
294
+
295
+ # if the traces have timing info then:
296
+ if not self_timed:
297
+ for request_index, request in enumerate(input_requests):
298
+ current_request_rate = _get_current_request_rate(
299
+ ramp_up_strategy,
300
+ ramp_up_start_rps,
301
+ ramp_up_end_rps,
302
+ request_index,
303
+ total_requests,
304
+ request_rate,
305
+ )
306
+ assert current_request_rate > 0.0, (
307
+ f"Obtained non-positive request rate {current_request_rate}."
308
+ )
309
+ request_rates.append(current_request_rate)
310
+ if current_request_rate == float("inf"):
311
+ delay_ts.append(0)
312
+ elif burstiness == float("inf"):
313
+ # when burstiness tends to infinity, the delay time becomes constant
314
+ # and tends to the inverse of the request rate
315
+ delay_ts.append(1.0 / current_request_rate)
316
+ else:
317
+ theta = 1.0 / (current_request_rate * burstiness)
318
+
319
+ # Sample the request interval from the gamma distribution.
320
+ # If burstiness is 1, it follows exponential distribution.
321
+ delay_ts.append(np.random.gamma(shape=burstiness, scale=theta))
322
+
323
+ # Calculate the cumulative delay time from the first sent out requests.
324
+ for i in range(1, len(delay_ts)):
325
+ delay_ts[i] += delay_ts[i - 1]
326
+ if ramp_up_strategy is None and delay_ts[-1] != 0:
327
+ # When ramp_up_strategy is not set, we assume the request rate is fixed
328
+ # and all requests should be sent in target_total_delay_s, the following
329
+ # logic would re-scale delay time to ensure the final delay_ts
330
+ # align with target_total_delay_s.
331
+ #
332
+ # NOTE: If we simply accumulate the random delta values
333
+ # from the gamma distribution, their sum would have 1-2% gap
334
+ # from target_total_delay_s. The purpose of the following logic is to
335
+ # close the gap for stabilizing the throughput data
336
+ # from different random seeds.
337
+ target_total_delay_s = total_requests / request_rate
338
+ normalize_factor = target_total_delay_s / delay_ts[-1]
339
+ delay_ts = [delay * normalize_factor for delay in delay_ts]
340
+ else:
341
+ for request_index, request in enumerate(input_requests):
342
+ # this is cumulative running ts, from which sleep is calculated later
343
+ delay_ts.append(request.timestamp)
344
+ # TODO: there is no notion of RPS here, may be we can calculate
345
+ # from the trace.
346
+ request_rates.append(0.0)
347
348
start_ts = time.time()
349
for request_index, request in enumerate(input_requests):
@@ -634,6 +645,7 @@ async def benchmark(
645
ramp_up_end_rps: int | None = None,
646
ready_check_timeout_sec: int = 600,
647
ssl_context: ssl.SSLContext | bool | None = None,
648
+ self_timed: bool = False,
649
):
650
try:
651
request_func = ASYNC_REQUEST_FUNCS[endpoint_type]
@@ -773,18 +785,20 @@ async def benchmark(
785
print("Profiler started")
786
787
distribution = "Poisson process" if burstiness == 1.0 else "Gamma distribution"
788
+ if not self_timed:
789
+ if ramp_up_strategy is not None:
790
+ print(f"Traffic ramp-up strategy: {ramp_up_strategy}.")
791
+ print(
792
+ f"Will increase RPS from {ramp_up_start_rps} to "
793
+ f"{ramp_up_end_rps} RPS over the duration of the benchmark."
794
+ )
795
+ else:
796
+ print(f"Traffic request rate: {request_rate}")
797
777
- if ramp_up_strategy is not None:
778
- print(f"Traffic ramp-up strategy: {ramp_up_strategy}.")
779
- print(
780
- f"Will increase RPS from {ramp_up_start_rps} to "
781
- f"{ramp_up_end_rps} RPS over the duration of the benchmark."
782
- )
798
+ print(f"Burstiness factor: {burstiness} ({distribution})")
799
+ print(f"Maximum request concurrency: {max_concurrency}")
800
else:
784
- print(f"Traffic request rate: {request_rate}")
785
-
786
- print(f"Burstiness factor: {burstiness} ({distribution})")
787
- print(f"Maximum request concurrency: {max_concurrency}")
801
+ print("Self timing is set, using the timestamps from the trace file.")
802
803
spec_decode_metrics_before = await fetch_spec_decode_metrics(base_url, session)
804
@@ -823,6 +837,7 @@ async def benchmark(
837
ramp_up_strategy,
838
ramp_up_start_rps,
839
ramp_up_end_rps,
840
+ self_timed,
841
):
842
if ramp_up_strategy is not None:
843
current_int_rps = int(current_request_rate)
@@ -1436,6 +1451,16 @@ def add_cli_args(parser: argparse.ArgumentParser):
1451
help="Set ignore_eos flag when sending the benchmark request."
1452
"Warning: ignore_eos is not supported in deepspeed_mii and tgi.",
1453
)
1454
+ parser.add_argument(
1455
+ "--self-timed",
1456
+ action=argparse.BooleanOptionalAction,
1457
+ default=None,
1458
+ help="Use timing information from the traces instead of the configuration. "
1459
+ "This is useful when replaying traces faithfully based on their timestamps. "
1460
+ "When unset, defaults to False, except for --dataset-name=timed_trace where "
1461
+ "it defaults to True. Use --no-self-timed to force off. When off, user "
1462
+ "defined generation rates are used and in trace timing info is ignored.",
1463
+ )
1464
parser.add_argument(
1465
"--percentile-metrics",
1466
type=str,
@@ -1767,6 +1792,24 @@ async def main_async(args: argparse.Namespace) -> dict[str, Any]:
1792
):
1793
args.ignore_eos = True
1794
1795
+ if args.dataset_name == "timed_trace":
1796
+ # timed_trace carries per-request timestamps;
1797
+ # ignore EOS so generation runs to the trace's specified output length,
1798
+ # and default to using those timestamps for scheduling unless the user
1799
+ # opted out.
1800
+ args.ignore_eos = True
1801
+ if args.self_timed is None:
1802
+ args.self_timed = True
1803
+ else:
1804
+ # if this is set for anything else, it is an error
1805
+ if args.self_timed is not None:
1806
+ raise ValueError(
1807
+ "--self-timed/--no-self-timed is only supported with "
1808
+ "--dataset-name=timed_trace"
1809
+ )
1810
+ # for any non self-timed trace, this is False
1811
+ args.self_timed = False
1812
+
1813
# Load the dataset.
1814
input_requests = get_samples(args, tokenizer)
1815
goodput_config_dict = check_goodput_args(args)
@@ -1846,6 +1889,7 @@ async def main_async(args: argparse.Namespace) -> dict[str, Any]:
1889
ramp_up_end_rps=args.ramp_up_end_rps,
1890
ready_check_timeout_sec=args.ready_check_timeout_sec,
1891
ssl_context=ssl_context,
1892
+ self_timed=args.self_timed,
1893
)
1894
1895
# Save config and results to json