diff --git a/internal/publish/index.go b/internal/publish/index.go index e9cc921..1b23816 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. @@ -74,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 @@ -207,6 +240,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 @@ -237,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 4f0cf0a..0b95504 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,8 @@ 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", + "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) @@ -285,8 +298,10 @@ 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", - "prefill_per_sec", "ttft_p50_ms", "caveats", "caveat_count", + "host_class", "gpu_mem", "rss_bytes", "rss_file_bytes", "rss_anon_bytes", + "sessions", "prompt_set", "decode_per_sec", + "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", @@ -724,3 +739,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..f1bdf2d 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` : "" @@ -173,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 @@ -231,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")}
@@ -482,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(" "); @@ -555,6 +568,144 @@ 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 goFixed(b / GIB, 1); +} + +function formatGiB(b) { + const n = formatGiBNum(b); + return n === "?" ? "?" : `${n} GiB`; +} + +// 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; + } + // scaled = round(mant * 2^exp * 10^digits), half to even. + const num = mant * 10n ** BigInt(digits); + let scaled; + if (exp >= 0) { + scaled = num << BigInt(exp); + } else { + const denom = 1n << BigInt(-exp); + const q = num / denom; + const r = num % denom; + const half = denom >> 1n; + 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)}`; + } + return neg ? `-${s}` : s; +} + +// 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)")); +}); 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); +});