Download speed-bench/plot_speed.py from julianmb/ds4fa: direct link, hf CLI and curl.
- Browser
- Download file 8.89 kB
-
https://huggingface.co/julianmb/ds4fa/resolve/main/speed-bench/plot_speed.py
- Command line
-
hf download hf://julianmb/ds4fa/speed-bench/plot_speed.py
-
curl -L -o plot_speed.py https://huggingface.co/julianmb/ds4fa/resolve/main/speed-bench/plot_speed.py
8.89 kB
| #!/usr/bin/env python3 | |
| """Generate an SVG throughput graph from a ds4-bench CSV file. | |
| The benchmark intentionally reports instantaneous throughput at each context | |
| frontier. This script keeps the plot equally direct: one line for incremental | |
| prefill t/s, one line for greedy generation t/s, and separate y axes because | |
| the two values live on very different scales. | |
| """ | |
| import argparse | |
| import csv | |
| import html | |
| import math | |
| from pathlib import Path | |
| PREFILL_COLOR = "#2563eb" | |
| GEN_COLOR = "#dc2626" | |
| TEXT_COLOR = "#1f2933" | |
| MUTED_COLOR = "#64748b" | |
| GRID_COLOR = "#e2e8f0" | |
| AXIS_COLOR = "#334155" | |
| def nice_ceil(value): | |
| """Round a positive axis maximum up to a human-friendly tick boundary.""" | |
| if value <= 0: | |
| return 1.0 | |
| magnitude = 10 ** math.floor(math.log10(value)) | |
| normalized = value / magnitude | |
| for step in (1, 2, 2.5, 3, 4, 5, 10): | |
| if normalized <= step: | |
| return step * magnitude | |
| return 10 * magnitude | |
| def nice_step(span, target_ticks): | |
| """Return a readable tick spacing close to span / target_ticks.""" | |
| if span <= 0: | |
| return 1.0 | |
| raw = span / target_ticks | |
| magnitude = 10 ** math.floor(math.log10(raw)) | |
| normalized = raw / magnitude | |
| for step in (1, 2, 2.5, 5, 10): | |
| if normalized <= step: | |
| return step * magnitude | |
| return 10 * magnitude | |
| def fmt_tick(value): | |
| if abs(value) >= 1000: | |
| return f"{value / 1000:g}k" | |
| return f"{value:g}" | |
| def read_points(path): | |
| rows = [] | |
| with path.open("r", encoding="utf-8-sig", newline="") as fp: | |
| reader = csv.DictReader(fp) | |
| required = {"ctx_tokens", "prefill_tps", "gen_tps"} | |
| missing = required.difference(reader.fieldnames or ()) | |
| if missing: | |
| missing_list = ", ".join(sorted(missing)) | |
| raise SystemExit(f"{path}: missing CSV column(s): {missing_list}") | |
| for row in reader: | |
| rows.append( | |
| ( | |
| int(row["ctx_tokens"]), | |
| float(row["prefill_tps"]), | |
| float(row["gen_tps"]), | |
| ) | |
| ) | |
| if len(rows) < 2: | |
| raise SystemExit(f"{path}: need at least two data rows") | |
| rows.sort(key=lambda item: item[0]) | |
| return rows | |
| def derive_title(csv_path): | |
| words = csv_path.stem.replace("_", " ").replace("-", " ").split() | |
| return " ".join(word.upper() if word[0:1].lower() == "m" and word[1:].isdigit() else word.capitalize() for word in words) + " t/s" | |
| def points_to_polyline(points, x_min, x_max, y_max, plot): | |
| left, top, width, height = plot | |
| def project(point): | |
| x, y = point | |
| px = left + (x - x_min) / (x_max - x_min) * width | |
| py = top + height - y / y_max * height | |
| return f"{px:.2f},{py:.2f}" | |
| return " ".join(project(point) for point in points) | |
| def render_svg(rows, title, width, height): | |
| margin_left = 82 | |
| margin_right = 82 | |
| margin_top = 66 | |
| margin_bottom = 72 | |
| plot = ( | |
| margin_left, | |
| margin_top, | |
| width - margin_left - margin_right, | |
| height - margin_top - margin_bottom, | |
| ) | |
| left, top, plot_width, plot_height = plot | |
| right = left + plot_width | |
| bottom = top + plot_height | |
| ctx_values = [row[0] for row in rows] | |
| prefill_values = [row[1] for row in rows] | |
| gen_values = [row[2] for row in rows] | |
| x_min = 0 | |
| x_max = max(ctx_values) | |
| prefill_max = nice_ceil(max(prefill_values) * 1.05) | |
| gen_max = nice_ceil(max(gen_values) * 1.05) | |
| x_step = nice_step(x_max - x_min, 6) | |
| x_ticks = [] | |
| tick = math.ceil(x_min / x_step) * x_step | |
| while tick <= x_max: | |
| x_ticks.append(tick) | |
| tick += x_step | |
| prefill_step = nice_step(prefill_max, 5) | |
| gen_step = nice_step(gen_max, 5) | |
| prefill_ticks = [tick for tick in frange(0, prefill_max, prefill_step)] | |
| gen_ticks = [tick for tick in frange(0, gen_max, gen_step)] | |
| prefill_points = [(row[0], row[1]) for row in rows] | |
| gen_points = [(row[0], row[2]) for row in rows] | |
| prefill_poly = points_to_polyline(prefill_points, x_min, x_max, prefill_max, plot) | |
| gen_poly = points_to_polyline(gen_points, x_min, x_max, gen_max, plot) | |
| parts = [ | |
| '<?xml version="1.0" encoding="UTF-8"?>', | |
| f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">', | |
| "<style>", | |
| "text { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }", | |
| ".title { font-size: 26px; font-weight: 700; fill: #1f2933; }", | |
| ".axis-label { font-size: 14px; font-weight: 600; fill: #334155; }", | |
| ".tick { font-size: 12px; fill: #64748b; }", | |
| ".legend { font-size: 13px; font-weight: 600; fill: #1f2933; }", | |
| "</style>", | |
| f'<rect width="{width}" height="{height}" fill="#ffffff"/>', | |
| f'<text class="title" x="{width / 2:.1f}" y="34" text-anchor="middle">{html.escape(title)}</text>', | |
| ] | |
| # Horizontal grid and left-axis labels use the prefill scale. | |
| for tick in prefill_ticks: | |
| y = bottom - tick / prefill_max * plot_height | |
| parts.append(f'<line x1="{left}" y1="{y:.2f}" x2="{right}" y2="{y:.2f}" stroke="{GRID_COLOR}" stroke-width="1"/>') | |
| parts.append(f'<text class="tick" x="{left - 12}" y="{y + 4:.2f}" text-anchor="end">{fmt_tick(tick)}</text>') | |
| # Right-axis labels use the generation scale. | |
| for tick in gen_ticks: | |
| y = bottom - tick / gen_max * plot_height | |
| parts.append(f'<text class="tick" x="{right + 12}" y="{y + 4:.2f}" text-anchor="start">{fmt_tick(tick)}</text>') | |
| for tick in x_ticks: | |
| x = left + (tick - x_min) / (x_max - x_min) * plot_width | |
| parts.append(f'<line x1="{x:.2f}" y1="{top}" x2="{x:.2f}" y2="{bottom}" stroke="{GRID_COLOR}" stroke-width="1"/>') | |
| parts.append(f'<text class="tick" x="{x:.2f}" y="{bottom + 24}" text-anchor="middle">{fmt_tick(tick)}</text>') | |
| parts.extend( | |
| [ | |
| f'<line x1="{left}" y1="{top}" x2="{left}" y2="{bottom}" stroke="{AXIS_COLOR}" stroke-width="1.4"/>', | |
| f'<line x1="{right}" y1="{top}" x2="{right}" y2="{bottom}" stroke="{AXIS_COLOR}" stroke-width="1.4"/>', | |
| f'<line x1="{left}" y1="{bottom}" x2="{right}" y2="{bottom}" stroke="{AXIS_COLOR}" stroke-width="1.4"/>', | |
| f'<text class="axis-label" x="{width / 2:.1f}" y="{height - 20}" text-anchor="middle">ctx size</text>', | |
| f'<text class="axis-label" x="22" y="{top + plot_height / 2:.1f}" text-anchor="middle" transform="rotate(-90 22 {top + plot_height / 2:.1f})">prefill t/s</text>', | |
| f'<text class="axis-label" x="{width - 22}" y="{top + plot_height / 2:.1f}" text-anchor="middle" transform="rotate(90 {width - 22} {top + plot_height / 2:.1f})">generation t/s</text>', | |
| f'<polyline fill="none" stroke="{PREFILL_COLOR}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" points="{prefill_poly}"/>', | |
| f'<polyline fill="none" stroke="{GEN_COLOR}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" points="{gen_poly}"/>', | |
| ] | |
| ) | |
| legend_x = right - 170 | |
| legend_y = top + 18 | |
| parts.extend( | |
| [ | |
| f'<rect x="{legend_x - 14}" y="{legend_y - 18}" width="176" height="62" rx="6" fill="#ffffff" stroke="#cbd5e1"/>', | |
| f'<rect x="{legend_x}" y="{legend_y - 6}" width="12" height="12" fill="{PREFILL_COLOR}"/>', | |
| f'<text class="legend" x="{legend_x + 22}" y="{legend_y + 5}">prefill</text>', | |
| f'<rect x="{legend_x}" y="{legend_y + 20}" width="12" height="12" fill="{GEN_COLOR}"/>', | |
| f'<text class="legend" x="{legend_x + 22}" y="{legend_y + 31}">generation</text>', | |
| ] | |
| ) | |
| parts.append("</svg>") | |
| return "\n".join(parts) + "\n" | |
| def frange(start, stop, step): | |
| value = start | |
| # A small epsilon keeps exact decimal steps from losing their final tick. | |
| while value <= stop + step * 0.001: | |
| yield round(value, 10) | |
| value += step | |
| def main(): | |
| parser = argparse.ArgumentParser(description="Plot ds4-bench throughput CSV data as SVG.") | |
| parser.add_argument("csv", type=Path, help="input CSV produced by ds4-bench") | |
| parser.add_argument("-o", "--output", type=Path, help="output SVG path") | |
| parser.add_argument("--title", help="graph title; defaults to a title derived from the CSV name") | |
| parser.add_argument("--width", type=int, default=960, help="SVG width in pixels") | |
| parser.add_argument("--height", type=int, default=540, help="SVG height in pixels") | |
| args = parser.parse_args() | |
| output = args.output | |
| if output is None: | |
| output = args.csv.with_name(f"{args.csv.stem}_ts.svg") | |
| rows = read_points(args.csv) | |
| title = args.title or derive_title(args.csv) | |
| output.write_text(render_svg(rows, title, args.width, args.height), encoding="utf-8") | |
| if __name__ == "__main__": | |
| main() | |