Skip to content
HN On Hacker News ↗

42x Faster Prompt Lookup Drafting in llama.cpp

▲ 89 points • 12 comments • by pptadversary • 2w ago • HN discussion ↗

Pangram verdict · v3.3

We believe this text is mainly human-written, with some AI content.

6 %

AI likelihood · overall

Human
98% human-written 2% AI-generated
SEGMENTS · HUMAN 1 of 1
SEGMENTS · AI 0 of 1
WORD COUNT 1,650
PEAK AI % 2% · §1
Analyzed
Sep 27
backend: pangram/v3.3
Segments scanned
1 windows
avg 1650 words each
Distribution
98 / 2%
human / AI fraction
Verdict
Human
Pangram v3.3

Article text · 1,650 words · 1 segments analyzed

Human AI-generated
§1 Human · 2%

42x faster prompt lookup drafting in llama.cpp Hayder Tirmazi [homepage] [github] [twitter] 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 and Martin Ankerl. Update: Daniel Lemire sent in a PR that makes prompt lookup drafting upto 4.2x faster on top of my original optimizations. His work makes the overall speedup upto 140x. I discuss more below. Many popular inference engines including llama.cpp and vllm, and machine learning libraries such as hugging face's transformers library, support prompt lookup decoding (also called n-gram speculation) 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. 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 and then I replay the WikiText-103 test text through llama-lookup-stats. I borrowed this evaluation method from the PR by @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. Stop Copying Maps The n-gram caches in llama.cpp are currently implemented as nested std::unordered_maps. 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 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. 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 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 (which were also recently added to Golang) and Martin Ankerl's unordered_dense maps. 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. 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.