Skip to content

Commit 181d22b

Browse files
committed
Add long-context benchmark evidence and GPU optimizations
1 parent d31cd8f commit 181d22b

31 files changed

Lines changed: 63654 additions & 158 deletions

‎README.md‎

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ Details: [`docs/Architecture.md`](docs/Architecture.md).
4343
| Is a whole short request faster? | **Yes in the 8-token smoke.** The local median was 216 ms versus 690 ms; hosted medians were 526–557 ms versus 635–1,671 ms. |
4444
| Does it use less memory? | **Yes in these CPU diagnostics.** The local smoke was 555 MiB versus 1,256 MiB; hosted Synapse used 539–553 MiB versus 575–1,203 MiB. |
4545
| Does it start answering fast? | **For the 5-token smoke prompt, yes.** On the hosted long prompt, Synapse's first token was slower than LLamaSharp on all three OSes even with batched prefill. |
46-
| Is it as fast as **MLX** on the Apple GPU? | **No.** MLX writes 225 tokens/s on the GPU. The new Synapse Metal backend wrote 135 tokens/s in one local 128-token run (llama.cpp Metal: 157). |
46+
| Does Synapse compute the same thing as **llama.cpp**? | **Yes.** On identical tokens, perplexity matches within 0.01% from 4k to 32k of context, and on the Apple GPU Synapse writes the same tokens (128 of 128 at 4k–16k). |
47+
| Is it as fast on the **Apple GPU** with long context? | **Writing, yes; the first token, not yet.** From 4k to 32k Synapse writes 90–140 tokens/s, level with llama.cpp (93–141); MLX falls to 60 at 32k. The first token takes 1.2–1.6× llama.cpp's time and 1.6–2.1× MLX's. |
48+
| Does it use less memory with long context? | **Same as llama.cpp, 25× less than MLX.** At 32k: 533 MiB peak footprint against 535 (llama.cpp) and 13,500 (MLX). |
49+
| Is the model still right on long prompts? | **As right as a 0.5B model gets.** Synapse, llama.cpp, and MLX give the same answers; the misses (a hidden number at 32k, keys among distractors, variable chains) happen in every engine, so they are the model's limits. |
4750
| How does **Microsoft Foundry Local** do? | **Fast, but quality-limited in this test.** With separate ONNX weights on about 6 cores, Qwen2.5 0.5B reached 231 tokens/s and the 7B models 22–25 tokens/s; none of the 7 model/device variants fully completed the 128-token instruction. |
4851
| Is it faster than the other .NET engine, dotLLM? | **In the same 8-token smoke, 14× faster.** |
4952

@@ -100,12 +103,22 @@ these diagnostics do not pass the 30-pair release gate. The performance
100103
workflow now assembles all raw artifacts into one results-table artifact and
101104
its final GitHub job summary.
102105

103-
### Long prompts
106+
### Long context on the Apple GPU
104107

105-
The plot below is the earlier scalar diagnostic. Current hosted results are
106-
in the table above and the benchmark details.
108+
![Long context on the Apple GPU: time to first token, writing speed, and memory from 4k to 32k](docs/images/long-context.svg)
107109

108-
![Long prompts: the wait before the first token grows](docs/images/first-token.svg)
110+
- **Tokens.** Every engine gets the same prompt, and its token IDs are
111+
checked before its answer counts. Synapse's answers match llama.cpp's
112+
byte for byte in 28 of 28 long-context questions.
113+
- **Memory.** Synapse keeps the KV cache at 384 MiB for 32k tokens (FP16) and
114+
maps the model file instead of copying it.
115+
- **Speed.** Writing is level with llama.cpp. The first token is the open gap:
116+
prompt attention and the prompt matrix multiply are slower. Removing the
117+
staged K/V tile from prompt attention already cut 32k from 40.5 s to 32.6 s.
118+
- **FP32 KV cache.** Synapse writes 73 tokens/s at 32k, against 10.7 for
119+
llama.cpp with the same cache type.
120+
- Details, every engine and KV cache type, and CPU rows: runs Q–T in
121+
[`benchmarks/README.md`](benchmarks/README.md).
109122

110123
### Microsoft Foundry Local, 6 models
111124

@@ -131,9 +144,11 @@ in the table above and the benchmark details.
131144
| | Problem | Fix | Where |
132145
|---|---|---|---|
133146
| 🔴 | Hosted long-prompt first token and 128-token decode trail the CPU references | Profile prefill and decode on each runner | `experiments/Synapse.ReferenceBenchmarks` |
134-
| 🟡 | Metal runs Qwen2 but decodes slower than llama.cpp and MLX; CUDA is written but has never run | FP16 KV cache, GPU-packed weights; run CUDA on an NVIDIA machine | `native/synapse-gpu` |
135-
| 🟡 | Long-output quality is unreviewed | Review generated text and add a quality gate before release claims | `.github/workflows/performance.yml` |
136-
| 🟡 | Takes token IDs, not text | Tokenizer and chat templates | not started |
147+
| 🔴 | Apple GPU first token is 1.2–1.6× llama.cpp's; the prompt matrix multiply alone is 1.55× slower at 512 tokens | Profile the GEMM with GPU counters (tile, half, and layout experiments did not close it) | `native/synapse-gpu/.../metal/shaders/matmul.metal` |
148+
| 🟡 | CPU first token is 1.9× llama.cpp's at 8k | Tiled, vectorized prompt attention | `Qwen2CpuAttention.cs` |
149+
| 🟡 | Smart context (query-aware KV pages) is proven only on CPU: +3.4% perplexity while reading 30% of an 8k context | Metal kernels matched to the C# selection, then measure speed and memory | ADR-016, `KvPageSelector.cs` |
150+
| 🟡 | CUDA is written but has never run | Run CUDA on an NVIDIA machine | `native/synapse-gpu` |
151+
| 🟡 | Free-form long answers are checked for agreement with llama.cpp, not reviewed for content | Human review plus exact-answer gates before release claims | `.github/workflows/performance.yml` |
137152
| 🟡 | Only Qwen2 runs | More model families | `src/Synapse.Runtime/Features/TextGeneration` |
138153
| 🟢 | Speed, memory, startup | Confirm with the 30-run release gate | `experiments/Synapse.ReferenceBenchmarks` |
139154

‎benchmarks/README.md‎

Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
The short version is in the [main README](../README.md#benchmark). This page
44
lists every recorded run, how it was measured, and its raw data.
55

6-
Runs A–K and N–O use Qwen2.5 0.5B Instruct, the only model Synapse runs today. Run L
6+
Runs A–K and N–S use Qwen2.5 0.5B Instruct, the only model Synapse runs today. Run L
77
measures Microsoft Foundry Local alone on six models from four families.
88

99
## Runs
@@ -24,7 +24,12 @@ measures Microsoft Foundry Local alone on six models from four families.
2424
| L. Foundry Local, 6 models, answer + dialogue | Not included (Foundry only) | runtime default (about 6 cores) | 1 warm-up + 3 measured | 14 JSON files + 2 default-context probes, `results/2026-09-28-m2-pro-foundry-local-*` |
2525
| M. Hosted CPU, MLX, and Foundry, 3 operating systems | Rust CPU kernels for Synapse | CPU 2 / external runtime default | CPU smoke 3+5; long/MLX/Foundry 1+3 | [GitHub run `36435838291`](https://github.com/managedcode/Synapse/actions/runs/36435838291), 22 raw artifacts |
2626
| N. Metal GPU long context, 40k and 131k synthetic tokens | Metal backend (ADR-012), FP32 KV | GPU | 1 measured per row | [JSON](results/2026-09-28-m2-pro-qwen2.5-0.5b-q8_0-metal-long-context-speed-diagnostic.json) |
27-
| O. Metal GPU pass-key retrieval, 4k to 120k | Metal backend, FP32 KV (native window) and FP16 KV (YaRN ×4) | GPU | 1 measured per row | [native](results/2026-09-28-m2-pro-qwen2.5-0.5b-q8_0-metal-passkey-native-f32-diagnostic.json), [YaRN](results/2026-09-28-m2-pro-qwen2.5-0.5b-q8_0-metal-passkey-yarn4-f16-diagnostic.json) |
27+
| O. Metal GPU pass-key retrieval, 4k to 120k | Metal backend, FP32 KV (native window) and FP16 KV (YaRN ×4) | GPU | 1 measured per row | [native](results/2026-09-28-m2-pro-qwen2.5-0.5b-q8_0-metal-passkey-native-f32-diagnostic.json), [YaRN](results/2026-09-28-m2-pro-qwen2.5-0.5b-q8_0-metal-passkey-yarn4-f16-diagnostic.json), [llama.cpp control, corrected](results/2026-09-28-m2-pro-llamacpp-metal-passkey-120k-control-diagnostic.json) |
28+
| P. Tokenizer parity, whole repository | Repo-owned BPE tokenizer (ADR-014) | — | 702 runs, both special modes | [JSON](results/2026-09-28-m2-pro-qwen2.5-tokenizer-corpus-parity.json) |
29+
| Q. Perplexity parity with llama.cpp, 4k to 32k | Metal FP32/FP16 KV, native CPU | GPU / CPU 8 | 1 measured per row | [JSON](results/2026-09-28-m2-pro-qwen2.5-0.5b-q8_0-perplexity-parity-diagnostic.json) |
30+
| R. Long-context answers, 4 engines, 4k to 32k | Metal FP32/FP16 KV, native CPU | GPU / CPU 8 | 1 measured per case | [GPU](results/2026-09-28-m2-pro-qwen2.5-0.5b-q8_0-long-context-quality-diagnostic.json), [CPU](results/2026-09-28-m2-pro-qwen2.5-0.5b-q8_0-long-context-quality-cpu-diagnostic.json) |
31+
| S. KV page activation quality (ADR-016) | Native CPU, C# page selection | CPU 8 | 1 measured per row | [JSON](results/2026-09-28-m2-pro-qwen2.5-0.5b-q8_0-kv-page-activation-cpu-diagnostic.json) |
32+
| **T. Context sweep, 4k to 32k, tokens + memory + speed (main README)** | Metal FP32/FP16 KV with prompt attention reading K/V directly; native CPU | GPU / CPU 8 | 1 warm-up + 2 measured, rotated | [JSON](results/2026-09-28-m2-pro-qwen2.5-0.5b-q8_0-context-sweep-diagnostic.json), [prompt IDs](scenarios/context-sweep/) |
2833

2934
Local machine: MacBook Pro, Apple M2 Pro (8 performance + 4 efficiency cores,
3035
19-core GPU), 32 GB, macOS 27.0 arm64. Every CPU sample starts a new process.
@@ -176,6 +181,118 @@ per-run Markdown table with runner, scenario, turn, subject, measured-round
176181
count, median timing/memory, output state, and source artifact. Missing or
177182
invalid evidence is listed and makes that reporting job fail.
178183

184+
## T. Context sweep, 4k to 32k: tokens, memory, and speed
185+
186+
**Method**
187+
- **Prompt.** One prompt per context: the pinned repository haystack, with a
188+
hidden number at 50% depth. The instruction asks for that number, then a
189+
detailed summary (`experiments ... sweep`). The exact prompt IDs are in
190+
[`scenarios/context-sweep/`](scenarios/context-sweep/).
191+
- **Runs.** Every engine gets the same text, and its prompt token IDs or
192+
counts are checked. Each sample is a fresh process: 1 warm-up plus 2
193+
measured rounds, with the engine order rotated. Greedy decoding, up to 128
194+
tokens.
195+
- **Columns:**
196+
- *KV MiB* is the cache the context needs, computed from the model
197+
geometry;
198+
- *peak RSS* and *peak footprint* are sampled from the whole process;
199+
- *TTFT* and *full generation* are each engine's own phase clock
200+
(llama.cpp perf lines, Synapse CLI timings, and client-side streaming
201+
for MLX);
202+
- *same tokens as reference* counts the leading output tokens shared with
203+
llama.cpp Metal FP16 KV, both re-tokenized by the repo tokenizer.
204+
- **Merged sources:**
205+
- the main sweep;
206+
- an MLX rerun, which replaced its rows because SwiftLM's resident prompt
207+
cache had answered repeated prompts: a fresh server per sample now;
208+
- a Synapse Metal rerun after the prompt-attention change.
209+
210+
| context | engine | KV | KV MiB | peak RSS MiB | peak footprint MiB | TTFT s | full generation s | decode tok/s | tokens | correct | same tokens as reference |
211+
|---:|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
212+
| 4096 | llamacpp-cpu | f16 | 48 | 1257 | 648 | 8.04 | 9.64 | 79.7 | 128 | 2/2 | 51 |
213+
| 4096 | llamacpp-metal | f16 | 48 | 828 | 125 | 0.85 | 1.75 | 141.4 | 128 | 2/2 | 128 |
214+
| 4096 | llamacpp-metal | f32 | 96 | 876 | 174 | 0.96 | 3.01 | 62.1 | 128 | 2/2 | 128 |
215+
| 4096 | llamacpp-metal | q8_0 | 26 | 808 | 103 | 0.92 | 1.97 | 120.4 | 128 | 2/2 | 59 |
216+
| 4096 | mlx-swiftlm | native | — | 657 | 1138 | 0.81 | 1.70 | 143.1 | 128 | 2/2 | 38 |
217+
| 4096 | synapse-metal | f16 | 48 | 606 | 174 | 1.34 | 2.24 | 139.8 | 128 | 2/2 | 128 |
218+
| 4096 | synapse-metal | f32 | 96 | 606 | 222 | 1.36 | 2.32 | 132.2 | 128 | 2/2 | 128 |
219+
| 4096 | synapse-native | f32 | 96 | 697 | 161 | 15.68 | 17.61 | 65.8 | 128 | 2/2 | 51 |
220+
| 8192 | llamacpp-cpu | f16 | 96 | 1270 | 703 | 32.23 | 34.60 | 53.6 | 128 | 2/2 | 43 |
221+
| 8192 | llamacpp-metal | f16 | 96 | 884 | 181 | 2.47 | 3.47 | 127.3 | 128 | 2/2 | 128 |
222+
| 8192 | llamacpp-metal | f32 | 192 | 981 | 277 | 2.64 | 6.17 | 36.0 | 128 | 2/2 | 128 |
223+
| 8192 | llamacpp-metal | q8_0 | 51 | 841 | 137 | 2.51 | 3.64 | 112.3 | 128 | 2/2 | 52 |
224+
| 8192 | mlx-swiftlm | native | — | 656 | 1898 | 2.05 | 3.16 | 114.4 | 128 | 0/2 | 1 |
225+
| 8192 | synapse-metal | f16 | 96 | 607 | 223 | 3.63 | 4.62 | 129.1 | 128 | 2/2 | 128 |
226+
| 8192 | synapse-metal | f32 | 192 | 606 | 318 | 3.82 | 4.88 | 120.0 | 128 | 2/2 | 128 |
227+
| 8192 | synapse-native | f32 | 192 | 805 | 265 | 59.75 | 62.89 | 40.4 | 128 | 2/2 | 67 |
228+
| 16384 | llamacpp-metal | f16 | 192 | 1004 | 300 | 7.40 | 8.50 | 115.9 | 128 | 0/2 | 128 |
229+
| 16384 | llamacpp-metal | f32 | 384 | 1189 | 487 | 8.10 | 14.23 | 20.7 | 128 | 0/2 | 128 |
230+
| 16384 | llamacpp-metal | q8_0 | 102 | 909 | 204 | 7.55 | 8.88 | 95.2 | 128 | 0/2 | 1 |
231+
| 16384 | mlx-swiftlm | native | — | 657 | 4401 | 5.32 | 6.76 | 88.3 | 128 | 0/2 | 42 |
232+
| 16384 | synapse-metal | f16 | 192 | 610 | 328 | 10.28 | 11.40 | 112.6 | 128 | 0/2 | 128 |
233+
| 16384 | synapse-metal | f32 | 384 | 612 | 520 | 11.06 | 12.34 | 99.1 | 128 | 0/2 | 128 |
234+
| 32768 | llamacpp-metal | f16 | 384 | 1208 | 535 | 26.62 | 26.81 | 92.9 | 19 | 2/2 | 18 |
235+
| 32768 | llamacpp-metal | f32 | 768 | 1610 | 906 | 27.36 | 29.04 | 10.7 | 19 | 2/2 | 18 |
236+
| 32768 | llamacpp-metal | q8_0 | 204 | 1049 | 344 | 25.52 | 25.76 | 75.1 | 19 | 2/2 | 18 |
237+
| 32768 | mlx-swiftlm | native | — | 657 | 13500 | 15.51 | 17.62 | 60.1 | 128 | 0/2 | 0 |
238+
| 32768 | synapse-metal | f16 | 384 | 624 | 533 | 32.64 | 32.84 | 90.0 | 19 | 2/2 | 18 |
239+
| 32768 | synapse-metal | f32 | 768 | 623 | 917 | 35.40 | 35.64 | 73.3 | 19 | 2/2 | 18 |
240+
241+
242+
**Reading it**
243+
- **Engine agreement.**
244+
- Synapse Metal wrote the same 128 tokens as llama.cpp at 4k, 8k, and
245+
16k, and the same whole answer at 32k.
246+
- At 16k every engine missed the hidden number, and at 8k MLX (with its
247+
own 8-bit weights) missed it too. These are model limits, not engine
248+
errors.
249+
- **Writing speed.** Synapse with FP16 KV matches llama.cpp with FP16 KV.
250+
With FP32 KV, llama.cpp falls to 10.7 tokens/s at 32k, while Synapse keeps
251+
73.
252+
- **Time to first token.** Synapse is 1.2–1.6× llama.cpp and 1.6–2.1× MLX.
253+
This is the open gap: the prompt matrix multiply is about 1.55× slower at
254+
512 tokens.
255+
- **Memory.**
256+
- The footprint counts memory the process owns. Synapse and llama.cpp
257+
also map the 644 MiB model file, while MLX copies its weights in. MLX
258+
peaks at 13.5 GB at 32k.
259+
- Synapse RSS stays near 610 MiB at every context while its footprint
260+
grows with the KV cache: the Metal buffers appear in the footprint, not
261+
in RSS.
262+
- **Limitations.**
263+
- These are diagnostics with 2 measured rounds each, not a paired release
264+
verdict.
265+
- Across two Synapse runs of identical decode code, 4k and 8k decode
266+
varied by 4–6%.
267+
- CPU rows run up to 8k only.
268+
269+
## P–S. Quality on long prompts (ADR-014, ADR-015, ADR-016)
270+
271+
Details and tables: [`docs/Features/LongContext.md`](../docs/Features/LongContext.md)
272+
and [`docs/Features/Tokenization.md`](../docs/Features/Tokenization.md).
273+
274+
- **P.** `synapse tokenize` matched `llama-tokenize --no-escape` in 702 of 702
275+
runs (351 files, 540,679 tokens).
276+
- **Q.** Perplexity on identical tokens:
277+
- Synapse against llama.cpp: 12.7724 vs 12.7728 at 4k, 6.8867 vs 6.8868 at
278+
16k, and 3.9686 vs 3.9687 at 32k. Every chunk agreed within 0.01%.
279+
- FP16 KV equals FP32 KV to the fourth decimal.
280+
- **R.** 28 exact-answer cases:
281+
- Synapse FP32 KV, Synapse FP16 KV, and llama.cpp answered byte-identically
282+
in 28 of 28. MLX, with its own weights, agreed in 23.
283+
- Every engine shares the same misses, which are 0.5B model limits:
284+
variable tracking 0 of 4, multi-key 9 of 12, and the single needle at 32k
285+
and 50% depth.
286+
- The corrected 120k control (llama.cpp `-f` drops a trailing newline)
287+
answers `6`, like Synapse.
288+
- **S.** Query-aware KV page activation, measured by decode-mode perplexity:
289+
- key-bound selection beats the equal-budget random control at every
290+
budget;
291+
- with 16-token pages and 2,048 tokens it costs +3.4% at 8k and +6.2% at
292+
16k.
293+
294+
It is CPU-only so far, so no speed or memory claim is made.
295+
179296
## A. 128-token answer (4 CPU engines, 2 threads)
180297

181298
| Engine | Load ms | First token ms | Speed tok/s | Process wall ms | Peak RSS MiB |

‎benchmarks/results/2026-09-28-m2-pro-llamacpp-metal-passkey-120k-control-diagnostic.json‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,27 @@
44
"recordedOn": "2026-09-28",
55
"host": "owner MacBook Pro, Apple M2 Pro (19-core GPU), 32 GB, macOS 27",
66
"subject": "llama.cpp llama-completion 0.4.1 (b29c606e2, Homebrew), Metal",
7-
"command": "llama-completion -m qwen2.5-0.5b-instruct-q8_0.gguf -f prompt.txt -c 131072 -n 12 --temp 0 -s 0 -ngl 99 -fa on --rope-scaling yarn --rope-scale 4 --yarn-orig-ctx 32768 -no-cnv --no-display-prompt",
7+
"command": "llama-completion -m qwen2.5-0.5b-instruct-q8_0.gguf -f prompt-plus-newline.txt -c 131072 -n 12 --temp 0 -s 0 -ngl 99 -fa on --rope-scaling yarn --rope-scale 4 --yarn-orig-ctx 32768 -no-cnv --no-display-prompt --no-escape",
88
"scenario": "passkey-qwen2.5, key 68317 at depth 0.1, 500 fillers before and 4496 after",
99
"promptTextSha256": "dbf038309ebf1d62f43f9e1ac4f74681dfcecd73235c6a780516950f3b99d344",
1010
"promptTokenIdsSha256": "9d94e457138d58a37ceaa26221054e1ce8d5bf268acb18b66bbd15de4429360a",
1111
"promptTokens": 119991,
1212
"promptTokenIdsIdenticalToSynapse": true,
13-
"output": " The grass is green. The sky is blue. The sun",
13+
"output": "6",
1414
"containsAnswer": false,
15-
"promptEvalMilliseconds": 288020.20,
16-
"promptTokensPerSecond": 416.60,
17-
"decodeMillisecondsPerToken": 20.86,
18-
"decodeTokensPerSecond": 47.94,
19-
"timeLPeakMemoryFootprintBytes": 1954515680,
15+
"promptEvalMilliseconds": 458669.90,
16+
"promptTokensPerSecond": 261.61,
17+
"decodeMillisecondsPerToken": null,
18+
"decodeTokensPerSecond": null,
19+
"timeLPeakMemoryFootprintBytes": 1981958880,
2020
"kvCache": "llama.cpp default (F16)",
21-
"note": "Same token IDs as the Synapse run that answered only 6; both engines miss the 10%-depth key at 120k, which points to the model under YaRN x4, not to either engine."
21+
"note": "On truly identical token IDs llama.cpp answers exactly like Synapse: the single digit 6, then end of turn, instead of 68317. Both engines agree; the miss is the model under YaRN x4.",
22+
"promptTokensEvaluatedByLlamaCpp": 119991,
23+
"timingNote": "Recorded while a CPU experiment ran in parallel; not speed evidence. The first run (288,020 ms, 416.6 tok/s) had one token fewer.",
24+
"correction": {
25+
"date": "2026-09-28",
26+
"finding": "The first run of this control passed prompt.txt with -f. llama.cpp -f drops one trailing newline, so it evaluated 119,990 tokens (its own log: prompt eval time = 288020.20 ms / 119990 tokens) although this file recorded 119,991 identical IDs. Without the newline after <|im_start|>assistant the model printed filler text.",
27+
"fix": "The rerun passes the prompt text plus one extra newline; llama.cpp then reports 119,991 prompt tokens, and llama-tokenize --no-escape of the prompt text equals the Synapse IDs.",
28+
"firstRunOutput": " The grass is green. The sky is blue. The sun"
29+
}
2230
}

0 commit comments

Comments
 (0)