Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
An internal engineering assistant receives one question:
Why did the auth tests fail?
Its permission filter returns four authorized text chunks (small passages) in a local fixture. Only one mentions the expired test fixture. Finding it still requires reading all four chunks. What happens when the same search has to inspect 50,000?
An algorithm is a recipe for completing a task; complexity describes how its work or memory grows as its input grows. We'll keep the four-row search visible while counting the work that a larger corpus would repeat.
What grows when the corpus grows
Before choosing a faster lookup, pin down what can grow. This lesson builds on the SQL permission filter: predicates, optionally reinforced by row-level security (per-row access rules), have already returned only auth project chunks permitted for this request. Four symbols name the inputs that later code will grow with:
| Symbol | Meaning here | Example |
|---|---|---|
n | authorized chunks eligible for ranking | 4, then 8, then 50,000 |
d | numbers in one embedding vector (a numerical representation of text) | 3 in the lab, 768 in the larger example |
k | citations returned to the answer generator | 2 or 5 |
t | tokens in a later prompt preview | 128 or 512 |
A permission filter can shrink n, and a wider embedding increases d. Returning two citations controls k, but it doesn't skip scoring every remaining candidate. That last distinction will matter when we replace a full sort with a heap.
Count a complete scan
Start with an inspectable retriever. It scores a chunk by counting question words that appear in the chunk text. The four rows below stay the running corpus for the rest of the chapter. Before running the code, predict one detail: the question matches one row, but how many rows will the function inspect?
1chunks = [
2 {"id": "auth-fixture", "text": "auth tests fail when fixture expires"},
3 {"id": "ci-timeout", "text": "ci job timeout after slow integration test"},
4 {"id": "cli-flag", "text": "readme documents old command line flag"},
5 {"id": "deploy-runbook", "text": "rollback service after canary error"},
6]
7
8def lexical_scan(question: str, allowed_chunks: list[dict]) -> tuple[list[tuple[int, str]], int]:
9 terms = set(question.lower().split())
10 matches = []
11 checked = 0
12 for chunk in allowed_chunks:
13 checked += 1
14 overlap = len(terms & set(chunk["text"].lower().split()))
15 if overlap:
16 matches.append((overlap, chunk["id"]))
17 return sorted(matches, reverse=True), checked
18
19matches, checked = lexical_scan("auth tests fixture", chunks)
20print("chunks_checked", checked)
21print("best_chunk", matches[0][1])1chunks_checked 4
2best_chunk auth-fixturechunks_checked isn't elapsed time. It's a count of the dominant repeated action: inspecting one candidate chunk. Four authorized chunks produce four inspections; forty thousand produce forty thousand.
Real text work also depends on chunk length. A configured chunk-size limit lets us use candidate count as a useful first model. Without that bound, words read per chunk become another growing input. A counter exposes repeated work; it doesn't measure how quickly a machine performs it.
Find that repeated operation before reaching for an index.
Big-O names the growth shape
Big-O gives an asymptotic upper bound: how a cost can grow as an input gets large, ignoring fixed multipliers and small-input details. The label alone doesn't say whether you're describing a worst case, an average case, or an amortized sequence of operations, so name both the operation and the kind of bound.[1]
Under the bounded-chunk simplification, this scan visits every authorized chunk. Its candidate-inspection work is tight: Θ(n), which also means O(n). Read Θ as "theta": it says the count grows linearly, not merely that linear growth is an upper limit.
Watch the count while repeating the same four rows. Repetition here changes workload size, not the meaning or quality of the documents:
1base_chunks = [
2 {"id": "auth-fixture", "text": "auth fixture expired"},
3 {"id": "ci-timeout", "text": "ci timeout"},
4 {"id": "cli-flag", "text": "old cli flag"},
5 {"id": "deploy-runbook", "text": "canary rollback"},
6]
7
8def count_checks(question: str, allowed_chunks: list[dict]) -> int:
9 terms = set(question.split())
10 checks = 0
11 for chunk in allowed_chunks:
12 checks += 1
13 _ = terms & set(chunk["text"].split())
14 return checks
15
16for copies in (1, 2, 4, 8):
17 corpus = base_chunks * copies
18 print(f"n={len(corpus):>2} checks={count_checks('auth fixture', corpus):>2}")1n= 4 checks= 4
2n= 8 checks= 8
3n=16 checks=16
4n=32 checks=32Doubling n doubles the repeated checks. Building a set of question terms still costs something, but that overhead doesn't grow once per candidate. Big-O points to the piece that becomes dangerous at larger scale.
Don't call the whole assistant "O(n)." Attach the claim to an operation: the candidate-inspection part of this bounded-length scan is Θ(n). The SQL filter, result ordering, network calls, and answer generation have their own costs.
There is another operation hiding in the function: it sorts its r matching chunks before returning them. Here r is the number of matches, and r <= n. The scan is Θ(n), while that ordering step is O(r log r). If every chunk matches, the whole function can be O(n log n). Naming each piece keeps a fast scan from hiding an expensive sort.
An authorized-chunk scan checks 8,000 bounded-length chunks. If the corpus doubles while the algorithm stays unchanged, about how many chunk inspections should you expect?
Answer
About 16,000. The scan is O(n) in candidate chunks, so doubling n doubles the dominant repeated work.
Choose a bound that says what you mean
Three words keep complexity claims honest. They answer a different question from the growth class itself:
| Bound | What it describes | Example in this lesson |
|---|---|---|
| Worst case | The most work allowed for an input of size n | A full sort takes O(n log n) comparisons in the worst case. |
| Average or expected case | Average under a stated input distribution, algorithm randomness, or hash assumption | Set membership is expected O(1) when hashes distribute keys well, but its worst case can be O(n). |
| Amortized | Average cost across a sequence, even when one operation is expensive | A Python list append is amortized O(1); a resize can copy O(n) references once. |
Θ is a tight growth class: both an upper and a lower bound hold, up to fixed multipliers for sufficiently large inputs.[2] The pair loop later performs exactly n * (n - 1) / 2 comparisons, so its loop count is Θ(n²); writing O(n²) alone states only the upper bound.
O is still useful when you promise only an upper bound, but it isn't automatically a worst-case label. Average-case analysis depends on an input distribution, expected-case analysis averages randomness or hash behavior, and amortized analysis accounts for operation history.
Pick a search that matches the data
Once the claim is scoped, choose a lookup that matches the data. An exact key in an unsorted list, an exact key in a sorted list, and semantic similarity are different problems. The Data Structures for AI lesson matched those questions to containers; here the same choices are about work. An approximate-nearest-neighbor (ANN) index changes the candidate path, so its recall needs measurement alongside latency. In the graph row, V counts nodes and E counts links:
| Search need | Required structure or precondition | Work per lookup | Other cost to remember |
|---|---|---|---|
| Exact key in unsorted candidates | None | Θ(n) worst-case scan | An early match can stop sooner |
| Exact key in sorted candidates | Persisted sorted order; binary search halves the remaining interval each step | O(log n) | Sorting costs O(n log n); inserting into a Python list can cost O(n) |
| Exact key in a hash set | Hashable keys and a usable hash distribution | Expected O(1), worst O(n) | Stores Θ(n) keys; hashing a long string still reads its characters |
| Shared prefix of stored strings or token IDs | Trie or radix tree | O(p + 1) expected descent for matched length p | Assumes constant-time child lookup; not semantic search |
| Follow citation or dependency links | Adjacency lists and a visited set | O(V + E) for a full traversal | Following cycles without tracking visited nodes can repeat forever |
| Nearest embedding | Vector representation and a distance rule | Exact scan is Θ(n * d) | ANN indexes change the candidate path, so measure recall as well as latency |
For 50,000 sorted IDs, binary search needs at most 16 interval decisions because . Sorting those IDs on every request would erase the benefit, so sorted order or a hash index must be built and maintained outside the hot path.
Binary search also can't rank embeddings. Semantic similarity doesn't provide one scalar ordering where nearby vectors are guaranteed to sit next to each other. That is a different input and a different index problem.
A hash set is the right tool when you already have the exact ID. It can't answer "which stored prompts start like this one?" That is a prefix problem. The Data Structures lesson built a character trie for token, tool, and top. This chapter counts that work, then compresses the trie into a structure serving engines actually use.
Binary search halves a sorted range
The four authorized chunk IDs are already sorted alphabetically. At index 2 sits cli-flag, so searching for that ID is a lucky one-step hit. On a miss, the ordering tells us which half can still contain the target.
lo is the first possible index and hi is one past the last. The remaining range is therefore [lo, hi). Integer division // 2 chooses a midpoint; moving a boundary discards the part that can't contain the target.
1sorted_ids = ["auth-fixture", "ci-timeout", "cli-flag", "deploy-runbook"]
2
3def binary_search(sorted_ids: list[str], target: str) -> tuple[int | None, int]:
4 lo, hi = 0, len(sorted_ids)
5 steps = 0
6 while lo < hi:
7 mid = (lo + hi) // 2
8 steps += 1
9 if sorted_ids[mid] == target:
10 return mid, steps
11 if sorted_ids[mid] < target:
12 lo = mid + 1
13 else:
14 hi = mid
15 return None, steps
16
17index, steps = binary_search(sorted_ids, "cli-flag")
18print("index", index)
19print("steps", steps)
20print("full_scan_checks", len(sorted_ids))1index 2
2steps 1
3full_scan_checks 4The search finishes in one midpoint check here. An early-stopping linear search would find cli-flag on its third check; the earlier ranking scan would inspect all four. Binary search's worst-case guarantee comes from halving the remaining interval, so its step count stays O(log n) even when n is tens of thousands. A midpoint check may perform both == and <; we're counting visited midpoints, not individual comparison operators.
Exact counts still need care. This half-open implementation can require 17 midpoint checks for 65,536 items because the final one-element interval needs its own comparison. The list has to stay sorted, and a new ID inserted in the middle of a Python list still costs O(n).
Prefix match, tries, and radix trees
Suppose several requests begin with the same system prompt and tool schema. A lookup that rereads that shared prefix for every stored request pays for the same tokens again and again. A trie stores one symbol per edge: lookup of a prefix of length p walks p edges, then enumeration costs whatever the returned completions require. Work to reach the prefix doesn't grow with unrelated keys elsewhere.
Long unique suffixes waste nodes. The words token and tokenize share token; tokenize then adds a chain i -> z -> e that never branches. A radix tree, also called a Patricia trie or compressed prefix tree, collapses each non-branching run into one edge that holds a sequence. Lookup still follows exact symbols, but it compares a whole shared run at once.
That's not radix sort, and it isn't nearest-neighbor search. A radix tree answers a longest exact prefix. An embedding index answers approximate nearby vectors. The names sound close enough to mix up, but the lookup contract is different.

Three stored prompts share the system and tool tokens, then split. The compressed edge stores system + tools once. A new request that starts the same way follows that edge before checking its remaining tokens. Keep that picture in mind while the next program counts the work with token IDs.
Count the difference on toy token IDs. 1, 2 is the shared system-and-tools prefix. 3 is user A. 4, 11 is user B's whole remaining path.
1prompts = [
2 [1, 2, 3, 10],
3 [1, 2, 4, 11],
4 [1, 2, 3, 12],
5]
6query = [1, 2, 3, 13]
7
8def naive_prefix(query: list[int], prompts: list[list[int]]) -> tuple[int, int]:
9 comparisons = 0
10 best = 0
11 for prompt in prompts:
12 shared = 0
13 for left, right in zip(query, prompt):
14 comparisons += 1
15 if left != right:
16 break
17 shared += 1
18 best = max(best, shared)
19 return best, comparisons
20
21# first token -> (edge tokens, child map)
22tree = {
23 1: (
24 (1, 2),
25 {
26 3: ((3,), {10: ((10,), {}), 12: ((12,), {})}),
27 4: ((4, 11), {}),
28 },
29 )
30}
31
32def radix_prefix(query: list[int], node: dict) -> tuple[int, int]:
33 comparisons = 0
34 index = 0
35 current = node
36 while index < len(query):
37 child = current.get(query[index])
38 if child is None:
39 break
40 edge, nxt = child
41 shared = 0
42 mismatch = False
43 for offset in range(min(len(query) - index, len(edge))):
44 comparisons += 1
45 if query[index + offset] != edge[offset]:
46 mismatch = True
47 break
48 shared += 1
49 index += shared
50 if mismatch or shared < len(edge):
51 break
52 current = nxt
53 return index, comparisons
54
55naive_len, naive_cmp = naive_prefix(query, prompts)
56radix_len, radix_cmp = radix_prefix(query, tree)
57print("shared_tokens", naive_len, radix_len)
58print("naive_comparisons", naive_cmp)
59print("radix_comparisons", radix_cmp)1shared_tokens 3 3
2naive_comparisons 11
3radix_comparisons 3The naive scan re-reads 1, 2 on every stored prompt. Three keys make 11 token comparisons. The radix walk compares the shared edge once, then the 3 branch, and stops: 3 explicit token comparisons for the same longest prefix of length 3. It also makes three dictionary lookups, including the failed lookup for 13; those aren't included in the comparison counter.
Indexing query[index + offset] avoids copying the remaining query at each edge. Writing query[index:] inside the loop would create a new list every time. Many short edges could then cause quadratic copying even while the token-comparison counter stayed linear.
Add a thousand other prompts that share only [1, 2] and the scan keeps paying per key. With expected constant-time child lookup, the tree's descent takes O(p + 1) work for p matched tokens. The extra 1 accounts for detecting a mismatch, including an immediate miss. Enumeration after the match is a separate cost, because returning more completions means doing more work.
For token sequences and , the longest common prefix is the greatest number of initial tokens that agree. In this notation, means the first tokens; includes the empty prefix:
Matching tokens can make previously computed model state reusable, but only when the model and other inputs that produced that state also match. The unmatched suffix still needs processing; a prefix hit doesn't make the whole request free.
Serving uses the same idea on token IDs. A prefix cache can reuse the prompt-processing work, called prefill, for a compatible cached prefix. SGLang's RadixAttention organizes token prefixes in a radix tree.[3] vLLM instead hashes full blocks of cached model state. Its keys include preceding blocks and additional identity such as adapter or multimodal inputs, and block boundaries limit the reused length. Exact token agreement is necessary, but doesn't by itself establish safe reuse across models or cache scopes.[4]
You don't need to implement the cache here. Keep its algorithm test: if the question is longest exact prefix, count work in p. If the question is nearby meaning, you're back in the scoring column.
Three cached prompts each share the first 6 tokens with a new request of length 8, then diverge. About how many token comparisons does a naive per-key scan do, and what does a radix-tree walk scale with?
Answer
The scan compares the 6 shared tokens plus the first mismatch on each of the 3 keys, about 21 comparisons. A radix-tree descent with first-token child lookup scales with prefix length p, not with how many other keys share an earlier edge.
Vector scoring adds another input
Exact prefix matching can't answer a nearby-meaning question. Semantic retrieval commonly represents the question and each chunk as embedding vectors, then computes a score. The NumPy and tensor shapes lesson already treated a vector as coordinates and a dot product as paired multiplies plus adds. Here we count those multiplies in plain Python so the new input stays visible.
Each exact dot-product score inspects d coordinates. When both vectors have length one, their dot product equals cosine similarity. The lab vectors below aren't normalized that way, so treat this as a raw dot product. Scoring all n allowed chunks performs work proportional to n * d.
Before running the code, predict the counter rather than the winner: four vectors with three coordinates each require how many coordinate multiplications, even when two query coordinates are zero?
1chunk_ids = ["auth-fixture", "ci-timeout", "cli-flag", "deploy-runbook"]
2chunk_vectors = [
3 [1.0, 0.2, 0.1],
4 [0.0, 1.0, 0.2],
5 [0.8, 0.1, 0.4],
6 [0.1, 0.2, 1.0],
7]
8question = [1.0, 0.0, 0.0]
9
10def dot(left: list[float], right: list[float]) -> tuple[float, int]:
11 score = 0.0
12 multiplies = 0
13 for a, b in zip(left, right, strict=True):
14 score += a * b
15 multiplies += 1
16 return score, multiplies
17
18scores = []
19coordinate_multiplies = 0
20for vector in chunk_vectors:
21 score, used = dot(question, vector)
22 scores.append(score)
23 coordinate_multiplies += used
24
25best = max(range(len(scores)), key=lambda i: scores[i])
26print("best_chunk", chunk_ids[best])
27print("scores", [round(s, 1) for s in scores])
28print("coordinate_multiplies", coordinate_multiplies)1best_chunk auth-fixture
2scores [1.0, 0.0, 0.8, 0.1]
3coordinate_multiplies 12The question vector is [1.0, 0.0, 0.0], so only the first coordinate of each chunk contributes to each sum. The other multiplies still happen; they add zero. zip(..., strict=True) pairs coordinates and raises ValueError if the lengths differ. max(..., key=lambda i: scores[i]) returns the index with the largest score. A dense matrix multiply performs the same coordinate products, often much faster than a Python loop.
Even though only coordinate 0 of the question vector is nonzero, this dense loop still performs all 12 multiplications (4 chunks 3 coordinates). A dense matrix operation doesn't automatically exploit arbitrary zero values. Sparse representations and specialized kernels can change the work, but they require a different execution path.
With 50,000 chunks and 768 coordinates, the same pass uses 38,400,000 multiplications before it chooses citations. The exact score pass is Θ(n * d) in time.
Storage has its own ledger. Those float32 vectors take 50,000 * 768 * 4 = 153,600,000 bytes, about 146.5 MiB: Θ(n * d) input memory, separate from temporary workspace. The build-it function later materializes its allowed rows, adding O(n) temporary references; a streaming exact scan can keep only the query vector and running winners in extra memory.
Return top-k without sorting every result
The assistant will cite only k = 2 chunks. A first version can score all candidates and fully sort every result. Before optimizing, notice the mismatch: the system needs two winners, but the sort orders every loser too. In product code, treat k <= 0 as an empty result so heap logic never reads from an empty list.
1scores = [
2 (1.0, "auth-fixture"),
3 (0.0, "ci-timeout"),
4 (0.8, "cli-flag"),
5 (0.1, "deploy-runbook"),
6]
7
8top_two = sorted(scores, reverse=True)[:2]
9print("top_two", top_two)
10print("fully_reordered_scores", len(scores))1top_two [(1.0, 'auth-fixture'), (0.8, 'cli-flag')]
2fully_reordered_scores 4This is correct. It also orders two losers that will never be shown. Full sorting costs O(n log n) comparisons in the worst case and materializes all n scored results. A request that needs only a small k shouldn't pay to order every loser. What smaller structure can keep the weakest winner easy to replace?
In Data Structures for AI you already used a min-heap so the weakest retained result sits at the root. A bounded min-heap stores only the top-k values seen so far. A stronger candidate can replace the current weakest winner. Repairing the heap follows one path through a tree with k entries. That path has at most about log2(k) parent-child steps: three for k = 8, ten for k = 1,024.
Python's heapq helpers implement that structure in the lab. Tuples compare left to right, so (score, id) breaks a score tie using the ID: this code favors the lexicographically larger string. Change the key if your requirement differs; for integer IDs, (score, -doc_id) makes smaller IDs win ties.
For 2 <= k <= n, maintaining the heap costs O(n log k) after scoring, with O(k) selection memory. Sorting the final winners adds O(k log k), still within that bound. For k = 1, one running maximum gives O(n) selection; for k > n, there are only n items to retain.[5]
The next function accepts the same scored rows and keeps at most two winners. Compare its result with the full sort:
1from heapq import heappush, heapreplace
2
3scores = [
4 (1.0, "auth-fixture"),
5 (0.0, "ci-timeout"),
6 (0.8, "cli-flag"),
7 (0.1, "deploy-runbook"),
8]
9
10def bounded_top_k(items: list[tuple[float, str]], k: int) -> list[tuple[float, str]]:
11 if k <= 0:
12 return []
13 heap: list[tuple[float, str]] = []
14 for item in items:
15 if len(heap) < k:
16 heappush(heap, item)
17 elif item > heap[0]:
18 heapreplace(heap, item)
19 return sorted(heap, reverse=True)
20
21top_two = bounded_top_k(scores, k=2)
22print("top_two", top_two)
23print("selection_values_stored", len(top_two))
24assert bounded_top_k(scores, k=0) == []1top_two [(1.0, 'auth-fixture'), (0.8, 'cli-flag')]
2selection_values_stored 2The heap improves selection, not exact vector scoring. You still paid O(n * d) to produce the four scores above, and you'd still pay 38.4 million coordinate products at n = 50,000, d = 768. A faster tail step can't fix an expensive upstream step.
You replace a full sort with a top-10 heap, but still score 50,000 vectors of dimension 768. Which cost remains O(n * d)?
Answer
The exact vector-scoring pass. The heap reduces selection to O(n log k) and O(k) retained values, but it doesn't remove the 38.4 million coordinate products.
Keep separate time and memory ledgers
The same algorithm can have one growth rate for work and another for storage. Before changing code, label each number as input memory, output memory, or temporary workspace:
| Operation | Time | Memory to account for |
|---|---|---|
| Exact vector score pass | Θ(n * d) | Resident vector matrix is Θ(n * d); a streamed pass needs O(d + k) extra for the query and winners |
| Full sort of scored results | O(n log n) worst case | The scored-result list is Θ(n); sorting may need additional implementation workspace |
| Binary search in a sorted ID list | O(log n) comparisons | The sorted list is Θ(n); the search itself uses O(1) extra |
| Radix-tree longest-prefix match | O(p + 1) expected descent with constant-time child lookup | Shared prefixes occupy shared edges; the walk uses O(1) extra |
Bounded top-k selection, 2 <= k <= n | O(n log k) worst case, or Θ(n) when k is fixed | Heap holds Θ(k) winners |
| Pairwise comparison without storing pairs | Θ(n²) | O(1) extra; materializing every pair would require Θ(n²) space too |
A heap can cut selection memory from Θ(n) to Θ(k), but it can't shrink a vector matrix that the service keeps resident. Likewise, changing a nested loop to stream its counter can save memory while leaving its Θ(n²) time unchanged. Time and memory need separate fixes.
When an exact dot-product scan blows past your compute budget at scale, the algorithmic remedy isn't to sort faster: it's to change the candidate-search path by using an Approximate Nearest Neighbor (ANN) index such as Hierarchical Navigable Small World (HNSW).

Spot a quadratic trap
Linear work doubles when the input doubles. Quadratic work grows far faster because each item is compared with many other items. The quickest way to spot it: ask whether the inner loop's range grows with the outer loop.
A tempting cleanup step might compare every retrieved chunk against every other retrieved chunk to remove near-duplicates. For n chunks it evaluates n * (n - 1) / 2 pairs, which is Θ(n²). Before running the counter, estimate the jump from 16 to 1,000 chunks; the exact output makes the trap hard to ignore.
1def pair_comparisons(n: int) -> int:
2 comparisons = 0
3 for left in range(n):
4 for right in range(left + 1, n):
5 comparisons += 1
6 return comparisons
7
8for n in (4, 8, 16, 1_000):
9 print(f"n={n:>4} pairs={pair_comparisons(n):>6}")1n= 4 pairs= 6
2n= 8 pairs= 28
3n= 16 pairs= 120
4n=1000 pairs=499500At n = 1,000, a request-time pairwise pass makes 499,500 comparisons. That's a concrete failure case: a harmless-looking loop can consume the latency budget before generation begins. The count came from the changing inner range, not from the fact that the code has two for statements.
Two nested loops aren't automatically quadratic. If the inner loop visits exactly 32 tokens for each chunk, the count is 32 * n, which is Θ(n) because 32 doesn't grow with n. Count the iterations, not the indentation.
If the requirement is only exact duplicate removal, a set changes the operation. It remembers texts already seen instead of comparing every pair. That is a change in the requirement, not a magic rewrite of semantic similarity.
1candidate_texts = [
2 "auth fixture expiry",
3 "auth test failure",
4 "auth fixture expiry",
5 "ci timeout retry",
6 "auth test failure",
7]
8
9seen = set()
10unique = []
11membership_checks = 0
12for text in candidate_texts:
13 membership_checks += 1
14 if text not in seen:
15 seen.add(text)
16 unique.append(text)
17
18print("unique_chunks", len(unique))
19print("membership_checks", membership_checks)1unique_chunks 3
2membership_checks 5This path performs exactly n membership checks. Under normal hash-table behavior, those checks take expected O(n) total time because each is expected O(1); collisions can make a single check O(n). Hashing still reads each text, so unbounded string length remains part of the real cost.
It doesn't solve semantic near-duplicate detection. Requirements determine the algorithm: don't silently replace "similar meaning" with "identical string" just to obtain a better complexity label.
The same square shape appears later in attention
The same counting habit will reappear in Transformer internals. For now, carry one preview: scaled dot-product attention computes a score from every query position against every key position. Vaswani et al. write that matrix as , so a self-attention sequence with t token positions has t * t score cells.[6]
You don't need the rest of the model yet. If the prompt doubles from 256 to 512 tokens, predict the cell count before looking at the program.
1def score_matrix_size(tokens: int) -> tuple[int, float]:
2 cells = tokens * tokens
3 mib_if_float32 = cells * 4 / (1024 * 1024)
4 return cells, mib_if_float32
5
6for tokens in (128, 256, 512, 1_024):
7 cells, mib = score_matrix_size(tokens)
8 print(f"tokens={tokens:>4} cells={cells:>7} raw_matrix_mib={mib:>4.2f}")1tokens= 128 cells= 16384 raw_matrix_mib=0.06
2tokens= 256 cells= 65536 raw_matrix_mib=0.25
3tokens= 512 cells= 262144 raw_matrix_mib=1.00
4tokens=1024 cells=1048576 raw_matrix_mib=4.00Doubling tokens quadruples cells. Treat this as a per-layer, per-head order of magnitude for a dense float32 score grid for one sequence during prefill, before multi-head batching, fused kernels, or KV-cache decode optimizations.
If each query and key has coordinates (the per-head width in later chapters), forming that grid also performs multiply-accumulate work per head, while the score grid itself takes values. This doesn't model a complete language model or serving memory yet. It transfers the algorithm skill: when you see an all-pairs matrix, ask whether an input is being squared.
A dense attention score grid grows from 256 to 512 token positions. How does its cell count change?
Answer
It grows from 256^2 to 512^2, so the grid has four times as many cells. This is the O(t^2) all-pairs warning sign.
Prefill versus decode: two computational regimes
An autoregressive Transformer has two useful phases to count. The dense-attention costs below are per head, with head width ; they don't include projections, feed-forward layers, or the rest of the serving request:
- Prefill (prompt evaluation): Process the prompt's positions together, possibly in chunks. Causal attention lets each position attend to itself and earlier positions. Dense attention work is per head, up to the causal constant factor. Matrix-matrix operations can reuse loaded values well, so large prefills often become compute-bound. Prefill contributes to time to first token (TTFT), which also includes queueing, preprocessing, and other work.
- Decode (token generation): For one sequence, a step adds one query against a cached history of length . Its attention work is per head. Small decode batches often become bandwidth-bound while reading model weights and KV state. Larger batches, kernel choices, and hardware can change the bottleneck. Decode contributes to inter-token latency (ITL); batching and scheduling also affect the gaps observed by a client.
Neither phase has one universal FLOPs-per-byte value. A batched decode also combines work across sequences, so it needn't execute as one literal vector-matrix kernel. Fused attention such as FlashAttention can avoid storing the full score grid in device memory while retaining dense attention's quadratic arithmetic.[7]

Attention compute scaling versus KV cache memory
The KV cache stores past key and value activations so decode doesn't recompute them at every step. One dense-attention decode step is linear in cached length, but the cache grows as generation continues. Across generated tokens after a prompt of length , attending to the growing history totals work per head. Caching saves recomputation; it doesn't make an arbitrarily long continuation linear in total output length.
For Llama 3 8B's 32 layers, 8 Grouped-Query Attention (GQA) key-value heads, and head dimension , assume a 16-bit KV cache (2 bytes per element).[8] Each stored token then requires:
At a concurrent batch size of streams, context length directly dictates your serving memory budget:
- At 1,024 tokens per stream: .
- At 4,096 tokens per stream: .
- At 8,192 tokens per stream: .
These counts include raw KV values only, with equal-length streams and no prefix sharing. Model weights, temporary workspace, cache metadata, and unused space in partially filled blocks consume additional memory. Cache quantization or sharing changes the count.

Measure scale and install a budget guard
Big-O tells you the shape, not whether today's workload meets a service objective. Count expected work to spot dangerous growth, then benchmark the real implementation on representative data and hardware. A guard can reject or reroute requests whose predicted work exceeds the supported budget.
The first guard uses a multiply count, not a made-up millisecond conversion. That keeps the example honest: hardware and kernels determine how many milliseconds those operations take.
1def exact_score_work(candidate_chunks: int, embedding_dim: int) -> int:
2 return candidate_chunks * embedding_dim
3
4def within_budget(candidate_chunks: int, embedding_dim: int, max_multiplies: int) -> bool:
5 return exact_score_work(candidate_chunks, embedding_dim) <= max_multiplies
6
7budget = 1_000_000
8for candidates in (500, 2_000, 50_000):
9 work = exact_score_work(candidates, embedding_dim=768)
10 print(candidates, work, within_budget(candidates, 768, budget))1500 384000 True
22000 1536000 False
350000 38400000 False
The rejected case doesn't justify weakening authorization or returning arbitrary evidence. Exact ranking over that many authorized candidates no longer fits this budget. Possible next experiments are a stricter legitimate filter, an offline-created index, or an approximate nearest-neighbor index evaluated for recall.
A later option is HNSW, a hierarchical proximity-graph approach to approximate nearest-neighbor search. It trades exact scoring of every vector for graph-guided candidate exploration. The original paper reports logarithmic scaling under its construction assumptions; treat that as a reason to measure recall and latency together, not as a free O(log n) replacement for the exact baseline.[9] That belongs in Vector DB Internals, not in this foundation exercise.
Guarding tail latency in production
Counting algorithmic operations gives you the asymptotic shape, but enforcing wall-clock budgets in production requires tracking where runtime time disappears:
- Look beyond the median: The mean and p50 are different statistics, and neither describes the slowest requests. An invented service could have a 20ms median and an 800ms p99. Measure the percentiles and deadlines your users need, then include queue time as well as computation in the budget.
- Profile Python collection: CPython uses reference counting plus a cyclic garbage collector. Collection can contribute pauses, but their duration depends on the object graph and runtime. Measure collection activity before changing it.
gc.disable()stops automatic cyclic collection; reference counting continues, and unreachable cycles can accumulate without a deliberate collection policy.[10] - Bound batch formation time: A rule that waits for exactly eight arrivals can delay a low-traffic request indefinitely. Ray Serve's
batch_wait_timeout_slimits how long it tries to fill a batch after the first request; Triton has its ownmax_queue_delay_microsecondssetting. These settings don't cap total request latency when execution is already busy.[11][12] - Measure preprocessing separately: Tokenization or cleaning can leave an accelerator waiting for input. Worker pools and overlapping preparation with execution can help, but they add scheduling, memory, and transfer costs. Compare the measured pipeline before and after rather than assuming more workers keep the GPU busy.
A separate architecture is retrieve, then refine: an inexpensive first-stage score proposes a shortlist, then a slower scorer or cross-encoder ranks only those survivors. A one-stage top-k heap isn't this pattern: it keeps the best rows under one score and returns those same scores. Refinement requires a second scoring function, and the shortlist must preserve authorization and enough recall for the final answer.
The fixture below makes that second stage visible using precomputed scores, not real model calls. Its rows have already passed the permission filter. The first stage reads four cheap scores; the second reads richer scores for only three shortlisted rows. A real reranker would compute those richer scores on demand. Here the counters track score reads so you can inspect which rows reach each stage.
1from heapq import nlargest
2
3authorized_rows = [
4 {"id": "auth-fixture", "cheap_score": 0.91, "refine_score": 0.80},
5 {"id": "ci-timeout", "cheap_score": 0.75, "refine_score": 0.55},
6 {"id": "cli-flag", "cheap_score": 0.88, "refine_score": 0.95},
7 {"id": "deploy-runbook", "cheap_score": 0.10, "refine_score": 0.20},
8]
9
10def retrieve_then_refine(
11 rows: list[dict],
12 shortlist_size: int,
13 result_size: int,
14) -> tuple[list[str], int, int]:
15 shortlist = nlargest(
16 shortlist_size,
17 rows,
18 key=lambda row: (row["cheap_score"], row["id"]),
19 )
20 refined = sorted(
21 ((row["refine_score"], row["id"]) for row in shortlist),
22 reverse=True,
23 )[:result_size]
24 return [row_id for _, row_id in refined], len(rows), len(shortlist)
25
26citations, candidate_scored, refinement_scored = retrieve_then_refine(
27 authorized_rows,
28 shortlist_size=3,
29 result_size=2,
30)
31print("candidate_stage_scored", candidate_scored)
32print("refinement_stage_scored", refinement_scored)
33print("citations", citations)
34assert citations == ["cli-flag", "auth-fixture"]
35assert candidate_scored == 4
36assert refinement_scored == 31candidate_stage_scored 4
2refinement_stage_scored 3
3citations ['cli-flag', 'auth-fixture']This is two-stage retrieval: the cheap score chooses candidates, and the richer score changes their order. If the first stage drops a relevant row, the second stage can't recover it, so measure shortlist recall before celebrating lower refinement cost.
A top-k heap keeps the strongest five rows under one embedding score. Has it performed retrieve-then-refine?
Answer
No. It performed one-stage scoring and selection. Retrieve-then-refine needs a distinct second scorer that runs only on the shortlist; a heap can select that shortlist, but it doesn't create the second stage.
Build it: an authorized exact top-k retriever
Now assemble an exact baseline with a guard that runs before vector scoring. This permission fixture contains three allowed auth rows, one private auth row, and one billing row. Only the allowed rows contribute to the scoring budget:
- Authorization happens first.
- Every authorized vector is scored exactly.
- A bounded heap retains only
kcitations. - A preflight count rejects work above the configured multiply budget.
1from heapq import heappush, heapreplace
2
3rows = [
4 {"id": "auth-fixture", "project": "auth", "principals": {"engineer:u3"}, "vector": [1.0, 0.0, 0.1]},
5 {"id": "auth-ci-timeout", "project": "auth", "principals": {"engineer:u3"}, "vector": [0.2, 1.0, 0.1]},
6 {"id": "auth-cli-flag", "project": "auth", "principals": {"engineer:u3"}, "vector": [0.8, 0.1, 0.4]},
7 {"id": "auth-secrets-private", "project": "auth", "principals": {"engineer:u1"}, "vector": [0.0, 0.3, 1.0]},
8 {"id": "billing-auth-fixture", "project": "billing", "principals": {"engineer:u3"}, "vector": [1.0, 0.0, 0.0]},
9]
10
11def dot(left: list[float], right: list[float]) -> float:
12 return sum(a * b for a, b in zip(left, right, strict=True))
13
14def authorized_exact_top_k(
15 question: list[float],
16 all_rows: list[dict],
17 project: str,
18 principal: str,
19 k: int,
20 max_multiplies: int = 1_000_000,
21) -> tuple[list[str], int]:
22 if k <= 0:
23 return [], 0
24 allowed = [
25 row
26 for row in all_rows
27 if row["project"] == project and principal in row["principals"]
28 ]
29 required_work = len(allowed) * len(question)
30 if required_work > max_multiplies:
31 raise ValueError(
32 f"exact scoring needs {required_work} multiplies; budget is {max_multiplies}"
33 )
34 heap: list[tuple[float, str]] = []
35 coordinate_multiplies = 0
36 for row in allowed:
37 score = dot(question, row["vector"])
38 coordinate_multiplies += len(question)
39 item = (score, row["id"])
40 if len(heap) < k:
41 heappush(heap, item)
42 elif item > heap[0]:
43 heapreplace(heap, item)
44 ranked = sorted(heap, reverse=True)
45 return [chunk_id for _, chunk_id in ranked], coordinate_multiplies
46
47citations, work = authorized_exact_top_k(
48 [1.0, 0.0, 0.0],
49 rows,
50 "auth",
51 "engineer:u3",
52 k=2,
53)
54print("citations", citations)
55print("coordinate_multiplies", work)
56assert "auth-secrets-private" not in citations
57assert "billing-auth-fixture" not in citations
58assert work == 9
59assert authorized_exact_top_k(
60 [1.0, 0.0, 0.0],
61 rows,
62 "auth",
63 "engineer:u3",
64 k=0,
65) == ([], 0)
66
67try:
68 authorized_exact_top_k(
69 [1.0, 0.0, 0.0], rows, "auth", "engineer:u3", k=2, max_multiplies=8
70 )
71except ValueError as error:
72 print("rejected:", error)
73else:
74 raise AssertionError("over-budget scoring should have been rejected")1citations ['auth-fixture', 'auth-cli-flag']
2coordinate_multiplies 9
3rejected: exact scoring needs 9 multiplies; budget is 8The first call scores three allowed vectors and returns two citations. The second call has room for only eight multiplies, so it fails before performing any dot product. Authorization still scans the input rows, and the guard doesn't bound that work or elapsed time. It protects one named stage.
For this lab, vectors contain finite numbers and must have the query's dimension. zip(..., strict=True) detects a mismatched dimension instead of silently dropping coordinates. Compare any optimized retriever against this exact ranking on the same allowed rows, with the same tie rule.
Check the growth by hand
Work these exercises without code first. Write down the growing input, the operation being counted, and the expected result before checking the sketches.
- A project has
n = 2,500authorized chunks, each withd = 384coordinates. How many coordinate multiplications does an exact dot-product scan perform? - A full sort orders 10,000 scored results even though the assistant returns
k = 5. Which part does a bounded heap reduce, and which part remains unchanged? - A near-duplicate cleanup compares each of 200 chunks with every later chunk. How many comparisons occur?
- An attention preview changes prompt length from 256 tokens to 1,024 tokens. By what factor does the number of score cells grow?
- Your guard allows one million coordinate multiplications. Does exact scoring fit for
n = 4,000,d = 256? - Three cached prompts share the first 6 tokens with a new 8-token request, then diverge. About how many token comparisons does a naive per-key scan do if each key stops at the first mismatch?
- A sorted catalog has 65,536 chunk IDs. What is the worst-case number of midpoint checks for binary search?
Solution sketches
2,500 * 384 = 960,000coordinate multiplications.- The heap reduces selecting the best five values from full-sort work to bounded top-k work. It doesn't remove the exact score pass that produced all 10,000 values.
200 * 199 / 2 = 19,900comparisons.- Token length grows by
1,024 / 256 = 4, so square score-cell count grows by4 * 4 = 16. - No.
4,000 * 256 = 1,024,000, which exceeds the one-million budget. - Each of the 3 keys compares 6 matches plus 1 mismatch, so about 21 comparisons. A radix-tree walk would scale with the prefix length, not with
n. - At most 17 midpoint checks. Although , this half-open binary search can spend a seventeenth comparison checking the final one-item interval. For example, searching for the first ID visits 17 midpoints.
Which input grows which cost
The recap table puts one final label on each operation: which input grows, what shape follows, and what an engineer should question first.
| Operation | Input that grows | Work shape | First engineering response |
|---|---|---|---|
| Scan authorized text chunks | n chunks | O(n) | Count candidates after permission filtering |
| Binary search a sorted ID list | n sorted keys | O(log n) | Keep the list sorted off the hot path |
| Longest exact prefix | matched length p | O(p + 1) expected radix descent | Count child lookups as well as token comparisons |
| Score exact embedding vectors | n chunks, d coordinates | O(n * d) | Measure score work before choosing an index |
Select best k scored chunks | n scores, 2 <= k <= n | O(n log k) with a heap | Don't fully sort discarded results |
| Compare every candidate pair | n chunks | O(n^2) | Challenge whether all-pairs work is required |
| Preview an attention score grid | t tokens | O(t^2) cells (per layer/head order of magnitude; prefill, before KV-cache decode) | Notice square growth before later model lessons |
Three rules carry into every later AI system chapter:
- Name the operation and its growing inputs before stating a complexity.
- Keep a smallest-correct baseline so optimization can be checked for correctness.
- Couple latency work with product requirements: authorization, citation quality, and recall don't disappear when a faster algorithm arrives.