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)}