@@ -70,9 +70,127 @@ def aggregate_results(results_dir: Path, model: str | None = None) -> dict[str,
7070 "available_models" : available_models ,
7171 "selected_model" : selected_model ,
7272 "selection_mode" : selection_mode ,
73+ "_raw_records" : records , # kept for saturation analysis; not serialised to JSON
7374 }
7475
7576
77+ def _compute_saturation (records : list [dict [str , Any ]]) -> list [dict [str , Any ]]:
78+ """
79+ For throughput_ramp and throughput_ramp_extended result files, partition requests
80+ by concurrency level (using scenario_config) and compute per-level throughput.
81+
82+ Throughput per level ≈ (total output tokens × concurrency) / sum(total_ms / 1000)
83+ This is an approximation for a semaphore-bounded concurrent workload.
84+
85+ Returns list of rows: {model, engine, scenario, concurrency, tokens_per_sec, requests}
86+ """
87+ rows : list [dict [str , Any ]] = []
88+ ramp_scenarios = {"throughput_ramp" , "throughput_ramp_extended" }
89+
90+ for rec in records :
91+ if rec .get ("scenario_name" ) not in ramp_scenarios :
92+ continue
93+ cfg = rec .get ("scenario_config" , {})
94+ concurrency_levels = cfg .get ("concurrency_levels" , [])
95+ rpl = cfg .get ("requests_per_level" , 100 )
96+ if not concurrency_levels or not rpl :
97+ continue
98+
99+ model_raw = rec .get ("run_metadata" , {}).get ("model" , "" )
100+ from analysis import get_engine_variant
101+ engine = get_engine_variant (rec )
102+ model = model_raw .split ("/" )[- 1 ] if model_raw else "unknown"
103+ scenario = rec .get ("scenario_name" , "unknown" )
104+
105+ sorted_requests = sorted (rec .get ("requests" , []), key = lambda r : r ["request_id" ])
106+
107+ for i , concurrency in enumerate (concurrency_levels ):
108+ level_reqs = sorted_requests [i * rpl : (i + 1 ) * rpl ]
109+ successful = [r for r in level_reqs if r .get ("success" )]
110+ if not successful :
111+ continue
112+ total_tokens = sum (r .get ("output_tokens" , 0 ) for r in successful )
113+ total_ms = sum (r .get ("total_ms" , 0.0 ) for r in successful )
114+ if total_ms <= 0 :
115+ continue
116+ # Approximation: concurrent throughput = (tokens × concurrency) / total_latency
117+ approx_tps = total_tokens * concurrency / (total_ms / 1000.0 )
118+ rows .append (
119+ {
120+ "model" : model ,
121+ "engine" : engine ,
122+ "scenario" : scenario ,
123+ "concurrency" : concurrency ,
124+ "tokens_per_sec" : approx_tps ,
125+ "n_requests" : len (successful ),
126+ }
127+ )
128+ return rows
129+
130+
131+ def _render_saturation (saturation_rows : list [dict [str , Any ]]) -> list [str ]:
132+ """Render a 'Saturation Analysis' section from per-level throughput rows."""
133+ if not saturation_rows :
134+ return []
135+
136+ from collections import defaultdict
137+
138+ lines : list [str ] = [
139+ "## Saturation Analysis" ,
140+ "" ,
141+ "Per-level throughput from `throughput_ramp` and `throughput_ramp_extended` runs. "
142+ "Throughput is approximated as `(output tokens × concurrency) / Σ(total_ms)`. "
143+ "The **saturation point** is the lowest concurrency level at which throughput is within 5% of the run maximum." ,
144+ "" ,
145+ ]
146+
147+ # Group by (model, engine, scenario)
148+ grouped : dict [tuple [str , str , str ], list [dict ]] = defaultdict (list )
149+ for row in saturation_rows :
150+ grouped [(row ["model" ], row ["engine" ], row ["scenario" ])].append (row )
151+
152+ # Collect all concurrency levels seen
153+ all_concurrencies : list [int ] = sorted ({r ["concurrency" ] for r in saturation_rows })
154+ conc_header = " | " .join (f"C={ c } " for c in all_concurrencies )
155+ conc_sep = " | " .join ("------" for _ in all_concurrencies )
156+
157+ lines .append (f"| Model | Engine | Scenario | { conc_header } | Saturation point |" )
158+ lines .append (f"|-------|--------|----------|{ conc_sep } |-----------------|" )
159+
160+ # Aggregate multiple runs by mean
161+ for key in sorted (grouped .keys ()):
162+ model , engine , scenario = key
163+ level_rows = grouped [key ]
164+
165+ # Average across multiple runs at the same concurrency
166+ from statistics import mean as _mean
167+ by_conc : dict [int , list [float ]] = defaultdict (list )
168+ for r in level_rows :
169+ by_conc [r ["concurrency" ]].append (r ["tokens_per_sec" ])
170+ avg_tps = {c : _mean (vs ) for c , vs in by_conc .items ()}
171+
172+ max_tps = max (avg_tps .values ()) if avg_tps else 1.0
173+ sat_point = "—"
174+ for c in all_concurrencies :
175+ tps = avg_tps .get (c , 0.0 )
176+ if tps >= max_tps * 0.95 :
177+ sat_point = f"C={ c } "
178+ break
179+
180+ cells = []
181+ for c in all_concurrencies :
182+ tps = avg_tps .get (c )
183+ if tps is None :
184+ cells .append ("—" )
185+ else :
186+ cells .append (f"{ tps :.0f} " )
187+ cells_str = " | " .join (cells )
188+ lines .append (f"| { model } | { engine } | { scenario } | { cells_str } | { sat_point } |" )
189+
190+ lines .append ("" )
191+ return lines
192+
193+
76194def render_markdown (summary : dict [str , Any ]) -> str :
77195 rows = summary ["rows" ]
78196 models = sorted ({row ["model" ] for row in rows })
@@ -105,6 +223,12 @@ def render_markdown(summary: dict[str, Any]) -> str:
105223 lines .append ("No result files found." )
106224 return "\n " .join (lines )
107225
226+ # Saturation analysis (requires access to raw records stored in summary)
227+ raw_records = summary .get ("_raw_records" , [])
228+ if raw_records :
229+ saturation_rows = _compute_saturation (raw_records )
230+ lines .extend (_render_saturation (saturation_rows ))
231+
108232 for model in models :
109233 lines .extend (
110234 [
0 commit comments