feat(eval): shared perplexity harness with llama.cpp-comparable sliding-window mode - #418
feat(eval): shared perplexity harness with llama.cpp-comparable sliding-window mode#418jamesburton wants to merge 14 commits into
Conversation
Adds the DotLLM.Core.Evaluation abstractions and the design document for a shared perplexity harness, addressing the blocker recorded in upstream #416: every remaining numerics-changing CPU lever is ungated because no harness exists. The contract models the one axis the existing per-test helpers actually diverged on -- IPerplexityModel.ReturnsAllRows -- so a single evaluator can replace both the O(n) all-rows CPU shape and the O(n^2) growing-prefix last-row-only shape without changing any number either currently produces. The evaluator deliberately never loads weights and takes an already-constructed model, and the corpus is streamed rather than materialized. Both are constraints from the planned iGPU follow-on: on unified-memory parts a large VRAM carve-out leaves host RAM scarce, and paying for the model twice is the failure mode. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine TDD tasks. Pins down what "llama.cpp-comparable" actually means before any code: window w covers [w*S, w*S+L) and scores absolute targets in [w*S+L-S, w*S+L), so scored ranges tile the corpus exactly and every scored token carries L-S tokens of context. S = L/2 reproduces llama.cpp's default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…indows (#231) Records every window passed to Forward so evaluator tests can assert on window tiling and on the O(n) vs O(n^2) strategy split, not just on the resulting perplexity number. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ends (#231) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ends (#231) Produces a numerically identical result to the all-rows path on the same tokens, which is what makes migrating the existing per-test helpers onto this harness a meaningful gate rather than an aspiration. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Window w covers [w*S, w*S+L) and scores absolute targets in [w*S+L-S, w*S+L), so scored ranges tile the corpus exactly and every scored token carries L-S tokens of context. S = L/2 reproduces llama.cpp's default of scoring the second half of each chunk. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reports window geometry and scored-token count alongside the figure: a perplexity without them is not comparable to anything, which is the failure this harness exists to prevent. Verified end-to-end on SmolLM-135M Q8_0 over wikitext-2 (ctx 256, stride 128, 2048 tokens): ppl 24.8800, 15 windows, 1920 scored -- matching the expected tiling arithmetic exactly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sitions (#231) Validating against llama.cpp build 8683 refuted two assumptions baked into the original design. 1. Advance and scored span are INDEPENDENT. llama.cpp advances by the full window yet scores only its second half, so its scored ranges have gaps. The single-stride model produced the same scored-token COUNT over a different token SET -- 24.88 vs llama.cpp's 24.01, a figure that looked comparable and was not. PerplexityOptions now carries UnscoredPrefix, and LlamaCppDefault() names the faithful configuration. 2. Positions restart at 0 per window. llama.cpp evaluates each chunk as an independent sequence; absolute positions ran past the model's max sequence length and threw on any corpus longer than it. Also fixes CorpusReader dropping the separating space when cutting a chunk: GPT-2-style BPE encodes a leading space into the following token, so dropping it silently changes the token stream versus a single-pass tokenization. Geometry now matches llama.cpp exactly (15 windows, 1920 scored). A +3.46% residual remains and is NOT yet explained -- see the next commit's notes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds PerplexityEvaluator.WindowObserver and the CLI's --per-window, --dump-tokens and --tokens-file. These exist because an aggregate perplexity cannot localize a disagreement: differencing per-window figures against another implementation, and feeding that implementation's exact token ids back through our scorer, separates tokenization from scoring from geometry. That separation immediately paid for itself -- see #237: diffing token streams against llama.cpp on wikitext-2 found 30 mismatches in 3904 tokens, all the same repeated pair, which is a real pre-tokenizer bug rather than anything about perplexity. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a shared perplexity evaluation harness to dotLLM, including a llama.cpp-comparable sliding-window mode, so numerics-changing kernel work can be gated by a repeatable evaluator rather than ad-hoc per-test helpers.
Changes:
- Introduces
DotLLM.Core.Evaluationcontracts (IPerplexityModel,PerplexityOptions/Result, modes) and an engine-sidePerplexityEvaluatorwith both teacher-forced and sliding-window scoring. - Adds streaming corpus tokenization (
CorpusReader) and aTransformerModeladapter (TransformerPerplexityModel) so evaluation can run without re-loading weights. - Exposes the harness via a new
dotllm perplexityCLI verb and adds unit tests covering evaluator geometry and stable log-prob scoring.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/DotLLM.Tests.Unit/Evaluation/PerplexityEvaluatorTests.cs | Unit coverage for teacher-forced vs sliding-window behavior, window tiling, and row/target mapping. |
| tests/DotLLM.Tests.Unit/Evaluation/LogProbTests.cs | Verifies numerical stability and invariants of log-softmax target log-probability. |
| tests/DotLLM.Tests.Unit/Evaluation/FakePerplexityModel.cs | Deterministic IPerplexityModel test double recording forward calls/windows. |
| tests/DotLLM.Tests.Unit/Evaluation/CorpusReaderTests.cs | Validates streamed tokenization ordering, max-tokens cap, and chunk-boundary handling. |
| src/DotLLM.Models/Evaluation/TransformerPerplexityModel.cs | Adapter from TransformerModel to IPerplexityModel for evaluation. |
| src/DotLLM.Engine/Evaluation/PerplexityEvaluator.cs | Core evaluator implementing teacher-forced and sliding-window perplexity computation. |
| src/DotLLM.Engine/Evaluation/LogProb.cs | Numerically stable log-softmax (log P(target)) helper used by the evaluator. |
| src/DotLLM.Engine/Evaluation/CorpusReader.cs | Streaming corpus reader that tokenizes in chunks without loading whole corpus text. |
| src/DotLLM.Core/Evaluation/PerplexityResult.cs | Defines modes/options/result data structures and llama.cpp-default preset. |
| src/DotLLM.Core/Evaluation/IPerplexityModel.cs | Defines the minimal model surface required for perplexity scoring. |
| src/DotLLM.Cli/Program.cs | Registers the new perplexity CLI command. |
| src/DotLLM.Cli/Commands/PerplexityCommand.cs | Implements dotllm perplexity: load → stream/tokenize or read ids → score → report. |
| docs/superpowers/specs/2026-07-30-perplexity-harness-design.md | Design spec documenting geometry/constraints and rationale. |
| docs/superpowers/plans/2026-07-30-perplexity-harness.md | Detailed implementation plan and acceptance/verification steps. |
Comments suppressed due to low confidence (1)
src/DotLLM.Cli/Commands/PerplexityCommand.cs:95
- The command currently requires --corpus even when --tokens-file is provided, which makes the documented "read pre-tokenized ids instead of tokenizing --corpus" path unusable. Validation should require either --corpus or --tokens-file, and check existence of the chosen input.
if (string.IsNullOrWhiteSpace(settings.Corpus))
{
AnsiConsole.MarkupLine("[red]--corpus is required.[/]");
return 1;
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| int cut = pending.LastIndexOf(' '); | ||
| if (cut < 0) continue; // no safe split point yet; keep accumulating |
| /// Defaults to <see cref="PerplexityMode.SlidingWindow"/> with <c>stride = context / 2</c>, which | ||
| /// reproduces llama.cpp's <c>--perplexity</c> methodology, so the reported figure is directly | ||
| /// comparable to published numbers for the same model, corpus, context and stride. | ||
| /// </remarks> |
| /// <param name="positions"> | ||
| /// Absolute position ids, one per token. Passed explicitly rather than derived, so a sliding | ||
| /// window can score at its true absolute positions instead of restarting at zero. | ||
| /// </param> |
| /// <summary> | ||
| /// Teacher-forced scoring over one window per stride step, each scored from a single forward | ||
| /// pass where the backend permits it. The established in-tree methodology (the "G1 precedent" | ||
| /// referenced by the CUDA prefill harnesses); preserved so existing quality gates keep their | ||
| /// meaning after consolidation. | ||
| /// </summary> |
… bar (#231) Validating the harness against llama.cpp on wikitext-2 turned up three defects, all in the part that claims comparability with published figures. 1. LlamaCppDefault scored one target too many. llama.cpp sets first = n_ctx/2 and then accumulates `count += n_ctx - first - 1`, scoring targets (n_ctx/2, n_ctx) — the token AT n_ctx/2 is context, never a target. We scored it too, giving n_ctx/2 targets per chunk against llama.cpp's n_ctx/2 - 1: the same name over a different token set, which is exactly the failure mode the mode exists to avoid. 2. No error bar. llama.cpp prints "PPL = 18.8440 +/- 0.14515" and the +/- is load-bearing: on a 2,286-token corpus this model's figure carries ~6.5%, so a 3% "discrepancy" against another implementation is noise. Without it the harness invites chasing phantoms — it cost a full investigation to learn that. Computed by Welford rather than E[x^2]-E[x]^2, whose cancellation returns ~1e-6 for a constant NLL where the true variance is 0. 3. --tokens-file still demanded --corpus, so the one flag that separates a tokenizer difference from a scoring one could not be used. It now replaces --corpus, and accepts the JSON-array form reference tools print. Validated on wikitext-2 test (SmolLM-135M Q8_0, n_ctx=512, 589 chunks, 150,195 scored tokens both sides): llama.cpp b8683 18.8440 +/- 0.14515 ours, llama's ids 18.8911 (+0.25%, 0.32 sigma) ours, end-to-end 18.8950 (+0.27%, 0.35 sigma) Tokenizer agreement is exact over the corpus: 0 mismatches in 301,948 ids (requires the #237 pre-tokenizer fix; without it, 44 mismatches).
Validated against llama.cpp on wikitext-2 — and three defects fixed in the processThe harness now reproduces llama.cpp to well within its own error bar. Getting there ResultSmolLM-135M Q8_0, wikitext-2 test,
The error bars agree to 0.2% — an independent check of the new statistic against What was wrong1. 2. No error bar. This one matters more than it looks. Before the fix I measured a Computed by Welford rather than the textbook 3. What the harness does and does not guarantee
Note on orderingEnd-to-end agreement depends on #417 — without that pre-tokenizer fix the same corpus Reproducing |
…space-run starts (#231) From review feedback. Five of the six points were documentation that had drifted from the implementation — bad anywhere, worse here, since this harness's entire value is that its stated geometry is exactly what it measures. - IPerplexityModel.Forward described `positions` as absolute and invited implementers to rely on that. Sliding-window has passed window-relative positions restarting at 0 since e303be4 (llama.cpp chunk semantics, and what lets a corpus longer than the model's max sequence length be scored at all). Contract now says so. - PerplexityMode.TeacherForced claimed "one window per stride step"; it scores one clamped window and ignores Stride/UnscoredPrefix. Documented as deliberate — the mode reproduces the pre-existing harnesses bit for bit, and giving it geometry would move the numbers their gates were calibrated against. - The CLI's remarks said stride = context/2. The default is stride = context with unscored prefix = context/2 + 1. Also corrected --unscored-prefix's own help text, which still said context/2 after ca8a92b changed it. The sixth was behavioural and is fixed: chunks were cut at the last whitespace *character*, which can land inside a run of them. A GPT-2-style pre-tokenizer treats a whitespace run as one unit, so splitting one yields a different stream than tokenizing the file in one pass. Cuts now land at the run's START, so the whole run moves into the carry. Verified against whole-file tokenization on wikitext-2 (1.29 MB, 301,948 tokens) at chunk sizes 997 / 4096 / 8192 / 65536 — identical streams every time. The smallest exercises ~1,290 boundaries; the previously-reported validation crossed only ~20, which was thin cover for a boundary bug. Tests: whitespace runs of spaces, mixed space/tab/newline, newlines-only and CRLF all assert no segment ends in whitespace. The whitespace-free case is asserted too, since it has no safe cut and is therefore read whole — a stated trade (correctness over bounded memory) rather than an accident.
|
All six addressed in c00068a — five were documentation that had drifted from the implementation,
I checked whether it actually bit before changing anything. It did not, but my prior evidence was The unbounded-carry half is real and I have documented it rather than papered over it: with no
CLI defaults (fixed, both sites). The remarks said Evaluation tests 26/26. |
…418) Ports upstream PR kkokosa#418 onto dev so the dev-only work (BitNet/I2_S, PQ2_0, Vulkan) has an evaluation gate to build on. Contract in DotLLM.Core.Evaluation, evaluator in DotLLM.Engine, TransformerModel adapter, and a `dotllm perplexity` CLI verb. Two modes: TeacherForced (the existing in-tree "G1 precedent" methodology, ratio-oriented, preserved verbatim) and SlidingWindow (llama.cpp-comparable, absolute-value oriented). The evaluator selects its strategy from IPerplexityModel.ReturnsAllRows -- the single axis the ten duplicated per-test helpers on this branch actually diverged on -- so one implementation serves both the O(n) all-rows CPU shape and the O(n^2) growing-prefix last-row-only CUDA shape. A test asserts the two produce an identical number on the same tokens, which is what makes migrating those helpers a meaningful gate rather than an aspiration. Advance and scored span are independent: llama.cpp advances by the full window yet scores only its second half, so PerplexityOptions carries UnscoredPrefix and LlamaCppDefault() names the faithful configuration. Positions restart at 0 per window, since each chunk is an independent sequence. The evaluator never loads weights and the corpus is streamed -- deliberate, for the planned iGPU follow-up: on UMA a large VRAM carve-out leaves host RAM scarce and perplexity is a long run of full-context prefills, so a second host-side copy of the weights is the failure mode to avoid. Geometry verified exact against llama-perplexity build 8683 (15 windows / 1920 scored tokens on SmolLM-135M Q8_0 over wikitext-2 at -c 256). A consistently negative ~1-3.4% residual against llama.cpp's absolute figure remains open and is documented on the PR; geometry and token streams are both exact, so it is a forward-pass difference, not a harness defect. Evaluation tests: 18 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reconciles a genuine divergence: while this session merged issue/231's perplexity harness directly from its fork branch, a concurrent session on Strix independently ported the SAME feature back from upstream (kkokosa/ dotLLM#418, meaning it was also contributed upstream), plus a separate new feature never on origin/dev before now -- issue #237's pre-tokenizer pipeline (BpeTokenizer.cs/Gpt2TiktokenEncoding.cs/TiktokenPreTokenizer.cs, StarCoder/SmolLM family support). Reconciled via git bundle (no push access from Strix to transfer the diverged history directly -- HTTPS credential store isn't available non-interactively over the SSH+PowerShell channel this session uses). Only one real conflict: Program.cs's perplexity command example (kept the --stride variant -- verified PerplexityCommand.Settings actually has a Stride option, so the simpler upstream-ported example was the stale one). Everything else merged clean -- the #231 content itself was byte-identical between both paths (confirms both ports landed the same end state). Full DotLLM.Tokenizers/Cli build clean; pre-tokenizer test suite (95 tests) passes with zero failures. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Apologies for the close/reopen noise — that was accidental on our side, not a withdrawal. The branch was deleted while tidying up after porting this work into our fork's integration branch, Still open for review, and no action needed from you. |
Tracked by jamesburton#231 (fork issue tracker) — deliberately not using a
Closeskeyword, since #231 here is an unrelated issue in this repo. Addresses the blocker recorded in #416: "Every remaining lever changes numerics and none of them can currently be evaluated."Summary
A shared perplexity harness with a llama.cpp-comparable sliding-window mode, so numerics-changing kernel work has an evaluation gate. Self-contained: abstractions in
DotLLM.Core.Evaluation, evaluator inDotLLM.Engine, aTransformerModeladapter, and adotllm perplexityCLI verb.What "llama.cpp-comparable" actually means
This turned out to be the substance of the work, so it is pinned down explicitly rather than asserted.
llama.cpp walks the corpus in non-overlapping chunks of
n_ctxand scores only the second half of each — the first half is context that is never scored, so the scored ranges have gaps. Generalised to(ContextLength L, Stride S, UnscoredPrefix P): windowwcovers[w*S, w*S + L)and scores absolute targets[w*S + P, w*S + L).PerplexityOptions.LlamaCppDefault(L)givesS = L,P = L/2.Advance and scored span are independent, and a single "stride" cannot express this. An earlier revision collapsed them, which produced the same scored-token count over a different token set — a figure that looks comparable and is not. It read 24.88 where llama.cpp read 24.01. Positions also restart at 0 per window, because each chunk is an independent sequence; absolute positions additionally throw on any corpus longer than the model's context.
Design notes
One axis, not two implementations. The ten existing per-test perplexity helpers on our fork diverged for exactly one reason: whether a backend's
Forwardreturns all rows or only the last. CPU returns[seqLen, vocab], so one pass scores every target (O(n)); CUDA returns the final row only, so each target needs a growing-prefix re-prefill (O(n²)). Modelling that asIPerplexityModel.ReturnsAllRowslets one evaluator serve both, and a test asserts the two paths produce an identical number on the same tokens.The evaluator never loads weights, and the corpus is streamed and tokenized in chunks. On unified-memory parts a large VRAM carve-out leaves host RAM scarce, and perplexity — a long run of full-context prefills rather than a single load — is the workload most punished by holding a second host-side copy.
TeacherForcedis preserved verbatim as the existing in-tree methodology. It is ratio-oriented (the signal is an OFF/ON ratio under a <1% gate), not comparable to published figures, and is documented as such.Verification
Geometry is exact against
llama-perplexitybuild 8683 (d0a6dfeb2) in every configuration tested — window and scored-token counts match: 4/512, 15/1920, 18/2304.Absolute figures, SmolLM-135M Q8_0 on wikitext-2 at
-c 256, with byte-exact tokenization (requires the fix in #417):Unit tests: 18 covering both modes, both backend shapes, window tiling, per-window position reset, row/target mapping, and a test asserting the llama.cpp-default and contiguous-tiling schemes score different token sets — they produce the same count, so a count check cannot distinguish them.
Known limitation, stated plainly
The residual above is not closed. It is consistently negative across slices (this harness scores slightly better than llama.cpp), which is the signature of a systematic forward-pass difference rather than a harness defect: geometry is exact, and the token streams are now byte-identical.
Ruled out by measurement, not argument: window geometry; token-stream content (0 mismatches over 3904 tokens after #417); the
--max-tokenscap; and per-chunk BOS substitution. The remaining untested hypothesis is batching — llama.cpp evaluates chunks withn_seq=8,batch_size=2048, while this harness runs one window per forward.So: trustworthy as a relative gate today (identical geometry, identical tokens, deterministic), and not yet something to quote as an absolute figure against published numbers. I would rather ship that distinction explicitly than imply a comparability I have not demonstrated.
Diagnostics included
--per-window,--dump-tokensand--tokens-fileseparate tokenization from scoring from geometry. They are not decoration — feeding llama.cpp's exact ids back through the scorer is what localized the original discrepancy, and diffing token streams is what found #417.Follow-up
The ten duplicated per-test helpers live only on our fork's
dev, so migrating them onto this harness — gated on producing numerically identical results — is a separate change and not part of this PR.