Skip to content

Commit 4c904c7

Browse files
committed
Add inner benchmark metrics component
1 parent 8c4f5a6 commit 4c904c7

8 files changed

Lines changed: 796 additions & 0 deletions

File tree

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""
2+
# Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License"
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
"""
16+
17+
import argparse
18+
import json
19+
import os
20+
import sys
21+
22+
23+
def read_last_line(file_path: str) -> str:
24+
with open(file_path, "rb") as f:
25+
f.seek(0, 2)
26+
file_size = f.tell()
27+
if file_size == 0:
28+
return ""
29+
pos = file_size - 1
30+
while pos > 0:
31+
f.seek(pos)
32+
char = f.read(1)
33+
if char == b"\n" and pos < file_size - 1:
34+
break
35+
pos -= 1
36+
if pos == 0:
37+
f.seek(0)
38+
return f.read().decode("utf-8").strip()
39+
40+
41+
def print_stat_block(data: dict, key: str, metric_name: str, header: str, is_time: bool = True):
42+
stats = data.get(key)
43+
if not stats:
44+
return
45+
suffix = "(ms)" if is_time else ""
46+
if key == "decode_speed":
47+
suffix = "(tok/s)"
48+
49+
print("{s:{c}^{n}}".format(s=header, n=50, c="-"))
50+
print("{:<40} {:<10.2f}".format(f"Mean {metric_name} {suffix}:", stats["mean"]))
51+
print("{:<40} {:<10.2f}".format(f"Median {metric_name} {suffix}:", stats["median"]))
52+
53+
for k, v in stats.items():
54+
if k.startswith("p"):
55+
label = k.upper()
56+
print("{:<40} {:<10.2f}".format(f"{label} {metric_name} {suffix}:", v))
57+
58+
59+
def main():
60+
parser = argparse.ArgumentParser(description="Read and display benchmark metrics from JSONL file.")
61+
parser.add_argument(
62+
"--file",
63+
type=str,
64+
default=None,
65+
help="Path to benchmark_metrics.jsonl. Defaults to $FD_LOG_DIR/benchmark_metrics.jsonl",
66+
)
67+
args = parser.parse_args()
68+
69+
if args.file:
70+
file_path = args.file
71+
else:
72+
log_dir = os.environ.get("FD_LOG_DIR", "./log")
73+
file_path = os.path.join(log_dir, "benchmark_metrics.jsonl")
74+
75+
if not os.path.exists(file_path):
76+
print(f"File not found: {file_path}", file=sys.stderr)
77+
sys.exit(1)
78+
79+
last_line = read_last_line(file_path)
80+
if not last_line:
81+
print("No data in file.", file=sys.stderr)
82+
sys.exit(1)
83+
84+
data = json.loads(last_line)
85+
86+
print("{s:{c}^{n}}".format(s=" Serving Benchmark Result ", n=50, c="="))
87+
print("{:<40} {:<10}".format("Timestamp:", data.get("timestamp", "N/A")))
88+
print("{:<40} {:<10}".format("Window size:", data.get("window_size", 0) or "all"))
89+
print("{:<40} {:<10}".format("Completed requests:", data.get("completed", 0)))
90+
print("{:<40} {:<10}".format("Total input tokens:", data.get("total_input_tokens", 0)))
91+
print("{:<40} {:<10}".format("Total output tokens:", data.get("total_output_tokens", 0)))
92+
93+
if "request_throughput" in data:
94+
print("{:<40} {:<10.3f}".format("Request throughput (req/s):", data["request_throughput"]))
95+
if "output_throughput" in data:
96+
print("{:<40} {:<10.2f}".format("Output token throughput (tok/s):", data["output_throughput"]))
97+
if "total_throughput" in data:
98+
print("{:<40} {:<10.2f}".format("Total Token throughput (tok/s):", data["total_throughput"]))
99+
100+
print_stat_block(data, "s_decode", "Decode", "解码速度(tok/s)", is_time=False)
101+
print_stat_block(data, "ttft_ms", "TTFT", "Time to First Token")
102+
print_stat_block(data, "s_ttft_ms", "S_TTFT", "Infer Time to First Token")
103+
print_stat_block(data, "tpot_ms", "TPOT", "Time per Output Token (excl. 1st token)")
104+
print_stat_block(data, "itl_ms", "S_ITL", "Infer Inter-token Latency")
105+
print_stat_block(data, "e2el_ms", "E2EL", "End-to-end Latency")
106+
print_stat_block(data, "s_e2el_ms", "S_E2EL", "Infer End-to-end Latency")
107+
print_stat_block(data, "input_len", "Cached Tokens", "Cached Tokens", is_time=False)
108+
print_stat_block(data, "s_input_len", "Input Length", "Infer Input Length", is_time=False)
109+
print_stat_block(data, "output_len", "Output Length", "Output Length", is_time=False)
110+
111+
print("=" * 50)
112+
113+
114+
if __name__ == "__main__":
115+
main()
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
"""
2+
# Copyright (c) 2025 PaddlePaddle Authors. All Rights Reserved.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
"""
16+
17+
import argparse
18+
import json
19+
import os
20+
import sys
21+
22+
import matplotlib.pyplot as plt
23+
import numpy as np
24+
25+
26+
def load_jsonl(file_path: str) -> list[dict]:
27+
records = []
28+
with open(file_path, encoding="utf-8") as f:
29+
for line in f:
30+
line = line.strip()
31+
if line:
32+
records.append(json.loads(line))
33+
return records
34+
35+
36+
_THROUGHPUT_KEYS = ["request_throughput", "output_throughput", "total_throughput"]
37+
38+
39+
def detect_metrics(records: list[dict]) -> list[str]:
40+
"""Detect metric names from JSONL records (throughput scalars + dict-valued keys)."""
41+
throughput_found = set()
42+
dict_metrics = []
43+
44+
for rec in records:
45+
for k in _THROUGHPUT_KEYS:
46+
if k in rec:
47+
throughput_found.add(k)
48+
if not dict_metrics:
49+
for k, v in rec.items():
50+
if isinstance(v, dict):
51+
dict_metrics.append(k)
52+
if len(throughput_found) == len(_THROUGHPUT_KEYS) and dict_metrics:
53+
break
54+
55+
metrics = [k for k in _THROUGHPUT_KEYS if k in throughput_found]
56+
metrics.extend(dict_metrics)
57+
return metrics
58+
59+
60+
def plot_metric(records: list[dict], metric: str, output_path: str):
61+
"""Plot a single metric for a chunk of records."""
62+
stat_keys = []
63+
for rec in records:
64+
val = rec.get(metric)
65+
if isinstance(val, dict):
66+
stat_keys = list(val.keys())
67+
break
68+
69+
if not stat_keys:
70+
values = [rec.get(metric) for rec in records if rec.get(metric) is not None]
71+
if not values:
72+
return
73+
x = np.arange(len(values))
74+
plt.figure(figsize=(12, 6))
75+
plt.plot(x, values, linewidth=2, label=metric)
76+
plt.xlabel("Request Index")
77+
plt.ylabel(metric)
78+
plt.title(f"{metric} over requests")
79+
plt.legend()
80+
plt.grid(True, alpha=0.3)
81+
plt.tight_layout()
82+
plt.savefig(output_path, dpi=150)
83+
plt.close()
84+
print(f"Saved: {output_path}")
85+
return
86+
87+
plt.figure(figsize=(12, 6))
88+
89+
for key in stat_keys:
90+
values = []
91+
for rec in records:
92+
val = rec.get(metric)
93+
if isinstance(val, dict) and key in val:
94+
values.append(val[key])
95+
else:
96+
values.append(None)
97+
98+
valid_x = [i for i, v in enumerate(values) if v is not None]
99+
valid_v = [v for v in values if v is not None]
100+
101+
if not valid_v:
102+
continue
103+
104+
linestyle = "-" if key == "mean" else "--"
105+
lw = 2 if key == "mean" else 1.5
106+
plt.plot(valid_x, valid_v, linewidth=lw, linestyle=linestyle, label=key)
107+
108+
plt.xlabel("Request Index")
109+
unit = ""
110+
if metric.endswith("_ms"):
111+
unit = " (ms)"
112+
elif metric in ("s_decode",):
113+
unit = " (tok/s)"
114+
elif metric == "request_throughput":
115+
unit = " (req/s)"
116+
elif metric in ("output_throughput", "total_throughput"):
117+
unit = " (tok/s)"
118+
plt.ylabel(f"{metric}{unit}")
119+
plt.title(f"{metric} over requests")
120+
plt.legend()
121+
plt.grid(True, alpha=0.3)
122+
plt.tight_layout()
123+
plt.savefig(output_path, dpi=150)
124+
plt.close()
125+
print(f"Saved: {output_path}")
126+
127+
128+
def main():
129+
parser = argparse.ArgumentParser(
130+
description="Plot benchmark metrics from JSONL file. "
131+
"Metrics and window_size are read from the data automatically. "
132+
"Records are grouped into window-sized chunks for per-step analysis."
133+
)
134+
parser.add_argument(
135+
"--file",
136+
type=str,
137+
default=None,
138+
help="Path to benchmark_metrics.jsonl. Defaults to $FD_LOG_DIR/benchmark_metrics.jsonl",
139+
)
140+
parser.add_argument(
141+
"--output-dir",
142+
type=str,
143+
default=None,
144+
help="Directory to save output PNG files. Defaults to $FD_LOG_DIR/benchmark_plots/",
145+
)
146+
args = parser.parse_args()
147+
148+
log_dir = os.environ.get("FD_LOG_DIR", "./log")
149+
150+
if args.file:
151+
file_path = args.file
152+
else:
153+
file_path = os.path.join(log_dir, "benchmark_metrics.jsonl")
154+
155+
if not os.path.exists(file_path):
156+
print(f"File not found: {file_path}", file=sys.stderr)
157+
sys.exit(1)
158+
159+
output_dir = args.output_dir if args.output_dir else os.path.join(log_dir, "benchmark_plots")
160+
161+
records = load_jsonl(file_path)
162+
if not records:
163+
print("No data in file.", file=sys.stderr)
164+
sys.exit(1)
165+
166+
window_size = records[0].get("window_size", 0)
167+
metrics = detect_metrics(records)
168+
169+
if not metrics:
170+
print("No plottable metrics found in data.", file=sys.stderr)
171+
sys.exit(1)
172+
173+
print(f"Loaded {len(records)} records from {file_path}")
174+
print(f"Window size: {window_size or 'all'}")
175+
print(f"Metrics: {', '.join(metrics)}")
176+
177+
if window_size > 0:
178+
num_chunks = (len(records) + window_size - 1) // window_size
179+
chunks = []
180+
for i in range(num_chunks):
181+
start = i * window_size
182+
end = min(start + window_size, len(records))
183+
chunks.append(records[start:end])
184+
print(f"Split into {num_chunks} step(s) of size {window_size}")
185+
else:
186+
chunks = [records]
187+
188+
os.makedirs(output_dir, exist_ok=True)
189+
190+
for step_idx, chunk in enumerate(chunks):
191+
if len(chunks) > 1:
192+
step_dir = os.path.join(output_dir, f"step_{step_idx + 1}")
193+
os.makedirs(step_dir, exist_ok=True)
194+
else:
195+
step_dir = output_dir
196+
197+
for metric in metrics:
198+
output_path = os.path.join(step_dir, f"{metric}.png")
199+
plot_metric(chunk, metric, output_path)
200+
201+
202+
if __name__ == "__main__":
203+
main()

fastdeploy/config.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1896,6 +1896,59 @@ def __str__(self):
18961896
return self.to_json_string()
18971897

18981898

1899+
class BenchmarkMetricsConfig:
1900+
"""Configuration for in-process benchmark metrics logger.
1901+
1902+
Args (passed as JSON dict via --benchmark-metrics-config):
1903+
window_size: Number of recent requests to aggregate. 0 = all requests (cumulative).
1904+
percentiles: Comma-separated percentile values to compute, e.g. "50,90,95,99".
1905+
metrics: Comma-separated metric names to report, or "all".
1906+
Available metrics (aligned with benchmark_serving.py --percentile-metrics):
1907+
ttft - Time to First Token (client arrival → first token)
1908+
s_ttft - Server TTFT (inference start → first token)
1909+
tpot - Time per Output Token (excluding first token)
1910+
itl - Inter-token Latency
1911+
e2el - End-to-end Latency (client arrival → last token)
1912+
s_e2el - Server E2EL (inference start → last token)
1913+
s_decode - Decode speed (tokens/s, excluding first token)
1914+
input_len - Prefix cache hit token count ("Cached Tokens" in benchmark_serving)
1915+
s_input_len - Infer input length (total prompt tokens on inference side)
1916+
output_len - Output token length per request
1917+
"""
1918+
1919+
_DEFAULTS = {
1920+
"window_size": 0,
1921+
"percentiles": "50,90,95,99",
1922+
"metrics": "all",
1923+
}
1924+
1925+
_ALL_METRICS = [
1926+
"ttft", # Time to First Token
1927+
"s_ttft", # Server TTFT
1928+
"tpot", # Time per Output Token
1929+
"itl", # Inter-token Latency
1930+
"e2el", # End-to-end Latency
1931+
"s_e2el", # Server E2EL
1932+
"s_decode", # Decode speed (tok/s)
1933+
"input_len", # Prefix cache hit tokens (= "Cached Tokens" in benchmark_serving)
1934+
"s_input_len", # Infer input length (total prompt tokens)
1935+
"output_len", # Output token length
1936+
]
1937+
1938+
def __init__(self, args: Optional[dict] = None):
1939+
for key, value in self._DEFAULTS.items():
1940+
setattr(self, key, value)
1941+
if args:
1942+
for key, value in args.items():
1943+
if key in self._DEFAULTS:
1944+
setattr(self, key, value)
1945+
self.percentile_values = [float(p.strip()) for p in self.percentiles.split(",") if p.strip()]
1946+
if self.metrics == "all":
1947+
self.selected_metrics = set(self._ALL_METRICS)
1948+
else:
1949+
self.selected_metrics = {m.strip() for m in self.metrics.split(",") if m.strip()}
1950+
1951+
18991952
class FDConfig:
19001953
"""
19011954
The configuration class which contains all fastdeploy-related configuration. This
@@ -1930,6 +1983,7 @@ def __init__(
19301983
tool_parser: str = None,
19311984
test_mode=False,
19321985
routing_replay_config: Optional[RoutingReplayConfig] = None,
1986+
benchmark_metrics_config=None,
19331987
deploy_modality: DeployModality = DeployModality.MIXED,
19341988
):
19351989
self.model_config: ModelConfig = model_config # type: ignore
@@ -1947,6 +2001,7 @@ def __init__(
19472001
self.structured_outputs_config: StructuredOutputsConfig = structured_outputs_config
19482002
self.router_config: RouterConfig = router_config
19492003
self.routing_replay_config = routing_replay_config
2004+
self.benchmark_metrics_config = benchmark_metrics_config
19502005
self.deploy_modality: DeployModality = deploy_modality
19512006

19522007
# Initialize cuda graph capture list

0 commit comments

Comments
 (0)