File size: 16,717 Bytes
d656a30 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 | """Step 6.2 (D-030): the README and docs/RESULTS.md plots, drawn from the same run as the tables.
render_plots(data, curve) returns {file name: SVG text}; scripts/bench_all.py writes them to docs/img/.
`data` is results.load_run(); `curve` is docs/data/train_curve.json (scripts/extract_train_curve.py).
- One transparent SVG per plot, readable on GitHub's light and dark themes: mid-gray text, and a
blue / orange pair checked for colour-vision deficiency on both backgrounds. Blue = ours, orange =
HF Transformers, gray = context. Every bar and line end carries its value, so color is never the
only key.
- Byte-identical between renders (fixed SVG hash salt, no date, text drawn as paths with the font
matplotlib ships), so re-rendering leaves git clean. matplotlib's version is pinned by uv.lock.
- Pyplot is not used: no global figure state, no GUI backend.
"""
from __future__ import annotations
import io
import matplotlib
from matplotlib.figure import Figure
from matplotlib.ticker import FixedLocator, FuncFormatter, NullLocator
from tinyllm.results import PAGED_ROW, n, need
OURS, HF, CONTEXT = "#3987e5", "#e0602d", "#8b949e"
INK = "#6e7681" # ≥ 4:1 on both white and GitHub's dark #0d1117
RC = {
"font.family": "DejaVu Sans", "font.size": 10, "svg.fonttype": "path", "svg.hashsalt": "tinyllm",
"text.color": INK, "axes.labelcolor": INK, "xtick.color": INK, "ytick.color": INK,
"axes.edgecolor": CONTEXT, "axes.linewidth": 0.8, "axes.spines.top": False, "axes.spines.right": False,
"axes.facecolor": "none", "figure.facecolor": "none", "savefig.transparent": True,
"xtick.major.size": 3, "ytick.major.size": 0, "legend.frameon": False,
}
GRID = {"color": CONTEXT, "alpha": 0.3, "linewidth": 0.6}
def _svg(fig: Figure) -> str:
buf = io.StringIO()
fig.savefig(buf, format="svg", metadata={"Date": None}, bbox_inches="tight", pad_inches=0.12)
return buf.getvalue()
def _head(ax, title: str, subtitle: str, lift: float = 0) -> None:
"""Left-aligned title and subtitle above the plot area, placed in points so they never overlap it."""
ax.annotate(title, (0, 1), xycoords="axes fraction", xytext=(0, 30 + lift), textcoords="offset points",
fontsize=12.5, fontweight="bold", va="bottom")
ax.annotate(subtitle, (0, 1), xycoords="axes fraction", xytext=(0, 12 + lift), textcoords="offset points",
fontsize=9.5, va="bottom")
def _foot(ax, text: str, below: float = 34) -> None:
ax.annotate(text, (0, 0), xycoords="axes fraction", xytext=(0, -below), textcoords="offset points",
fontsize=8, va="top")
def _source(data: dict, name: str) -> str:
commit = (((data[name].get("provenance") or {}).get("git") or {}).get("commit") or "?")[:7]
return f"tiny-llm · {data.get('_run_dir', 'runs/bench/v1b')} · commit {commit} · RTX 3070 Ti Laptop GPU"
def decode_b1(data: dict) -> Figure:
hf = data["tl-hf"]
ceiling = hf["bandwidth_bytes_s"] / hf["weight_bytes"]
engines = [("tl-kv-graph", "Ours: KV cache + CUDA graphs", OURS), ("tl-kv", "Ours: KV cache", CONTEXT),
("tl-hf", "HF Transformers generate", HF), ("tl-naive", "Ours: naive, no cache", CONTEXT)]
rows = [(label, need(data[nm]["rows"], nm, batch=1, prompt_len=128), c) for nm, label, c in engines]
rows.sort(key=lambda r: -r[1]["decode_tok_s"])
fig = Figure(figsize=(7.6, 2.5))
ax = fig.add_subplot()
y = range(len(rows))
ax.barh(y, [r["decode_tok_s"] for _, r, _ in rows], height=0.62, color=[c for *_, c in rows])
ax.set_yticks(list(y), [label for label, *_ in rows])
ax.invert_yaxis()
for i, (_, r, _) in enumerate(rows):
ax.text(r["decode_tok_s"] + ceiling * 0.015, i, f"{n(r['decode_tok_s'])} tok/s · {r['pct_of_ceiling']:.0f}%",
va="center", fontsize=9.5)
ax.axvline(ceiling, color=INK, linewidth=1.2, linestyle=(0, (4, 3)))
ax.text(ceiling * 1.015, len(rows) - 1, f"bandwidth\nceiling\n{n(ceiling)} tok/s", va="center", fontsize=9)
ax.set_xlim(0, ceiling * 1.28)
ax.xaxis.set_major_locator(FixedLocator([0, 50, 100, 150, 200]))
ax.grid(axis="x", **GRID)
ax.set_axisbelow(True)
ax.spines["left"].set_visible(False)
ax.set_xlabel("decode tokens / s at batch 1")
_head(ax, "TinyLlama 1.1B decode speed, batch 1",
f"128-token prompt, 256 new tokens. % = share of the {n(ceiling)} tok/s ceiling "
"(bandwidth ÷ weight bytes).")
_foot(ax, _source(data, "tl-kv-graph"), below=40)
return fig
def batch_scaling(data: dict) -> Figure:
hf = data["tl-hf"]
ceiling = hf["bandwidth_bytes_s"] / hf["weight_bytes"]
batches = sorted({r["batch"] for r in data["tl-kv-graph"]["rows"]} & {r["batch"] for r in hf["rows"]})
tok = {nm: [need(data[nm]["rows"], nm, batch=b, prompt_len=128)["decode_tok_s"] for b in batches]
for nm in ("tl-kv-graph", "tl-hf")}
fig = Figure(figsize=(7.6, 3.6))
ax = fig.add_subplot()
ax.plot(batches, [ceiling * b for b in batches], color=INK, linewidth=1.2, linestyle=(0, (4, 3)),
label=f"bandwidth ceiling ({n(ceiling)} tok/s × batch)")
ax.plot(batches, tok["tl-kv-graph"], color=OURS, linewidth=2, marker="o", markersize=7,
label="Ours: KV cache + CUDA graphs")
ax.plot(batches, tok["tl-hf"], color=HF, linewidth=2, marker="s", markersize=6.5,
label="HF Transformers generate")
# Every line rises to the right: just left of our markers (incoming line below, ceiling well above) and
# below-right of HF's are clear.
for nm, off, ha, va in (("tl-kv-graph", (-8, 2), "right", "bottom"), ("tl-hf", (6, -7), "left", "top")):
for b, v in zip(batches, tok[nm]):
ax.annotate(n(v), (b, v), xytext=off, textcoords="offset points", fontsize=9, ha=ha, va=va)
ax.set_xscale("log", base=2)
ax.set_yscale("log")
ax.xaxis.set_major_locator(FixedLocator(batches))
ax.xaxis.set_major_formatter(FuncFormatter(lambda v, _: f"{v:.0f}"))
ax.xaxis.set_minor_locator(NullLocator())
ticks = [t for t in (25, 50, 100, 200, 400, 800, 1600, 3200, 6400)
if min(tok["tl-hf"][0], tok["tl-kv-graph"][0]) / 2 <= t <= ceiling * batches[-1] * 1.5]
ax.yaxis.set_major_locator(FixedLocator(ticks))
ax.yaxis.set_major_formatter(FuncFormatter(lambda v, _: n(v)))
ax.yaxis.set_minor_locator(NullLocator())
ax.set_xlim(batches[0] / 1.7, batches[-1] * 1.9)
ax.set_ylim(min(tok["tl-hf"][0], tok["tl-kv-graph"][0]) / 1.8, ceiling * batches[-1] * 1.4)
ax.grid(axis="y", **GRID)
ax.set_xlabel("batch size (sequences decoded together)")
ax.set_ylabel("decode tokens / s (log scale)")
ax.legend(loc="upper left", fontsize=9)
wmib = hf["weight_bytes"] / 2**20
_head(ax, "Decode throughput vs batch size, TinyLlama 1.1B",
f"Each decode step reads all {n(wmib)} MiB of weights once; a batch shares that read. 128-token prompt.")
_foot(ax, _source(data, "tl-kv-graph"), below=40)
return fig
def train_loss(curve: dict) -> Figure:
tps = curve["tokens_per_step"]
tr, va = curve["train"], curve["val"]
top = 3.2
fig = Figure(figsize=(7.6, 3.2))
ax = fig.add_subplot()
ax.plot([s * tps / 1e6 for s, _ in tr], [v for _, v in tr], color=CONTEXT, linewidth=0.9, label="train loss")
ax.plot([s * tps / 1e6 for s, _ in va], [v for _, v in va], color=OURS, linewidth=2, marker="o",
markersize=3.5, label="validation loss")
last_s, last_v = va[-1]
ax.annotate(f"val {last_v:.4f}", (last_s * tps / 1e6, last_v), xytext=(-4, 14), textcoords="offset points",
ha="right", fontsize=9.5)
ax.annotate(f"← loss starts at {va[0][1]:.1f}, above the chart", (0.06, 0.97), xycoords="axes fraction",
fontsize=8.5, va="top")
ax.set_ylim(1.0, top)
ax.set_xlim(0, tr[-1][0] * tps / 1e6 * 1.01)
ax.grid(axis="y", **GRID)
ax.set_xlabel("training tokens (millions)")
ax.set_ylabel("cross-entropy loss")
ax.legend(loc="upper right", fontsize=9)
every = va[1][0] - va[0][0] if len(va) > 1 else 0
_head(ax, "Training our 30M-parameter model from scratch on TinyStories",
f"{curve['params'] / 1e6:.1f}M parameters, {tr[-1][0] * tps / 1e6:,.0f}M tokens"
+ (f"; validation every {every} steps ({n(every * tps / 1e6)}M tokens)" if every else "") + ".")
_foot(ax, f"tiny-llm · docs/data/train_curve.json (from {curve.get('source', 'the training log')})", below=40)
return fig
def decode_profile(data: dict) -> Figure:
d = data["tl-profile"]
modes = [("eager", "Eager: one launch per kernel"), ("graph", "CUDA graph: one launch per step")]
fig = Figure(figsize=(7.6, 2.1))
ax = fig.add_subplot()
for i, (mode, _) in enumerate(modes):
m = d["modes"][mode]
busy = 1 - m["gpu_idle_frac"]
ax.barh(i, busy, height=0.58, color=OURS, label="GPU busy" if i == 0 else None)
ax.barh(i, m["gpu_idle_frac"], left=busy, height=0.58, color=CONTEXT, alpha=0.55,
label="GPU idle, waiting for launches" if i == 0 else None)
ops = m.get("gpu_ops_per_step")
ax.text(1.02, i, f"{m['ms_per_step']:.1f} ms / step, idle {m['gpu_idle_frac']:.0%}"
+ (f"\n{n(ops)} kernels" if ops else ""), va="center", fontsize=9)
ax.set_yticks(range(len(modes)), [label for _, label in modes])
ax.invert_yaxis()
ax.set_xlim(0, 1)
ax.xaxis.set_major_formatter(FuncFormatter(lambda v, _: f"{v:.0%}"))
ax.spines["left"].set_visible(False)
ax.set_xlabel("share of one decode step")
ax.legend(loc="lower left", bbox_to_anchor=(0, 1.0), ncols=2, fontsize=9, borderaxespad=0.2)
speed = d["modes"]["eager"]["ms_per_step"] / d["modes"]["graph"]["ms_per_step"]
_head(ax, "Why CUDA graphs help: TinyLlama 1.1B decode step, batch 1",
f"In eager mode Python launches every kernel and the GPU waits; a graph replays the whole step "
f"at once ({speed:.2f}× faster).", lift=16) # above the legend
_foot(ax, _source(data, "tl-profile"), below=40)
return fig
def serving(data: dict) -> Figure:
tls, ms = data["tl-serving"]["rows"], data["30m-serving"]["rows"]
st = need(tls, "tl-serving", engine="static", size=8)
left = [("Static batching", st), ("Continuous batching", need(tls, "tl-serving", engine="continuous", size=8)),
("Continuous + CUDA graphs", need(tls, "tl-serving", engine="cont+graph", size=8))]
c8 = need(ms, "30m-serving", engine="continuous", size=8)
pg = need(ms, "30m-serving", engine=PAGED_ROW[0], size=PAGED_ROW[1])
right = [(f"Contiguous KV: {c8['peak_active']} sequences", c8), (f"Paged KV: {pg['peak_active']} sequences", pg)]
fig = Figure(figsize=(7.6, 2.4))
axes = fig.subplots(1, 2, gridspec_kw={"wspace": 1.05, "width_ratios": [1, 1]})
for ax, rows, title in ((axes[0], left, "TinyLlama 1.1B, 8 slots"),
(axes[1], right, f"30M model, same {c8['kv_reserved_mib']:.0f} MiB of KV")):
base = rows[0][1]["tok_s"]
vals = [r["tok_s"] for _, r in rows]
ax.barh(range(len(rows)), vals, height=0.6, color=OURS)
ax.set_yticks(range(len(rows)), [label for label, _ in rows])
ax.invert_yaxis()
for i, v in enumerate(vals):
ax.text(v + max(vals) * 0.03, i, n(v) + (f" ({v / base:.2f}×)" if i else ""), va="center", fontsize=9)
ax.set_xlim(0, max(vals) * 1.45)
ax.xaxis.set_major_locator(FixedLocator([]))
ax.spines["left"].set_visible(False)
ax.spines["bottom"].set_visible(False)
ax.set_title(title, loc="left", fontsize=10, color=INK)
_head(axes[0], f"Serving throughput, {data['tl-serving']['workload']['requests']} mixed-length requests",
"Tokens / s; × = vs the first row. Continuous batching refills a slot as soon as its request ends; "
"the paged cache fits more sequences in the same memory.", lift=18) # above the panel titles
_foot(axes[0], _source(data, "tl-serving"), below=12)
return fig
AQUA = "#1aa775" # third engine; blue / orange / aqua checked for colour-vision deficiency on both themes
LOAD_STYLE = {"Ours": (OURS, "o", 7), "vLLM": (HF, "s", 6.5), "llama.cpp": (AQUA, "D", 6)}
def load_sweep(load: dict) -> Figure:
"""docs/data/load_sweep.json (scripts/extract_load_sweep.py): the 4.8a equal-seat sweep, one row per rate."""
col = {c: i for i, c in enumerate(load["columns"])}
eng = {name: [[p[col[c]] for p in e["points"]] for c in ("rate_rps", "achieved_rps", "ttft_p95_s", "shed_rate")]
for name, e in load["engines"].items()}
top = max(r[-1] for r, *_ in eng.values())
fig = Figure(figsize=(7.6, 3.3))
left, right = fig.subplots(1, 2, gridspec_kw={"wspace": 0.7})
peak = max(max(a) for _, a, *_ in eng.values())
left.plot([0, peak * 1.12], [0, peak * 1.12], color=INK, linewidth=1.2, linestyle=(0, (4, 3)))
left.annotate("every request\nserved", (peak, peak), xytext=(-8, 0), textcoords="offset points", ha="right",
va="center", fontsize=8.5)
slo = load["slo_ttft_p95_s"]
right.axhline(slo, color=INK, linewidth=1.2, linestyle=(0, (4, 3)))
right.annotate(f"SLO {n(slo)} s", (top, slo), xytext=(0, 3), textcoords="offset points", ha="right", va="bottom",
fontsize=8.5)
for name, (rate, done, ttft, shed) in eng.items():
c, m, ms = LOAD_STYLE[name]
for ax, y in ((left, done), (right, ttft)):
ax.plot(rate, y, color=c, linewidth=2, marker=m, markersize=ms)
left.annotate(f"{name} {done[-1]:.1f}", (rate[-1], done[-1]), xytext=(7, 0), textcoords="offset points",
va="center", fontsize=9, annotation_clip=False)
if shed[-1] > 0: # the gap between our flat line and the diagonal is rejected, not queued
i = len(rate) - 2
left.annotate(f"{shed[i] * 100:.0f}–{shed[-1]:.0%} rejected\nwith a 429", (rate[-1], done[-1]), xytext=(0, 9),
textcoords="offset points", ha="right", va="bottom", fontsize=8.5)
end = f"{name} {ttft[-1]:.0f} s" if ttft[-1] >= 10 else f"{name} {ttft[-1]:.1f} s"
if shed[-1] > 0: # flat because the bounded queue rejects, not because the engine is faster
right.annotate(end + ": capped;\na full queue answers\nnew requests with a 429", (rate[-1], ttft[-1]),
xytext=(7, 6), textcoords="offset points", va="top", fontsize=9, annotation_clip=False)
else:
right.annotate(end, (rate[-1], ttft[-1]), xytext=(7, 0), textcoords="offset points", va="center",
fontsize=9, annotation_clip=False)
for ax in (left, right):
ax.set_xlim(0, top * 1.04)
ax.xaxis.set_major_locator(FixedLocator([0, 4, 8, 12, 16]))
ax.grid(axis="y", **GRID)
ax.set_xlabel("offered requests / s")
left.set_ylim(0, peak * 1.12)
left.set_title("Completed requests / s", loc="left", fontsize=10, color=INK)
right.set_yscale("log")
right.yaxis.set_major_locator(FixedLocator([0.1, 1, 10, 100]))
right.yaxis.set_major_formatter(FuncFormatter(lambda v, _: f"{v:g} s"))
right.yaxis.set_minor_locator(NullLocator())
right.set_ylim(0.02, max(max(t) for _, _, t, _ in eng.values()) * 2)
right.set_title("Time to first token p95, served requests", loc="left", fontsize=10, color=INK)
w = load["workload"]
_head(left, "TinyLlama 1.1B under load: 8 sequences per engine",
f"Poisson arrivals, {w['max_tokens']} new tokens per request, {w['duration']:.0f} s per rate. Past capacity "
"we reject the excess; vLLM and llama.cpp queue it.", lift=18) # above the panel titles
commit = next(iter(load["engines"].values()))["commit"]
_foot(left, f"tiny-llm · docs/data/load_sweep.json ({load['source']}) · commit {commit} · "
"RTX 3070 Ti Laptop GPU, thermally throttled", below=40)
return fig
DRAW = {"decode_b1.svg": decode_b1, "batch_scaling.svg": batch_scaling,
"decode_profile.svg": decode_profile, "serving.svg": serving}
def render_plots(data: dict, curve: dict | None, load: dict | None = None) -> dict[str, str]:
"""{file name: SVG text}; train_loss.svg and load_sweep.svg only when their docs/data files exist."""
with matplotlib.rc_context(RC):
out = {name: _svg(draw(data)) for name, draw in DRAW.items()}
if curve is not None:
out["train_loss.svg"] = _svg(train_loss(curve))
if load is not None:
out["load_sweep.svg"] = _svg(load_sweep(load))
return out
|