main
py 1,325 lines 41.6 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 from __future__ import annotations
5
6 import argparse
7 import html as _html
8 import json
9 import os
10 from contextlib import nullcontext
11 from dataclasses import dataclass
12 from importlib import util
13 from pathlib import Path
14
15 import pandas as pd
16
17 pd.options.display.float_format = "{:.2f}".format
18 plotly_found = util.find_spec("plotly.express") is not None
19
20 DEFAULT_INFO_COLS = [
21 "Model",
22 "Dataset Name",
23 "Input Len",
24 "Output Len",
25 # "TP Size",
26 # "PP Size",
27 "# of max concurrency.",
28 "qps",
29 ]
30
31 # Safety net: if any DataFrame leaks into to_html(), keep precision at 2.
32 pd.set_option("display.precision", 2)
33 pd.set_option("display.float_format", lambda x: f"{x:.2f}")
34
35
36 # -----------------------------
37 # Concurrency normalization (NEW, small)
38 # -----------------------------
39 def _find_concurrency_col(df: pd.DataFrame) -> str:
40 for c in [
41 "# of max concurrency.",
42 "# of max concurrency",
43 "Max Concurrency",
44 "max_concurrency",
45 "Concurrency",
46 ]:
47 if c in df.columns:
48 return c
49
50 for c in df.columns:
51 if "concurr" in str(c).lower():
52 s = df[c]
53 if s.dtype.kind in "iu" and s.nunique() > 1 and s.min() >= 1:
54 return c
55
56 raise ValueError(
57 "Cannot infer concurrency column. "
58 "Please rename the column to one of the known names "
59 "or add an explicit override (e.g., --concurrency-col)."
60 )
61
62
63 def _normalize_concurrency_in_df(
64 df: pd.DataFrame, canonical: str = "# of max concurrency."
65 ) -> pd.DataFrame:
66 if canonical in df.columns:
67 return df
68 detected = _find_concurrency_col(df)
69 if detected in df.columns and detected != canonical:
70 return df.rename(columns={detected: canonical})
71 df[canonical] = pd.NA
72 return df
73
74
75 # -----------------------------
76 # Core data compare
77 # -----------------------------
78 def compare_data_columns(
79 files: list[str],
80 name_column: str,
81 data_column: str,
82 info_cols: list[str],
83 drop_column: str,
84 debug: bool = False,
85 ):
86 """Align concatenation by keys derived from info_cols instead of row order.
87 - Pick one canonical key list: subset of info_cols present in ALL files.
88 - For each file: set index to those keys, aggregate duplicates
89 (mean for metric, first for names).
90 - Concat along axis=1 (indexes align), then reset_index so callers can
91 group by columns.
92 - If --debug, add a <file_label>_name column per file.
93
94 Minimal fix to support different max_concurrency lists across files:
95 - normalize concurrency column naming to "# of max concurrency."
96 - align on UNION of keys (missing points become NaN)
97 - BUGFIX: don't drop throughput rows based on P99/Median presence
98 """
99 print("\ncompare_data_column:", data_column)
100
101 frames = []
102 raw_data_cols: list[str] = []
103
104 # Determine key cols after normalizing concurrency
105 cols_per_file: list[set] = []
106 for f in files:
107 try:
108 df_tmp = pd.read_json(f, orient="records")
109 except Exception as err:
110 raise ValueError(f"Failed to read {f}") from err
111 df_tmp = _normalize_concurrency_in_df(df_tmp, canonical="# of max concurrency.")
112 cols_per_file.append(set(df_tmp.columns))
113
114 key_cols = [c for c in info_cols if all(c in cset for cset in cols_per_file)]
115 if not key_cols:
116 key_cols = [c for c in info_cols if c in list(cols_per_file[0])]
117 if not key_cols:
118 raise ValueError(
119 "No common key columns found from info_cols across the input files."
120 )
121
122 union_index = None
123 metas: list[pd.DataFrame] = []
124 staged: list[tuple[str, pd.Series, pd.Series | None]] = []
125
126 for file in files:
127 df = pd.read_json(file, orient="records")
128 df = _normalize_concurrency_in_df(df, canonical="# of max concurrency.")
129
130 # BUGFIX: only drop rows for latency-like metrics; throughput rows may have
131 # NaN in P99/Median columns even if the column exists in the JSON.
132 metric_lc = str(data_column).lower()
133 is_latency_metric = (
134 "ttft" in metric_lc
135 or "tpot" in metric_lc
136 or "p99" in metric_lc
137 or "median" in metric_lc
138 or metric_lc.strip() in {"p99", "median"}
139 )
140 if is_latency_metric and drop_column in df.columns:
141 df = df.dropna(subset=[drop_column], ignore_index=True)
142
143 for c in (
144 "Input Len",
145 "Output Len",
146 "TP Size",
147 "PP Size",
148 "# of max concurrency.",
149 "qps",
150 ):
151 if c in df.columns:
152 df[c] = pd.to_numeric(df[c], errors="coerce")
153
154 for c in key_cols:
155 if c not in df.columns:
156 df[c] = pd.NA
157
158 df_idx = df.set_index(key_cols, drop=False)
159
160 meta = df_idx[key_cols]
161 if not meta.index.is_unique:
162 meta = meta.groupby(level=key_cols, dropna=False).first()
163
164 file_label = "/".join(file.split("/")[:-1]) or os.path.basename(file)
165
166 if data_column in df_idx.columns:
167 s = df_idx[data_column]
168 if not s.index.is_unique:
169 s = s.groupby(level=key_cols, dropna=False).mean()
170 else:
171 # keep NA series to preserve meta keys for union_index
172 s = pd.Series(pd.NA, index=meta.index)
173 s.name = file_label
174
175 name_s = None
176 if debug and name_column in df_idx.columns:
177 name_s = df_idx[name_column]
178 if not name_s.index.is_unique:
179 name_s = name_s.groupby(level=key_cols, dropna=False).first()
180 name_s.name = f"{file_label}_name"
181
182 if union_index is None:
183 union_index = meta.index
184 else:
185 union_index = union_index.union(meta.index)
186 metas.append(meta)
187
188 staged.append((file_label, s, name_s))
189
190 if union_index is None:
191 raise ValueError("No data found after loading inputs.")
192
193 # meta first (union-aligned): build UNION meta across all files
194 if metas:
195 meta_union = pd.concat(metas, axis=0)
196 # Collapse duplicates on the MultiIndex; keep first non-null per column
197 meta_union = meta_union.groupby(level=key_cols, dropna=False).first()
198 frames.append(meta_union.reindex(union_index))
199
200 # values + ratios (union-aligned)
201 metric_series_aligned: list[pd.Series] = []
202 for file_label, s, name_s in staged:
203 s_aligned = s.reindex(union_index)
204 frames.append(s_aligned)
205 raw_data_cols.append(file_label)
206 metric_series_aligned.append(s_aligned)
207
208 if debug and name_s is not None:
209 frames.append(name_s.reindex(union_index))
210
211 if len(metric_series_aligned) >= 2:
212 base = metric_series_aligned[0]
213 current = metric_series_aligned[-1]
214 if "P99" in str(data_column) or "Median" in str(data_column):
215 ratio = base / current
216 else:
217 ratio = current / base
218 ratio = ratio.mask(base == 0)
219 ratio.name = f"Ratio 1 vs {len(metric_series_aligned)}"
220 frames.append(ratio)
221
222 concat_df = pd.concat(frames, axis=1).reset_index(drop=True)
223
224 front = [c for c in info_cols if c in concat_df.columns]
225 rest = [c for c in concat_df.columns if c not in front]
226 concat_df = concat_df[front + rest]
227
228 print(raw_data_cols)
229 return concat_df, raw_data_cols
230
231
232 # -----------------------------
233 # Split helper
234 # -----------------------------
235 def split_json_by_tp_pp(
236 input_file: str = "benchmark_results.json", output_root: str = "."
237 ) -> list[str]:
238 with open(input_file, encoding="utf-8") as f:
239 data = json.load(f)
240
241 if isinstance(data, dict):
242 for key in ("results", "serving_results", "benchmarks", "data"):
243 if isinstance(data.get(key), list):
244 data = data[key]
245 break
246
247 df = pd.DataFrame(data)
248
249 name_col = next(
250 (c for c in ["Test name", "test_name", "Test Name"] if c in df.columns), None
251 )
252 if name_col:
253 df = df[
254 df[name_col].astype(str).str.contains(r"serving", case=False, na=False)
255 ].copy()
256
257 rename_map = {
258 "tp_size": "TP Size",
259 "tensor_parallel_size": "TP Size",
260 "pp_size": "PP Size",
261 "pipeline_parallel_size": "PP Size",
262 }
263 df.rename(
264 columns={k: v for k, v in rename_map.items() if k in df.columns}, inplace=True
265 )
266
267 if "TP Size" not in df.columns:
268 df["TP Size"] = 1
269 if "PP Size" not in df.columns:
270 df["PP Size"] = 1
271
272 df["TP Size"] = pd.to_numeric(df["TP Size"], errors="coerce").fillna(1).astype(int)
273 df["PP Size"] = pd.to_numeric(df["PP Size"], errors="coerce").fillna(1).astype(int)
274
275 saved_paths: list[str] = []
276 for (tp, pp), group_df in df.groupby(["TP Size", "PP Size"], dropna=False):
277 folder_name = os.path.join(output_root, f"tp{int(tp)}_pp{int(pp)}")
278 os.makedirs(folder_name, exist_ok=True)
279 filepath = os.path.join(folder_name, "benchmark_results.json")
280 group_df.to_json(filepath, orient="records", indent=2, force_ascii=False)
281 print(f"Saved: {filepath}")
282 saved_paths.append(filepath)
283
284 return saved_paths
285
286
287 # -----------------------------
288 # Styling helpers
289 # -----------------------------
290 def _highlight_threshold(
291 df: pd.DataFrame,
292 threshold: float,
293 slack_pct: float = 0.0,
294 ) -> pd.io.formats.style.Styler:
295 conc_col = _find_concurrency_col(df)
296 key_cols = [
297 c
298 for c in ["Model", "Dataset Name", "Input Len", "Output Len", conc_col]
299 if c in df.columns
300 ]
301 conf_cols = [
302 c for c in df.columns if c not in key_cols and not str(c).startswith("Ratio")
303 ]
304 conf_cols = [c for c in conf_cols if pd.api.types.is_numeric_dtype(df[c])]
305
306 try:
307 slack_pct = float(slack_pct or 0.0)
308 except Exception:
309 slack_pct = 0.0
310 slack_limit = threshold * (1.0 + slack_pct / 100.0)
311
312 def _cell(v):
313 if pd.isna(v):
314 return ""
315 if v <= threshold:
316 # Strict SLA
317 return "background-color:#e6ffe6;font-weight:bold;"
318 if v <= slack_limit:
319 # Within slack range
320 return "background-color:#ffe5cc;font-weight:bold;"
321 return ""
322
323 return df.style.map(_cell, subset=conf_cols)
324
325
326 def highlight_ratio_columns(styler: pd.io.formats.style.Styler):
327 ratio_cols = [c for c in styler.data.columns if "ratio" in str(c).lower()]
328 if not ratio_cols:
329 return styler
330
331 styler = styler.apply(
332 lambda _: ["background-color: #fff3b0"] * len(styler.data),
333 subset=ratio_cols,
334 axis=0,
335 )
336
337 styler = styler.set_table_styles(
338 [
339 {
340 "selector": f"th.col_heading.level0.col{i}",
341 "props": [("background-color", "#fff3b0")],
342 }
343 for i, col in enumerate(styler.data.columns)
344 if col in ratio_cols
345 ],
346 overwrite=False,
347 )
348 return styler
349
350
351 def _apply_two_decimals(
352 styler: pd.io.formats.style.Styler,
353 ) -> pd.io.formats.style.Styler:
354 df = styler.data
355 num_cols = df.select_dtypes("number").columns
356 if len(num_cols) == 0:
357 return styler
358 return styler.format({c: "{:.2f}" for c in num_cols}, na_rep="")
359
360
361 # -----------------------------
362 # Export helpers (Excel + CSV)
363 # -----------------------------
364 def _sanitize_sheet_name(name: str) -> str:
365 """Excel sheet constraints:
366 - max 31 chars
367 - cannot contain: : \ / ? * [ ]
368 - cannot be empty
369
370 NOTE: Use fast, non-regex operations here to avoid the third-party `regex`
371 module's compile overhead/edge-cases on some systems.
372 """
373 name = "sheet" if name is None else str(name)
374
375 # Replace illegal characters with underscore.
376 trans = str.maketrans(
377 {
378 ":": "_",
379 "\\": "_",
380 "/": "_",
381 "?": "_",
382 "*": "_",
383 "[": "_",
384 "]": "_",
385 }
386 )
387 name = name.translate(trans)
388
389 # Strip quotes/spaces and collapse whitespace.
390 name = name.strip().strip("'")
391 name = " ".join(name.split())
392
393 if not name:
394 name = "sheet"
395 return name[:31]
396
397
398 def _group_to_sheet_base(group_cols: list[str], gkey_tuple) -> str:
399 d = dict(zip(group_cols, gkey_tuple))
400
401 # Always keep input/output lengths (these are important).
402 ilen = d.get("Input Len", "")
403 olen = d.get("Output Len", "")
404 lens = f"_{ilen}x{olen}" if ilen != "" and olen != "" else ""
405
406 # Shorten model name aggressively to make room for lens.
407 model = d.get("Model", "model")
408 leaf = str(model).split("/")[-1]
409
410 max_model_len = max(1, 31 - len(lens))
411 model_short = leaf[:max_model_len]
412
413 return _sanitize_sheet_name(f"{model_short}{lens}")
414
415
416 def _write_tables_to_excel_sheet(
417 writer: pd.ExcelWriter, sheet: str, blocks: list[tuple[str, pd.DataFrame]]
418 ):
419 """Write all blocks to a sheet with a single to_excel() call.
420
421 Pandas+openpyxl can be extremely slow when called many times per sheet.
422 We flatten blocks into one table with a 'Section' column to keep structure
423 while making Excel generation fast and deterministic.
424 """
425 if not blocks:
426 pd.DataFrame().to_excel(writer, sheet_name=sheet, index=False)
427 return
428
429 combined_parts: list[pd.DataFrame] = []
430 for title, df in blocks:
431 df2 = df.copy()
432 # Put the section label as the first column for readability.
433 df2.insert(0, "Section", title)
434 combined_parts.append(df2)
435
436 combined = pd.concat(combined_parts, axis=0, ignore_index=True, sort=False)
437 combined.to_excel(writer, sheet_name=sheet, index=False)
438
439
440 def _safe_filename(s: str) -> str:
441 # Fast path without the third-party `regex` module.
442 s = " ".join(str(s).strip().split())
443 allowed = []
444 for ch in s:
445 if ch.isalnum() or ch in "._-":
446 allowed.append(ch)
447 else:
448 allowed.append("_")
449 out = "".join(allowed)
450 return out[:180] if len(out) > 180 else out
451
452
453 # -----------------------------
454 # vLLM environment export helper
455 # -----------------------------
456 def _parse_vllm_env_txt(env_path: Path) -> pd.DataFrame:
457 """Parse vllm_env.txt into a flat table (Section, Key, Value).
458
459 Supports:
460 - section headers as standalone lines (no ':' or '=')
461 - key-value lines like 'OS: Ubuntu ...'
462 - env var lines like 'HF_HOME=/data/hf'
463 """
464 lines = env_path.read_text(encoding="utf-8", errors="replace").splitlines()
465 section = "General"
466 rows: list[dict] = []
467
468 def set_section(s: str):
469 nonlocal section
470 s = (s or "").strip()
471 if s:
472 section = s
473
474 for raw in lines:
475 stripped = raw.strip()
476 if not stripped:
477 continue
478 # divider lines like =====
479 if set(stripped) <= {"="}:
480 continue
481
482 # section header heuristic: short standalone line
483 if ":" not in stripped and "=" not in stripped and len(stripped) <= 64:
484 if stripped.lower().startswith("collecting environment information"):
485 continue
486 set_section(stripped)
487 continue
488
489 # env var style: KEY=VALUE (and not a URL with :)
490 if "=" in stripped and ":" not in stripped:
491 k, v = stripped.split("=", 1)
492 k = k.strip()
493 v = v.strip()
494 if k:
495 rows.append({"Section": section, "Key": k, "Value": v})
496 continue
497
498 # key: value
499 if ":" in stripped:
500 k, v = stripped.split(":", 1)
501 k = k.strip()
502 v = v.strip()
503 if k:
504 rows.append({"Section": section, "Key": k, "Value": v})
505 continue
506
507 return pd.DataFrame(rows, columns=["Section", "Key", "Value"])
508
509
510 def _load_env_df_for_inputs(args, files: list[str]) -> pd.DataFrame | None:
511 """Load vllm_env.txt next to the *original* input JSON file.
512
513 Note: when only one -f is provided, the script may split JSON into ./splits/...,
514 but vllm_env.txt typically lives next to the original benchmark_results.json.
515 """
516 base_dir: Path | None = None
517 if getattr(args, "file", None):
518 base_dir = Path(args.file[0]).resolve().parent
519 elif files:
520 base_dir = Path(files[0]).resolve().parent
521 if base_dir is None:
522 return None
523
524 env_path = base_dir / "vllm_env.txt"
525 if not env_path.exists():
526 return None
527 df = _parse_vllm_env_txt(env_path)
528 return df
529
530
531 # -----------------------------
532 # Valid max concurrency summary helpers
533 # -----------------------------
534 def _config_value_columns(df: pd.DataFrame, conc_col: str) -> list[str]:
535 key_cols = [
536 c
537 for c in ["Model", "Dataset Name", "Input Len", "Output Len"]
538 if c in df.columns
539 ]
540 exclude = set(key_cols + [conc_col, "qps", "QPS"])
541
542 cols: list[str] = []
543 for c in df.columns:
544 if c in exclude:
545 continue
546 lc = str(c).lower()
547 if lc.startswith("ratio"):
548 continue
549 if lc.endswith("_name") or lc == "test name" or lc == "test_name":
550 continue
551 if pd.api.types.is_numeric_dtype(df[c]):
552 cols.append(c)
553 return cols
554
555
556 def _max_concurrency_ok(
557 df: pd.DataFrame,
558 conc_col: str,
559 cfg_col: str,
560 threshold: float,
561 slack_pct: float = 0.0,
562 ):
563 if df is None or conc_col not in df.columns or cfg_col not in df.columns:
564 return pd.NA
565
566 d = df[[conc_col, cfg_col]].copy()
567 d[conc_col] = pd.to_numeric(d[conc_col], errors="coerce")
568 d[cfg_col] = pd.to_numeric(d[cfg_col], errors="coerce")
569 d = d.dropna(subset=[conc_col, cfg_col])
570
571 if d.empty:
572 return pd.NA
573
574 # Accept values up to (1 + slack_pct%) above the SLA.
575 try:
576 slack_pct = float(slack_pct or 0.0)
577 except Exception:
578 slack_pct = 0.0
579 effective_limit = float(threshold) * (1.0 + slack_pct / 100.0)
580
581 ok = d[d[cfg_col] <= effective_limit]
582 if ok.empty:
583 return pd.NA
584
585 return ok[conc_col].max()
586
587
588 def _value_at_concurrency(df: pd.DataFrame, conc_col: str, cfg_col: str, conc_value):
589 if (
590 df is None
591 or conc_col not in df.columns
592 or cfg_col not in df.columns
593 or pd.isna(conc_value)
594 ):
595 return pd.NA
596
597 d = df[[conc_col, cfg_col]].copy()
598 d[conc_col] = pd.to_numeric(d[conc_col], errors="coerce")
599 d[cfg_col] = pd.to_numeric(d[cfg_col], errors="coerce")
600
601 conc_value = pd.to_numeric(conc_value, errors="coerce")
602 if pd.isna(conc_value):
603 return pd.NA
604
605 hit = d[d[conc_col] == conc_value]
606 if hit.empty:
607 return pd.NA
608 return hit[cfg_col].iloc[0]
609
610
611 def build_valid_max_concurrency_summary_html(
612 tput_group_df: pd.DataFrame | None,
613 ttft_group_df: pd.DataFrame | None,
614 tpot_group_df: pd.DataFrame | None,
615 conc_col: str,
616 args,
617 ) -> str:
618 if ttft_group_df is None and tpot_group_df is None:
619 return ""
620
621 ttft_cols = (
622 _config_value_columns(ttft_group_df, conc_col)
623 if ttft_group_df is not None
624 else []
625 )
626 tpot_cols = (
627 _config_value_columns(tpot_group_df, conc_col)
628 if tpot_group_df is not None
629 else []
630 )
631 tput_cols = (
632 _config_value_columns(tput_group_df, conc_col)
633 if tput_group_df is not None
634 else []
635 )
636
637 if ttft_group_df is not None and tpot_group_df is not None:
638 cfg_cols = [c for c in ttft_cols if c in tpot_cols]
639 if tput_group_df is not None:
640 cfg_cols = [c for c in cfg_cols if c in tput_cols] or cfg_cols
641 else:
642 cfg_cols = ttft_cols or tpot_cols
643
644 if not cfg_cols:
645 cfg_cols = sorted(set(ttft_cols) | set(tpot_cols) | set(tput_cols), key=str)
646
647 # Display SLA ranges in the table header (SLA .. SLA*(1+slack))
648 ttft_hi = args.ttft_max_ms * (1.0 + args.ttft_slack_pct / 100.0)
649 tpot_hi = args.tpot_max_ms * (1.0 + args.tpot_slack_pct / 100.0)
650 ttft_range = f"{args.ttft_max_ms:g}–{ttft_hi:g} ms (+{args.ttft_slack_pct:g}%)"
651 tpot_range = f"{args.tpot_max_ms:g}–{tpot_hi:g} ms (+{args.tpot_slack_pct:g}%)"
652
653 rows = []
654 for cfg in cfg_cols:
655 ttft_max = (
656 _max_concurrency_ok(
657 ttft_group_df, conc_col, cfg, args.ttft_max_ms, args.ttft_slack_pct
658 )
659 if ttft_group_df is not None
660 else pd.NA
661 )
662 tpot_max = (
663 _max_concurrency_ok(
664 tpot_group_df, conc_col, cfg, args.tpot_max_ms, args.tpot_slack_pct
665 )
666 if tpot_group_df is not None
667 else pd.NA
668 )
669 both = (
670 pd.NA
671 if (pd.isna(ttft_max) or pd.isna(tpot_max))
672 else min(ttft_max, tpot_max)
673 )
674
675 tput_at_both = (
676 _value_at_concurrency(tput_group_df, conc_col, cfg, both)
677 if tput_group_df is not None
678 else pd.NA
679 )
680 ttft_at_both = (
681 _value_at_concurrency(ttft_group_df, conc_col, cfg, both)
682 if ttft_group_df is not None
683 else pd.NA
684 )
685 tpot_at_both = (
686 _value_at_concurrency(tpot_group_df, conc_col, cfg, both)
687 if tpot_group_df is not None
688 else pd.NA
689 )
690
691 rows.append(
692 {
693 "Configuration": cfg,
694 f"Max {conc_col} (TTFT ≤ {ttft_range})": ttft_max,
695 f"Max {conc_col} (TPOT ≤ {tpot_range})": tpot_max,
696 f"Max {conc_col} (Both)": both,
697 "Output Tput @ Both (tok/s)": tput_at_both,
698 "TTFT @ Both (ms)": ttft_at_both,
699 "TPOT @ Both (ms)": tpot_at_both,
700 }
701 )
702
703 summary_df = pd.DataFrame(rows)
704
705 for c in summary_df.columns:
706 if c == "Configuration":
707 continue
708 summary_df[c] = pd.to_numeric(summary_df[c], errors="coerce")
709
710 both_col = f"Max {conc_col} (Both)"
711
712 formatters = {}
713 for c in summary_df.columns:
714 if c == "Configuration":
715 continue
716 formatters[c] = lambda v: "" if pd.isna(v) else f"{float(v):.2f}"
717
718 styler = summary_df.style.format(formatters)
719
720 def _green(v):
721 return "background-color:#e6ffe6;font-weight:bold;" if pd.notna(v) else ""
722
723 if both_col in summary_df.columns:
724 styler = styler.map(_green, subset=[both_col])
725
726 title = (
727 '<div style="font-size: 1.15em; font-weight: 700; margin: 12px 0 6px 0;">'
728 "Valid Max Concurrency Summary"
729 "</div>\n"
730 )
731 return title + styler.to_html(table_attributes='border="1" class="dataframe"')
732
733
734 def build_valid_max_concurrency_summary_df(
735 tput_group_df: pd.DataFrame | None,
736 ttft_group_df: pd.DataFrame | None,
737 tpot_group_df: pd.DataFrame | None,
738 conc_col: str,
739 args,
740 ) -> pd.DataFrame | None:
741 if ttft_group_df is None and tpot_group_df is None:
742 return None
743
744 ttft_cols = (
745 _config_value_columns(ttft_group_df, conc_col)
746 if ttft_group_df is not None
747 else []
748 )
749 tpot_cols = (
750 _config_value_columns(tpot_group_df, conc_col)
751 if tpot_group_df is not None
752 else []
753 )
754 tput_cols = (
755 _config_value_columns(tput_group_df, conc_col)
756 if tput_group_df is not None
757 else []
758 )
759
760 if ttft_group_df is not None and tpot_group_df is not None:
761 cfg_cols = [c for c in ttft_cols if c in tpot_cols]
762 if tput_group_df is not None:
763 cfg_cols = [c for c in cfg_cols if c in tput_cols] or cfg_cols
764 else:
765 cfg_cols = ttft_cols or tpot_cols
766
767 if not cfg_cols:
768 cfg_cols = sorted(set(ttft_cols) | set(tpot_cols) | set(tput_cols), key=str)
769
770 ttft_hi = args.ttft_max_ms * (1.0 + args.ttft_slack_pct / 100.0)
771 tpot_hi = args.tpot_max_ms * (1.0 + args.tpot_slack_pct / 100.0)
772 ttft_range = f"{args.ttft_max_ms:g}–{ttft_hi:g} ms (+{args.ttft_slack_pct:g}%)"
773 tpot_range = f"{args.tpot_max_ms:g}–{tpot_hi:g} ms (+{args.tpot_slack_pct:g}%)"
774
775 rows = []
776 for cfg in cfg_cols:
777 ttft_max = (
778 _max_concurrency_ok(
779 ttft_group_df, conc_col, cfg, args.ttft_max_ms, args.ttft_slack_pct
780 )
781 if ttft_group_df is not None
782 else pd.NA
783 )
784 tpot_max = (
785 _max_concurrency_ok(
786 tpot_group_df, conc_col, cfg, args.tpot_max_ms, args.tpot_slack_pct
787 )
788 if tpot_group_df is not None
789 else pd.NA
790 )
791 both = (
792 pd.NA
793 if (pd.isna(ttft_max) or pd.isna(tpot_max))
794 else min(ttft_max, tpot_max)
795 )
796
797 tput_at_both = (
798 _value_at_concurrency(tput_group_df, conc_col, cfg, both)
799 if tput_group_df is not None
800 else pd.NA
801 )
802 ttft_at_both = (
803 _value_at_concurrency(ttft_group_df, conc_col, cfg, both)
804 if ttft_group_df is not None
805 else pd.NA
806 )
807 tpot_at_both = (
808 _value_at_concurrency(tpot_group_df, conc_col, cfg, both)
809 if tpot_group_df is not None
810 else pd.NA
811 )
812
813 rows.append(
814 {
815 "Configuration": cfg,
816 f"Max {conc_col} (TTFT ≤ {ttft_range})": ttft_max,
817 f"Max {conc_col} (TPOT ≤ {tpot_range})": tpot_max,
818 f"Max {conc_col} (Both)": both,
819 "Output Tput @ Both (tok/s)": tput_at_both,
820 "TTFT @ Both (ms)": ttft_at_both,
821 "TPOT @ Both (ms)": tpot_at_both,
822 }
823 )
824
825 df = pd.DataFrame(rows)
826 for c in df.columns:
827 if c != "Configuration":
828 df[c] = pd.to_numeric(df[c], errors="coerce")
829 return df
830
831
832 # -----------------------------
833 # Plot helper
834 # -----------------------------
835 def _add_limit_line(fig, y_value: float, label: str):
836 fig.add_hline(
837 y=y_value,
838 line_dash="dash",
839 line_color="red" if "ttft" in label.lower() else "blue",
840 annotation_text=f"{label}: {y_value} ms",
841 annotation_position="top left",
842 )
843 if plotly_found:
844 import plotly.graph_objects as go
845
846 fig.add_trace(
847 go.Scatter(
848 x=[None],
849 y=[None],
850 mode="lines",
851 line=dict(
852 dash="dash",
853 color="red" if "ttft" in label.lower() else "blue",
854 ),
855 name=label,
856 )
857 )
858
859
860 # -----------------------------
861 # Refactored main + group-first report
862 # -----------------------------
863 @dataclass(frozen=True)
864 class MetricPlan:
865 data_cols: list[str]
866 drop_column: str
867
868
869 def build_parser() -> argparse.ArgumentParser:
870 parser = argparse.ArgumentParser()
871 parser.add_argument(
872 "-f", "--file", action="append", type=str, help="input file name"
873 )
874 parser.add_argument(
875 "--debug", action="store_true", help="show all information for debugging"
876 )
877 parser.add_argument(
878 "--plot",
879 action=argparse.BooleanOptionalAction,
880 default=True,
881 help="plot perf diagrams or not --no-plot --plot",
882 )
883 parser.add_argument(
884 "-x",
885 "--xaxis",
886 type=str,
887 default="# of max concurrency.",
888 help="column name to use as X Axis in comparison graph",
889 )
890 parser.add_argument(
891 "-l",
892 "--latency",
893 type=str,
894 default="p99",
895 help="take median|p99 for latency like TTFT/TPOT",
896 )
897 parser.add_argument(
898 "--ttft-max-ms",
899 type=float,
900 default=3000.0,
901 help="Reference limit for TTFT plots (ms)",
902 )
903 parser.add_argument(
904 "--tpot-max-ms",
905 type=float,
906 default=100.0,
907 help="Reference limit for TPOT plots (ms)",
908 )
909
910 # ---- SLA tolerance (slack) options ----
911 parser.add_argument(
912 "--ttft-slack-pct",
913 type=float,
914 default=5.0,
915 help="Allowed percentage above TTFT SLA (default: 5).",
916 )
917 parser.add_argument(
918 "--tpot-slack-pct",
919 type=float,
920 default=5.0,
921 help="Allowed percentage above TPOT SLA (default: 5).",
922 )
923
924 # ---- export options ----
925 parser.add_argument(
926 "--excel-out",
927 type=str,
928 default="perf_comparison.xlsx",
929 help="Write one sheet per (Model, Dataset, Input Len, Output Len).",
930 )
931 parser.add_argument(
932 "--csv-out-dir",
933 type=str,
934 default="",
935 help="If set, write per-group per-metric CSVs into this directory.",
936 )
937
938 return parser
939
940
941 def choose_metrics(latency: str) -> MetricPlan:
942 latency = (latency or "").lower()
943 drop_column = "P99"
944
945 if "median" in latency:
946 return MetricPlan(
947 data_cols=["Output Tput (tok/s)", "Median TTFT (ms)", "Median"],
948 drop_column=drop_column,
949 )
950
951 return MetricPlan(
952 data_cols=["Output Tput (tok/s)", "P99 TTFT (ms)", "P99"],
953 drop_column=drop_column,
954 )
955
956
957 def prepare_input_files(args, info_cols: list[str]) -> tuple[list[str], list[str]]:
958 if not args.file:
959 raise ValueError("No input files provided. Use -f/--file.")
960
961 if len(args.file) == 1:
962 files = split_json_by_tp_pp(args.file[0], output_root="splits")
963 info_cols = [c for c in info_cols if c not in ("TP Size", "PP Size")]
964 else:
965 files = args.file
966
967 return files, info_cols
968
969
970 def get_y_axis_col(info_cols: list[str], xaxis: str) -> str:
971 y_axis_index = info_cols.index(xaxis) if xaxis in info_cols else 6
972 return info_cols[y_axis_index]
973
974
975 def get_group_cols(output_df: pd.DataFrame, info_cols: list[str]) -> list[str]:
976 filtered_info_cols = info_cols[:4]
977 group_cols = [c for c in filtered_info_cols if c in output_df.columns]
978 if not group_cols:
979 raise ValueError(
980 f"No valid group-by columns. Expected subset: {filtered_info_cols}, "
981 f"but DataFrame has: {list(output_df.columns)}"
982 )
983 return group_cols
984
985
986 def normalize_group_key(name):
987 return name if isinstance(name, tuple) else (name,)
988
989
990 def group_filename(name, prefix: str = "perf_comparison_") -> str:
991 name_vals = normalize_group_key(name)
992 safe = ",".join(map(str, name_vals)).replace(",", "_").replace("/", "-")
993 return f"{prefix}{safe}.html"
994
995
996 def build_group_suffix(group_cols: list[str], name) -> str:
997 name_vals = normalize_group_key(name)
998 return " , ".join(f"{col} : [ {val} ] " for col, val in zip(group_cols, name_vals))
999
1000
1001 def render_metric_table_html(
1002 display_group: pd.DataFrame,
1003 metric_label: str,
1004 group_suffix: str,
1005 args,
1006 ) -> str:
1007 title = (
1008 f'<div style="font-size: 1.25em; font-weight: 600; margin: 12px 0;">'
1009 f"{_html.escape(metric_label)}"
1010 f" — {_html.escape(group_suffix)}"
1011 f"</div>\n"
1012 )
1013
1014 metric_name = metric_label.lower()
1015 if "ttft" in metric_name:
1016 styler = _highlight_threshold(
1017 display_group, args.ttft_max_ms, args.ttft_slack_pct
1018 )
1019 elif ("tpot" in metric_name) or ("median" in metric_name) or ("p99" in metric_name):
1020 styler = _highlight_threshold(
1021 display_group, args.tpot_max_ms, args.tpot_slack_pct
1022 )
1023 else:
1024 styler = display_group.style
1025
1026 styler = _apply_two_decimals(styler)
1027 styler = highlight_ratio_columns(styler)
1028
1029 return title + styler.to_html(table_attributes='border="1" class="dataframe"')
1030
1031
1032 def maybe_write_plot(
1033 main_fh,
1034 sub_fh,
1035 group_df: pd.DataFrame,
1036 raw_data_cols: list[str],
1037 metric_label: str,
1038 y_axis_col: str,
1039 args,
1040 ):
1041 if not (args.plot and plotly_found):
1042 return
1043
1044 import plotly.express as px
1045
1046 df = group_df[raw_data_cols].sort_values(by=y_axis_col)
1047 df_melted = df.melt(
1048 id_vars=y_axis_col,
1049 var_name="Configuration",
1050 value_name=metric_label,
1051 )
1052
1053 fig = px.line(
1054 df_melted,
1055 x=y_axis_col,
1056 y=metric_label,
1057 color="Configuration",
1058 title=f"{metric_label} vs {y_axis_col}",
1059 markers=True,
1060 )
1061
1062 fig.update_traces(hovertemplate="%{y:.2f}<extra></extra>")
1063 fig.update_yaxes(tickformat=".2f")
1064
1065 metric_name = metric_label.lower()
1066 if "ttft" in metric_name:
1067 _add_limit_line(fig, args.ttft_max_ms, "TTFT limit")
1068 elif ("tpot" in metric_name) or ("median" in metric_name) or ("p99" in metric_name):
1069 _add_limit_line(fig, args.tpot_max_ms, "TPOT limit")
1070
1071 html = fig.to_html(full_html=True, include_plotlyjs="cdn")
1072 main_fh.write(html)
1073 sub_fh.write(html)
1074
1075
1076 def build_group_keys(
1077 df: pd.DataFrame, group_cols: list[str], sort_cols: list[str] | None = None
1078 ):
1079 if sort_cols:
1080 df = df.sort_values(by=sort_cols)
1081 gb = df.groupby(group_cols, dropna=False)
1082 return [k for k, _ in gb]
1083
1084
1085 def write_report_group_first(
1086 files: list[str], info_cols: list[str], plan: MetricPlan, args
1087 ):
1088 name_column = "Test name"
1089 y_axis_col = get_y_axis_col(info_cols, args.xaxis)
1090
1091 print("comparing : " + ", ".join(files))
1092
1093 metric_cache: dict[str, tuple[pd.DataFrame, list[str]]] = {}
1094 group_cols_canonical: list[str] | None = None
1095
1096 for metric_label in plan.data_cols:
1097 output_df, raw_data_cols = compare_data_columns(
1098 files,
1099 name_column,
1100 metric_label,
1101 info_cols,
1102 plan.drop_column,
1103 debug=args.debug,
1104 )
1105
1106 raw_data_cols = list(raw_data_cols)
1107 raw_data_cols.insert(0, y_axis_col)
1108
1109 group_cols = get_group_cols(output_df, info_cols)
1110 if group_cols_canonical is None:
1111 group_cols_canonical = group_cols
1112 else:
1113 group_cols_canonical = [c for c in group_cols_canonical if c in group_cols]
1114
1115 metric_cache[metric_label] = (
1116 output_df.sort_values(by=args.xaxis),
1117 raw_data_cols,
1118 )
1119
1120 if not group_cols_canonical:
1121 raise ValueError("No canonical group columns found across metrics.")
1122
1123 first_metric = plan.data_cols[0]
1124 first_df_sorted, _ = metric_cache[first_metric]
1125 group_keys = build_group_keys(
1126 first_df_sorted, group_cols_canonical, sort_cols=[args.xaxis]
1127 )
1128
1129 metric_groupbys = {
1130 metric_label: df.groupby(group_cols_canonical, dropna=False)
1131 for metric_label, (df, _) in metric_cache.items()
1132 }
1133
1134 csv_dir = Path(args.csv_out_dir) if args.csv_out_dir else None
1135 if csv_dir:
1136 csv_dir.mkdir(parents=True, exist_ok=True)
1137
1138 excel_path = args.excel_out or "perf_comparison.xlsx"
1139 disable_excel = os.getenv("VLLM_COMPARE_DISABLE_EXCEL", "0") == "1"
1140
1141 # Prefer xlsxwriter for speed; fallback to openpyxl if unavailable.
1142 excel_engine = (
1143 os.getenv("VLLM_COMPARE_EXCEL_ENGINE", "xlsxwriter").strip() or "xlsxwriter"
1144 )
1145 if excel_engine == "xlsxwriter" and util.find_spec("xlsxwriter") is None:
1146 excel_engine = "openpyxl"
1147
1148 excel_engine_kwargs = {}
1149 if excel_engine == "xlsxwriter":
1150 # Reduce memory pressure & usually faster writes.
1151 excel_engine_kwargs = {"options": {"constant_memory": True}}
1152
1153 xw_ctx = (
1154 nullcontext(None)
1155 if disable_excel
1156 else pd.ExcelWriter(
1157 excel_path, engine=excel_engine, engine_kwargs=excel_engine_kwargs
1158 )
1159 )
1160 with xw_ctx as xw:
1161 used_sheets: set[str] = set()
1162 # ---- Environment sheet (first) ----
1163 env_sheet = _sanitize_sheet_name("Environment")
1164 env_df = _load_env_df_for_inputs(args, files)
1165 if xw is not None:
1166 if env_df is None or env_df.empty:
1167 pd.DataFrame(
1168 [
1169 {
1170 "Section": "Environment",
1171 "Key": "vllm_env.txt",
1172 "Value": "NOT FOUND (or empty)",
1173 }
1174 ]
1175 ).to_excel(xw, sheet_name=env_sheet, index=False)
1176 else:
1177 env_df.to_excel(xw, sheet_name=env_sheet, index=False)
1178 used_sheets.add(env_sheet)
1179 with open("perf_comparison.html", "w", encoding="utf-8") as main_fh:
1180 main_fh.write('<meta charset="utf-8">\n')
1181 for gkey in group_keys:
1182 gkey_tuple = normalize_group_key(gkey)
1183 suffix = build_group_suffix(group_cols_canonical, gkey_tuple)
1184 sub_path = group_filename(gkey_tuple)
1185 group_header = (
1186 '<div style="font-size: 1.4em; font-weight: 700; '
1187 'margin: 18px 0 10px 0;">'
1188 f"{_html.escape(suffix)}"
1189 "</div>\n"
1190 )
1191
1192 main_fh.write(group_header)
1193
1194 do_excel = xw is not None
1195 sheet = _group_to_sheet_base(group_cols_canonical, gkey_tuple)
1196 sheet_base = sheet
1197 if do_excel:
1198 dedup_i = 1
1199 while sheet in used_sheets:
1200 dedup_i += 1
1201 suffix = f"_{dedup_i}"
1202 # Ensure uniqueness even when sheet names are truncated.
1203 base = str(sheet_base)
1204 keep = max(1, 31 - len(suffix))
1205 sheet = _sanitize_sheet_name(base[:keep] + suffix)
1206 used_sheets.add(sheet)
1207
1208 excel_blocks: list[tuple[str, pd.DataFrame]] = []
1209
1210 with open(sub_path, "w", encoding="utf-8") as sub_fh:
1211 sub_fh.write('<meta charset="utf-8">\n')
1212 sub_fh.write(group_header)
1213 tput_group_df = None
1214 ttft_group_df = None
1215 tpot_group_df = None
1216 conc_col = args.xaxis
1217
1218 for metric_label in plan.data_cols:
1219 gb = metric_groupbys[metric_label]
1220 df_sorted, raw_data_cols = metric_cache[metric_label]
1221
1222 try:
1223 group_df = gb.get_group(gkey)
1224 except KeyError:
1225 missing = (
1226 '<div style="font-size: 1.1em; font-weight: 600; '
1227 'margin: 10px 0;">'
1228 f"{_html.escape(metric_label)} — missing for this group"
1229 "</div>\n"
1230 )
1231 main_fh.write(missing)
1232 sub_fh.write(missing)
1233 continue
1234
1235 if conc_col not in group_df.columns:
1236 conc_col = _find_concurrency_col(group_df)
1237
1238 mn = metric_label.lower().strip()
1239 if "tok/s" in mn:
1240 tput_group_df = group_df
1241 elif "ttft" in mn:
1242 ttft_group_df = group_df
1243 elif mn in ("p99", "median") or "tpot" in mn:
1244 tpot_group_df = group_df
1245
1246 display_group = group_df.drop(
1247 columns=group_cols_canonical, errors="ignore"
1248 )
1249
1250 html = render_metric_table_html(
1251 display_group, metric_label, suffix, args
1252 )
1253 main_fh.write(html)
1254 sub_fh.write(html)
1255
1256 maybe_write_plot(
1257 main_fh,
1258 sub_fh,
1259 group_df=group_df,
1260 raw_data_cols=raw_data_cols,
1261 metric_label=metric_label,
1262 y_axis_col=y_axis_col,
1263 args=args,
1264 )
1265
1266 excel_blocks.append(
1267 (metric_label, group_df.reset_index(drop=True))
1268 )
1269 if csv_dir:
1270 fn = _safe_filename(
1271 f"{sheet}__{metric_label}".replace(" ", "_").replace(
1272 "/", "_"
1273 )
1274 )
1275 group_df.to_csv(csv_dir / f"{fn}.csv", index=False)
1276
1277 summary_html = build_valid_max_concurrency_summary_html(
1278 tput_group_df=tput_group_df,
1279 ttft_group_df=ttft_group_df,
1280 tpot_group_df=tpot_group_df,
1281 conc_col=conc_col,
1282 args=args,
1283 )
1284 if summary_html:
1285 main_fh.write(summary_html)
1286 sub_fh.write(summary_html)
1287
1288 summary_df = build_valid_max_concurrency_summary_df(
1289 tput_group_df=tput_group_df,
1290 ttft_group_df=ttft_group_df,
1291 tpot_group_df=tpot_group_df,
1292 conc_col=conc_col,
1293 args=args,
1294 )
1295 if summary_df is not None:
1296 excel_blocks.append(
1297 ("Valid Max Concurrency Summary", summary_df)
1298 )
1299 if csv_dir:
1300 fn = _safe_filename(
1301 f"{sheet}__Valid_Max_Concurrency_Summary"
1302 )
1303 summary_df.to_csv(csv_dir / f"{fn}.csv", index=False)
1304
1305 if do_excel:
1306 _write_tables_to_excel_sheet(xw, sheet, excel_blocks)
1307
1308 if disable_excel:
1309 print("Skipped Excel generation (VLLM_COMPARE_DISABLE_EXCEL=1).")
1310 else:
1311 print(f"Wrote Excel: {excel_path}")
1312 if csv_dir:
1313 print(f"Wrote CSVs under: {csv_dir}")
1314
1315
1316 def main():
1317 args = build_parser().parse_args()
1318 info_cols = list(DEFAULT_INFO_COLS)
1319 plan = choose_metrics(args.latency)
1320 files, info_cols = prepare_input_files(args, info_cols)
1321 write_report_group_first(files, info_cols, plan, args)
1322
1323
1324 if __name__ == "__main__":
1325 main()