mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-07 14:21:06 -10:00
GUI-FB3 item 4: the speed cards show the run average when idle
The Monitor tab (serve/web/app.js), the API monitor page (monitor.js, monitor.html) and the Terminal Overview (strata_cli/overview.py) show the token-weighted
average of the run instead of the last request's speed when the server is idle; live values are unchanged. Ported by hand onto rc1 from the community patch
by keinebenutzer: https://github.com/keinebenutzer/STRATA-average-patch (MIT, Copyright (c) 2026 keinebenutzer), made against v0.1.55 c62d5e3a. Read in full
(no network, telemetry, eval or markup in what it adds); none of its files was run.
Tests: serve/test_speed_average.py (the page code run in node, display-only guard), tests/terminal/test_overview.py (edge cases, fallback).
This commit is contained in:
1 parent
4a93c5428c
commit
6acd50efe4
6 files changed
+182
-11
No files matched your search
@@ -0,0 +1,112 @@
|
||||
"""The run average of the speed cards (community patch: keinebenutzer/STRATA-average-patch, MIT, made against v0.1.55 c62d5e3a; ported by hand onto rc1).
|
||||
|
||||
serve/web/app.js (the Monitor tab's Decode and Prefill cards), serve/web/monitor.js and monitor.html (the API monitor page) and strata_cli/overview.py (the Terminal
|
||||
Overview) show, when the server is idle, the token-weighted average of the run instead of the last request's speed. Live values stay live. This file runs the
|
||||
browser code the way a page would (a few lines of node, no browser, no network) and checks that what was added is display only."""
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
WEB = Path(__file__).resolve().parent / "web"
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
def run_node(source: str) -> object:
|
||||
done = subprocess.run([NODE, "-e", source], capture_output=True, text=True, timeout=60, check=True)
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
def function_source(text: str, name: str) -> str:
|
||||
"""One top-level function of a browser script, by its opening line to its closing brace in column 0."""
|
||||
start = text.index(f"function {name}(")
|
||||
end = text.index("\n}\n", start) + 3
|
||||
return text[start:end]
|
||||
|
||||
|
||||
def cards_block(text: str) -> str:
|
||||
"""The lines of renderMonitor that make the Decode and Prefill cards."""
|
||||
start = text.index(" // the eight cards")
|
||||
return text[start:text.index(' spark("sp-speed"', start)]
|
||||
|
||||
|
||||
@unittest.skipUnless(NODE, "node is needed to run the page's own code")
|
||||
class MonitorTab(unittest.TestCase):
|
||||
"""serve/web/app.js: the two cards, run with stand-ins for the page's helpers."""
|
||||
|
||||
def cards(self, live, last, totals):
|
||||
block = cards_block((WEB / "app.js").read_text(encoding="utf-8"))
|
||||
script = ("const calls = []; const setMetric = (...a) => calls.push(a); const fmt = (n, d = 0) => Number(n).toFixed(d);\n"
|
||||
f"function run(live, last, totals) {{\n{block}\n}}\nrun({json.dumps(live)}, {json.dumps(last)}, {json.dumps(totals)});\nconsole.log(JSON.stringify(calls));")
|
||||
script = script.replace("spark(", "(() => {})(")
|
||||
calls = run_node(script)
|
||||
return {c[0]: (c[1], c[3]) for c in calls}
|
||||
|
||||
TOTALS = {"requests": 12, "prompt_tokens": 90000, "reused": 5000, "output_tokens": 40000, "prompt_ms": 120000.0, "decode_ms": 1000000.0}
|
||||
LAST = {"decode_tok_s": 50.5, "prompt_ms": 2000.0, "prompt_tokens": 1000, "reused": 0}
|
||||
|
||||
def test_idle_shows_the_run_average_for_both_cards(self):
|
||||
cards = self.cards({"state": "idle"}, self.LAST, self.TOTALS)
|
||||
self.assertEqual(cards["speed"], ("40.0", "Decode · run average"))
|
||||
self.assertEqual(cards["prefill"], ("708", "Prefill · run average"), "(90000 - 5000 reused) tokens in 120 s")
|
||||
|
||||
def test_live_values_stay_live(self):
|
||||
self.assertEqual(self.cards({"state": "generating", "tok_s": 31.5, "prefill_tok_s_mean": 600}, self.LAST, self.TOTALS)["speed"], ("31.5", "Decode now"))
|
||||
self.assertEqual(self.cards({"state": "generating", "tok_s": 31.5, "prefill_tok_s_mean": 600}, self.LAST, self.TOTALS)["prefill"], ("600", "Prefill this request"))
|
||||
self.assertEqual(self.cards({"state": "reading", "prefill_tok_s_mean": 820}, self.LAST, self.TOTALS)["prefill"], ("820", "Prefill now"))
|
||||
|
||||
def test_without_totals_it_is_the_last_request_and_never_an_invented_zero(self):
|
||||
for totals in (None, {}, {"output_tokens": 0, "decode_ms": 0, "prompt_tokens": 0, "prompt_ms": 0}):
|
||||
cards = self.cards({"state": "idle"}, self.LAST, totals)
|
||||
self.assertEqual(cards["speed"], ("50.5", "Decode last request"), totals)
|
||||
self.assertEqual(cards["prefill"], ("500", "Prefill last request"), totals)
|
||||
cards = self.cards({"state": "idle"}, None, None)
|
||||
self.assertEqual(cards["speed"], (None, "Decode"))
|
||||
self.assertEqual(cards["prefill"], (None, "Prefill"))
|
||||
|
||||
|
||||
@unittest.skipUnless(NODE, "node is needed to run the page's own code")
|
||||
class MonitorPage(unittest.TestCase):
|
||||
def average(self, rows):
|
||||
source = function_source((WEB / "monitor.js").read_text(encoding="utf-8"), "averageDecodeSpeed")
|
||||
return run_node(f"{source}\nconsole.log(JSON.stringify(averageDecodeSpeed({json.dumps(rows)})));")
|
||||
|
||||
def test_it_is_token_weighted_over_the_finished_requests(self):
|
||||
long_one = {"timings": {"predicted_n": 19511, "predicted_ms": 386000}}
|
||||
short_one = {"timings": {"predicted_n": 11, "predicted_ms": 410}}
|
||||
self.assertAlmostEqual(self.average([long_one, short_one]), (19511 + 11) / (386410 / 1000), places=6)
|
||||
self.assertGreater(self.average([long_one, short_one]), 50, "a plain mean of the two (about 38) would let the short one count as much as the long one")
|
||||
|
||||
def test_requests_without_timings_or_with_one_token_are_left_out(self):
|
||||
self.assertIsNone(self.average([]))
|
||||
self.assertIsNone(self.average([{}, {"timings": None}, {"timings": {"predicted_n": 1, "predicted_ms": 10}}, {"timings": {"predicted_n": 5, "predicted_ms": 0}},
|
||||
{"timings": {"predicted_n": "9", "predicted_ms": 5}}]))
|
||||
self.assertEqual(self.average([{"timings": {"predicted_n": 100, "predicted_ms": 2000}}, {}]), 50)
|
||||
|
||||
def test_the_card_says_what_it_shows(self):
|
||||
html = (WEB / "monitor.html").read_text(encoding="utf-8")
|
||||
self.assertIn("Average decode speed", html)
|
||||
self.assertIn("Run average", html)
|
||||
self.assertNotIn("Last decode speed", html)
|
||||
js = (WEB / "monitor.js").read_text(encoding="utf-8")
|
||||
self.assertIn('text("speed", speed(averageDecodeSpeed(records.filter(r => !active(r)))))', js)
|
||||
|
||||
|
||||
class AddedCodeIsDisplayOnly(unittest.TestCase):
|
||||
"""What the patch added reads numbers the page already has and writes text with textContent: no request, no eval, no markup, no address."""
|
||||
|
||||
def test_nothing_but_arithmetic_and_text(self):
|
||||
pieces = [cards_block((WEB / "app.js").read_text(encoding="utf-8")), function_source((WEB / "monitor.js").read_text(encoding="utf-8"), "averageDecodeSpeed"),
|
||||
(Path(__file__).resolve().parents[1] / "strata_cli" / "overview.py").read_text(encoding="utf-8")]
|
||||
pieces[2] = pieces[2][pieces[2].index("def run_average_speed"):pieces[2].index("def _cards")]
|
||||
forbidden = re.compile(r"\b(fetch|XMLHttpRequest|WebSocket|sendBeacon|eval|Function|import|require|innerHTML|insertAdjacentHTML|document\.write|"
|
||||
r"subprocess|socket|urllib|requests|os\.system|open)\s*\(|https?://|\.src\s*=|new Image")
|
||||
for piece in pieces:
|
||||
self.assertIsNone(forbidden.search(piece), forbidden.search(piece) and forbidden.search(piece).group(0))
|
||||
self.assertIn("textContent", (WEB / "monitor.js").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+9
-4
@@ -464,13 +464,18 @@ function renderMonitor(live, hw, st, eng, h, last, requests, totals, kept) {
|
||||
$("state-bar").style.width = `${pct}%`;
|
||||
|
||||
// the eight cards
|
||||
const speed = live.state === "generating" ? live.tok_s : last ? last.decode_tok_s : null;
|
||||
// Idle: not the last request's speed (one request is noisy) but the run's average from the server's totals
|
||||
// (token-weighted, the same figure the engine's DONE lines and Prometheus report). Live stays live.
|
||||
const avgDecode = totals && totals.decode_ms > 0 && totals.output_tokens > 0 ? totals.output_tokens / (totals.decode_ms / 1000) : null;
|
||||
const readTokens = totals ? totals.prompt_tokens - (totals.reused || 0) : 0;
|
||||
const avgPrefill = totals && totals.prompt_ms > 0 && readTokens > 0 ? readTokens / (totals.prompt_ms / 1000) : null;
|
||||
const speed = live.state === "generating" ? live.tok_s : avgDecode != null ? avgDecode : last ? last.decode_tok_s : null;
|
||||
setMetric("speed", speed == null ? null : fmt(speed, 1), "t/s",
|
||||
live.state === "generating" ? "Decode now" : last ? "Decode last request" : "Decode");
|
||||
live.state === "generating" ? "Decode now" : avgDecode != null ? "Decode · run average" : last ? "Decode last request" : "Decode");
|
||||
const prefill = live.state !== "idle" ? live.prefill_tok_s_mean
|
||||
: last && last.prompt_ms > 0 ? Math.max(0, last.prompt_tokens - (last.reused || 0)) / (last.prompt_ms / 1000) : null;
|
||||
: avgPrefill != null ? avgPrefill : last && last.prompt_ms > 0 ? Math.max(0, last.prompt_tokens - (last.reused || 0)) / (last.prompt_ms / 1000) : null;
|
||||
setMetric("prefill", prefill == null ? null : fmt(prefill), "t/s",
|
||||
live.state === "reading" ? "Prefill now" : live.state === "generating" ? "Prefill this request" : last ? "Prefill last request" : "Prefill");
|
||||
live.state === "reading" ? "Prefill now" : live.state === "generating" ? "Prefill this request" : avgPrefill != null ? "Prefill · run average" : last ? "Prefill last request" : "Prefill");
|
||||
spark("sp-speed", h.tok_s);
|
||||
spark("sp-prefill", h.prefill_tok_s_mean);
|
||||
// a model split across several cards (issue #112): the cards show their total / mean / hottest, and each card's own
|
||||
|
||||
@@ -23,7 +23,7 @@ header{height:70px;padding:0 32px;display:flex;align-items:center;justify-conten
|
||||
<div class="card"><div class="label">Model</div><div id="model-state" class="value"><span class="dot"></span><span id="loaded">Connecting…</span></div><div id="model" class="model">127.0.0.1:5001/v1</div></div>
|
||||
<div class="card"><div class="label">Requests retained</div><div id="count" class="value">—</div><div id="active" class="small">Waiting for server</div></div>
|
||||
<div class="card"><div class="label">Last wall-clock</div><div id="wall" class="value">—</div><div class="small">Includes queue and model loading</div></div>
|
||||
<div class="card"><div class="label">Last decode speed</div><div id="speed" class="value">—</div><div class="small">Engine timing · tokens / second</div></div>
|
||||
<div class="card"><div class="label">Average decode speed</div><div id="speed" class="value">—</div><div class="small">Run average · tokens / second</div></div>
|
||||
</div>
|
||||
<section class="panel"><div class="panel-bar"><h2>Requests</h2><div class="filters"><input id="search" aria-label="Filter requests" placeholder="Search ID or endpoint"><select id="filter" aria-label="Request status"><option value="all">All statuses</option><option value="active">Active</option><option value="completed">Completed</option><option value="error">Errors</option></select></div></div>
|
||||
<div class="workspace"><div id="requests" class="requests" aria-label="Request list"></div><div class="detail">
|
||||
|
||||
+14
-1
@@ -6,6 +6,19 @@ document.documentElement.dataset.theme = localStorage.getItem("strata.theme") ||
|
||||
const seconds = n => typeof n === "number" ? `${n.toFixed(2)} s` : "—";
|
||||
const speed = n => typeof n === "number" ? n.toFixed(1) : "—";
|
||||
const active = r => !["completed", "error", "disconnected"].includes(r.state);
|
||||
// The run's average decode speed: every token produced over the run divided by the run's decode time, from each
|
||||
// finished request's engine timings (predicted_n over predicted_ms). Token-weighted, so a short request does not
|
||||
// outweigh a long one - the same figure the engine's DONE lines and the server's totals report. Not the last request.
|
||||
function averageDecodeSpeed(rows) {
|
||||
let tokens = 0, ms = 0;
|
||||
for (const r of rows) {
|
||||
const t = r.timings;
|
||||
if (!t) continue;
|
||||
const n = t.predicted_n, d = t.predicted_ms;
|
||||
if (typeof n === "number" && typeof d === "number" && n >= 2 && d > 0) { tokens += n; ms += d; }
|
||||
}
|
||||
return ms > 0 ? tokens / (ms / 1000) : null;
|
||||
}
|
||||
function text(id, value) { const el = $(id); if (el.textContent !== String(value)) el.textContent = value; }
|
||||
async function api(path, body) {
|
||||
const response = await fetch(path, {cache:"no-store", headers:{...(apiKey ? {Authorization:`Bearer ${apiKey}`} : {}), ...(body !== undefined ? {"Content-Type":"application/json"} : {})}, ...(body !== undefined ? {method:"POST", body:JSON.stringify(body)} : {})});
|
||||
@@ -85,7 +98,7 @@ async function refresh() {
|
||||
text("model", status.model); text("count", records.length);
|
||||
text("active", inflight ? `${inflight} active / queued` : status.auto_load ? "Loads automatically on the next request" : "No active requests");
|
||||
const last = records.find(r => !active(r));
|
||||
text("wall", last ? seconds(last.wallclock_s) : "—"); text("speed", speed(last?.timings?.predicted_per_second));
|
||||
text("wall", last ? seconds(last.wallclock_s) : "—"); text("speed", speed(averageDecodeSpeed(records.filter(r => !active(r)))));
|
||||
$("load").disabled = operating || Boolean(inflight) || status.loaded; $("unload").disabled = operating || Boolean(inflight) || !status.loaded;
|
||||
list(); if (selected) await showDetail();
|
||||
text("updated", `Live · updated ${new Date().toLocaleTimeString()}`);
|
||||
|
||||
+14
-1
@@ -61,6 +61,16 @@ def request_speed(r: dict) -> float | None:
|
||||
return round(out / (ms / 1000.0), 1) if out and out >= 2 and ms and ms > 0 else None # one token has no decode span (a probe showed 1,250 tok/s)
|
||||
|
||||
|
||||
def run_average_speed(metrics: dict) -> float | None:
|
||||
"""The run's average decode speed: every token the server produced over the whole run divided by the run's
|
||||
decode time (the server's `totals`, the same token-weighted figure the engine's DONE lines and Prometheus use).
|
||||
Not the last request's speed - a single request is noisy; this is the average over everything since the start.
|
||||
None when the server reported no totals (never an invented zero)."""
|
||||
totals = metrics.get("totals") or {}
|
||||
out, ms = _num(totals.get("output_tokens")), _num(totals.get("decode_ms"))
|
||||
return round(out / (ms / 1000.0), 1) if out and out >= 2 and ms and ms > 0 else None
|
||||
|
||||
|
||||
def _cards(hw: dict, static: dict) -> list[dict]:
|
||||
"""Each graphics card's own readings when the server lists two or more (hardware.gpus); [] for one card or none: the Overview then
|
||||
draws exactly what it always drew."""
|
||||
@@ -125,7 +135,10 @@ def build(metrics: dict | None) -> dict | None:
|
||||
elif state == "Reading" and _num(live.get("prefill_tok_s_mean")) is not None:
|
||||
speed, speed_note = _num(live.get("prefill_tok_s_mean")), "reading the prompt"
|
||||
else:
|
||||
speed, speed_note = (request_speed(last) if last else None), "last request"
|
||||
# Idle: not the last request's speed (one request is noisy) but the run's average, from the server's totals.
|
||||
speed, speed_note = run_average_speed(metrics), "average of the run"
|
||||
if speed is None:
|
||||
speed, speed_note = (request_speed(last) if last else None), "last request"
|
||||
slots, total_slots = _num(engine.get("expert_slots")), _num(engine.get("experts"))
|
||||
cache_mib = _num(engine.get("expert_cache_mib"))
|
||||
gen, genmax, width = _num(hw.get("gpu_pcie_gen")), _num(hw.get("gpu_pcie_gen_max")), _num(hw.get("gpu_pcie_width"))
|
||||
|
||||
@@ -32,7 +32,9 @@ def metrics(state="reading", **over):
|
||||
"requests": [{"id": "a", "time": now - 5, "duration_s": 429.8, "finish_reason": "stop", "prompt_tokens": 17183, "reused": 0,
|
||||
"output_tokens": 19511, "decode_ms": 386000},
|
||||
{"id": "b", "time": now - 9, "duration_s": 4.0, "finish_reason": "stop", "prompt_tokens": 626, "reused": 0,
|
||||
"output_tokens": 11, "decode_ms": 410}]}
|
||||
"output_tokens": 11, "decode_ms": 410}],
|
||||
"totals": {"requests": 12, "prompt_tokens": 90000, "reused": 5000, "output_tokens": 40000,
|
||||
"prompt_ms": 120000.0, "decode_ms": 1000000.0}}
|
||||
m.update(over)
|
||||
return m
|
||||
|
||||
@@ -58,14 +60,40 @@ class Build(unittest.TestCase):
|
||||
self.assertEqual(m["context"][0], 36742 + 120)
|
||||
self.assertEqual(m["progress"], (120.0, 4096.0))
|
||||
|
||||
def test_idle_shows_the_last_request(self):
|
||||
def test_idle_shows_the_run_average(self):
|
||||
m = overview.build(metrics("idle"))
|
||||
self.assertEqual(m["state"], "Idle")
|
||||
self.assertEqual(m["speed"][1], "last request")
|
||||
self.assertAlmostEqual(m["speed"][0], 50.5, places=1) # 19511 tokens in 386 s
|
||||
self.assertEqual(m["speed"][1], "average of the run")
|
||||
self.assertAlmostEqual(m["speed"][0], 40.0, places=1) # totals: 40000 tokens in 1000 s, not the last request's 50.5
|
||||
self.assertEqual(m["context"][0], 17183 + 19511)
|
||||
self.assertEqual(m["progress"], (None, None))
|
||||
|
||||
def test_the_run_average_is_token_weighted_and_never_invented(self):
|
||||
"""Community patch (keinebenutzer/STRATA-average-patch, MIT): the idle tile is every token over the run's decode time, from the server's totals."""
|
||||
self.assertEqual(overview.run_average_speed({"totals": {"output_tokens": 40000, "decode_ms": 1000000.0}}), 40.0)
|
||||
# a long request and a short one: the long one counts for what it is (a plain mean of the two would be 30.3)
|
||||
self.assertEqual(overview.run_average_speed({"totals": {"output_tokens": 19522, "decode_ms": 386410.0}}), 50.5)
|
||||
for totals in (None, {}, {"output_tokens": 0, "decode_ms": 100.0}, {"output_tokens": 1, "decode_ms": 100.0}, {"output_tokens": 500, "decode_ms": 0},
|
||||
{"output_tokens": 500}, {"decode_ms": 500.0}, {"output_tokens": True, "decode_ms": 5.0}, {"output_tokens": "9", "decode_ms": 3.0},
|
||||
{"output_tokens": float("nan"), "decode_ms": 3.0}):
|
||||
self.assertIsNone(overview.run_average_speed({"totals": totals}), totals)
|
||||
self.assertIsNone(overview.run_average_speed({}))
|
||||
|
||||
def test_without_totals_idle_falls_back_to_the_last_request(self):
|
||||
m = metrics("idle")
|
||||
del m["totals"]
|
||||
built = overview.build(m)
|
||||
self.assertEqual(built["speed"][1], "last request")
|
||||
self.assertAlmostEqual(built["speed"][0], 50.5, places=1)
|
||||
m = metrics("idle", totals={"requests": 0, "output_tokens": 0, "decode_ms": 0.0})
|
||||
self.assertEqual(overview.build(m)["speed"][1], "last request", "an empty run is not an average of zero")
|
||||
|
||||
def test_live_values_stay_live(self):
|
||||
"""Only the idle value is an average: generating and reading are what they were."""
|
||||
self.assertEqual(overview.build(metrics("generating"))["speed"][:2], (50.4, "now"))
|
||||
self.assertEqual(overview.build(metrics("reading"))["speed"][:2], (820.0, "reading the prompt"))
|
||||
self.assertIn("average of the run", draw(overview.build(metrics("idle"))))
|
||||
|
||||
def test_queued_and_error(self):
|
||||
self.assertEqual(overview.build(metrics("idle", live={"state": "idle", "queued": 3}))["state"], "Queued")
|
||||
self.assertEqual(overview.build(metrics("idle", engine={"model": "x", "error": "boom"}))["state"], "Error")
|
||||
|
||||
Reference in new issue
Block a user