Skip to content
Skip to lesson
Back to Topics
LearnComputing FoundationsAlgorithms for ML Engineers
⚙️EasyMLOps & Deployment

Algorithms for ML Engineers

When four authorized chunks become 50,000, count which operation repeats before you choose a scan, heap, prefix tree, or latency guard.

29 min read
Learning path
Step 9 of 199 in the full curriculum

Personalize this lesson

Adapt explanations and teaching visuals to your background and preferred voice.

Suppose you ask an internal engineering assistant:

Why did the auth tests fail?

Its permission filter returns four authorized text chunks (small passages) in a local . Only one mentions an expired test setup, so finding it means inspecting all four. That is a reasonable place to start. What changes when the same search has 50,000 candidates?

That scan works. The question is how much work it repeats as the corpus grows. 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 in view while counting the work a larger corpus would repeat.

What grows when the corpus grows

Before comparing lookups, let's be clear about what each one receives. The SQL permission filter, optionally reinforced by row-level security (per-row access rules), has already returned only auth project chunks permitted for this request. Four symbols name the inputs that later code will grow with:

SymbolMeaning hereExample
nauthorized chunks eligible for ranking4, then 8, then 50,000
dnumbers in one vector (a numerical representation of text)3 in the lab, 768 in the larger example
kcitations returned to the answer generator2 or 5
ttokens in a later prompt preview128 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

Let's start with a retriever we can inspect. It scores a chunk by counting question words that appear in its text. The four rows below stay with us throughout the chapter. Before you run the code, make a prediction: the question matches one row, but how many rows will the function inspect?

count-one-scan.py
chunks = [
    {"id": "auth-fixture", "text": "auth tests fail when fixture expires"},
    {"id": "ci-timeout", "text": "ci job timeout after slow integration test"},
    {"id": "cli-flag", "text": "readme documents old command line flag"},
    {"id": "deploy-runbook", "text": "rollback service after canary error"},
]

def lexical_scan(question: str, allowed_chunks: list[dict]) -> tuple[list[tuple[int, str]], int]:
    terms = set(question.lower().split())
    matches = []
    checked = 0
    for chunk in allowed_chunks:
        checked += 1
        overlap = len(terms & set(chunk["text"].lower().split()))
        if overlap:
            matches.append((overlap, chunk["id"]))
    return sorted(matches, reverse=True), checked

matches, checked = lexical_scan("auth tests fixture", chunks)
print("chunks_checked", checked)
print("best_chunk", matches[0][1])
Output
chunks_checked 4
best_chunk auth-fixture

chunks_checked isn't elapsed time. It makes one repeated action visible: inspecting a candidate chunk. Four authorized chunks mean four inspections; forty thousand mean forty thousand. We can now see exactly what grows when this scan handles a larger corpus.

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

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:

observe-linear-growth.py
base_chunks = [
    {"id": "auth-fixture", "text": "auth fixture expired"},
    {"id": "ci-timeout", "text": "ci timeout"},
    {"id": "cli-flag", "text": "old cli flag"},
    {"id": "deploy-runbook", "text": "canary rollback"},
]

def count_checks(question: str, allowed_chunks: list[dict]) -> int:
    terms = set(question.split())
    checks = 0
    for chunk in allowed_chunks:
        checks += 1
        _ = terms & set(chunk["text"].split())
    return checks

for copies in (1, 2, 4, 8):
    corpus = base_chunks * copies
    print(f"n={len(corpus):>2} checks={count_checks('auth fixture', corpus):>2}")
Output
n= 4 checks= 4
n= 8 checks= 8
n=16 checks=16
n=32 checks=32

Doubling 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 helps us keep our attention on the part that can become expensive at scale.

It would be misleading to call the whole assistant "O(n)." Keep the claim attached to one operation: the candidate-inspection part of this bounded-length scan is Θ(n). The SQL filter, result ordering, network calls, and answer generation each have their own costs.

There's another operation inside 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 sorting is O(r log r). If every chunk matches, the full function can be O(n log n). Looking at the steps separately keeps the scan from hiding an expensive sort.

Choose a bound that says what you mean

The growth class tells us how work scales. These three labels answer a different question: what kind of bound are we describing?

BoundWhat it describesExample in this lesson
Worst caseThe most work allowed for an input of size nA full sort takes O(n log n) comparisons in the worst case.
Average or expected caseAverage under a stated input distribution, algorithm randomness, or hash assumptionSet membership is expected O(1) when hashes distribute keys well, but its worst case can be O(n).
AmortizedAverage cost across a sequence, even when one operation is expensiveA 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.

A correct label still doesn't choose the lookup. The scan we counted grows with every authorized chunk. When does the data let a cheaper search win?

Pick a search that matches the data

A cheaper lookup wins only when you already have the structure that search needs. Finding an exact key in an unsorted list, finding one in a sorted list, and finding similar meaning are different jobs. The Data Structures for AI lesson matched those jobs to containers. Here we'll compare the work they require. An approximate-nearest-neighbor (ANN) index changes which candidates we inspect, so measure its recall alongside latency. In the graph row, V counts nodes and E counts links:

Search needRequired structure or preconditionWork per lookupOther cost to remember
Exact key in unsorted candidatesNoneΘ(n) worst-case scanAn early match can stop sooner
Exact key in sorted candidatesPersisted sorted order; binary search halves the remaining interval each stepO(log n)Sorting costs O(n log n); inserting into a Python list can cost O(n)
Exact key in a hash setHashable keys and a usable hash distributionExpected O(1), worst O(n)Stores Θ(n) keys; hashing a long string still reads its characters
Shared prefix of stored strings or token IDsTrie or O(p + 1) expected descent for matched length pAssumes constant-time child lookup; not semantic search
Follow citation or dependency linksAdjacency lists and a visited setO(V + E) for a full traversalFollowing cycles without tracking visited nodes can repeat forever
Nearest embedding representation and a distance ruleExact 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 216=65,5362^{16} = 65{,}536. 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. cli-flag happens to sit at the midpoint, so this search gets a lucky one-step hit. On a miss, the ordering still tells us which half could 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.

count-binary-search-steps.py
sorted_ids = ["auth-fixture", "ci-timeout", "cli-flag", "deploy-runbook"]

def binary_search(sorted_ids: list[str], target: str) -> tuple[int | None, int]:
    lo, hi = 0, len(sorted_ids)
    steps = 0
    while lo < hi:
        mid = (lo + hi) // 2
        steps += 1
        if sorted_ids[mid] == target:
            return mid, steps
        if sorted_ids[mid] < target:
            lo = mid + 1
        else:
            hi = mid
    return None, steps

index, steps = binary_search(sorted_ids, "cli-flag")
print("index", index)
print("steps", steps)
print("full_scan_checks", len(sorted_ids))
Output
index 2
steps 1
full_scan_checks 4

The 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

Imagine several requests that begin with the same system prompt and tool schema. If a lookup rereads that shared prefix for every stored request, it pays for those tokens again and again. A stores one symbol per edge: lookup of a prefix of length p walks p edges, then enumeration costs whatever the returned completions require. Reaching the prefix doesn't get more expensive just because unrelated keys were added 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 , 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.

The names are easy to mix up, so keep the jobs in view. A radix tree answers a longest exact prefix. An embedding index finds approximate nearby vectors. Radix sort is a third, separate algorithm.

Diagram showing root, 1, 2: system + tools, shared prefix, and 3: user A.
root, 1, 2: system + tools, shared prefix, and 3: user A.

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.

count-prefix-comparisons.py
prompts = [
    [1, 2, 3, 10],
    [1, 2, 4, 11],
    [1, 2, 3, 12],
]
query = [1, 2, 3, 13]

def naive_prefix(query: list[int], prompts: list[list[int]]) -> tuple[int, int]:
    comparisons = 0
    best = 0
    for prompt in prompts:
        shared = 0
        for left, right in zip(query, prompt):
            comparisons += 1
            if left != right:
                break
            shared += 1
        best = max(best, shared)
    return best, comparisons

# first token -> (edge tokens, child map)
tree = {
    1: (
        (1, 2),
        {
            3: ((3,), {10: ((10,), {}), 12: ((12,), {})}),
            4: ((4, 11), {}),
        },
    )
}

def radix_prefix(query: list[int], node: dict) -> tuple[int, int]:
    comparisons = 0
    index = 0
    current = node
    while index < len(query):
        child = current.get(query[index])
        if child is None:
            break
        edge, nxt = child
        shared = 0
        mismatch = False
        for offset in range(min(len(query) - index, len(edge))):
            comparisons += 1
            if query[index + offset] != edge[offset]:
                mismatch = True
                break
            shared += 1
        index += shared
        if mismatch or shared < len(edge):
            break
        current = nxt
    return index, comparisons

naive_len, naive_cmp = naive_prefix(query, prompts)
radix_len, radix_cmp = radix_prefix(query, tree)
print("shared_tokens", naive_len, radix_len)
print("naive_comparisons", naive_cmp)
print("radix_comparisons", radix_cmp)
Output
shared_tokens 3 3
naive_comparisons 11
radix_comparisons 3

The naive scan rereads 1, 2 for every stored prompt, so three keys take 11 token comparisons. The radix walk reads the shared edge once, follows the 3 branch, and stops after 3 explicit token comparisons. Both find the same longest prefix of length 3. The tree 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 xx and yy, the longest common prefix is the greatest number pp of initial tokens that agree. In this notation, x1:jx_{1:j} means the first jj tokens; j=0j=0 includes the empty prefix:

p=max⁡{j:x1:j=y1:j}.p=\max\{j:x_{1:j}=y_{1:j}\}.

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 can reuse the prompt-processing work, called , for a compatible cached prefix. 's organizes token prefixes in a radix tree.[3] 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 n×dn \times d scoring column.

Vector scoring adds another input

We have answered one question: which prompts share an exact prefix? To find text with nearby meaning, semantic retrieval commonly represents the question and each chunk as embedding vectors, then computes a score. The NumPy and tensor shapes lesson treated a vector as coordinates and a as paired multiplies plus adds. Here we'll count those multiplies in plain Python so the growing input stays visible.

Each exact dot-product score inspects d coordinates. When both vectors have Euclidean norm one, meaning ∑ixi2=1\sqrt{\sum_i x_i^2}=1, their dot product equals . This describes their magnitude, not their number of coordinates. 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?

count-vector-scoring-work.py
chunk_ids = ["auth-fixture", "ci-timeout", "cli-flag", "deploy-runbook"]
chunk_vectors = [
    [1.0, 0.2, 0.1],
    [0.0, 1.0, 0.2],
    [0.8, 0.1, 0.4],
    [0.1, 0.2, 1.0],
]
question = [1.0, 0.0, 0.0]

def dot(left: list[float], right: list[float]) -> tuple[float, int]:
    score = 0.0
    multiplies = 0
    for a, b in zip(left, right, strict=True):
        score += a * b
        multiplies += 1
    return score, multiplies

scores = []
coordinate_multiplies = 0
for vector in chunk_vectors:
    score, used = dot(question, vector)
    scores.append(score)
    coordinate_multiplies += used

best = max(range(len(scores)), key=lambda i: scores[i])
print("best_chunk", chunk_ids[best])
print("scores", [round(s, 1) for s in scores])
print("coordinate_multiplies", coordinate_multiplies)
Output
best_chunk auth-fixture
scores [1.0, 0.0, 0.8, 0.1]
coordinate_multiplies 12

The 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 n×dn \times d 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 ×\times 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.

Scoring work is only half the picture. 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 answer needs only k = 2 citations, but a full sort orders every candidate. That's the mismatch to keep in mind as we choose a selection method. In product code, treat k <= 0 as an empty result so heap logic never reads from an empty list.

full-sort-is-correct-but-does-extra-work.py
scores = [
    (1.0, "auth-fixture"),
    (0.0, "ci-timeout"),
    (0.8, "cli-flag"),
    (0.1, "deploy-runbook"),
]

top_two = sorted(scores, reverse=True)[:2]
print("top_two", top_two)
print("fully_reordered_scores", len(scores))
Output
top_two [(1.0, 'auth-fixture'), (0.8, 'cli-flag')]
fully_reordered_scores 4

The result is correct, but two losers were sorted even though they'll never be shown. Full sorting costs O(n log n) comparisons in the worst case and materializes all n scored results. When k is small, what structure could keep the weakest winner easy to replace without ordering every candidate?

In Data Structures for AI you already used a so the weakest retained result sits at the root. A bounded min-heap stores only the 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 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.

Here's the invariant that lets us trust the heap: after each input, it contains the strongest k items seen so far, or all the items if fewer than k have arrived. Once it's full, a candidate no stronger than the root can't displace a winner. A stronger candidate replaces the weakest winner, and the heap is repaired. The condition item > heap[0] matters because heapreplace replaces the root even when the incoming item is worse.[5]

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.[6]

The next function accepts the same scored rows and keeps at most two winners. Compare its result with the full sort:

keep-only-top-k-scores.py
from heapq import heappush, heapreplace

scores = [
    (1.0, "auth-fixture"),
    (0.0, "ci-timeout"),
    (0.8, "cli-flag"),
    (0.1, "deploy-runbook"),
]

def bounded_top_k(items: list[tuple[float, str]], k: int) -> list[tuple[float, str]]:
    if k <= 0:
        return []
    heap: list[tuple[float, str]] = []
    for item in items:
        if len(heap) < k:
            heappush(heap, item)
        elif item > heap[0]:
            heapreplace(heap, item)
    return sorted(heap, reverse=True)

top_two = bounded_top_k(scores, k=2)
print("top_two", top_two)
print("selection_values_stored", len(top_two))
assert bounded_top_k(scores, k=0) == []
for fixture in (scores, list(reversed(scores)), [(0.5, "a"), (0.5, "c"), (0.5, "b")]):
    for k in (1, 2, len(fixture), len(fixture) + 1):
        assert bounded_top_k(fixture, k) == sorted(fixture, reverse=True)[:k]
Output
top_two [(1.0, 'auth-fixture'), (0.8, 'cli-flag')]
selection_values_stored 2

The 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.

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:

OperationTimeMemory 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 resultsO(n log n) worst caseThe scored-result list is Θ(n); sorting may need additional implementation workspace
Binary search in a sorted ID listO(log n) comparisonsThe sorted list is Θ(n); the search itself uses O(1) extra
Radix-tree longest-prefix matchO(p + 1) expected descent with constant-time child lookupShared prefixes occupy shared edges; the walk uses O(1) extra
Bounded top-k selection, 2 <= k <= nO(n log k) worst case, or Θ(n) when k is fixedHeap 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 exact vector scoring exceeds the compute budget, changing the sort won't help because the score pass comes first. We need to change which candidates the search examines, then compare that path against the exact baseline.

An exact scan compares a 768-dimensional query with all 50,000 vectors, requiring 38.4 million coordinate multiplications. A separate toy two-dimensional graph walk moves toward the query as distance falls from 12.0 to 1.4 units. In a hypothetical count example, scoring 160 vectors requires 122,880 coordinate multiplications, excluding graph traversal. Approximate recall must be measured against exact top-k.
If a graph search scores 160 vectors, its dot products use 160 × 768 = 122,880 coordinate multiplications. This calculation assumes 160 visits; actual visit counts and search quality depend on the index and query. Measure total latency and recall, including graph traversal, filters, and index memory.

Spot a quadratic trap

Linear work doubles when the input doubles. Quadratic work grows much faster because each item is compared with many others. To spot the difference in a loop, ask whether the inner range grows along with the outer one.

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.

count-all-pairs.py
def pair_comparisons(n: int) -> int:
    comparisons = 0
    for left in range(n):
        for right in range(left + 1, n):
            comparisons += 1
    return comparisons

for n in (4, 8, 16, 1_000):
    print(f"n={n:>4} pairs={pair_comparisons(n):>6}")
Output
n=   4 pairs=     6
n=   8 pairs=    28
n=  16 pairs=   120
n=1000 pairs=499500

At 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.

For exact duplicate removal, a set changes the operation. It remembers texts already seen instead of comparing every pair. Semantic similarity still needs a method that compares meaning or representation.

deduplicate-exact-text-with-a-set.py
candidate_texts = [
    "auth fixture expiry",
    "auth test failure",
    "auth fixture expiry",
    "ci timeout retry",
    "auth test failure",
]

seen = set()
unique = []
membership_checks = 0
for text in candidate_texts:
    membership_checks += 1
    if text not in seen:
        seen.add(text)
        unique.append(text)

print("unique_chunks", len(unique))
print("membership_checks", membership_checks)
Output
unique_chunks 3
membership_checks 5

This path performs exactly n membership checks. With normal hash-table behavior, each check takes expected O(1), so the pass takes expected O(n) total time. Collisions can make one check O(n). Hashing still reads each text, so unbounded string length remains part of the real cost.

It still can't tell whether two different strings mean nearly the same thing. That's a different requirement, so don't trade away semantic matching just to get a nicer complexity label.

The same square shape appears later in attention

The same counting habit will reappear in 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 QK⊤QK^{\top}, so a sequence with t token positions has t * t score cells.[7]

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.

preview-square-token-work.py
def score_matrix_size(tokens: int) -> tuple[int, float]:
    cells = tokens * tokens
    mib_if_float32 = cells * 4 / (1024 * 1024)
    return cells, mib_if_float32

for tokens in (128, 256, 512, 1_024):
    cells, mib = score_matrix_size(tokens)
    print(f"tokens={tokens:>4} cells={cells:>7} raw_matrix_mib={mib:>4.2f}")
Output
tokens= 128 cells=  16384 raw_matrix_mib=0.06
tokens= 256 cells=  65536 raw_matrix_mib=0.25
tokens= 512 cells= 262144 raw_matrix_mib=1.00
tokens=1024 cells=1048576 raw_matrix_mib=4.00

Doubling tokens quadruples cells. Treat this as a per-layer, per-head order of magnitude for a dense float32 score grid for one sequence during , before multi-head batching, fused kernels, or decode optimizations.

If each query and key has dkd_k coordinates (the per-head width in later chapters), forming that grid also performs Θ(t2dk)\Theta(t^{2} d_{k}) multiply-accumulate work per head, while the score grid itself takes Θ(t2)\Theta(t^{2}) values. This doesn't model a complete language model or serving memory yet. It transfers the algorithm skill: when you see an all-pairs , ask whether an input is being squared.

Prefill versus decode: two computational regimes

An autoregressive Transformer processes the prompt before it starts generating, then adds one token at a time. These are two useful phases to count separately. The dense-attention costs below are per head, with head width dkd_k; they don't include projections, feed-forward layers, or the rest of the serving request:

  1. Prefill (prompt evaluation): Process the prompt's TT positions together, possibly in chunks. Causal attention lets each position attend to itself and earlier positions. Dense attention work is Θ(T2dk)\Theta(T^2 d_k) 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.
  2. Decode (token generation): For one sequence, a step adds one query against a cached history of length tt. Its attention work is Θ(tdk)\Theta(t d_k) 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.[8]

Prefill scores causal pairs among all prompt positions together, so dense work grows as Θ(T² dₖ) per head and contributes to time to first token. During decode, one query scores the cached keys and combines the cached values into an attention context vector; each step costs Θ(t dₖ) per head and contributes to inter-token latency.
Count dense attention separately from the whole model: Θ(T² d_k) for prefill and Θ(t d_k) for one decode step. Large prefill batches often favor compute reuse; small decode batches often wait on memory. Measure each workload on its target hardware because these tendencies vary by system.

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 GG generated tokens after a prompt of length TT, attending to the growing history totals Θ((GT+G2)dk)\Theta((GT + G^2)d_k) work per head. Caching saves recomputation; it doesn't make an arbitrarily long continuation linear in total output length.

For an exact decode trace, start with eight completed positions already cached. Consuming one additional input appends its key and value before its causal attention reads nine positions, including itself. Two more consumed inputs read ten and eleven positions. Those three rows total 30 key reads and leave eleven cached positions. In general, G consumed inputs after T previously cached positions read G × T + G(G + 1)/2 keys per head. This counts consumed decoder inputs; prompt prefill already produces the logits for the first sampled output.

For Llama 3 8B's 32 layers, 8 Grouped-Query Attention (GQA) key-value heads, and head dimension dk=128d_k = 128, assume a 16-bit KV cache (2 bytes per element).[9] Each stored token then requires:

KV memory per token=2×32 layers×8 KV heads×128×2 bytes=131,072 bytes=128 KiB.\text{KV memory per token} = 2 \times 32 \text{ layers} \times 8 \text{ KV heads} \times 128 \times 2 \text{ bytes} = 131{,}072 \text{ bytes} = 128 \text{ KiB}.

The first factor of 2 counts separate keys and values; the last counts bytes per element. This model has 32 query heads, but groups of four share one key-value head. Cache storage therefore uses the 8 KV heads, not the 32 query heads. Each query head still computes its own attention scores.[10][9]

At a concurrent batch size of B=16B = 16 streams, context length directly dictates your serving memory budget:

  • At 1,024 tokens per stream: 16×1,024×128 KiB=2.0 GiB16 \times 1{,}024 \times 128 \text{ KiB} = 2.0 \text{ GiB}.
  • At 4,096 tokens per stream: 16×4,096×128 KiB=8.0 GiB16 \times 4{,}096 \times 128 \text{ KiB} = 8.0 \text{ GiB}.
  • At 8,192 tokens per stream: 16×8,192×128 KiB=16.0 GiB16 \times 8{,}192 \times 128 \text{ KiB} = 16.0 \text{ GiB}.

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.

For one sequence, conceptual dense attention score grids grow from 128² = 16,384 cells to 2,048² = 4,194,304 cells per head. In a separate Llama 3 8B raw-cache estimate, each token uses 128 KiB; for 16 equal-length streams, contexts from 512 to 8,192 tokens use 1 to 16 GiB of KV values. Fused kernels may avoid storing the score grid, and model weights and workspace add memory beyond raw KV.
The upper illustration counts cells in a conceptual t2t^2 score grid. Implementations may avoid materializing that grid, and GPU utilization depends on kernels and workload. The lower illustration counts raw KV values for the specified architecture and dtype. Doubling cached length doubles this memory; it quadruples dense prefill's score-grid cells.

Measure scale and install a budget guard

Big-O shows how a workload can grow, but it can't tell you whether today's hardware meets the service objective. Count expected work to spot risk, 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.

guard-exact-retrieval-work.py
def exact_score_work(candidate_chunks: int, embedding_dim: int) -> int:
    return candidate_chunks * embedding_dim

def within_budget(candidate_chunks: int, embedding_dim: int, max_multiplies: int) -> bool:
    return exact_score_work(candidate_chunks, embedding_dim) <= max_multiplies

budget = 1_000_000
for candidates in (500, 2_000, 50_000):
    work = exact_score_work(candidates, embedding_dim=768)
    print(candidates, work, within_budget(candidates, 768, budget))
Output
500 384000 True
2000 1536000 False
50000 38400000 False
Diagram showing Count n * d, Within budget?, yes, and Run exact top-k.
Count n * d, Within budget?, yes, and Run exact top-k.

When a request exceeds the budget, authorization still applies and the answer still needs relevant 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 index evaluated for recall.

A later option is , 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.[11] That belongs in Vector DB Internals, not in this foundation exercise.

Guarding tail latency in production

Operation counts tell us how work grows. To enforce a wall-clock budget in production, we also need to learn where the time goes:

There is another useful retrieval pattern: retrieve, then refine. A quick first-stage score proposes a shortlist, then a slower scorer or ranks only those candidates. A one-stage top-k heap still uses one score for both selection and ranking. Refinement needs 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.

retrieve-then-refine.py
from heapq import nlargest

authorized_rows = [
    {"id": "auth-fixture", "cheap_score": 0.91, "refine_score": 0.80},
    {"id": "ci-timeout", "cheap_score": 0.88, "refine_score": 0.55},
    {"id": "cli-flag", "cheap_score": 0.75, "refine_score": 0.95},
    {"id": "deploy-runbook", "cheap_score": 0.10, "refine_score": 0.20},
]

def retrieve_then_refine(
    rows: list[dict],
    shortlist_size: int,
    result_size: int,
) -> tuple[list[str], int, int]:
    shortlist = nlargest(
        shortlist_size,
        rows,
        key=lambda row: (row["cheap_score"], row["id"]),
    )
    refined = sorted(
        ((row["refine_score"], row["id"]) for row in shortlist),
        reverse=True,
    )[:result_size]
    return [row_id for _, row_id in refined], len(rows), len(shortlist)

citations, candidate_scored, refinement_scored = retrieve_then_refine(
    authorized_rows,
    shortlist_size=3,
    result_size=2,
)
print("candidate_stage_scored", candidate_scored)
print("refinement_stage_scored", refinement_scored)
print("citations", citations)
assert citations == ["cli-flag", "auth-fixture"]
assert candidate_scored == 4
assert refinement_scored == 3

# Offline reference: rank every authorized row under the final scorer.
reference_ids = {
    row["id"]
    for row in sorted(
        authorized_rows, key=lambda row: (row["refine_score"], row["id"]), reverse=True
    )[:2]
}
for shortlist_size in (2, 3):
    result_ids, _, scored = retrieve_then_refine(authorized_rows, shortlist_size, 2)
    recovered = len(set(result_ids) & reference_ids)
    print(f"shortlist={shortlist_size} refine_reads={scored} recovered={recovered}/2 ids={result_ids}")
    assert recovered == (1 if shortlist_size == 2 else 2)
Output
candidate_stage_scored 4
refinement_stage_scored 3
citations ['cli-flag', 'auth-fixture']
shortlist=2 refine_reads=2 recovered=1/2 ids=['auth-fixture', 'ci-timeout']
shortlist=3 refine_reads=3 recovered=2/2 ids=['cli-flag', 'auth-fixture']

Here the cheap score chooses candidates and the richer score changes their order. A shortlist of two still returns two citations, but it drops cli-flag, the final scorer's best result. Refinement can reorder auth-fixture and ci-timeout; it can't bring back a row it never sees. The three-row shortlist keeps both reference winners.

Here recall at 2 against the final-score reference is the number of recovered reference IDs divided by 2: 1/2 versus 2/2. Computing that reference requires all four richer scores during offline evaluation. It doesn't belong in the live shortcut, and a perfect result on this one fixture doesn't establish corpus-wide recall. Compare authorized candidates under the same final scoring and tie rules, then measure shortlist quality before celebrating lower refinement cost.

Build it: an authorized exact top-k retriever

The guard runs before vector scoring, and the permission fixture makes the boundary visible: it 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 k citations.
  • A preflight count rejects work above the configured multiply budget.
authorized-exact-top-k-retriever.py
from heapq import heappush, heapreplace

rows = [
    {"id": "auth-fixture", "project": "auth", "principals": {"engineer:u3"}, "vector": [1.0, 0.0, 0.1]},
    {"id": "auth-ci-timeout", "project": "auth", "principals": {"engineer:u3"}, "vector": [0.2, 1.0, 0.1]},
    {"id": "auth-cli-flag", "project": "auth", "principals": {"engineer:u3"}, "vector": [0.8, 0.1, 0.4]},
    {"id": "auth-secrets-private", "project": "auth", "principals": {"engineer:u1"}, "vector": [0.0, 0.3, 1.0]},
    {"id": "billing-auth-fixture", "project": "billing", "principals": {"engineer:u3"}, "vector": [1.0, 0.0, 0.0]},
]

def dot(left: list[float], right: list[float]) -> float:
    return sum(a * b for a, b in zip(left, right, strict=True))

def authorized_exact_top_k(
    question: list[float],
    all_rows: list[dict],
    project: str,
    principal: str,
    k: int,
    max_multiplies: int = 1_000_000,
) -> tuple[list[str], int]:
    if k <= 0:
        return [], 0
    allowed = [
        row
        for row in all_rows
        if row["project"] == project and principal in row["principals"]
    ]
    required_work = len(allowed) * len(question)
    if required_work > max_multiplies:
        raise ValueError(
            f"exact scoring needs {required_work} multiplies; budget is {max_multiplies}"
        )
    heap: list[tuple[float, str]] = []
    coordinate_multiplies = 0
    for row in allowed:
        score = dot(question, row["vector"])
        coordinate_multiplies += len(question)
        item = (score, row["id"])
        if len(heap) < k:
            heappush(heap, item)
        elif item > heap[0]:
            heapreplace(heap, item)
    ranked = sorted(heap, reverse=True)
    return [chunk_id for _, chunk_id in ranked], coordinate_multiplies

citations, work = authorized_exact_top_k(
    [1.0, 0.0, 0.0],
    rows,
    "auth",
    "engineer:u3",
    k=2,
)
print("citations", citations)
print("coordinate_multiplies", work)
assert "auth-secrets-private" not in citations
assert "billing-auth-fixture" not in citations
assert work == 9
assert authorized_exact_top_k(
    [1.0, 0.0, 0.0],
    rows,
    "auth",
    "engineer:u3",
    k=0,
) == ([], 0)

try:
    authorized_exact_top_k(
        [1.0, 0.0, 0.0], rows, "auth", "engineer:u3", k=2, max_multiplies=8
    )
except ValueError as error:
    print("rejected:", error)
else:
    raise AssertionError("over-budget scoring should have been rejected")
Output
citations ['auth-fixture', 'auth-cli-flag']
coordinate_multiplies 9
rejected: exact scoring needs 9 multiplies; budget is 8

The first call stays within budget, scores three allowed vectors, and returns two citations. The second call has room for only eight multiplies, so it stops before 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, which is why we need to measure the rest of the request too.

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

You've seen these counts in code. Try each one without code now: write down the input that grows, the operation you're counting, and your expected result before checking the sketches.

  1. A project has n = 2,500 authorized chunks, each with d = 384 coordinates. How many coordinate multiplications does an exact dot-product scan perform?
  2. 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?
  3. A near-duplicate cleanup compares each of 200 chunks with every later chunk. How many comparisons occur?
  4. An attention preview changes prompt length from 256 to 1,024 tokens. By what factor does the number of score cells grow?
  5. Your guard allows one million coordinate multiplications. Does exact scoring fit for n = 4,000, d = 256?
  6. 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?
  7. A sorted catalog has 65,536 chunk IDs. What is the worst-case number of midpoint checks for binary search?

Solution sketches

  1. 2,500 * 384 = 960,000 coordinate multiplications.
  2. 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.
  3. 200 * 199 / 2 = 19,900 comparisons.
  4. Token length grows by 1,024 / 256 = 4, so square score-cell count grows by 4 * 4 = 16.
  5. No. 4,000 * 256 = 1,024,000, which exceeds the one-million budget.
  6. 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.
  7. At most 17 midpoint checks. Although 216=65,5362^{16} = 65{,}536, 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

When a request slows down, start by asking which input grew and what operation now repeats. This table gives you a first place to look:

OperationInput that growsWork shapeFirst engineering response
Scan authorized text chunksn chunksO(n)Count candidates after permission filtering
Binary search a sorted ID listn sorted keysO(log n)Keep the list sorted off the hot path
Longest exact prefixmatched length pO(p + 1) expected radix descentCount child lookups as well as token comparisons
Score exact embedding vectorsn chunks, d coordinatesO(n * d)Measure score work before choosing an index
Select best k scored chunksn scores, 2 <= k <= nO(n log k) with a heapDon't fully sort discarded results
Compare every candidate pairn chunksO(n^2)Challenge whether all-pairs work is required
Preview an attention score gridt tokensO(t^2) cells (per layer/head order of magnitude; prefill, before KV-cache decode)Notice square growth before later model lessons

Keep these three habits as you move into later AI systems:

  1. Name the operation and its growing inputs before stating a complexity.
  2. Keep a smallest-correct baseline so optimization can be checked for correctness.
  3. Couple latency work with product requirements: authorization, citation quality, and recall don't disappear when a faster algorithm arrives.
Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.A lexical retriever's candidate-scan step inspects each authorized chunk once, and chunk length is bounded. If allowed chunks grow from 4 to 32 for the same question, what claim is justified?
2.A permission-aware query returns 600 chunks for auth and 12,000 chunks for a large project. Both use 256-coordinate embeddings, and the exact-scoring budget is 1,000,000 coordinate multiplications per request. Which decision follows?
3.The lexical scan visits all n bounded-length chunks and then sorts r matches. Which complexity statement is most precise?
4.A service keeps 50,000 float32 vectors of dimension 768 in memory and streams scores through a k=5 heap. Which memory statement is accurate?
5.A retriever computes an exact dot product for every authorized vector and then returns sorted(scores, reverse=True)[:3]. If the full sort is replaced with a bounded min-heap, what changes?
6.An exact retriever filters by project and principal before scoring. Seven rows have four-coordinate vectors: four auth rows allow engineer:u3, one auth row allows engineer:u1, and two billing rows allow engineer:u3. For auth, engineer:u3, and k = 2, what follows?
7.A teammate proposes a request-time cleanup that compares each unordered pair among 2,000 retrieved snippets once to remove semantically similar text. What is the risk, and when is a set a valid replacement?
8.A dense self-attention score grid grows from 384 to 1,152 token positions. What happens to its cell count?
9.A serving cache looks up a new prompt against stored token sequences. Two designs are proposed: a radix tree over token IDs, and an HNSW index over embeddings. Which statement is accurate?
10.A half-open binary search starts with lo = 0 and hi = 131,072 and looks for the first item in a sorted list of distinct keys. How many midpoint checks does it make?

10 questions remaining.

References

big-O notation

Paul E. Black · 2019

https://xlinux.nist.gov/dads/HTML/bigOnotation.html

Theta

Paul E. Black · 2016

https://xlinux.nist.gov/dads/HTML/theta.html

SGLang: Efficient Execution of Structured Language Model Programs

Zheng, L., Yin, L., Xie, Z., et al. · 2023 · arXiv:2312.07104

https://arxiv.org/abs/2312.07104

Automatic Prefix Caching

vLLM · 2026

https://docs.vllm.ai/en/stable/design/prefix_caching/

heapq: Heap queue algorithm

Python Software Foundation · 2026

https://docs.python.org/3/library/heapq.html

Introduction to Algorithms (3rd ed.)

Cormen, T. H., Leiserson, C. E., Rivest, R. L., & Stein, C. · 2009

https://mitpress.mit.edu/9780262033848/introduction-to-algorithms/

Attention Is All You Need.

Vaswani, A., et al. · 2017

https://arxiv.org/abs/1706.03762

FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness

Dao, T., Fu, D. Y., Ermon, S., Rudra, A., Ré, C. · 2022 · NeurIPS 2022

https://arxiv.org/abs/2205.14135

Llama model specifications

Meta · 2026

https://github.com/meta-llama/llama-models/blob/main/models/sku_list.py

GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints.

Ainslie, J., et al. · 2023 · EMNLP 2023

https://arxiv.org/abs/2305.13245

Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs

Malkov, Y. A., & Yashunin, D. A. · 2018 · IEEE Transactions on Pattern Analysis and Machine Intelligence

https://arxiv.org/abs/1603.09320

gc: Garbage Collector interface

Python Software Foundation · 2026

https://docs.python.org/3/library/gc.html

Ray Serve: Dynamic Request Batching

Ray contributors · 2026

https://docs.ray.io/en/latest/serve/advanced-guides/dyn-req-batch.html

Triton Inference Server: Dynamic Batcher

NVIDIA · 2026

https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/user_guide/batcher.html

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.