From 21e81f0202ec8ba716ec5c51960f8c41e6ef2b42 Mon Sep 17 00:00:00 2001 From: midagedev Date: Wed, 14 Oct 2026 11:49:37 +0900 Subject: [PATCH 1/2] publish, web: the run page shows the tape's memory The card's MEMORY block (GPU used of total, host RSS) never reached the site: the index carried no memory, so a model larger than its card read the same on the page as one that fit. The index now copies gpu_mem and the RSS figures from the tape with memorySection's gates, and the page prints them in the card's GiB rounding. No D1 column: the page reads index_json, and a reindex repairs runs already published. Co-Authored-By: Claude Opus 5.5 --- internal/publish/index.go | 44 ++++++++++++ internal/publish/index_test.go | 123 ++++++++++++++++++++++++++++++++- web/src/run.js | 73 +++++++++++++++++++ web/test/memory.test.mjs | 57 +++++++++++++++ 4 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 web/test/memory.test.mjs diff --git a/internal/publish/index.go b/internal/publish/index.go index e9cc921..3d81ede 100644 --- a/internal/publish/index.go +++ b/internal/publish/index.go @@ -22,6 +22,16 @@ import ( // it does not know by name rather than mis-indexing it quietly. const IndexSchema = 1 +// GPUMem is one card at the end of the run, the figures the card's MEMORY +// block prints for it (internal/card memorySection / vramLine). Index is the +// device's own, including 0. UsedBytes and VRAMBytes are bytes; 0 was not +// read and omitempty keeps it off the wire rather than printing a zero. +type GPUMem struct { + Index int `json:"index"` + UsedBytes int64 `json:"used_bytes,omitempty"` + VRAMBytes int64 `json:"vram_bytes,omitempty"` +} + // Index is what the search reads: one row per published run. It is derived // from a tape by IndexOf and uploaded beside it. type Index struct { @@ -66,6 +76,18 @@ type Index struct { RAMBytes int64 `json:"ram_bytes,omitempty"` HostClass string `json:"host_class,omitempty"` + // Memory the card's MEMORY block prints, copied from the tape the way + // memorySection reads it. gpu_mem is one entry per GPUsAtEnd sample, + // omitted on a unified-memory host (the card prints "Unified …" there, + // not a per-GPU line) and when no sample was taken. VRAMBytes on an + // entry is that card's capacity from Host.GPUs, by index, and 0 stays + // absent. The RSS fields are Memory.AtEnd; a zero RSS is no /proc view, + // which the card prints as "?" — so it is absent here, never a 0. + GPUMem []GPUMem `json:"gpu_mem,omitempty"` + RSSBytes int64 `json:"rss_bytes,omitempty"` + RSSFileBytes int64 `json:"rss_file_bytes,omitempty"` + RSSAnonBytes int64 `json:"rss_anon_bytes,omitempty"` + // What it did. Mode is the summary's own (tape.ModeChat, // tape.ModeDecision), absent for a benchmark (TTP-187): a reader that // shows a rate must know first whether the row has one. @@ -207,6 +229,28 @@ func IndexOf(t *tape.Tape) Index { } } + // The same two gates memorySection uses before it draws a per-GPU line: + // a unified host has one pool, and an empty end-of-run sample is "VRAM + // ?", not a row of zeros. Capacity is the host card's, looked up by + // index — the sample's position is not the card's. + if tape.Unified(s.Host) == "" && len(s.GPUsAtEnd) > 0 { + total := map[int]int64{} + for _, g := range s.Host.GPUs { + total[g.Index] = g.VRAMBytes + } + idx.GPUMem = make([]GPUMem, len(s.GPUsAtEnd)) + for i, g := range s.GPUsAtEnd { + idx.GPUMem[i] = GPUMem{Index: g.Index, UsedBytes: g.UsedBytes, VRAMBytes: total[g.Index]} + } + } + // Host RSS only when a /proc view was taken. File and anon ride along; + // a zero of either is omitempty, the card's "?". + if m := s.Memory.AtEnd; m.RSSBytes > 0 { + idx.RSSBytes = m.RSSBytes + idx.RSSFileBytes = m.RSSFileBytes + idx.RSSAnonBytes = m.RSSAnonBytes + } + idx.Mode = s.Mode if d := s.Decision; s.IsDecision() && d != nil { // No stream count: the burst lanes are requests in flight, and a diff --git a/internal/publish/index_test.go b/internal/publish/index_test.go index 4f0cf0a..7ec03bc 100644 --- a/internal/publish/index_test.go +++ b/internal/publish/index_test.go @@ -55,6 +55,17 @@ func TestIndexOfSingleStream(t *testing.T) { RAMBytes: 68719476736, HostClass: "2x rtx-3090", + // The card's MEMORY block. The want grew the fields the example + // tape already carries (GPUsAtEnd used bytes, Host.GPUs capacity, + // Memory.AtEnd); no assertion was loosened. + GPUMem: []GPUMem{ + {Index: 0, UsedBytes: 25555748454, VRAMBytes: 25769803776}, + {Index: 1, UsedBytes: 24481361100, VRAMBytes: 25769803776}, + }, + RSSBytes: 1288490189, + RSSFileBytes: 858993459, + RSSAnonBytes: 429496730, + Sessions: 1, DecodePerSec: 17.4, // single stream: the stream's own rate PrefillPerSec: 610, @@ -188,6 +199,7 @@ func TestIndexOfZeroTape(t *testing.T) { "batch", "ubatch", "ngl", "offload", "draft_model", "draft_accept", "throttled", "cold", "power_w", "power_limit_w", "file_bytes", "active_params", "n_experts", "n_experts_used", + "gpu_mem", "rss_bytes", "rss_file_bytes", "rss_anon_bytes", } { if bytes.Contains(b, []byte(`"`+key+`"`)) { t.Errorf("unset field %q present in zero-tape JSON: %s", key, b) @@ -285,7 +297,8 @@ func TestIndexJSONRoundTrip(t *testing.T) { "model_dir", "params", "moe", "quant_raw", "quant_id", "quant_bits", "engine_kind", "engine_version", "os", "gpus_raw", "gpu_id", "gpu_ids", "gpu_count", "vram_bytes", "ram_bytes", - "host_class", "sessions", "prompt_set", "decode_per_sec", + "host_class", "gpu_mem", "rss_bytes", "rss_file_bytes", "rss_anon_bytes", + "sessions", "prompt_set", "decode_per_sec", "prefill_per_sec", "ttft_p50_ms", "caveats", "caveat_count", "prompt_n", "predicted_n", "min_predicted_n", "reasoning_n", "cache_hit_ratio", "ctx_size", "n_slots", "fa", "kv_cache", @@ -724,3 +737,111 @@ func TestIndexOfNamesTheCardsTheServerUsed(t *testing.T) { t.Errorf("unknown use: gpu_ids %v gpus_raw %v count %d, want both cards", idx.GPUIDs, idx.GPUsRaw, idx.GPUCount) } } + +// TestIndexOfMemory pins the figures the card's MEMORY block prints +// (internal/card memorySection / vramLine). They are copied off the tape: +// one gpu_mem entry per GPUsAtEnd sample, vram from Host.GPUs by index and +// omitted at 0, RSS from Memory.AtEnd and omitted when RSSBytes is 0, and +// no gpu_mem on a unified host, where the card prints "Unified …" instead. +func TestIndexOfMemory(t *testing.T) { + t.Run("two gpus and rss", func(t *testing.T) { + // Host order is not sample order, and the second card's capacity + // was not read: vram is matched by index, and 0 stays off the wire. + s := tape.RunSummary{ + Host: tape.HostInfo{GPUs: []tape.GPUInfo{ + {Index: 1, Name: "NVIDIA GeForce RTX 3060", VRAMBytes: 0}, + {Index: 0, Name: "NVIDIA GeForce RTX 3060", VRAMBytes: 12884901888}, + }}, + GPUsAtEnd: []tape.GPUSample{ + {Index: 0, UsedBytes: 11164188672}, + {Index: 1, UsedBytes: 8589934592}, + }, + Memory: tape.MemorySummary{AtEnd: tape.MemSample{ + RSSBytes: 12456681472, + RSSFileBytes: 11947323392, + RSSAnonBytes: 263991296, + }}, + } + idx := IndexOf(&tape.Tape{Summary: s}) + want := []GPUMem{ + {Index: 0, UsedBytes: 11164188672, VRAMBytes: 12884901888}, + {Index: 1, UsedBytes: 8589934592}, + } + if !reflect.DeepEqual(idx.GPUMem, want) { + t.Errorf("GPUMem = %+v, want %+v", idx.GPUMem, want) + } + if idx.RSSBytes != 12456681472 || idx.RSSFileBytes != 11947323392 || idx.RSSAnonBytes != 263991296 { + t.Errorf("RSS = %d / %d / %d, want 12456681472 / 11947323392 / 263991296", + idx.RSSBytes, idx.RSSFileBytes, idx.RSSAnonBytes) + } + b, err := json.Marshal(idx) + if err != nil { + t.Fatalf("marshal: %v", err) + } + const gpuMem = `"gpu_mem":[{"index":0,"used_bytes":11164188672,"vram_bytes":12884901888},{"index":1,"used_bytes":8589934592}]` + if !bytes.Contains(b, []byte(gpuMem)) { + t.Errorf("gpu_mem JSON = %s, want it to contain %s", b, gpuMem) + } + for _, key := range []string{`"rss_bytes":12456681472`, `"rss_file_bytes":11947323392`, `"rss_anon_bytes":263991296`} { + if !bytes.Contains(b, []byte(key)) { + t.Errorf("missing %s in %s", key, b) + } + } + // The unread capacity is absent, not a zero. + if bytes.Contains(b, []byte(`"vram_bytes":0`)) { + t.Errorf("vram_bytes 0 present in %s", b) + } + }) + + t.Run("nothing measured", func(t *testing.T) { + // A host inventory is not a reading. GPUsAtEnd empty and RSS 0 + // leave every memory key off the wire, even when a file byte was + // filled in beside a zero RSS — the card prints Host RSS only when + // RSSBytes > 0. + s := tape.RunSummary{ + Host: tape.HostInfo{GPUs: []tape.GPUInfo{ + {Index: 0, Name: "NVIDIA GeForce RTX 3060", VRAMBytes: 12884901888}, + }}, + Memory: tape.MemorySummary{AtEnd: tape.MemSample{RSSFileBytes: 11947323392}}, + } + b, err := json.Marshal(IndexOf(&tape.Tape{Summary: s})) + if err != nil { + t.Fatalf("marshal: %v", err) + } + for _, key := range []string{"gpu_mem", "rss_bytes", "rss_file_bytes", "rss_anon_bytes"} { + if bytes.Contains(b, []byte(`"`+key+`"`)) { + t.Errorf("unset field %q present in %s", key, b) + } + } + }) + + t.Run("unified host", func(t *testing.T) { + // The samples and the capacity are present, so a missing gpu_mem + // is the unified rule (tape.Unified), not an empty reading. RSS + // still travels: the card prints Host RSS on a unified host too. + s := tape.RunSummary{ + Host: tape.HostInfo{ + GPUs: []tape.GPUInfo{{Index: 0, Name: "NVIDIA GB10", VRAMBytes: 128849018880}}, + }, + GPUsAtEnd: []tape.GPUSample{{Index: 0, UsedBytes: 11164188672}}, + Memory: tape.MemorySummary{AtEnd: tape.MemSample{ + RSSBytes: 12456681472, + RSSFileBytes: 11947323392, + RSSAnonBytes: 263991296, + }}, + } + if tape.Unified(s.Host) == "" { + t.Fatal("fixture is not unified; the case proves nothing") + } + b, err := json.Marshal(IndexOf(&tape.Tape{Summary: s})) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if bytes.Contains(b, []byte(`"gpu_mem"`)) { + t.Errorf("gpu_mem present on a unified host: %s", b) + } + if !bytes.Contains(b, []byte(`"rss_bytes":12456681472`)) { + t.Errorf("rss dropped on a unified host: %s", b) + } + }) +} diff --git a/web/src/run.js b/web/src/run.js index 91ec4e5..038b7e7 100644 --- a/web/src/run.js +++ b/web/src/run.js @@ -145,6 +145,7 @@ function runPage(row, idx, base) { ["engine", [idx.engine_kind, idx.engine_version].filter(Boolean).join(" ") || "?"], ["host", [idx.os, idx.host_class].filter(Boolean).join(" · ") || "?"], ["gpus", gpusRow(idx)], + ["memory", memoryRow(idx)], isDecision(idx) ? ["mode", `decision model · POST /v1/systemone${ idx.decision_req_per_sec ? ` · ${fmt(idx.decision_req_per_sec)} req/s back to back` : "" @@ -555,6 +556,78 @@ function bits(b) { // derived — unknown parts are omitted, and a row with nothing known reads // `?` (size/workload/context/config) or is left out by the table's `—` // filter (draft/machine, which apply only sometimes). + +// gib is 2^30, the card's unit (internal/card/format.go). +const GIB = 1024 * 1024 * 1024; + +// formatGiBNum is internal/card/format.go formatGiBNum: one decimal of +// bytes/2^30, and "?" at or below zero. strconv.FormatFloat(v, 'f', 1, 64) +// rounds half to even on the exact float64; Number.prototype.toFixed does +// not (1.25 prints "1.3" there and "1.2" on the card), so the digits are +// taken from the float's bits. +function formatGiBNum(b) { + if (!(b > 0)) return "?"; + return goFixed1(b / GIB); +} + +function formatGiB(b) { + const n = formatGiBNum(b); + return n === "?" ? "?" : `${n} GiB`; +} + +// goFixed1 is strconv.FormatFloat(v, 'f', 1, 64) for a finite positive v: +// round the exact value to one decimal, ties to even. +function goFixed1(v) { + const buf = new ArrayBuffer(8); + const f64 = new Float64Array(buf); + const u64 = new BigUint64Array(buf); + f64[0] = v; + const bits = u64[0]; + let exp = Number((bits >> 52n) & 0x7ffn); + let mant = bits & 0xfffffffffffffn; + if (exp === 0) { + exp = 1 - 1023 - 52; + } else { + mant |= 1n << 52n; + exp = exp - 1023 - 52; + } + // tenths = round(mant * 2^exp * 10), half to even. + const num = mant * 10n; + let tenths; + if (exp >= 0) { + tenths = num << BigInt(exp); + } else { + const denom = 1n << BigInt(-exp); + const q = num / denom; + const r = num % denom; + const half = denom >> 1n; + tenths = r > half || (r === half && (q & 1n) === 1n) ? q + 1n : q; + } + const s = tenths.toString(); + return tenths < 10n ? `0.${s}` : `${s.slice(0, -1)}.${s.slice(-1)}`; +} + +// memoryRow is the card's MEMORY lines that the index carries, in one cell: +// "GPU0 10.4/12.0 GiB · host RSS 11.6 GiB (file 11.1 / anon 0.2)". Each +// gpu_mem entry is "GPUn used/total GiB", or "GPUn used GiB" when the +// capacity was not read. A row with nothing known reads "?", like size. +export function memoryRow(idx) { + const parts = []; + if (Array.isArray(idx.gpu_mem)) { + for (const g of idx.gpu_mem) { + const name = `GPU${g.index}`; + const used = formatGiBNum(g.used_bytes); + parts.push(g.vram_bytes > 0 ? `${name} ${used}/${formatGiBNum(g.vram_bytes)} GiB` : `${name} ${used} GiB`); + } + } + if (idx.rss_bytes > 0) { + parts.push( + `host RSS ${formatGiB(idx.rss_bytes)} (file ${formatGiBNum(idx.rss_file_bytes)} / anon ${formatGiBNum(idx.rss_anon_bytes)})`, + ); + } + return parts.length ? parts.join(" · ") : "?"; +} + function sizeRow(idx) { const parts = []; if (idx.params) parts.push(`${fmtB(idx.params)} params`); diff --git a/web/test/memory.test.mjs b/web/test/memory.test.mjs new file mode 100644 index 0000000..d7537b4 --- /dev/null +++ b/web/test/memory.test.mjs @@ -0,0 +1,57 @@ +// The run page's memory row, in the card's wording +// (internal/card/card.go vramLine, format.go formatGiB / formatGiBNum). +// GiB is 2^30 with one decimal. Unknown is "?". +// +// Run: node --test test/*.test.mjs (from web/) + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { memoryRow, serveRunPage } from "../src/run.js"; + +const EXAMPLE = { + gpu_mem: [{ index: 0, used_bytes: 11164188672, vram_bytes: 12884901888 }], + rss_bytes: 12456681472, + rss_file_bytes: 11947323392, + rss_anon_bytes: 263991296, +}; + +test("the memory row prints the card's GiB line", () => { + assert.equal( + memoryRow(EXAMPLE), + "GPU0 10.4/12.0 GiB · host RSS 11.6 GiB (file 11.1 / anon 0.2)", + ); +}); + +test("a memory row with none of the fields is ?", () => { + assert.equal(memoryRow({ schema: 1, model_raw: "x" }), "?"); +}); + +test("a gpu with no capacity prints used GiB only", () => { + assert.equal(memoryRow({ gpu_mem: [{ index: 0, used_bytes: 11164188672 }] }), "GPU0 10.4 GiB"); +}); + +test("the run page puts memory after gpus", async () => { + const row = { + id: "abcdefgh", + created_at: "2026-09-19T07:00:00Z", + private: 0, + owned: 1, + card_key: null, + tape_ext: ".toktape", + tape_bytes: 100, + index_json: JSON.stringify({ + schema: 1, + recorded_at: "2026-09-13T14:25:30Z", + gpus_raw: ["NVIDIA GeForce RTX 3060"], + ...EXAMPLE, + }), + }; + const env = { DB: { prepare: () => ({ bind: () => ({ first: async () => row }) }) } }; + const resp = await serveRunPage("abcdefgh", env, "https://tape.midagedev.com"); + const html = await resp.text(); + const gpus = html.indexOf(">gpus<"); + const memory = html.indexOf(">memory<"); + assert.ok(gpus !== -1 && memory > gpus, "memory does not follow gpus"); + assert.ok(html.includes("GPU0 10.4/12.0 GiB · host RSS 11.6 GiB (file 11.1 / anon 0.2)")); +}); From 61a14515e62572bd47218ae96f6271c6a9395a0b Mon Sep 17 00:00:00 2001 From: midagedev Date: Wed, 14 Oct 2026 12:21:55 +0900 Subject: [PATCH 2/2] publish, web: a short prompt's prefill is not shown as a measurement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under tape.MinPrefillPromptTokens the server's prefill rate is mostly the fixed per-request cost; the card says so, the site printed it as a plain figure on the run page, in the description and in the search. The index now marks short_prompt (card.ShortPrompt, one owner), leaves the rate out so the search column stays NULL, and carries the probe's own rate and fixed cost. The run page shows the probe in the stat and a prefill row ("not measured: 33-token prompt · probe … on one stream · … fixed"), and the description quotes the probe instead. The two Go-rounding helpers in run.js are one. Co-Authored-By: Claude Opus 5.5 --- internal/publish/index.go | 30 +++++- internal/publish/index_short_test.go | 132 ++++++++++++++++++++++++ internal/publish/index_test.go | 4 +- web/src/run.js | 110 +++++++++++++++++--- web/test/shortprompt.test.mjs | 149 +++++++++++++++++++++++++++ 5 files changed, 407 insertions(+), 18 deletions(-) create mode 100644 internal/publish/index_short_test.go create mode 100644 web/test/shortprompt.test.mjs diff --git a/internal/publish/index.go b/internal/publish/index.go index 3d81ede..1b23816 100644 --- a/internal/publish/index.go +++ b/internal/publish/index.go @@ -96,7 +96,18 @@ type Index struct { PromptSet string `json:"prompt_set,omitempty"` DecodePerSec float64 `json:"decode_per_sec,omitempty"` PrefillPerSec float64 `json:"prefill_per_sec,omitempty"` - TTFTp50Ms float64 `json:"ttft_p50_ms,omitempty"` + // ShortPrompt is card.ShortPrompt: the prompt was too short for + // PrefillPerSec to be a prefill measurement. Omitted otherwise. When + // it is set, PrefillPerSec is left unset so the search column stays + // NULL — the tape still holds the server's figure. + ShortPrompt bool `json:"short_prompt,omitempty"` + // The probe pass's own figures, present on the same gate as the card's + // probeLine (Probe.PrefillPerSecond > 0). Pointers so a measured fixed + // cost of 0 ms stays on the wire: formatProbeMs treats 0 as a reading, + // and omitempty on a float would drop it. index_json only — no D1 column. + ProbePrefillPerSec *float64 `json:"probe_prefill_per_sec,omitempty"` + ProbeFixedMs *float64 `json:"probe_fixed_ms,omitempty"` + TTFTp50Ms float64 `json:"ttft_p50_ms,omitempty"` // The figures behind a rate (TTP-130). Every field is additive and // omitempty: absent means unknown, and the reader prints ? or omits @@ -281,6 +292,23 @@ func IndexOf(t *tape.Tape) Index { idx.PrefillPerSec = s.Timings.PromptPerSecond idx.TTFTp50Ms = s.Timings.TTFTMs } + // Decision rows are not part of this: a decision has no prefill + // measurement to qualify, and its row is left exactly as the block + // above wrote it (internal/tape.IsDecision). + if !s.IsDecision() { + // card.ShortPrompt, not a second copy of the threshold. + if card.ShortPrompt(&s) { + idx.ShortPrompt = true + idx.PrefillPerSec = 0 + } + // probeLine's gate. A refused fit and a missing probe are each + // "not observed" and travel as nothing. + if p := s.Probe; p != nil && p.PrefillPerSecond > 0 { + rate, fixed := p.PrefillPerSecond, p.FixedMs + idx.ProbePrefillPerSec = &rate + idx.ProbeFixedMs = &fixed + } + } // The figures behind a rate (TTP-130): the card's "in" figure, its // "out" figure, and everything that qualifies them. MinPredictedN lives diff --git a/internal/publish/index_short_test.go b/internal/publish/index_short_test.go new file mode 100644 index 0000000..512c1fc --- /dev/null +++ b/internal/publish/index_short_test.go @@ -0,0 +1,132 @@ +package publish + +import ( + "encoding/json" + "testing" + + "github.com/midagedev/toktape/internal/tape" +) + +// TestIndexOfShortPrompt is the search contract for a prompt under +// tape.MinPrefillPromptTokens (card.ShortPrompt): the server's prefill rate +// stays on the tape and leaves the index, because it is not a prefill +// measurement. The probe's own figures travel instead, on probeLine's gate +// (PrefillPerSecond > 0). Assertions are on the marshalled keys. +func TestIndexOfShortPrompt(t *testing.T) { + probe := &tape.ProbeSummary{PrefillPerSecond: 679, FixedMs: 134} + + t.Run("33 tokens with a probe", func(t *testing.T) { + m := indexKeys(t, oneStream(33, 206, probe)) + if m["short_prompt"] != true { + t.Errorf("short_prompt = %v, want true", m["short_prompt"]) + } + if _, ok := m["prefill_per_sec"]; ok { + t.Errorf("prefill_per_sec = %v, want the key absent", m["prefill_per_sec"]) + } + if m["probe_prefill_per_sec"] != float64(679) { + t.Errorf("probe_prefill_per_sec = %v, want 679", m["probe_prefill_per_sec"]) + } + if m["probe_fixed_ms"] != float64(134) { + t.Errorf("probe_fixed_ms = %v, want 134", m["probe_fixed_ms"]) + } + }) + + t.Run("5673 tokens", func(t *testing.T) { + m := indexKeys(t, oneStream(5673, 206, probe)) + if _, ok := m["short_prompt"]; ok { + t.Errorf("short_prompt = %v, want the key absent", m["short_prompt"]) + } + if m["prefill_per_sec"] != float64(206) { + t.Errorf("prefill_per_sec = %v, want 206", m["prefill_per_sec"]) + } + // The probe is a fact about the machine, not about this prompt. + if m["probe_prefill_per_sec"] != float64(679) || m["probe_fixed_ms"] != float64(134) { + t.Errorf("probe fields = %v / %v, want 679 / 134", m["probe_prefill_per_sec"], m["probe_fixed_ms"]) + } + }) + + t.Run("short with no probe", func(t *testing.T) { + m := indexKeys(t, oneStream(33, 206, nil)) + if m["short_prompt"] != true { + t.Errorf("short_prompt = %v, want true", m["short_prompt"]) + } + if _, ok := m["prefill_per_sec"]; ok { + t.Errorf("prefill_per_sec = %v, want the key absent", m["prefill_per_sec"]) + } + for _, key := range []string{"probe_prefill_per_sec", "probe_fixed_ms"} { + if _, ok := m[key]; ok { + t.Errorf("key %q present on a tape with no probe: %v", key, m[key]) + } + } + }) + + // A measured intercept of 0 ms is a reading (formatProbeMs), not an + // unknown, so the key stays on the wire. + t.Run("probe fixed cost of 0", func(t *testing.T) { + m := indexKeys(t, oneStream(5673, 206, &tape.ProbeSummary{PrefillPerSecond: 679, FixedMs: 0})) + if m["probe_fixed_ms"] != float64(0) { + t.Errorf("probe_fixed_ms = %v, want 0 present", m["probe_fixed_ms"]) + } + if _, ok := m["probe_fixed_ms"]; !ok { + t.Error("probe_fixed_ms key absent; 0 ms is a reading") + } + }) + + // A refused fit (rate 0, points kept) is the same "not observed" as no + // probe: probeLine prints nothing. + t.Run("refused fit", func(t *testing.T) { + m := indexKeys(t, oneStream(33, 206, &tape.ProbeSummary{PrefillPerSecond: 0, FixedMs: 0})) + for _, key := range []string{"probe_prefill_per_sec", "probe_fixed_ms"} { + if _, ok := m[key]; ok { + t.Errorf("key %q present on a refused fit: %v", key, m[key]) + } + } + }) + + // Decision rows stay as they were: no short_prompt, no probe keys, and + // the prefill figure the old path copied is still copied. + t.Run("decision untouched", func(t *testing.T) { + s := oneStream(33, 206, probe) + s.Mode = tape.ModeDecision + m := indexKeys(t, s) + if _, ok := m["short_prompt"]; ok { + t.Errorf("short_prompt on a decision row: %v", m["short_prompt"]) + } + for _, key := range []string{"probe_prefill_per_sec", "probe_fixed_ms"} { + if _, ok := m[key]; ok { + t.Errorf("key %q on a decision row: %v", key, m[key]) + } + } + if m["prefill_per_sec"] != float64(206) { + t.Errorf("prefill_per_sec = %v, want the decision path left at 206", m["prefill_per_sec"]) + } + }) +} + +// oneStream is a one-stream tape whose whole prompt is promptN tokens +// (Cache.PromptTotal, the count card.ShortPrompt asks through PromptTokens) +// and whose server prefill rate is rate. The rate is what must disappear +// from the index when the prompt is short. +func oneStream(promptN int, rate float64, probe *tape.ProbeSummary) tape.RunSummary { + return tape.RunSummary{ + Concurrency: 1, + Cache: tape.CacheSummary{PromptTotal: promptN}, + Timings: tape.TimingsSummary{PromptN: promptN, PromptPerSecond: rate}, + Probe: probe, + } +} + +// indexKeys is the marshalled index as a key set: an omitempty field that +// did not travel is not in the map. +func indexKeys(t *testing.T, s tape.RunSummary) map[string]any { + t.Helper() + b, err := json.Marshal(IndexOf(&tape.Tape{Summary: s})) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return m +} diff --git a/internal/publish/index_test.go b/internal/publish/index_test.go index 7ec03bc..0b95504 100644 --- a/internal/publish/index_test.go +++ b/internal/publish/index_test.go @@ -200,6 +200,7 @@ func TestIndexOfZeroTape(t *testing.T) { "throttled", "cold", "power_w", "power_limit_w", "file_bytes", "active_params", "n_experts", "n_experts_used", "gpu_mem", "rss_bytes", "rss_file_bytes", "rss_anon_bytes", + "short_prompt", "probe_prefill_per_sec", "probe_fixed_ms", } { if bytes.Contains(b, []byte(`"`+key+`"`)) { t.Errorf("unset field %q present in zero-tape JSON: %s", key, b) @@ -299,7 +300,8 @@ func TestIndexJSONRoundTrip(t *testing.T) { "os", "gpus_raw", "gpu_id", "gpu_ids", "gpu_count", "vram_bytes", "ram_bytes", "host_class", "gpu_mem", "rss_bytes", "rss_file_bytes", "rss_anon_bytes", "sessions", "prompt_set", "decode_per_sec", - "prefill_per_sec", "ttft_p50_ms", "caveats", "caveat_count", + "prefill_per_sec", "short_prompt", "probe_prefill_per_sec", "probe_fixed_ms", + "ttft_p50_ms", "caveats", "caveat_count", "prompt_n", "predicted_n", "min_predicted_n", "reasoning_n", "cache_hit_ratio", "ctx_size", "n_slots", "fa", "kv_cache", "batch", "ubatch", "ngl", "offload", "draft_model", "draft_accept", diff --git a/web/src/run.js b/web/src/run.js index 038b7e7..f1bdf2d 100644 --- a/web/src/run.js +++ b/web/src/run.js @@ -174,9 +174,16 @@ function runPage(row, idx, base) { ] : [ ["decode", idx.decode_per_sec, "tok/s"], - ["prefill", idx.prefill_per_sec, "tok/s"], + prefillFigure(idx), ["ttft p50", idx.ttft_p50_ms, "ms"], ]; + // The facts row qualifies the prefill stat. Absent when the run is neither + // short nor probed, and the table's "—" filter is not how that is said. + const prefill = prefillFacts(idx); + if (prefill) { + const at = rows.findIndex(([k]) => k === "workload"); + rows.splice(at < 0 ? rows.length : at + 1, 0, ["prefill", prefill]); + } const pageURL = `${base}/r/${row.id}`; // When the note's title is set it becomes the h1 and the model name moves @@ -232,9 +239,9 @@ ${noteSection(row.note)}
${figures .map( - ([k, v, u]) => `
${esc(k)}
${fmt( - v, - )}${esc(u)}
`, + ([k, v, u, raw]) => `
${esc(k)}
${ + raw ? esc(v) : fmt(v) + }${esc(u)}
`, ) .join("\n")}
@@ -483,7 +490,12 @@ function shareDescription(idx, row) { if (idx.decision_cold_ms) figures.push(`cold ${fmt(idx.decision_cold_ms)} ms`); if (idx.decision_req_per_sec) figures.push(`${fmt(idx.decision_req_per_sec)} req/s`); } - if (idx.prefill_per_sec) figures.push(`prefill ${fmt(idx.prefill_per_sec)} tok/s`); + // A short prompt has no prefill measurement (short_prompt). The clause + // becomes the probe's rate when one was indexed, and is left out otherwise + // — never the rate the index omitted. + if (idx.short_prompt) { + if (idx.probe_prefill_per_sec > 0) figures.push(`probe prefill ${fmt(idx.probe_prefill_per_sec)} tok/s`); + } else if (idx.prefill_per_sec) figures.push(`prefill ${fmt(idx.prefill_per_sec)} tok/s`); if (idx.ttft_p50_ms) figures.push(`ttft p50 ${fmt(idx.ttft_p50_ms)} ms`); if (idx.caveat_count) figures.push(`${idx.caveat_count} caveat${idx.caveat_count === 1 ? "" : "s"}`); let s = parts.join(" "); @@ -567,7 +579,7 @@ const GIB = 1024 * 1024 * 1024; // taken from the float's bits. function formatGiBNum(b) { if (!(b > 0)) return "?"; - return goFixed1(b / GIB); + return goFixed(b / GIB, 1); } function formatGiB(b) { @@ -575,36 +587,102 @@ function formatGiB(b) { return n === "?" ? "?" : `${n} GiB`; } -// goFixed1 is strconv.FormatFloat(v, 'f', 1, 64) for a finite positive v: -// round the exact value to one decimal, ties to even. -function goFixed1(v) { +// prefillFigure is the prefill stat. A short prompt never shows its own +// rate: the probe's figure when the index has one (the card's rounding, +// formatRate), or a dash. The fourth element says the number is already +// text, so the stat renderer does not pass it through fmt. A normal run +// is the old triple and still goes through fmt. +function prefillFigure(idx) { + if (idx.short_prompt) { + if (idx.probe_prefill_per_sec > 0) return ["prefill", formatRate(idx.probe_prefill_per_sec), "tok/s probe", true]; + return ["prefill", "—", "short prompt", true]; + } + return ["prefill", idx.prefill_per_sec, "tok/s"]; +} + +// prefillFacts is the facts-table prefill row, in probeLine's words +// (internal/card/card.go): "probe 679 tok/s on one stream · 134 ms fixed". +// A short prompt leads with the reason. Neither a short prompt nor a probe +// means no row. A decision row is not given one. +function prefillFacts(idx) { + if (isDecision(idx)) return ""; + const probe = probeClause(idx); + if (idx.short_prompt) { + const n = idx.prompt_n > 0 ? String(idx.prompt_n) : "?"; + const head = `not measured: ${n}-token prompt`; + return probe ? `${head} · ${probe}` : head; + } + return probe; +} + +// probeClause is probeLine for a rate the index carried. Fixed cost is +// formatted even at 0 (formatProbeMs: zero is a reading). An absent fixed +// cost is not a reading, and the clause is left out rather than inventing +// "0 ms". +function probeClause(idx) { + if (!(idx.probe_prefill_per_sec > 0)) return ""; + const ms = idx.probe_fixed_ms; + if (typeof ms !== "number" || !Number.isFinite(ms)) return ""; + return `probe ${formatRateUnit(idx.probe_prefill_per_sec)} on one stream · ${formatProbeMs(ms)} fixed`; +} + +// formatRate is internal/card/format.go formatRate: one decimal below 100, +// integer at or above, "?" at or below zero. +function formatRate(v) { + if (!(v > 0)) return "?"; + return v < 100 ? goFixed(v, 1) : goFixed(v, 0); +} + +// formatRateUnit is formatRate with " tok/s" (and "? tok/s" for unknown). +function formatRateUnit(v) { + const r = formatRate(v); + return r === "?" ? "? tok/s" : `${r} tok/s`; +} + +// formatProbeMs is internal/card/format.go formatProbeMs: an integer +// millisecond count, where 0 is a reading rather than unknown. +function formatProbeMs(v) { + return `${goFixed(v, 0)} ms`; +} + +// goFixed is strconv.FormatFloat(v, 'f', digits, 64) for a finite v: the +// exact binary value rounded to `digits` decimals, ties to even, which +// toFixed (ties away) does not match. The sign is kept: formatProbeMs can +// name a negative intercept if a fit ever produces one. +function goFixed(v, digits) { const buf = new ArrayBuffer(8); const f64 = new Float64Array(buf); const u64 = new BigUint64Array(buf); f64[0] = v; const bits = u64[0]; + const neg = (bits >> 63n) === 1n; let exp = Number((bits >> 52n) & 0x7ffn); let mant = bits & 0xfffffffffffffn; + if (exp === 0x7ff) return neg ? "-?" : "?"; if (exp === 0) { exp = 1 - 1023 - 52; } else { mant |= 1n << 52n; exp = exp - 1023 - 52; } - // tenths = round(mant * 2^exp * 10), half to even. - const num = mant * 10n; - let tenths; + // scaled = round(mant * 2^exp * 10^digits), half to even. + const num = mant * 10n ** BigInt(digits); + let scaled; if (exp >= 0) { - tenths = num << BigInt(exp); + scaled = num << BigInt(exp); } else { const denom = 1n << BigInt(-exp); const q = num / denom; const r = num % denom; const half = denom >> 1n; - tenths = r > half || (r === half && (q & 1n) === 1n) ? q + 1n : q; + scaled = r > half || (r === half && (q & 1n) === 1n) ? q + 1n : q; + } + let s = scaled.toString(); + if (digits > 0) { + s = s.padStart(digits + 1, "0"); + s = `${s.slice(0, -digits)}.${s.slice(-digits)}`; } - const s = tenths.toString(); - return tenths < 10n ? `0.${s}` : `${s.slice(0, -1)}.${s.slice(-1)}`; + return neg ? `-${s}` : s; } // memoryRow is the card's MEMORY lines that the index carries, in one cell: diff --git a/web/test/shortprompt.test.mjs b/web/test/shortprompt.test.mjs new file mode 100644 index 0000000..6063322 --- /dev/null +++ b/web/test/shortprompt.test.mjs @@ -0,0 +1,149 @@ +// A short prompt is not a prefill measurement (card.ShortPrompt, tape.MinPrefillPromptTokens). +// The run page shows the probe, or says so; the search prints no prefill for it. +// +// Run: node --test test/*.test.mjs (from web/) + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { serveRunPage } from "../src/run.js"; +import { resultRow } from "../src/search.js"; + +const BASE = "https://tape.midagedev.com"; + +const STAT_PROBE = `
prefill
679tok/s probe
`; +const STAT_SHORT = `
prefill
—short prompt
`; +const STAT_NORMAL = `
prefill
610tok/s
`; + +const ROW_BOTH = `prefillnot measured: 33-token prompt · probe 679 tok/s on one stream · 134 ms fixed`; +const ROW_SHORT = `prefillnot measured: 33-token prompt`; +const ROW_PROBE = `prefillprobe 679 tok/s on one stream · 134 ms fixed`; +const ROW_ZERO = `prefillprobe 679 tok/s on one stream · 0 ms fixed`; + +const REPLAY = "Replay the run in the browser, read the transcript, or download the record."; + +function envFor(idx) { + const row = { + id: "abcdefgh", + created_at: "2026-09-19T07:00:00Z", + private: 0, + owned: 1, + card_key: null, + tape_ext: ".toktape", + tape_bytes: 100, + index_json: JSON.stringify({ + schema: 1, + recorded_at: "2026-09-13T14:25:30Z", + model_raw: "demo.gguf", + decode_per_sec: 17.4, + ...idx, + }), + }; + return { DB: { prepare: () => ({ bind: () => ({ first: async () => row }) }) } }; +} + +async function page(idx) { + const resp = await serveRunPage("abcdefgh", envFor(idx), BASE); + return resp.text(); +} + +// Both description metas are the same string (head() writes it twice). +function description(html) { + const meta = //.exec(html); + const og = //.exec(html); + assert.ok(meta, "no description meta"); + assert.ok(og, "no og:description"); + assert.equal(og[1], meta[1]); + return meta[1]; +} + +test("short prompt with a probe: the stat is the probe and the row says both", async () => { + const html = await page({ + short_prompt: true, + prompt_n: 33, + prefill_per_sec: 206, + probe_prefill_per_sec: 679, + probe_fixed_ms: 134, + }); + assert.ok(html.includes(STAT_PROBE), "prefill stat"); + assert.equal(html.includes("206"), false, "the short-prompt rate is on the page"); + assert.ok(html.includes(ROW_BOTH), "prefill facts row"); + assert.equal(description(html), `probe prefill 679 tok/s. ${REPLAY}`); +}); + +test("5673-token prompt: the stat is the run's prefill and the row is the probe only", async () => { + const html = await page({ + prompt_n: 5673, + prefill_per_sec: 610, + probe_prefill_per_sec: 679, + probe_fixed_ms: 134, + }); + assert.ok(html.includes(STAT_NORMAL), "prefill stat"); + assert.equal(html.includes("tok/s probe"), false); + assert.equal(html.includes("short prompt"), false); + assert.equal(html.includes("not measured"), false); + assert.ok(html.includes(ROW_PROBE), "prefill facts row"); + assert.equal(description(html), `prefill 610 tok/s. ${REPLAY}`); +}); + +test("short prompt with no probe: a dash, and no prefill clause in the summary", async () => { + const html = await page({ + short_prompt: true, + prompt_n: 33, + prefill_per_sec: 206, + }); + assert.ok(html.includes(STAT_SHORT), "prefill stat"); + assert.equal(html.includes("206"), false, "the short-prompt rate is on the page"); + assert.ok(html.includes(ROW_SHORT), "prefill facts row"); + assert.equal(html.includes("probe"), false); + assert.equal(description(html), REPLAY); +}); + +test("a normal run with no probe has no prefill facts row", async () => { + const html = await page({ prompt_n: 5673, prefill_per_sec: 610 }); + assert.ok(html.includes(STAT_NORMAL), "prefill stat"); + assert.equal(html.includes(`prefill`), false); +}); + +test("a measured probe fixed cost of 0 ms is printed, not dropped", async () => { + const html = await page({ + prompt_n: 5673, + prefill_per_sec: 610, + probe_prefill_per_sec: 679, + probe_fixed_ms: 0, + }); + assert.ok(html.includes(ROW_ZERO), "prefill facts row"); +}); + +test("a short-prompt search row prints no prefill: its D1 column is NULL", () => { + // The index omits prefill_per_sec on a short prompt, so row.js stores NULL; + // the search draws from its columns and never reads index_json. + const html = resultRow( + { + id: "abcdefgh", + created_at: "2026-09-19T07:00:00Z", + owned: 1, + decode_per_sec: 17.4, + prefill_per_sec: null, + }, + new URL(`${BASE}/`), + ); + assert.equal(html.includes("prefill"), false); +}); + +test("a normal search row still prints its prefill", () => { + const html = resultRow( + { + id: "abcdefgh", + created_at: "2026-09-19T07:00:00Z", + owned: 1, + decode_per_sec: 17.4, + prefill_per_sec: 610, + probe_prefill_per_sec: 679, + }, + new URL(`${BASE}/`), + ); + assert.ok(html.includes(`prefill 610 tok/s`)); + assert.equal(html.includes("679"), false); + assert.equal(html.includes("—"), false); +});