|
| 1 | +import re |
| 2 | +import os |
| 3 | +from pathlib import Path |
| 4 | +from typing import Any |
| 5 | +import pandas as pd |
| 6 | +import plotly.express as px |
| 7 | +import streamlit as st |
| 8 | +st.set_page_config( |
| 9 | + layout="wide", page_icon=":chart_with_upwards_trend:", page_title="TPS Degradation", initial_sidebar_state="collapsed") |
| 10 | + |
| 11 | +IGNORE_COLS = {"bench", "workers", "tps", |
| 12 | + "iterations", "ns/op", "B/op", "allocs/op"} |
| 13 | +DEFAULT_BENCH_DIR = "bench" |
| 14 | + |
| 15 | + |
| 16 | +def _is_number(s: str) -> bool: |
| 17 | + try: |
| 18 | + float(s) |
| 19 | + return True |
| 20 | + except ValueError: |
| 21 | + return False |
| 22 | + |
| 23 | + |
| 24 | +def simple_parser(path: Path) -> pd.DataFrame: |
| 25 | + rows = [] |
| 26 | + for ln in path.read_text().splitlines(): |
| 27 | + if not ln.startswith("Benchmark"): |
| 28 | + continue |
| 29 | + first, *cols = ln.split() |
| 30 | + if not cols: |
| 31 | + continue |
| 32 | + |
| 33 | + bench_name, *params = first.split("/") |
| 34 | + if params: |
| 35 | + parts = params[-1].rsplit("-", 1) |
| 36 | + if len(parts) == 2 and parts[1].isdigit(): |
| 37 | + last_param, workers = parts[0], int(parts[1]) |
| 38 | + else: |
| 39 | + last_param, workers = params[-1], 1 |
| 40 | + row: dict[str, Any] = {"bench": bench_name, "workers": workers} |
| 41 | + for p in [*params[:-1], last_param]: |
| 42 | + if "=" in p: |
| 43 | + k, v = p.split("=", 1) |
| 44 | + row[k] = v |
| 45 | + else: |
| 46 | + parts = bench_name.rsplit("-", 1) |
| 47 | + if len(parts) == 2 and parts[1].isdigit(): |
| 48 | + bench_name, workers = parts[0], int(parts[1]) |
| 49 | + else: |
| 50 | + workers = 1 |
| 51 | + row = {"bench": bench_name, "workers": workers} |
| 52 | + |
| 53 | + row["iterations"] = int(cols.pop(0)) |
| 54 | + |
| 55 | + i = 0 |
| 56 | + while i < len(cols): |
| 57 | + pval = cols[i] |
| 58 | + j = i + 1 |
| 59 | + while j < len(cols) and not _is_number(cols[j]): |
| 60 | + j += 1 |
| 61 | + pname = " ".join(cols[i + 1:j]) |
| 62 | + row[pname] = float(pval) |
| 63 | + i = j |
| 64 | + |
| 65 | + rows.append(row) |
| 66 | + |
| 67 | + df = pd.DataFrame(rows) |
| 68 | + if df.empty: |
| 69 | + return df |
| 70 | + |
| 71 | + if "TPS" in df.columns: |
| 72 | + df = df.rename(columns={"TPS": "tps"}) |
| 73 | + for col in [c for c in df.columns if c.startswith("ns/op") and "(" in c]: |
| 74 | + label = col.split("(")[1].rstrip(")") |
| 75 | + df[f"{label} (ms)"] = df[col] / 1e6 |
| 76 | + df = df.drop(columns=[col]) |
| 77 | + |
| 78 | + if 'tps' in df.columns and 'workers' in df.columns: |
| 79 | + # Little's Law (latency = concurrency / throughput) |
| 80 | + df['avg (ms)'] = df['workers'] * 1000 / df['tps'] |
| 81 | + |
| 82 | + return df |
| 83 | + |
| 84 | + |
| 85 | +# --- STRUCTURED PARALLEL LOG PARSER --- |
| 86 | + |
| 87 | +ANSI_ESCAPE_RE = re.compile(r'\x1b\[[0-9;]*m') |
| 88 | +PARALLEL_RUN_RE = re.compile(r'=== RUN\s+(\S+)/(.+?)_with_(\d+)_workers') |
| 89 | +PARALLEL_THROUGHPUT_RE = re.compile(r'Pure Throughput\s+([\d.]+)/s') |
| 90 | +PARALLEL_LATENCY_RE = re.compile( |
| 91 | + r'(Min|P50 \(Median\)|Average|P95|P99\.9|P99|P5|Max)\s+' |
| 92 | + r'([\d.]+(?:ms|[n\xb5\xc2]+s|s))') |
| 93 | + |
| 94 | + |
| 95 | +def _parse_ms(s: str) -> float: |
| 96 | + m = re.match(r'([\d.]+)(.*)', s) |
| 97 | + val, unit = float(m.group(1)), m.group(2) # type: ignore |
| 98 | + if unit == 'ms': |
| 99 | + return val |
| 100 | + if unit == 'ns': |
| 101 | + return val / 1e6 |
| 102 | + if unit == 's': |
| 103 | + return val * 1e3 |
| 104 | + if '\xb5' in unit or 'µ' in unit: |
| 105 | + return val / 1e3 |
| 106 | + raise ValueError(f"unknown unit: {unit}") |
| 107 | + |
| 108 | + |
| 109 | +LATENCY_KEYS = { |
| 110 | + 'P50 (Median)': 'p50 (ms)', |
| 111 | + 'P5': 'p5 (ms)', |
| 112 | + 'P95': 'p95 (ms)', |
| 113 | + 'P99': 'p99 (ms)', |
| 114 | +} |
| 115 | +DISPLAY_LATENCY = {'avg (ms)', 'p50 (ms)', 'p95 (ms)'} |
| 116 | +WORKERS_RE = re.compile(r'^Workers\s+(\d+)') |
| 117 | + |
| 118 | + |
| 119 | +def parse_parallel_log(path: Path, default_bench: str | None = None) -> pd.DataFrame: |
| 120 | + text = ANSI_ESCAPE_RE.sub('', path.read_text()) |
| 121 | + rows: list[dict] = [] |
| 122 | + current: dict = {} |
| 123 | + has_run_lines = bool(PARALLEL_RUN_RE.search(text)) |
| 124 | + |
| 125 | + for line in text.splitlines(): |
| 126 | + line = line.strip() |
| 127 | + |
| 128 | + m = PARALLEL_RUN_RE.match(line) |
| 129 | + if m: |
| 130 | + if current: |
| 131 | + rows.append(current) |
| 132 | + bench, params_raw, workers = m.group( |
| 133 | + 1), m.group(2), int(m.group(3)) |
| 134 | + current = {'bench': bench, 'workers': workers} |
| 135 | + setup_m = re.search(r'Setup\((.+?)\)', params_raw) |
| 136 | + if setup_m: |
| 137 | + for token in setup_m.group(1).split(',_'): |
| 138 | + token = token.strip('_').lstrip('#') |
| 139 | + if '_' in token: |
| 140 | + k, v = token.split('_', 1) |
| 141 | + current[k] = v |
| 142 | + continue |
| 143 | + |
| 144 | + if not has_run_lines: |
| 145 | + m = WORKERS_RE.match(line) |
| 146 | + if m: |
| 147 | + workers = int(m.group(1)) |
| 148 | + if current: |
| 149 | + rows.append(current) |
| 150 | + current = { |
| 151 | + 'bench': default_bench or path.stem, |
| 152 | + 'workers': workers, |
| 153 | + } |
| 154 | + continue |
| 155 | + |
| 156 | + m = PARALLEL_THROUGHPUT_RE.match(line) |
| 157 | + if m: |
| 158 | + current['tps'] = float(m.group(1)) |
| 159 | + continue |
| 160 | + |
| 161 | + m = PARALLEL_LATENCY_RE.match(line) |
| 162 | + if m and m.group(1) in LATENCY_KEYS: |
| 163 | + current[LATENCY_KEYS[m.group(1)]] = _parse_ms(m.group(2)) |
| 164 | + continue |
| 165 | + |
| 166 | + m = re.match(r'Total Ops\s+(\d+)', line) |
| 167 | + if m: |
| 168 | + current['iterations'] = int(m.group(1)) |
| 169 | + |
| 170 | + if current: |
| 171 | + rows.append(current) |
| 172 | + return pd.DataFrame(rows) |
| 173 | + |
| 174 | + |
| 175 | +def has_multi_nc(df: pd.DataFrame) -> bool: |
| 176 | + return "nc" in df.columns and df["nc"].nunique() > 1 |
| 177 | + |
| 178 | + |
| 179 | +# --- PLOTTING --- |
| 180 | + |
| 181 | + |
| 182 | +def _tps_fig(df, color_col, title): |
| 183 | + fig = px.line(df, x='workers', y='tps', color=color_col, |
| 184 | + symbol=color_col, |
| 185 | + markers=True, title=title, |
| 186 | + labels={'workers': 'Workers', 'tps': 'TPS', color_col: ''}, |
| 187 | + symbol_sequence=SHAPES) |
| 188 | + fig.update_yaxes( |
| 189 | + nticks=25, |
| 190 | + ) |
| 191 | + fig.update_layout(template='plotly_white', |
| 192 | + hovermode='x unified') |
| 193 | + return fig |
| 194 | + |
| 195 | + |
| 196 | +def _latency_fig(df, color_col, dash_col, title): |
| 197 | + latency_cols = [c for c in df.columns if c in DISPLAY_LATENCY] |
| 198 | + if not latency_cols: |
| 199 | + return None |
| 200 | + melted = df.melt(id_vars=[color_col, 'workers'], value_vars=latency_cols, |
| 201 | + var_name='percentile', value_name='latency') |
| 202 | + dash_map = {'avg (ms)': 'dash', 'p50 (ms)': 'solid', 'p95 (ms)': 'dot'} |
| 203 | + fig = px.line(melted, x='workers', y='latency', |
| 204 | + color=color_col, line_dash=dash_col, markers=True, title=title, |
| 205 | + labels={'workers': 'Workers', |
| 206 | + 'latency': 'Latency (ms)', color_col: ''}, |
| 207 | + line_dash_map=dash_map) |
| 208 | + fig.update_layout(template='plotly_white', hovermode='x unified') |
| 209 | + return fig |
| 210 | + |
| 211 | + |
| 212 | +def _aggregate(df, group_cols): |
| 213 | + numeric = [c for c in df.select_dtypes( |
| 214 | + include='number').columns if c != 'workers'] |
| 215 | + return (df.groupby(group_cols)[numeric].mean().reset_index().sort_values('workers')) |
| 216 | + |
| 217 | + |
| 218 | +def make_figures(df): |
| 219 | + param_cols = [ |
| 220 | + c for c in df.columns if c not in IGNORE_COLS and not c.endswith("(ms)")] |
| 221 | + figs = [] |
| 222 | + |
| 223 | + for bench, bdf in df.groupby('bench'): |
| 224 | + bdf = bdf.copy() |
| 225 | + varying = [c for c in param_cols if c in bdf and bdf[c].nunique() > 1] |
| 226 | + fixed = [c for c in param_cols if c in bdf and bdf[c].nunique() |
| 227 | + <= 1 and bdf[c].notna().any()] |
| 228 | + |
| 229 | + bdf['series'] = ( |
| 230 | + bdf[varying].astype(str).apply( |
| 231 | + lambda r: ', '.join(f'{k}={v}' for k, v in r.items()), axis=1) |
| 232 | + if varying else bench |
| 233 | + ) |
| 234 | + |
| 235 | + agg = _aggregate(bdf, ['series', 'workers']) |
| 236 | + fixed_str = ', '.join(str(bdf[c].dropna().iloc[0]) for c in fixed) |
| 237 | + suffix = f' ({fixed_str})' if fixed_str else '' |
| 238 | + |
| 239 | + figs.append(_tps_fig(agg, 'series', f'{bench}{suffix}')) |
| 240 | + lat = _latency_fig(agg, 'series', 'percentile', |
| 241 | + f'{bench} - Latency{suffix}') |
| 242 | + if lat: |
| 243 | + figs.append(lat) |
| 244 | + |
| 245 | + return figs |
| 246 | + |
| 247 | + |
| 248 | +def parse_combined(dfs: dict[str, pd.DataFrame]): |
| 249 | + for name, df in dfs.items(): |
| 250 | + df['bench'] = name |
| 251 | + dct = {} |
| 252 | + for df in dfs.values(): |
| 253 | + for key, group in df.groupby("nc"): |
| 254 | + dct[key] = pd.concat( |
| 255 | + [dct.get(key, pd.DataFrame()), group], ignore_index=True) |
| 256 | + return dct |
| 257 | + |
| 258 | + |
| 259 | +SHAPES = ['circle', 'square', 'diamond', 'cross', 'hexagon', 'star'] |
| 260 | + |
| 261 | + |
| 262 | +def make_combined_figures(dfs, local_dfs): |
| 263 | + figs = {"_All": None} |
| 264 | + all_dfs = [] |
| 265 | + for name, d in local_dfs.items(): |
| 266 | + d = d.copy() |
| 267 | + d["bench"] = name |
| 268 | + all_dfs.append(_aggregate(d, ["bench", "workers"])) |
| 269 | + |
| 270 | + for nc, df in sorted(dfs.items(), key=lambda x: x[0]): |
| 271 | + agg = _aggregate(df, ["bench", "workers"]) |
| 272 | + d = agg.copy() |
| 273 | + d["bench"] = d["bench"] + f" (nc={nc})" |
| 274 | + all_dfs.append(d) |
| 275 | + local_parts = [] |
| 276 | + for name, ldf in local_dfs.items(): |
| 277 | + ldf = ldf.copy() |
| 278 | + ldf['bench'] = name |
| 279 | + local_parts.append(ldf) |
| 280 | + agg = _aggregate( |
| 281 | + pd.concat([agg, *local_parts], ignore_index=True), |
| 282 | + ["bench", "workers"] |
| 283 | + ) |
| 284 | + |
| 285 | + figs[nc] = _tps_fig(agg, 'bench', f'TPS (nc={nc})') |
| 286 | + lat = _latency_fig(agg, 'bench', 'percentile', f'Latency (nc={nc})') |
| 287 | + if lat: |
| 288 | + figs[f"{nc}_latency"] = lat |
| 289 | + figs["_All"] = _tps_fig( |
| 290 | + pd.concat(all_dfs, ignore_index=True), 'bench', 'TPS (All)') |
| 291 | + return figs |
| 292 | + |
| 293 | + |
| 294 | +def _get_dir(): |
| 295 | + def_dir = Path(os.environ.get("DEF_BENCH", DEFAULT_BENCH_DIR)) |
| 296 | + |
| 297 | + with st.sidebar: |
| 298 | + directory = st.text_input( |
| 299 | + "Directory", value=str(def_dir) if def_dir.exists() else "", key="benchdir") |
| 300 | + os.environ["DEF_BENCH"] = directory |
| 301 | + |
| 302 | + if not directory: |
| 303 | + st.info(f"Please input a Folder in the Sidebar") |
| 304 | + return None |
| 305 | + directory = Path(directory) |
| 306 | + if not directory.exists(): |
| 307 | + st.error(f"Directory `{directory}` does not exist") |
| 308 | + return None |
| 309 | + return directory |
| 310 | + |
| 311 | + |
| 312 | +def main(): |
| 313 | + directory = _get_dir() |
| 314 | + if not directory: |
| 315 | + return |
| 316 | + single = {} |
| 317 | + |
| 318 | + for path in sorted(directory.glob("*.log")): |
| 319 | + df = parse_parallel_log(path) |
| 320 | + if df.empty: |
| 321 | + continue |
| 322 | + with st.expander(f"`{path.stem}`"): |
| 323 | + for fig in make_figures(df): |
| 324 | + st.plotly_chart(fig) |
| 325 | + st.dataframe(df) |
| 326 | + single[path.stem] = df |
| 327 | + |
| 328 | + all_dfs = {} |
| 329 | + for path in sorted(directory.glob("*.txt")): |
| 330 | + df = simple_parser(path) |
| 331 | + if df.empty: |
| 332 | + continue |
| 333 | + all_dfs[path.stem] = df |
| 334 | + with st.expander(f"`{path.stem}`"): |
| 335 | + for fig in make_figures(df): |
| 336 | + st.plotly_chart(fig) |
| 337 | + st.dataframe(df) |
| 338 | + |
| 339 | + multi = {n: df.copy() for n, df in all_dfs.items() if has_multi_nc(df)} |
| 340 | + single.update({n: df.copy() |
| 341 | + for n, df in all_dfs.items() if not has_multi_nc(df)}) |
| 342 | + |
| 343 | + dfs = parse_combined({ |
| 344 | + n: df for n, df in multi.items() |
| 345 | + }) |
| 346 | + |
| 347 | + figs = make_combined_figures(dfs, local_dfs=single) |
| 348 | + st.subheader("Combined TPS") |
| 349 | + tabs = st.tabs(list(figs.keys())) |
| 350 | + for fig, tab in zip(figs.values(), tabs): |
| 351 | + with tab: |
| 352 | + st.plotly_chart(fig) |
| 353 | + |
| 354 | + |
| 355 | +if __name__ == "__main__": |
| 356 | + main() |
0 commit comments