2026-10-11 16:38 UTC

Hayder Tirmazi claims four implementation changes to llama.cpp's n-gram caches (unnecessary map copies removed, flat hash maps, and related fixes) make prompt-lookup drafting up to 42x faster with up to 2.6x less memory at unchanged acceptance rates; an upstream llama.cpp merge or independent reproduction would establish lean n-gram-cache engineering as a standard local-inference optimization.

state: watchingheat: lowuncertainty: mediumconvergesscott: mediumllama-cpp speculative-decoding prompt-lookup-decoding local-inferenceHayder Tirmazi
Surfaced 2026-09-27T22:56:59Z β€” "I make drafting for prompt lookup decoding in llama.cpp up to 42x faster while using up to 2.6x less memory through a set of simple perform β€” The upstream-merge path is now publicly contested: an HN counterparty voice (PicardsFlute) confirms the llama.cpp side asked Tirmazi to resolve the dispute privately and faults his public escalation and 'bot posts on Reddit', upgrading 'Yet Another Fork' from a Reddit worry to the likely delivery outcome (Tirmazi's fork, ik_llama.cpp, or cherry-picks rather than mainline). Attention is a long tail β€” Reddit at ~26% of peak rate on the same thread, HN merely modest β€” so heat cools to low despite the 93rd-percentile/magnitude-valve reading: the three original objects are deepening, not spreading; there are no new implementations, communities, or outlets, and the merge-or-replication predicate still moves on PR cadence, not comment cadence.

What is this?

Prompt-lookup decoding is a draft-model-free form of speculative decoding that proposes continuations by matching the current token suffix against n-grams of prior context β€” a natural fit for repetitive text like code and logs. llama.cpp ships this as n-gram cache / lookup drafters inside its speculative-decoding layer (common/speculative). Hayder Tirmazi reports that four implementation-level fixes to those caches β€” removing unnecessary map copies and switching to flat hash maps β€” make the drafting step itself up to 42x faster and up to 2.6x lighter on memory with unchanged acceptance rates, backed per the case by a public results repo and open PRs. The supplied coverage does not independently verify Tirmazi's numbers, PR status, or identity, but it confirms the surrounding pattern: llama.cpp has recently carried similar copy-elimination patches (MTP logit copying PR #23198, KV-cache cell copies PR #24277, CUDA stream-k overhead #22298) where 'identical behavior, fewer copies' is treated as a mergeable pure win, so merge-or-replication is a plausible resolution path for this claim.

Why it matters to Scott

Bears on the local serving stack Scott actually runs (gamepc with its Ollama endpoint): if the n-gram-cache fixes merge β€” and the grounding notes llama.cpp has recently merged similar copy-elimination patches as pure wins β€” prompt-lookup drafting becomes near-free and ~2.6x lighter, which would make draft-model-free speculative decoding a default-on lever for his bulk-generation workloads rather than a specialist feature. It also independently lands where his hardware-aware-local-inference page argues β€” memory pressure and implementation quality, not just placement, precision and compilation, are first-order local-inference levers β€” though the payoff stays contingent on upstream merge or independent reproduction.
dev:concept.hardware-aware-local-inferencedev:project.gamepcdev:technology.ollamaradar:concept.speculative-decodingradar:concept.llama-cppradar:llama-cpp-adaptive-mtpradar:llama-cpp-specdec-moe-fusion
queries asked of Scott's wikis
  • llama.cpp local inference stack tokens per second
  • speculative decoding prompt lookup in agent harness
  • coding agent token throughput latency
  • local model inference economics vs cloud API
  • independent benchmark replication performance claims I tested

Measured heat

now 0 pts/hpeak 25 pts/hcomments 0/hpeers p14momentum: steady3 platformsage 386h
points/hour across evidence Β· reading as of 2026-10-12 02:59:37.977291+11:00 Β· deterministic, not a model opinion

How the heat travelled

09-25 14:00⭐ origin echo-reconstructed"I make drafting for prompt lookup decoding in llama.cpp up to 42x faster while using up to 2.6x less memory through a set of simple perform
Hayder Tirmazi on blog (echo) Β· attributed from hn.story.49859982
β€”
09-26 19:57first on hacker news Β· published Β· +30.0h42x faster prompt lookup drafting in llama.cpp
pptadversary
β€”
09-27 00:23first on r/LocalLLaMA Β· published Β· +34.4h42x Faster Prompt Lookup Drafting in llama.cpp
Available_Pressure47
β€”
09-26 19:57amplified on hacker newshn.story.49859982
pptadversary
peak 89 Β· 12 comments Β· 18% of case engagement
09-27 00:23amplified on r/LocalLLaMA πŸ‘‘reddit.post.1wr5ylm
Available_Pressure47
peak 646 Β· 181 comments Β· 82% of case engagement
09-26 20:20our radar first saw it Β· +30.4hdiscovery anchor: hn.story.49859982β€”
09-27 22:55reached heat=high Β· +56.9h Β· via ledgerβ€”β€”
pace: p90 vs 1032 stories at the 336h mark (now 386h old) β€” ahead of debian-llm-usage-vote (1.0x), behind omarchy-any-process-root-escalation (1.0x)

Evidence (3) β€” ⭐ canonical anchor

sourceobjectauthorscorecomments
🟧 hn42x faster prompt lookup drafting in llama.cpp
Retrieved article excerpt

Open article Β· Retrieved 2026-09-26T20:26:32.627999+00:00

### 42x faster prompt lookup drafting in llama.cpp

Hayder Tirmazi

[[homepage](https://jadidbourbaki.github.io/)]
[[github](https://github.com/jadidbourbaki)]
[[twitter](https://x.com/jadidbourbaki)]
  
This article was originally published on 2026-09-26.

**TL;DR** I make drafting for prompt lookup decoding in llama.cpp up to 42x faster while using up to 2.6x less memory through
a set of simple performance optimizations largely based on the work of
[Daniel Lemire](https://github.com/lemire) and [Martin Ankerl](https://github.com/martinus).

Drafting latency per drafted token by corpus size. Upstream llama.cpp takes 8.54, 45.61, 59.73, 83.46, 113.46, and 165.48 Β΅s for corpora of 0, 25, 50, 100, 200, and 541 MB. With all four changes, drafting takes 0.89, 3.06, 3.25, 3.32, 3.47, and 3.98 Β΅s.

Many popular inference engines including llama.cpp and vllm, and machine learning libraries
such as hugging face's transformers library, support
[prompt lookup decoding](https://github.com/apoorvumang/prompt-lookup-decoding)
(also called [n-gram speculation](https://x.com/joao_gante/status/1747322413006643259))
for faster token generation. Prompt lookup decoding is technically a special case of speculative decoding
that uses a really stupid draft model, an n-gram model. When prompt lookup decoding is used,
the inference engine drafts the next $k$ tokens using the following rule.

Let $x\_1, \ldots, x\_t$ be the current tokens of a model.
An n-gram is a sequence of $n$ consecutive tokens. For example, a 3-gram would be $(x\_1, x\_2, x\_3)$ or
$(x\_2, x\_3, x\_4)$ or, in general, $(x\_i, x\_{i+1}, x\_{i+2})$ for any $i \in \{1, \ldots, t-2\}$.
Now an n-gram model is a probabilistic model that predicts the next token based on the previous $n - 1$ tokens.
The idea is extremely simple. You first select some corpus of text and parse it into n-grams. You then
count the frequency of each n-gram. When your n-gram model needs to predict the next token after a sequence
of $n-1$ tokens, you make the n-gram model select the token that most frequently follows that sequence of
$n-1$ tokens in your corpus.

llama.cpp maintains three types of *n-gram caches*. Let $\eta$ be any n-gram and $y$
be any token. An n-gram cache
is a data structure that stores $c(\eta, y)$, i.e., the count of how many times the token $y$ follows the n-gram $\eta$,
for all n-grams $\eta$ and all tokens $y$ in a given corpus and vocabulary. The three n-gram caches used
by llama.cpp are the context cache, the dynamic cache, and the static cache.

llama.cpp's context cache stores n-grams of sizes 1 to 4 for the current tokens $x\_1, \ldots, x\_t$ being
processed by the model. The context cache is updated as the model generates new tokens. The
dynamic cache stores the counts of n-grams from previous runs of the model, e.g., any earlier
conversations. Finally, the static cache stores n-grams of size 2 from a static text corpus, built
with `llama-lookup-create`.
I denote the context, dynamic, and static caches by $c\_{\text{ctx}}$, $c\_{\text{dyn}}$, and $c\_{\text{st}}$, respectively.

llama.cpp drafts a new token using its n-gram caches in the following way. Let $X\_n = (x\_{t-n+1}, \ldots, x\_t)$ be the previous
$n$ tokens processed by the model. For all tokens $y$ in the vocabulary, llama.cpp computes a score using the formula

$$s\_n^{f}(y) = f(X\_n, y) \cdot w(y) \,\, \text{where} \,\,
w(y) = \begin{cases} 100 \, c\_{\text{st}}(X\_2, y) & \text{if $c\_{\text{st}}(X\_2, y) > 0$} \\ 1 & \text{otherwise} \end{cases}$$

where $f$ is either the context cache $c\_{\text{ctx}}$ or the dynamic cache $c\_{\text{dyn}}$.
Note that the weight $w(y)$ favors tokens that also agree with the static cache. Without a
static cache, $w(y) = 1$ for every token.
For each $n$, llama.cpp takes the highest-scoring token $y^\* = \arg\max\_y s\_n^{f}(y)$. Let
$F(X\_n) = \sum\_y f(X\_n, y)$ be the number of times $X\_n$ appeared with a token after it.
llama.cpp drafts $y^\*$ based on two configurable thresholds $a\_n$ and $p\_n$ in the following way.

$$F(X\_n) \ge a\_n \,\, \text{and} \,\, f(X\_n, y^\*) \ge p\_n \, F(X\_n)$$

In other words, $X\_n$ must appear at least $a\_n$ times and the token $y^\*$
must have followed $X\_n$ in at least a fraction $p\_n$ of those occurrences for $y^\*$
to be accepted as a draft token. As of release b11182 of llama.cpp, the thresholds are hard-coded
as follows.
For the context cache,
$(a\_1, a\_2, a\_3, a\_4) = (2, 2, 1, 1)$ and $(p\_1, p\_2, p\_3, p\_4) = (0.66, 0.5, 0.5, 0.5)$. For the
dynamic cache, $(a\_1, a\_2, a\_3, a\_4) = (4, 3, 2, 2)$ and $(p\_1, p\_2, p\_3, p\_4) = (0.75, 0.66, 0.66, 0.66)$.
llama.cpp tries $n = 4, 3, 2, 1$ and drafts the first $y^\*$ that passes the conditions above. It first scores with
$c\_{\text{ctx}}$. It scores with $c\_{\text{dyn}}$ only when no candidate from $c\_{\text{ctx}}$
passes for any $n$. If no candidate from $c\_{\text{dyn}}$ passes either, llama.cpp falls back to
relying only on the static cache (as opposed to only using it to reweight candidates in the other caches).
As a side note, the static cache's thresholds in llama.cpp are the same values as the context cache's thresholds for the corresponding $n$,
i.e., $n = 2$. Let $C\_{\text{st}}(X\_2) = \sum\_y c\_{\text{st}}(X\_2, y)$. llama.cpp takes the token $y$ with the
largest $c\_{\text{st}}(X\_2, y)$ and drafts it when
$C\_{\text{st}}(X\_2) \ge a\_2 = 2$ and $c\_{\text{st}}(X\_2, y) \ge p\_2 \, C\_{\text{st}}(X\_2) = 0.5 \, C\_{\text{st}}(X\_2)$.
If the static cache also fails, llama.cpp does not draft the next token.

#### Experimental Setup

llama.cpp's repository includes an example for prompt lookup decoding
[here](https://github.com/ggml-org/llama.cpp/tree/master/examples/lookup).
It includes two tools I use: `llama-lookup-create` for building a static cache from a corpus
and `llama-lookup-stats` for benchmarking prompt lookup decoding.
`llama-lookup-stats` essentially reads a file and treats the file's tokens
as the output of a model. It runs the drafting loop from llama.cpp over the simulated
"model output" (i.e. the file) and records how many drafted tokens match the file, the time it took
to draft the tokens, and the time it took to load the static ngram cache.

I build the static caches using `llama-lookup-create` with
[WikiText-103](https://arxiv.org/abs/1609.07843) and then I
replay the WikiText-103 test text through `llama-lookup-stats`. I borrowed
this evaluation method from the
[PR](https://github.com/ggml-org/llama.cpp/pull/5479)
by [@JohannesGaessler](https://github.com/JohannesGaessler) that added
the static n-gram cache to llama.cpp. Note that since I am not making any
algorithmic modifications to how prompt lookup decoding works in llama.cpp, the dataset
mainly matters for the acceptance rate, which my changes leave unchanged. Just to be safe, I make
sure my changes still have almost identical acceptance rates to the original implementation.
The important metrics here that actually change are 1) latency
per drafted token, 2) the load time of the static cache, and 3) the memory used by the static cache.

I also wanted to observe how the performance changes with different corpus sizes for the static
n-gram cache. So in addition to evaluating the full corpus of WikiText-103, which is about
541 MB, I also build static caches from the first 25, 50, 100, and 200 MB of the WikiText-103 training text.
A corpus size of 0 in the figures means I run without a static cache, which measures the context and
dynamic caches alone.
For all the results in this work, I am reporting the median of 3 runs with the error bars displaying the
min and the max value for the runs. Following the llama.cpp PR I linked
in the previous paragraph, I also benchmark assuming a model context side of 4096 tokens.
I run all my experiments on an Apple M4 Pro with 14 cores and 48 GB of memory.
All of my code and results are in this
[repository](https://github.com/jadidbourbaki/ngram-cache-bench).

#### Stop Copying Maps

The n-gram caches in llama.cpp are currently implemented as nested `std::unordered_map`s. An outer
map sends each n-gram to an inner map of the tokens that follow it and their counts.
This one is almost more of a bug fix than an optimization. I found that the inner maps were
being copied unnecessarily in multiple places on every drafting step.
I created this simple [PR](https://github.com/jadidbourbaki/llama.cpp/pull/2)
to read them by reference instead.
This immediately made drafting 4.5x to 25.6x faster depending on the size of the
corpus (see figure below). The latency is the average time spent
drafting per drafted token.

Drafting latency per drafted token by corpus size. Baseline takes 8.54, 45.61, 59.73, 83.46, 113.46, and 165.48 Β΅s for corpora of 0, 25, 50, 100, 200, and 541 MB. The PR takes 1.89, 4.12, 4.42, 4.64, 5.62, and 6.47 Β΅s.
Static cache load time by corpus size. Baseline takes 0.44, 0.90, 1.33, 2.46, and 5.49 s for corpora of 25, 50, 100, 200, and 541 MB. The PR takes 0.47, 0.85, 1.30, 2.52, and 5.28 s.
Peak memory by corpus size. Baseline peaks at 0.89, 1.11, 1.29, 1.60, 2.11, and 3.47 GB for corpora of 0, 25, 50, 100, 200, and 541 MB. The PR peaks at 0.90, 1.15, 1.35, 1.64, 2.18, and 3.55 GB.

#### Outer Map -> Flat Hash Map

llama.cpp implements an n-gram cache as a map of maps.

```
typedef std::unordered_map<common_ngram, common_ngram_cache_part,
        common_ngram_hash_function> common_ngram_cache;
```

The outer map, `common_ngram_cache`, maps each n-gram to an inner map. The inner
map, a `common_ngram_cache_part`, map stores the counts of each token in the vocabulary
that follows the given n-gram. As an example, if
"of the" is followed by "city" 6 times, "war" 3 times, and "year" once, the n-gram cache looks like this.

```
common_ngram_cache
  ("of", "the")  ->  common_ngram_cache_part { "city": 6, "war": 3, "year": 1 }
  ("in", "the")  ->  common_ngram_cache_part { ... }
  ....
```

llama.cpp currently implements both the outer and inner maps as an `std::unordered_map`.
However, the standard library's implementation of `std::unordered_map` is
[famously slow](https://stackoverflow.com/a/42588384)
because it uses chaining for collision resolution with linked lists as its buckets
which is cache unfriendly. There are many great alternatives here such as Google's
[Swiss Tables](https://abseil.io/about/design/swisstables) (which
were also [recently added](https://go.dev/blog/swisstable) to Golang)
and [Martin Ankerl's](https://github.com/martinus) [unordered\_dense maps](https://github.com/martinus/unordered_dense).
I decided to go with `ankerl::unordered_dense` because 1) I really like its design and
performance, and 2) it is less of an annoyance than trying
to add all of abseil as a dependency to llama.cpp.

My change is in this [PR](https://github.com/jadidbourbaki/llama.cpp/pull/5).
This makes 1) loading the static n-gram cache 1.41x to 1.65x faster depending on the size of the corpus,
2) drafting a new token 1.02x to 1.13x faster, and 3) the static cache use 1.07x to 1.11x
less memory. See the figures below. Note that I use the `segmented_map` variant
of `ankerl::unordered_dense` instead of the default `map` variant.
The default `map` variant keeps all entries in one vector that doubles as it fills.
When I experimented with the `map` variant on the full 541 MB corpus,
the final doubling of the vectors caused the static cache to use 1.16x more memory than the baseline.
Note that the baseline here is my previous PR where I removed
the unnecessary map copying. The `segmented_map` variant avoids this issue by growing the map in
segments of 4096 bytes allowing for lower peak memory usage.

Drafting latency per drafted token by corpus size. The copy fix takes 1.89, 4.12, 4.42, 4.64, 5.62, and 6.47 Β΅s for corpora of 0, 25, 50, 100, 200, and 541 MB. The PR takes 1.72, 3.94, 4.11, 4.55, 4.96, and 5.81 Β΅s.
Static cache load time by corpus size. The copy fix takes 0.47, 0.85, 1.30, 2.52, and 5.28 s for cor
pptadversary8912
🟧 echo.blog ⭐"I make drafting for prompt lookup decoding in llama.cpp up to 42x faster while using up to 2.6x less memory through a set of simple performHayder Tirmaziβ€”β€”
🟠 reddit42x Faster Prompt Lookup Drafting in llama.cpp
LocalLLaMA
Available_Pressure47646181

Interpretation history

Decision trace