AI Harness Engineering

On-Device Embeddings

Extraction at the edge. How a semantic search over forty thousand photos runs entirely on a phone — the millisecond budget, the encoder that had to be distilled to fit it, the index that has to live in a few megabytes, the wake word that never sleeps, and the uncomfortable fact that an embedding is not an anonymized version of what produced it.

Prerequisites: an embedding is a vector whose distances mean something. Nothing else. Every budget, every byte, and every millisecond is derived here from scratch.
10
Chapters
7
Simulations
0
Assumed Knowledge

Chapter 0: Search Without Sending

Open your photos app and try to find one specific picture: the dog on the beach, the one with the orange sky. You know it exists. You took it maybe three years ago. You have roughly forty thousand photos and no idea what date it was.

You can scroll. That is the state of the art for most people. Forty thousand photos at, say, twelve thumbnails per screen is 3,333 screens of scrolling, and you will give up before screen forty.

What you want to type is “my dog on the beach at sunset” and get the picture. That is a semantic search — a search where the match is on meaning, not on filename or folder or an exact tag someone typed in. And the mechanism that makes it possible is the subject of this lesson: an embedding, a fixed-length list of numbers produced by a neural network such that things which mean similar things land near each other, and the model that produces embeddings for pictures and the model that produces embeddings for sentences deliberately put them in the same space.

Given that, search is almost embarrassingly simple. Turn the sentence into a vector. Turn every photo into a vector, once, ahead of time. Compare the sentence vector to all forty thousand photo vectors and return the closest few.

Two places that computation could happen

There are exactly two architectures here, and choosing between them is the whole subject.

Architecture A — send it up. Upload every photo to a server, run a large image encoder there, store the vectors in a hosted vector database, and route each query through the network. This is how nearly every product built between 2015 and 2022 worked.

Architecture B — keep it down. Run the image encoder on the phone, store the vectors on the phone, run the search on the phone. Nothing about the photos ever reaches a server.

Architecture A sounds obviously easier, and for the model it is. So let us count what it actually costs the user, because the numbers are not close.

The upload, in arithmetic

Take forty thousand photos at an average of 3 MB per JPEG — a conservative figure for a modern phone camera. Total bytes to upload:

40,000 × 3 MB = 120,000 MB = 120 GB

On a decent home connection with 20 Mbit/s of upload (upload is usually the slow direction), convert bytes to bits and divide:

120 × 109 bytes × 8 bits/byte = 9.6 × 1011 bits
9.6 × 1011 ÷ (20 × 106 bits/s) = 48,000 s = 13 hours 20 minutes

Thirteen hours of saturated upstream, and that is before the server has done a single multiplication. On a metered mobile plan, 120 GB is the entire month for most people, several times over.

Now price Architecture B’s equivalent — not the photos, just the vectors. A common embedding width in this family is 512 numbers, stored as 32-bit floats:

40,000 × 512 × 4 bytes = 81,920,000 bytes = 81.9 MB

Ratio against the raw upload:

120 × 109 ÷ (81.92 × 106) = 1,465× smaller

And by Chapter 3 we will have that 81.9 MB down to 5.28 MB without meaningfully hurting the search — a factor of nearly twenty-three thousand against the raw pixels. Hold that number; it is the reason the whole approach works.

The two ledgers

Slide the library size and watch both bills. Left: what Architecture A must move over the network before it can answer anything. Right: what Architecture B has to hold in the phone’s memory. The bars are on a shared logarithmic scale, because a linear one would make the right-hand bar invisible.

photos in library40,000
vector bytes each2048

What has to be true for Architecture B to work

This is the specification that the next eight chapters satisfy. Write it out honestly, because every clause turns into an engineering constraint with a number attached.

RequirementWhat it really demandsChapter
An image encoder that fitsSmall enough to download inside an app, fast enough to run 40,000 times without cooking the phone, accurate enough that the search is worth using1, 2
A text encoder in the same spaceA second model, aligned to the first, that turns an arbitrary typed sentence into a comparable vector — on device, at query time1, 2
An index that fits in RAMTens of megabytes, not gigabytes, because a background task on a phone is killed for memory long before it is killed for slowness3, 4
A search that feels instantUnder about 100 ms end to end, including tokenizing, encoding, scanning, and loading thumbnails1, 4
An energy budgetIndexing the whole library must cost a small single-digit percentage of one battery charge, or the OS will simply stop letting you run1, 5
A defensible privacy story“Nothing leaves the device” is a strong claim. “Only embeddings leave the device” is a much weaker one than it sounds7

Why “edge” is a different problem, not a smaller one

The tempting mental model is that on-device machine learning is server machine learning with the numbers scaled down. It is not. Three constraints exist on a phone that simply do not exist in a data centre, and each one changes what the right design is, not merely how big it can be.

First, the budget is a triple, not a scalar. In a data centre you optimize cost per query, and everything — memory, compute, energy — converts into dollars. On a device, latency, memory and energy are three separate hard walls with three separate enforcement mechanisms. Blow the latency budget and the feature feels broken. Blow the memory budget and the operating system terminates your process. Blow the energy budget and the user uninstalls you after seeing your name in the battery settings.

Second, the work is bimodal. Query-time work is latency-critical and rare. Indexing work is latency-irrelevant and enormous. These want opposite optimizations: the query path should be small and warm and resident; the indexing path should be batched, thermally throttled, and happy to be suspended for six hours. Designing one pipeline for both is the most common on-device mistake.

Third, you cannot patch the hardware. The server engineer who is memory-bound buys a machine with more bandwidth. You ship to a five-year-old phone with a neural accelerator that supports eight-bit integers and not much else, and your only remaining knob is the model.

The reframing that makes the rest of this lesson make sense. On the server, the question is “what is the best embedding I can compute?” On the device, the question is “given 30 milliseconds, 40 megabytes, and 2 millijoules, what is the best embedding I can compute?” That is a constrained optimization, and constrained problems have different winners. A model that is 2% more accurate and 6× slower does not win here. It does not even place.

Concept → realization: the whole system in twelve lines

Before any of the hard parts, here is the entire thing, so you know what every later chapter is optimizing. Read the shapes; they are the spine of the lesson.

python
# ---- indexing: runs once per photo, in the background, on the NPU ----
for photo in library:                    # 40,000 of these
    px  = decode_and_resize(photo)          # uint8[256,256,3]
    z   = image_tower(px)                   # float32[512]  (unit norm)
    z   = z[:128] / norm(z[:128])          # float32[128]  <- matryoshka truncation, Ch3
    q, s = quantize_int8(z)                 # int8[128] + 2 scalars, Ch3
    index.append(q, s)                      # 132 bytes on disk

# ---- query: runs on every keystroke, on the CPU, must feel instant ----
tok  = tokenize("my dog on the beach at sunset")   # int32[77]
zq   = text_tower(tok)[:128]                        # float32[128], same space
zq   = zq / norm(zq)
sims = index.dot_all(zq)                # float32[40000]  <- flat scan, Ch4
top  = argsort(sims)[-50:]              # the answer

Nine lines of logic. Every remaining chapter is about one of them: how image_tower got small enough to call forty thousand times (Chapters 1, 2), why the truncate-then-quantize order on line 4 is not optional (Chapter 3), why dot_all is a plain loop and not a fancy graph index (Chapter 4), and what happens when you decide to sync index to a server (Chapter 7).

The same shape, three products

Photo search is the friendly example, but the pattern — embed on device, compare to something stored on device — is the same in three very different products, and this lesson covers all three because their budgets differ by orders of magnitude and the differences are instructive.

ProductWhat is embeddedRateHard constraint
Photo search (Ch 0–4)a 12-megapixel image40,000 times, once, then rarelyPeak memory and total energy of the indexing sweep
Wake word (Ch 5)one second of microphone audio50 times per second, foreverAverage power in milliwatts, and false alarms per day
Machine health (Ch 6)one second of vibrationonce per second, forever, on a microcontrollerKilobytes of SRAM, and one false alarm per day

Notice that only the first one cares about the latency of a single call. The other two care about power, which is energy per call multiplied by calls per second — a different number entirely, and one that punishes a design that merely looks fast.

Price the naive on-device design, before anything clever

Architecture B looked like an easy win against the upload, but “smaller than 120 GB” is a low bar. The honest question is whether the obvious implementation — take the encoder’s native output and store it — is something you could ship. Do that arithmetic now, because the number it produces is what every later chapter is negotiating against.

A CLIP-style encoder emits a 512-dimensional vector of 32-bit floats. Per photo:

512 × 4 B = 2,048 B per vector
40,000 × 2,048 = 81,920,000 B = 81.9 MB

Eighty-two megabytes, resident, for the search index of a photo app. Against the 120 GB upload that is 1,465× smaller, which sounds like victory. Against a phone it is a problem: a background task on a mobile operating system typically gets tens of megabytes before the system reclaims it, and an app that keeps 82 MB alive just to answer “beach at sunset” is an app that gets killed while the user is scrolling.

Now price the search itself. Scoring one query means a dot product against every stored vector:

40,000 × 512 = 20.48 million multiply–accumulates per query

Twenty million MACs sounds alarming and is not — a phone core does billions per second. But look at what must be read to feed them: all 81.9 MB, once, per query. That is the actual cost, and noticing it now is the difference between optimizing the right thing and the wrong thing for the rest of the lesson.

The ratio that decides everything. Divide the arithmetic by the bytes: 20.48 M MACs ÷ 81.9 MB = 0.25 MACs per byte. A phone can do roughly sixteen MACs in the time it takes to read one byte from memory (Chapter 1 derives that number). We need a quarter of one. The search is not compute-limited by a factor of sixty — it is starved, waiting on memory, and every millisecond you spend making the arithmetic faster buys you nothing at all.

That single observation reorganizes the whole design. If the bottleneck is bytes, then the fix is fewer bytes, and there are exactly two ways to have fewer bytes: fewer numbers per vector (Chapter 3’s dimension) or fewer bits per number (Chapter 3’s precision). Both shrink memory and time together, which is why they turn out to be the two cheapest and most powerful dials on the board.

For a preview of how far that goes: keep 128 of the 512 dimensions and store each as an 8-bit integer instead of a 32-bit float.

128 × 1 B = 128 B per vector  →  40,000 × 128 = 5.12 MB of vector payload
81.9 ÷ 5.12 = 16× smaller, and 16× faster to scan

A real record carries a few housekeeping bytes on top of the payload — an identifier, a dequantization scale, a stored norm — which is why the same index appears later as 132 or 148 bytes per photo depending on which extras it needs. Chapter 3 lays out the record byte by byte. The payload is what dominates, and the payload just fell by sixteen.

Sixteen times, from two changes that require no new model and no new algorithm. Whether the results are still correct after those cuts is the entire subject of Chapter 3, and the answer is more encouraging than you would guess.

Why one hundred milliseconds

Budgets get quoted as if they came from physics. They come from human perception, and it is worth knowing where the numbers are from so that you can argue with them.

Classic interaction research gives three thresholds. Under roughly 100 ms, a response feels instantaneous — the user perceives their own action as having caused the result directly. Under about 1 second, the flow of thought is uninterrupted, though the delay is noticed. Past 10 seconds, attention leaves and the user starts doing something else.

A search box is the most direct kind of interaction there is, so it wants the first threshold. That is where the 100 ms in this lesson comes from — not from a hardware limit, but from the fact that above it, the feature stops feeling like part of the phone and starts feeling like a network request. And that framing gives you the argument you will actually need: if a product manager asks for a better encoder that costs 60 ms more, the answer is not “that is slow” but “that moves us out of the instantaneous band, so the search will feel like the web instead of like the device.”

Key insight for the whole lesson. An on-device embedding system has four independent dials: architecture (which encoder), dimension (how many numbers per vector), precision (how many bits per number), and index (how you search them). They interact, they have wildly different costs, and the biggest wins almost always come from the two cheapest dials — dimension and precision — which is exactly backwards from where most teams start.
Why is on-device embedding a different design problem rather than a scaled-down version of server-side embedding?

Chapter 1: The Budget

“It has to run in 30 milliseconds” is the kind of sentence that gets said in planning meetings and then never derived. Let us derive it, because the number is not one number — it is three different numbers for three different regimes, and knowing which regime you are in tells you which of the four dials to turn.

Where 30 milliseconds comes from

Regime 1 — interactive query. The classic human-factors result is that responses under about 100 ms feel instantaneous; past that, the user perceives the system reacting rather than simply being. So the whole query path gets 100 ms. Spend it:

StageBudgetWhy
Tokenize the query text~0.2 msByte-pair encoding of a short sentence; pure string work
Text encoder forward pass~2 msThe number we will justify below
Scan the index~3 msChapter 4 derives this
Load and decode 50 thumbnails~40 msDisk plus JPEG decode; usually the real cost
Layout and render~15 msOne frame at 60 Hz plus layout
Total~60 ms40 ms of slack against the 100 ms wall

Note what the table teaches immediately: the neural network is 2 of 60 milliseconds. If you spent a month making the text encoder twice as fast you would win one millisecond of a sixty-millisecond path. The thumbnails are the bill. This will keep happening.

Regime 2 — live camera. If you are embedding what the camera sees, every frame, the budget is the frame interval. At 30 frames per second that is

1 ÷ 30 = 0.0333 s = 33.3 ms per frame

and the model does not get all of it — the camera pipeline, the preview render, and any drawing you do share the frame. Twenty milliseconds for the model is a realistic slice. This is where the famous 30 ms figure lives.

Regime 3 — background indexing. There is no latency budget at all. Nobody is waiting. What exists instead is a thermal budget and an energy budget, and they are the subject of the last third of this chapter.

Say which regime you are in before quoting a millisecond figure. “Our encoder runs in 1.5 ms” is a statement about regime 2. It says almost nothing about whether you can index a library in regime 3, because regime 3 is limited by joules and degrees, and a model can be fast and thermally ruinous at the same time.

The four currencies

Every on-device model spends four things, and they are only loosely related to each other. Confusing them is the single most common source of bad on-device decisions.

CurrencyUnitWhat it buys / limitsCommon mistake
ParameterscountApp download size, disk, resident memoryAssuming parameters predict latency. They do not.
MACsmultiply–accumulates per callTime on a compute-bound acceleratorAssuming MACs predict latency. They do, but only when compute-bound.
Memory trafficbytes moved per callTime on a bandwidth-bound layer; most of the energyIgnoring it entirely, which is why small models often disappoint.
Energymillijoules per callBattery percentage, thermal headroomBelieving it is proportional to MACs. It is mostly proportional to bytes moved.

A multiply–accumulate, precisely

A MAC is one multiply plus one add: acc += a * b. It is the atom of neural network arithmetic because every convolution and every matrix multiply is a pile of them. Hardware vendors usually quote TOPS — tera-operations per second — and count a MAC as two operations. So:

1 MAC = 2 ops  ⇒  a “2 TOPS” accelerator does at most 1 × 1012 MACs per second

There is a second, nastier ambiguity. Papers report “FLOPs” for models, and in the efficient-vision literature that number is almost always MACs, not floating-point operations. When MobileCLIP’s paper lists its MCi0 image encoder at 2.4 G FLOPs at 256×256 input, read it as 2.4 billion MACs — 4.8 billion arithmetic operations. We will carry it as MACs and say so every time.

Worked example: can MCi0 hit 30 ms on a phone NPU?

Real numbers, from the MobileCLIP paper (Vasu et al., 2023). The MCi0 image encoder has 11.8 M parameters in its ImageNet-classification form (11.4 M as the image tower of MobileCLIP-S0), takes 2.4 G MACs at 256×256, and the authors measure 1.5 ms on an iPhone 12 Pro Max. Our job is to reproduce that order of magnitude from first principles so we can predict it for hardware we do not have.

Step 1 — the compute time. Assume a modest neural accelerator rated at 2 TOPS for 8-bit integers. Peak MAC rate:

2 × 1012 ops/s ÷ 2 ops/MAC = 1 × 1012 MACs/s

Nobody achieves peak. Real convolution stacks with varying shapes, depthwise layers, and layout changes get somewhere between 20% and 60% of it. Take 40%:

0.40 × 1 × 1012 = 4 × 1011 MACs/s
tcompute = 2.4 × 109 ÷ (4 × 1011) = 6.0 × 10-3 s = 6.0 ms

Step 2 — the memory time. Every weight must be read at least once. At int8, 11.4 M parameters is 11.4 MB. A phone’s accelerator might see 25 GB/s of usable bandwidth:

tmemory = 11.4 × 106 B ÷ (25 × 109 B/s) = 4.56 × 10-4 s = 0.456 ms

Step 3 — which one wins? The two times are not added; the hardware overlaps them. The larger one dominates, and there is a clean way to know which without doing both calculations. Define arithmetic intensity: MACs performed per byte read.

I = 2.4 × 109 MACs ÷ (11.4 × 106 bytes) = 210 MACs per byte

And define the machine balance: how many MACs the chip can do in the time it takes to read one byte.

B = (4 × 1011 MACs/s) ÷ (25 × 109 B/s) = 16 MACs per byte

Since I = 210 > 16 = B, this workload is compute-bound by a factor of 13. The prediction is 6.0 ms, dominated by arithmetic. The paper measures 1.5 ms — four times better, which tells you the A14’s neural engine is both faster than our 2 TOPS assumption and better utilized than 40%. That is exactly what a first-principles estimate is for: it gets you the right order of magnitude and tells you which resource to argue about.

The roofline habit. Compute I for your model and B for your chip. If I > B you are compute-bound: buy speed by cutting MACs (smaller resolution, fewer channels, fewer layers). If I < B you are bandwidth-bound: cutting MACs does nothing and you must cut bytes instead (quantize the weights, fuse layers, keep activations in on-chip memory). Teams routinely spend months on the wrong side of this inequality.

The layer that is bandwidth-bound anyway

The 210 figure is an average over the whole network, and averages lie. Consider a single 1×1 convolution at the deep end of the network: input 8×8×512, output 8×8×512.

MACs = 8 × 8 × 512 × 512 = 16,777,216
weight bytes (int8) = 512 × 512 = 262,144
I = 16,777,216 ÷ 262,144 = 64 MACs/byte

Comfortably compute-bound. Now the same layer applied to a 1×1 spatial grid — a classifier head, or the final projection:

MACs = 1 × 1 × 512 × 512 = 262,144,   I = 262,144 ÷ 262,144 = 1 MAC/byte

Sixty-four times less intense, and now sixteen times below the machine balance of 16. That layer runs at 1/16 of the chip’s peak no matter how you optimize the arithmetic, because it is waiting on memory. This is why the last few layers of a small network often take a disproportionate share of the runtime, and why “we cut 30% of the MACs and got 4% faster” is such a common and confusing result.

Latency budget calculator

Set the model’s MACs and weight bytes, and the chip’s peak rate, utilization and bandwidth. The bar shows compute time against memory time; the marker shows the roofline verdict. Push utilization down or bandwidth down and watch which wall you hit. The dashed lines are the 33.3 ms frame budget and the 6 ms slice we derived.

model MACs (G)2.4
weights (MB, int8)11
chip peak (TOPS)2.0
utilization (%)40
bandwidth (GB/s)26

Parameters are not latency: the text tower proves it

MobileCLIP-S0 pairs the 11.4 M-parameter MCi0 image tower with a text tower called MCt that has 42.4 M parameters — nearly four times as many — and runs in 1.6 ms, essentially the same time as the image tower’s 1.5 ms. How?

Two reasons, and both are worth internalizing.

Reason one: most of those parameters are a lookup table. CLIP-family text encoders use a byte-pair vocabulary of 49,408 tokens. At the usual base text width of 512, the token embedding table alone is

49,408 × 512 = 25,296,896 parameters ≈ 25.3 M

which is over half of MCt’s 42.4 M. And a lookup table costs exactly zero MACs. You read 77 rows out of 49,408 and move on. Those 25 million parameters cost you disk and RAM and nothing else.

Reason two: the sequence is 77 positions, not 65,536. A 256×256 image is 65,536 pixels; the convolution stack processes a spatial grid that starts enormous. The text encoder processes 77 token positions. For a transformer-shaped block, MACs are roughly (sequence length) × (non-embedding parameters):

77 × (42.4 − 25.3) × 106 = 77 × 17.1 × 1061.32 G MACs
1.32 × 109 ÷ (4 × 1011) = 3.3 ms

Same ballpark as the measured 1.6 ms, again with our conservative chip. So the text tower is bigger on disk and comparable in time. If you were optimizing for app download size you would attack the text tower; if you were optimizing for indexing throughput you would attack the image tower. They are different problems and the parameter count told you nothing about which.

The rule. Parameters predict download size and resident memory. MACs predict time on a compute-bound accelerator. Bytes moved predict time on a bandwidth-bound one, and energy nearly always. Three currencies, three predictions, and a model can be cheap in one and ruinous in another.

Worked example: the energy bill for indexing 40,000 photos

This is regime 3, and it is where on-device projects actually die. Compute it properly.

Step 1 — energy per model call. Mobile accelerators land in the neighbourhood of 1–3 TOPS per watt for int8 work. Take 2 TOPS/W, meaning 2 × 1012 operations per joule. Our encoder does 2.4 G MACs = 4.8 G ops:

Emodel = 4.8 × 109 ops ÷ (2 × 1012 ops/J) = 2.4 × 10-3 J = 2.4 mJ

Step 2 — energy for everything else. Before the model sees anything, a 12-megapixel JPEG must be decoded and resized to 256×256. A hardware JPEG decoder is efficient but not free; call it 30 mJ for a 12 MP frame, a plausible measured ballpark and one you must confirm on your own hardware. Add ~0.6 mJ for the resize and normalize.

Ephoto = 30 + 0.6 + 2.4 = 33 mJ

Step 3 — the sweep.

40,000 × 33 mJ = 1,320,000 mJ = 1,320 J

Step 4 — as a fraction of a battery. A 4,400 mAh cell at a nominal 3.85 V holds

4.4 Ah × 3.85 V = 16.94 Wh = 16.94 × 3,600 = 60,984 J
1,320 ÷ 60,984 = 0.0216 = 2.16% of one full charge

That is an acceptable number: about the same as fifteen minutes of screen-on time, spent once, overnight, while charging. The project survives.

Now look at where the 2.16% went.

model share = 2.4 ÷ 33 = 7.3%    decode share = 30 ÷ 33 = 90.9%

The neural network is one-fourteenth of the bill. If you halved the model — a genuinely hard research achievement — you would move total energy from 2.16% to 2.08% of a charge. If instead you used the photo library’s existing thumbnails rather than decoding full-resolution originals, you might cut the decode cost by 20× and take the whole sweep to 0.24% of a charge.

The on-device lesson that generalizes. Measure the pipeline, not the model. Preprocessing, file I/O, colour conversion, and format decoding routinely dominate, because they are unglamorous and nobody profiles them. The model is the part you can talk about at a conference; the JPEG decoder is the part paying for the battery.

Thermal: why 10 minutes of work takes 40 minutes

Wall-clock time for the sweep, if it ran flat out:

40,000 × 15.3 ms = 612,000 ms = 612 s = 10.2 minutes

(taking 8 ms decode + 1 ms resize + 6 ms encode + 0.3 ms write per photo). But the phone will not let you. Sustained heavy accelerator use raises the skin temperature, the governor throttles clocks, and the OS suspends background tasks that misbehave. A realistic sustained duty cycle for a background indexer is 25–40%. At 25%:

10.2 ÷ 0.25 = 40.8 minutes of wall clock

which is fine if you run it while charging and screen-off, and is a disaster if you run it during a user’s first launch while they wait. That scheduling decision is worth more than any model choice on this page.

Concept → realization: what “fits” means in bytes

Put the whole memory picture in one table so you know what you are shipping. Byte counts use 106 per MB, the convention app stores use.

ItemSize at fp32Size at int8Resident when?
MCi0 image tower (11.4 M)45.6 MB11.4 MBOnly during the background sweep
MCt text tower (42.4 M)169.6 MB42.4 MBOnly while a search session is open
Peak activations, 256×256~2 MB~0.5 MBTransient, inside the call
Index, 40,000 × 512-d fp3281.9 MBAlways, if you want instant search
Index, 40,000 × 128-d int8 (Ch 3)5.3 MBAlways

Read the last two rows against each other. The index is the thing that has to be resident all the time, and it is the thing Chapter 3 shrinks by a factor of fifteen for almost nothing. The towers are big but episodic: you load the image tower during the sweep and unload it, you load the text tower when the user opens search and unload it when they leave. Episodic memory is cheap; resident memory is what gets your process killed.

Your model has arithmetic intensity I = 4 MACs/byte and your chip’s machine balance is B = 16 MACs/byte. You cut the model’s MACs in half. What happens to latency?

Chapter 2: Shrinking the Encoder

Chapter 1 gave us a budget. Now we need a model that fits inside it and is still worth running. There are three genuinely different ways to make an encoder smaller, they compose, and people constantly confuse them.

KnobWhat it changesWhat it costsTypical win
ArchitectureThe shape of the computation — which operators, how many channels, what resolutionA full retrain, and search effort to find the shape2–10× on latency
DistillationWhat the small network knows. Same shape, better weightsA big teacher and a training run+5 to +14 accuracy points at fixed size
QuantizationHow each number is stored. Same shape, same knowledge, fewer bitsA calibration pass, sometimes a fine-tune4× on size, 2–4× on time

Pruning is a fourth — removing weights or channels outright — covered in the site’s TinyML series. This chapter builds the first three on a real system, MobileCLIP, and then does the size arithmetic.

Architecture: what a mobile-shaped encoder looks like

MobileCLIP’s image tower, MCi, is built from the FastViT family: reparameterizable convolution blocks plus convolutional token mixing, rather than a pure vision transformer. Two design decisions in it are worth staring at because they are counterintuitive.

Decision one: lower the feed-forward expansion, raise the depth. A standard transformer feed-forward block expands the channel width by 4× and contracts it back. The MobileCLIP authors set that expansion to 3.0 and added layers, keeping the image encoder’s parameter count the same. Their reported finding is that this has minimal impact on latency but a good improvement in capacity.

Why would that be? Because a wide feed-forward layer is a big matrix multiply with low arithmetic intensity per parameter at the small spatial resolutions where it lives — exactly the bandwidth-bound regime from Chapter 1. Trading width for depth moves parameters into places where they are reused across more spatial positions. Same parameter budget, more useful work per byte read.

Decision two: the three MCi variants differ only in width and depth. Here is the published stage configuration, where C is channels per stage and L is blocks per stage:

VariantChannels per stageBlocks per stageParamsMACs @256LatencyImageNet top-1
MCi064, 128, 256, 5122, 6, 10, 211.8 M2.4 G1.5 ms82.2%
MCi164, 128, 256, 5124, 12, 20, 421.9 M4.7 G2.5 ms83.8%
MCi280, 160, 320, 6404, 12, 24, 436.3 M7.8 G3.6 ms84.5%

MCi1 is MCi0 with every stage doubled in depth. MCi2 is MCi1 made 25% wider. Do the marginal arithmetic, because this is how you decide which one to ship:

MCi0 → MCi1:   (83.8 − 82.2) ÷ (2.5 − 1.5) = 1.6 / 1.0 = 1.60 points per ms
MCi1 → MCi2:   (84.5 − 83.8) ÷ (3.6 − 2.5) = 0.7 / 1.1 = 0.64 points per ms

The first millisecond buys 1.6 points; the next 1.1 milliseconds buy 0.7. Diminishing returns, as always — but now quantified, so “which variant” is an arithmetic question rather than a taste question. Multiply by your call count: for the 40,000-photo sweep, choosing MCi2 over MCi0 costs 40,000 × 2.1 ms = 84 s of extra compute and roughly 2.3 extra points of accuracy.

The text encoder ablation, which is the best table in the paper

MobileCLIP’s text tower MCt is a hybrid: convolutional “Text-RepMixer” blocks plus a few self-attention blocks. The authors swept how many self-attention layers to include, and the result is the cleanest illustration of marginal return you will find.

Self-attention layers01246
Parameters (M)38.339.340.442.444.5
Latency (ms)1.21.31.41.61.9
Zero-shot IN-val (%)57.960.060.260.860.9

Compute accuracy per millisecond for the first step and the last step, by hand:

0 → 1 attention layer:   (60.0 − 57.9) ÷ (1.3 − 1.2) = 2.1 / 0.1 = 21.0 points per ms
4 → 6 attention layers:   (60.9 − 60.8) ÷ (1.9 − 1.6) = 0.1 / 0.3 = 0.33 points per ms
ratio = 21.0 ÷ 0.33 = 63× worse marginal return

One attention layer in an otherwise convolutional text encoder is worth 2.1 accuracy points for a tenth of a millisecond. Layers five and six are worth 0.1 points for three-tenths of a millisecond. The paper picks 4, which is where the curve has clearly flattened but has not yet started costing you for nothing.

Why the first attention layer matters so much. Convolutions mix neighbouring tokens. A caption like “a dog running on a beach at sunset” needs long-range binding — “dog” to “running”, “sunset” to the whole scene — and a stack of small convolutions reaches that only by depth. One global self-attention layer provides all-to-all mixing in a single step. The second one adds much less because the first already broke the locality bottleneck. Architecture ablations usually have a shape like this: one qualitative capability, then diminishing quantitative refinement.

Distillation: MobileCLIP’s multi-modal reinforced training

Architecture gets you a fast network. It does not get you an accurate one: small contrastive models trained the ordinary way are markedly worse, because contrastive learning on noisy web captions is data-hungry and a small model cannot absorb enough signal per example.

MobileCLIP’s answer is a training method, not an architecture, and the trick is worth understanding in full because it is reusable.

The ordinary recipe. Take image–caption pairs off the web. Encode both. Push matching pairs together and non-matching pairs apart with a contrastive loss. Problem: web captions are noisy and often not descriptive (“IMG_2231”, “so cute!!”), so the supervision per example is weak.

The reinforced recipe, in three moves.

Move 1 — synthetic captions. Run a captioning model (the paper uses CoCa ViT-L-14) over every image and generate several descriptive captions. Now each image has its real caption and a handful of manufactured ones that actually describe the pixels.

Move 2 — an ensemble teacher. Run a set of strong CLIP encoders — the paper uses OpenAI’s ViT-L-14 and a DataComp-XL ViT-L-14 — over the images and captions and record their embeddings. The student is then trained not only to match the right caption, but to reproduce the similarity structure that the big teachers see. That is knowledge distillation, applied to a contrastive space.

Move 3 — store all of it. This is the part that makes it practical. Captions, image augmentation parameters, and teacher embeddings are computed once, offline, and stored alongside the dataset. The result is called a reinforced dataset. The paper ships two: DataCompDR-12M with 30 stored image augmentations per sample, and DataCompDR-1B with 10 — five synthetic captions each, in both cases. During training you read the teacher’s answer off disk instead of running a ViT-L-14 forward pass. The paper reports that training on DataCompDR therefore carries no time overhead versus non-reinforced training.

once, offline
for each image: k augmentations, s synthetic captions, teacher embeddings for all of them
↓ written into the dataset itself
reinforced dataset
DataCompDR-12M (30 augs) · DataCompDR-1B (10 augs) · 5 synthetic captions each
↓ every training step, read instead of recompute
student loss
(1 − λ) × contrastive(real + synthetic captions)  +  λ × match-the-teacher
↓ 200k iterations, global batch 65,536, AdamW, λ = 1.0 for the small variants
MobileCLIP-S0
MCi0 + MCt, 11.4 M + 42.4 M params, 1.5 + 1.6 ms

Why the small dataset stores more augmentations than the big one

Thirty augmentations for the 12-million-sample set and ten for the billion-sample set reads backwards the first time. Surely the bigger dataset gets the bigger treatment? It does not, and working out why turns a memorized number into a rule you can apply to your own training run.

One stored augmentation is consumed per pass over a sample. Store fewer than the number of passes and the model sees the same crop twice; store more and the extra ones are dead bytes on disk. So the right count is the epoch count. Count epochs for each dataset from the published schedule:

DataCompDR-12M:   45,000 iters × 8,192 batch = 368.6 M seen ÷ 12.8 M = 28.8 passes → 30
DataCompDR-1B:   200,000 iters × 65,536 batch = 13.1 B seen ÷ 1.28 B = 10.2 passes → 10

There it is. The 12M set is small, so a fixed compute budget sweeps it thirty times; the 1B set is large, so the same style of budget sweeps it ten. The augmentation count is not a quality dial that someone tuned. It is the epoch count, precomputed and written into the dataset.

Cross-check it against storage, because a surprising number deserves a second source. Each teacher embedding is 1536-dimensional bfloat16, so 1536 × 2 = 3,072 bytes. Plain DataComp costs 0.9 TB for 12.8 M samples, which is 0.9 × 1012 ÷ 12.8 × 106 = 70 kB per sample of image and caption — and the same 70 kB for DataComp-1B (90 TB ÷ 1.28 B). Now price the reinforcement: one embedding per augmentation, plus one per caption (five synthetic and one real).
30 augs:   (30 + 6) × 3,072 = 110.6 kB   ·   10 augs:   (10 + 6) × 3,072 = 49.2 kB
The paper reports 1.9 TB for DataCompDR-12M (148 kB per sample) and 140 TB for DataCompDR-1B (109 kB per sample). Subtract the 70 kB payload and the reinforcements weigh 78 kB and 39 kB — 0.71 and 0.79 of the uncompressed figures, consistent with the lossless Gzip the paper says it applies. Swap the counts and the predictions swap with them: 12M would land at 70 + 35 = 105 kB against a reported 148, and 1B at 70 + 78 = 148 kB against a reported 109. Neither fits. 30 for 12M, 10 for 1B.

That cross-check is worth the space for a second reason. One sentence in the paper’s own ablation section states the assignment the other way round, contradicting both its dataset section and its storage tables. Papers contain typos. The defence is not to read more carefully; it is to hold every quoted number against a second number that would have to move with it.

The loss, and the coefficient that is quoted wrong more often than any other

The student’s total loss is a single mixing coefficient λ between the ordinary CLIP contrastive term and the distillation term:

Ltotal = (1 − λ) · LCLIP + λ · LDistill

LCLIP is “get this specific pair right” — the standard contrastive loss over a batch of b image–text pairs. LDistill is “see the batch the way the teachers see it”: build the b × b matrix of image-to-text similarities, row-softmax it for both student and teacher, and take the KL divergence between the two, symmetrized over image-to-text and text-to-image. Note the shape carefully — the distillation target is a b × b matrix of relative preferences, not a d-dimensional vector. The student is never asked to reproduce the teacher’s coordinates, only its rankings — which is what lets an 11.4 M-parameter student learn from a far larger ViT-L-14 teacher whose embeddings are a different width entirely. The stored teacher vectors are 1536-dimensional, being the concatenation of two 768-D ensemble members; MobileCLIP-S0’s own embedding is 512-D. Those two numbers never have to match, because only the b × b similarity matrices are compared, and a similarity matrix has the same shape no matter what width produced it.

Now the part that is easy to get wrong, and that circulates in secondhand summaries. The paper’s hyperparameter table lists a CLIP loss weight of 0.25 and a knowledge-distillation weight of 0.75 — that is λ = 0.75 — and that table is the recipe for MobileCLIP-B, the ViT-B/16-based model. The small variants, the ones that matter for a phone, are different: S0, S1 and S2 use λ = 1.0. Same hyperparameters otherwise, but the contrastive term is multiplied by (1 − 1.0) = 0, so it contributes nothing. MobileCLIP-S0 is trained by pure distillation.

Why would the small models want the more extreme setting? Because λ trades classification against retrieval, and the paper measures the whole curve:

λ0.10.30.50.70.80.91.0
Zero-shot IN-val (%)54.457.459.560.761.561.661.7
Flickr30k retrieval71.471.873.874.273.173.272.0

Read the two rows against each other by hand. Classification climbs monotonically all the way to pure distillation; retrieval peaks in the middle and falls off:

λ = 0.7 → 1.0:   IN-val 60.7 → 61.7 = +1.0,   Flickr30k 74.2 → 72.0 = −2.2

So λ = 1.0 buys one point of classification for two points of retrieval, and λ ≈ 0.75 splits the difference. MobileCLIP-B, the flagship trained for the 38-benchmark average, takes the balanced setting. The small variants take the classification-favouring end. Neither is “the” recipe, and quoting 0.25/0.75 as if it were MobileCLIP-S0’s recipe is precisely the error this section exists to prevent.

If λ = 1.0 zeroes the contrastive loss, why bother generating synthetic captions at all? Because the captions are not consumed by the contrastive term — they are consumed by the batches. The distillation loss is evaluated on batches drawn from real captions, synthetic captions, or both, and the paper’s ablation at λ = 1.0 is stark: real captions only gives 56.4 IN-val / 57.0 Flickr30k; synthetic only gives 49.8 / 72.2; alternating between them gives 57.3 / 68.6; and using both in the same step gives 61.7 / 72.0. The teacher’s similarity matrix over a synthetic caption is a different, cleaner piece of supervision than its matrix over “IMG_2231”, and the student wants both kinds. Turning off the contrastive term does not turn off the captions; it changes who reads them.

How much does the training method buy? A controlled row

The paper runs the same architectures on DataComp-12M and on the reinforced DataCompDR-12M, for the same number of iterations, and the difference is entirely the training data’s extra content:

Image encoderParamsDataComp-12MDataCompDR-12MGain
MobileNetV3-L4.9 M34.1%44.7%+10.6
ViT-T/165.6 M32.9%44.1%+11.2
ResNet-5024.6 M40.4%51.9%+11.5
FastViT-MA3643.5 M45.2%58.9%+13.7

Ten to fourteen points of zero-shot ImageNet accuracy, on identical architectures, from changing nothing but what the dataset contains. Put that next to the architecture table: going from MCi0 to MCi2 was worth 2.3 points and cost 2.1 ms per call. The training method was worth 10–14 points and costs nothing at inference.

The single most important trade in on-device ML. Inference-time cost is paid by every user, on every call, forever. Training-time cost is paid once, by you. Any technique that converts inference cost into training cost is enormously favourable, and distillation is the purest example: the student is not one operation slower for having had a teacher. This is why “we distilled it” beats “we made it bigger” in every mobile roadmap.

The headline results, and what they mean for our photo app

MobileCLIP-S0 is MCi0 plus MCt: 11.4 M + 42.4 M parameters, 1.5 + 1.6 ms on an iPhone 12 Pro Max, 67.8% zero-shot ImageNet-val, and 58.1 average over the 38-benchmark DataComp suite. The paper’s framing is that S0 is roughly 5× faster and 3× smaller than OpenAI’s ViT-B/16 CLIP at the same average accuracy, and that MobileCLIP-S2 is 2.3× faster than the previous best ViT-B/16-based CLIP while being more accurate.

Also reported: the training approach delivers 10× to 1000× improved learning efficiency compared with non-reinforced CLIP training — 10× on iterations and 100× on data for ImageNet-val, and up to 1000× on Flickr30k retrieval.

Plug S0 into Chapter 1’s sweep: 40,000 photos at 1.5 ms of model time is 60 seconds of accelerator work. The decode is still the bill. The model has stopped being the problem, which is precisely the point of the whole exercise.

Quantization: the mechanics, derived

Now the third knob. Quantization replaces a 32-bit float with a small integer plus a shared scale. For symmetric 8-bit quantization of a group of weights w:

s = max|w| ÷ 127     qi = round(wi ÷ s)     ŵi = qi × s

Here s is the scale — the real-world value of one integer step — and q is an integer in [−127, 127]. Store q as one byte and s once per group. The reconstruction ŵ is not equal to w; the difference is quantization error, and the entire art is controlling it.

The crucial and non-obvious fact: s is set by the largest value in the group, but the error it inflicts is suffered by every value in the group. One outlier ruins the neighbourhood. Let us prove it with arithmetic you can check by hand.

Worked example: one outlier costs three bits

Take six weights from a row, five of them ordinary and one large:

w = [ 0.12, −0.05, 0.31, 0.02, −0.28, 2.40 ]

Case A — per-tensor scale, outlier included. max|w| = 2.40, so

sA = 2.40 ÷ 127 = 0.0188976
ww / sq = roundŵ = q·serror
0.126.35060.113386−0.006614
−0.05−2.646−3−0.056693−0.006693
0.3116.404160.302362−0.007638
0.021.05810.018898−0.001102
−0.28−14.816−15−0.283465−0.003465
2.40127.0001272.4000000.000000

Mean absolute error over the five small weights:

(0.006614 + 0.006693 + 0.007638 + 0.001102 + 0.003465) ÷ 5 = 0.025512 ÷ 5 = 0.005102

Case B — the outlier lives in a different group. Now the five small weights get their own scale: max = 0.31, so

sB = 0.31 ÷ 127 = 0.00244094
ww / sqŵerror
0.1249.162490.119606−0.000394
−0.05−20.484−20−0.048819+0.001181
0.31127.0001270.3100000.000000
0.028.19480.019528−0.000472
−0.28−114.712−115−0.280709−0.000709
mean |error| = (0.000394 + 0.001181 + 0 + 0.000472 + 0.000709) ÷ 5 = 0.002756 ÷ 5 = 0.000551

The comparison.

0.005102 ÷ 0.000551 = 9.26× more error when the outlier shares the group

And the reason is mechanical — it is exactly the ratio of the scales:

sA ÷ sB = 2.40 ÷ 0.31 = 7.74   ⇒   log2(7.74) = 2.95 bits

One large weight in the group costs every other weight in that group roughly three bits of effective precision. Your int8 quantization delivers, in practice, about five bits of usable resolution on the weights that matter. That is why per-channel quantization (a separate scale per output channel) and group-wise quantization (a separate scale per 64 or 128 weights) are not micro-optimizations — they are the difference between a model that survives quantization and one that does not.

The overhead of finer scales, priced

Every extra scale is a stored number. Group size g with an fp16 scale and an fp16 zero-point costs 4 bytes per group, spread over g weights:

overhead bits per weight = 32 ÷ g
Group size gOverhead bits/weightEffective bitsSize of 11.4 M weights
whole tensor≈08.0011.40 MB
per output channel (~512)0.06258.0611.49 MB
1280.2508.2511.76 MB
321.0009.0012.83 MB

Per-channel scales cost you 0.8% more storage and buy you the 9× error reduction we just computed. It is the easiest trade in this entire lesson and it is still, somehow, skipped.

Weights versus activations: two very different problems

Quantizing weights is comparatively easy: they are fixed, you can look at them all, you can choose scales offline. Quantizing activations is hard: their range depends on the input, so the scale must either be recomputed per call (dynamic quantization, costing a pass over the tensor to find the max) or fixed in advance from a calibration set (static quantization, which fails whenever a real input exceeds what the calibration set contained).

A concrete failure mode: you calibrate on 500 daytime photos, the activation range comes out as [−6, 6], you set the scale, you ship. A user photographs a bright specular highlight, an activation hits 14, and it clips to 6. Not a small error — a hard truncation, in one channel, early in the network, that propagates through every subsequent layer. The symptom is that the model works beautifully on your test set and produces nonsense on 0.3% of real photos, which is exactly the hardest kind of bug to find.

Int8 quantization error explorer

A group of weights, drawn as bars, with the int8 grid drawn behind them. Slide the outlier up and watch the grid coarsen for everyone. Switch to per-channel and the outlier moves to its own group. The panel reports the step size, the mean absolute error over the five ordinary weights, and how many bits of effective precision the outlier costs them.

outlier magnitude2.40
bits8

Worked example: size math for the whole shipped system

Now assemble the app. Bytes at 106 per MB.

ComponentParamsfp32fp16int8int4
MCi0 image tower11.4 M45.6 MB22.8 MB11.4 MB5.7 MB
MCt text tower42.4 M169.6 MB84.8 MB42.4 MB21.2 MB
Both towers53.8 M215.2 MB107.6 MB53.8 MB26.9 MB

Check one cell by hand: 42.4 × 106 parameters × 1 byte = 42.4 × 106 bytes = 42.4 MB. And at fp32, × 4 = 169.6 MB.

An extra move that matters more than it should: 25.3 M of MCt’s 42.4 M is the token embedding table, and lookup tables tolerate aggressive quantization far better than compute weights do, because their errors do not compound through layers. Quantize the table to int4 and the rest to int8:

25.3 M × 0.5 B + 17.1 M × 1 B = 12.65 + 17.1 = 29.75 MB
saving vs. uniform int8: 42.4 − 29.75 = 12.65 MB, a 30% cut on the text tower

Total shipped model size drops from 53.8 MB to 41.2 MB. For an app that also has to contain, you know, an app, that is a real decision.

Order of operations. Architecture first (it determines everything downstream and requires the longest retrain). Then distillation (it needs the architecture fixed). Then quantization (it is a post-processing step on a trained model, and if it hurts too much, quantization-aware training is a short fine-tune, not a new project). Doing them in the other order means redoing all of them.
A group of weights is quantized to int8 with a shared scale. One weight in the group is 8× larger than the rest. Roughly what happens to the other weights?

Chapter 3: Dimension × Precision

The model is small. Now shrink the index, which is the thing that must stay resident forever and therefore the thing most likely to get your process killed.

There are two independent axes, and the good news is that they multiply.

bytes per vector = d × (b / 8) + overhead

where d is the number of dimensions kept and b is bits per dimension. Halve d, quarter b, and you have divided by eight. The question is what it costs in retrieval quality, and the honest answer depends entirely on whether the embedding was trained to be truncatable.

Why you cannot just chop a normal embedding

Take a 768-dimensional embedding from an ordinary encoder and keep the first 128 numbers. What did you lose?

For a typical trained encoder, the information is spread roughly evenly across coordinates — there is no reason for coordinate 3 to matter more than coordinate 500, because nothing in the loss ever asked for that. So a unit-norm vector distributes its squared length across all 768 slots, and keeping the first 128 keeps about

128 ÷ 768 = 0.167 = 16.7% of the squared norm

The truncated vector is essentially a random 128-dimensional projection of the original. Random projections do preserve inner products — that is the Johnson–Lindenstrauss result — but only up to a noise floor that scales as one over the square root of the kept dimension:

standard error of the estimated cosine ≈ 1 ÷ √d′ = 1 ÷ √128 = 0.088

So any two photos whose true cosine similarity to your query differs by less than about 0.09 will swap places essentially at random. In a photo library, the top-20 results for “dog on a beach” are all within 0.09 of each other — that is what it means for them all to be good matches. You have destroyed the ranking you cared about and kept the ranking you did not.

Matryoshka: train the vector to be truncatable

Matryoshka Representation Learning (Kusupati et al., 2022) fixes this by changing the training objective, not the model. The idea is one sentence long: compute the loss at several nested prefix lengths at once.

Pick a nesting set, say M = {8, 16, 32, 64, 128, 256, 512, 1024, 2048}. For each m in M, take the first m coordinates of the embedding, push them through a head, and compute the task loss. Sum:

LMRL = Σm ∈ M cm · L( headm( z[0:m] ), y )

Now think about what gradient coordinate 3 receives. It appears in every term of the sum — the 8-dimensional one, the 16-dimensional one, all of them. Coordinate 1000 appears only in the terms with m ≥ 1024. The early coordinates are under nine times the pressure, so the optimizer puts the most broadly useful information there. The result is a vector ordered by importance, coarse to fine — nested dolls.

The paper reports: up to 14× smaller embedding size for ImageNet-1K classification at the same accuracy, up to 14× real-world speedups for large-scale retrieval, and up to 2% improvement on long-tail few-shot classification — all while being as robust as the original representation, and with no additional cost at inference or deployment. That last clause is the one that matters for us: MRL is free at the edge. You pay for it once, in the teacher’s training run.

The realization, in one line of code. A matryoshka embedding is not a special object. It is a normal float array whose author promised that prefixes are meaningful. The on-device code is z[:128]. The entire technique lives in someone else’s loss function, and your job is knowing whether they used it — because z[:128] compiles either way and silently produces garbage when they did not.

Worked example: truncate, and then renormalize (do not skip this)

Take an 8-dimensional embedding built from raw scores u = [6, 4, 3, 2, 1.5, 1.2, 1, 0.8]. First normalize it:

||u||2 = 36 + 16 + 9 + 4 + 2.25 + 1.44 + 1 + 0.64 = 70.33
||u|| = √70.33 = 8.3863
z = [0.7154, 0.4770, 0.3577, 0.2385, 0.1789, 0.1431, 0.1192, 0.0954]

Now truncate to the first four coordinates and measure how much length survived:

0.71542 + 0.47702 + 0.35772 + 0.23852 = 0.5118 + 0.2275 + 0.1279 + 0.0569 = 0.9241
||z[0:4]|| = √0.9241 = 0.9613

So 92.4% of the squared norm lives in the first half — that is what MRL training buys, versus the 50% you would expect from an unordered vector. Renormalize:

z′ = z[0:4] ÷ 0.9613 = [0.7442, 0.4962, 0.3721, 0.2481]

Why the division is not cosmetic. Suppose photo A retains 92.4% of its energy in the first four coordinates and photo B, a fuzzier image, retains only 80%. If you skip renormalization and rank by raw dot product, A gets a systematic multiplicative advantage of

√(0.9241 / 0.80) = √1.1551 = 1.0748

a 7.5% ranking bonus that has nothing to do with whether A matches the query. It is a bias toward “photos whose meaning is concentrated in the early coordinates,” which is not a category anybody asked to search for. Skipping one division introduces a systematic retrieval bug that no unit test will catch, because every individual similarity looks plausible.

Composing truncation with quantization

Now stack the second axis. After truncating to 128 dimensions and renormalizing, quantize the vector to int8 with a per-vector scale:

s = max|z′| ÷ 127,   stored as one fp16   (plus one fp16 for the original norm, if you want it back)
bytes per vector = 128 × 1 + 2 + 2 = 132 bytes

The full index, for 40,000 photos:

40,000 × 132 = 5,280,000 bytes = 5.28 MB

against the fp32 768-dimensional baseline:

40,000 × 768 × 4 = 122,880,000 bytes = 122.88 MB
compression = 3,072 ÷ 132 = 23.3×

How much does int8 hurt an embedding? Much less than you fear

Here is a genuinely surprising result and it is worth deriving, because it explains why quantizing vectors is safe while quantizing weights is dangerous.

A unit-norm 128-dimensional vector has coordinates averaging 1/√128 = 0.0884 in magnitude; the largest might be around 0.25. So the per-vector scale is

s = 0.25 ÷ 127 = 0.0019685

Rounding error per coordinate is roughly uniform on [−s/2, +s/2], with variance s2/12. Over 128 coordinates the error vector has expected squared length

E||e||2 = 128 × s2 / 12 = 128 × (0.0019685)2 / 12 = 128 × 3.875×10-6 / 12 = 4.133 × 10-5
||e|| = √(4.133 × 10-5) = 0.00643

The cosine between the true vector and its reconstruction, for an error orthogonal in expectation, is

cos ≈ 1 ÷ √(1 + ||e||2) = 1 ÷ √(1.0000413) = 0.99998

Two-hundred-thousandths of a percent. Int8 quantization of a normalized embedding is, for retrieval purposes, free.

Why weights are different. A weight’s quantization error is multiplied by an activation and then fed into the next layer, whose error is multiplied again. Errors compound multiplicatively through depth. An embedding’s quantization error is the last thing that happens before you take a dot product — nothing downstream amplifies it. Same technique, same bit width, wildly different risk. This is why “we quantized to int8” is a meaningless sentence until you say what you quantized.

Going further: binary embeddings

If int8 is nearly free, what about one bit per dimension? Keep only the sign:

bi = 1 if zi > 0, else 0   ⇒   128 bits = 16 bytes
40,000 × 16 = 640,000 bytes = 640 kB   (3,072 / 16 = 192× smaller)

And the comparison becomes a Hamming distance — XOR two 128-bit words and count the set bits, which every modern CPU does in a couple of instructions. There is a clean relationship between Hamming distance and angle for random-ish vectors: the probability that two coordinates disagree in sign is approximately θ/π, where θ is the angle between the vectors. So

Hamming(a, b) ≈ d · θ / π   ⇒   θ ≈ π · Hamming / d

Worked: two 128-bit codes differ in 20 positions.

θ ≈ 3.1416 × 20 ÷ 128 = 0.4909 rad = 28.1°  ⇒  cos θ ≈ 0.882

The cost is resolution: with 128 bits there are only 129 possible distance values, so huge numbers of photos tie. That is not a defect if you use binary codes for what they are good at — a fast, coarse filter.

The pattern that makes all of this work: coarse pass, then rescore

Do not choose one precision. Use two.

stage 1 — scan everything, cheaply
40,000 × 64-bit binary codes = 320 kB. Hamming distance via popcount. Keep the best 200.
↓ 200 candidates out of 40,000, a 0.5% shortlist
stage 2 — rescore the shortlist, accurately
200 × 768-d fp16 = 307 kB read, 153,600 MACs. Exact cosine. Reorder.
↓ the 50 you show the user

Cost arithmetic for stage 1: 40,000 popcounts of a 64-bit word. A phone core does on the order of 109 of those per second, so

40,000 ÷ 109 = 4 × 10-5 s = 40 microseconds

Stage 2: 200 × 768 = 153,600 MACs, plus 307 kB of sequential reads. Both are far under a millisecond. Total: well under 1 ms for a search over 40,000 photos, using 320 kB of resident memory for the coarse codes and reading the full vectors from disk only for 200 of them.

The one thing that can go wrong, and how to measure it. Stage 2 can only reorder what stage 1 handed it. If the true best photo was ranked 400th by the binary codes, no amount of exact rescoring will find it. So the quantity you must measure is shortlist recall: over a held-out set of real queries, what fraction of the exact top-10 appears in the coarse top-k? Sweep k until it plateaus. The usual answer for binary-then-exact is k around 10–20× the number of results you display — but “usual” is not a substitute for measuring it on your embeddings.
Dimension × precision grid

Each cell is one design: kept dimensions across, bits per dimension down. Cell colour is index size for the library you set; the number inside is megabytes. Outlined cells fit the memory budget. Toggle between a matryoshka-trained embedding and an ordinary one to see the quality column collapse.

library size40,000
memory budget (MB)20

Concept → realization: the index record, byte by byte

Here is what actually sits on disk, per photo, in the design we have arrived at. Total 148 bytes.

c
struct IndexRecord {          // 148 bytes, packed, one per photo
    uint64_t asset_id;        // 8  B  - photo library identifier
    uint64_t coarse;          // 8  B  - 64-bit sign code, stage 1
    int8_t   vec[128];        // 128 B - matryoshka prefix, renormalized, int8
    uint16_t scale_f16;       // 2  B  - per-vector scale s = max|z| / 127
    uint16_t norm_f16;        // 2  B  - original ||z[0:128]|| before renormalizing
};

// 40,000 photos -> 5,920,000 bytes = 5.92 MB resident.
// The coarse codes alone, gathered into one contiguous array for
// stage 1, are 40,000 * 8 = 320 kB - and that array is the ONLY
// thing that has to be hot in cache during a scan.

Note the layout decision hiding in that comment. If you store the records as an array of structs, stage 1 touches 8 useful bytes out of every 148, so a 320 kB working set becomes 5.92 MB of memory traffic — eighteen times more than necessary, and enough to blow out the cache. Store the coarse codes in their own contiguous array (a struct of arrays) and stage 1 streams 320 kB, which fits comfortably in a phone’s last-level cache. Same data, same algorithm, an order of magnitude in real latency.

You truncate a 768-dim embedding to its first 128 coordinates but forget to renormalize before comparing. What specific bug have you introduced?

Chapter 4: The Index on the Phone

You have 40,000 vectors of 128 int8 numbers. A query arrives. What data structure do you use?

The reflex answer, imported from server-side vector databases, is an approximate nearest neighbour index — HNSW, usually. This chapter argues that on a phone the reflex is usually wrong, and does the arithmetic to say exactly when it stops being wrong. The conclusion is uncomfortable for people who like building indexes: up to a few hundred thousand vectors, a plain loop wins.

The plain loop, priced honestly

Flat search, also called brute force or exhaustive search, computes the similarity to every stored vector and keeps the best k. For N vectors of d dimensions:

work = N × d multiply–accumulates     traffic = N × (d + overhead) bytes

For N = 100,000 and d = 128 at int8:

MACs = 100,000 × 128 = 12,800,000 = 12.8 M
bytes = 100,000 × 132 = 13,200,000 = 13.2 MB

Compute time. ARM cores have an instruction (SDOT) that performs sixteen 8-bit multiply-accumulates in one go on a 128-bit register. At 2 GHz with two such pipes the theoretical peak is 2 × 109 × 16 × 2 = 64 × 109 int8 MACs per second. Real code with loads and reductions gets maybe an eighth of that; call it 8 × 109:

tcompute = 12.8 × 106 ÷ (8 × 109) = 1.6 × 10-3 s = 1.6 ms

Memory time. The scan is perfectly sequential, so it gets close to peak bandwidth. At 15 GB/s:

tmemory = 13.2 × 106 ÷ (15 × 109) = 8.8 × 10-4 s = 0.88 ms

Arithmetic intensity is 12.8 M / 13.2 MB = 0.97 MACs per byte, versus a machine balance here of 8 × 109 / 15 × 109 = 0.53. So it is mildly compute-bound: about 1.6 ms on one core, and under half a millisecond across four.

Now the comparison that should end most arguments:

PropertyFlat scanHNSW
Query time, N = 100k~1.6 ms (one core)~0.15–0.3 ms
Extra memoryzero~13 MB (derived below) — doubles the index
Build timezerotens of seconds on a phone, thermally throttled
Recall@1exactly 100%95–99%, depending on parameters you must tune
Insert one photoappend 132 bytesa graph insertion touching many existing nodes
Delete one photoremove 132 bytestombstone now, full rebuild later
Code you own~40 linesa library, its memory model, and its failure modes

You are buying 1.3 milliseconds — inside a 60-millisecond query path where thumbnails cost 40 — and paying with double the resident memory, a rebuild problem, and a silent recall loss.

Deriving HNSW’s memory, because nobody publishes it

Hierarchical Navigable Small World graphs (Malkov & Yashunin, 2016) build a multi-layer proximity graph. Each element is assigned a maximum layer drawn from an exponentially decaying distribution, upper layers act as an express highway, and search descends greedily. It is an excellent structure. It is also, on a phone, expensive in exactly the currency you have least of.

The level of a node is drawn as level = ⌊−ln(U) · mL⌋ for a uniform U, with the standard choice mL = 1/ln(M). Then

P(level ≥ 1) = P( −ln U · mL ≥ 1 ) = P( U ≤ e−1/mL ) = e−ln M = 1 / M

A pleasing result: with the standard parameter choice, exactly one node in M is promoted to each next layer. With M = 16, the expected number of upper-layer memberships per node is a geometric sum:

Σk≥1 (1/16)k = (1/16) ÷ (1 − 1/16) = 1/15 = 0.0667

Layer 0 stores up to Mmax0 = 2M = 32 links per node; each upper-layer membership stores up to M = 16. Expected links per node:

32 + 0.0667 × 16 = 32 + 1.07 = 33.07 links

At 4 bytes per neighbour identifier:

33.07 × 4 = 132.3 bytes of graph per node

Which is — and this is a genuinely useful coincidence to remember — exactly the size of the vector itself in our design. HNSW with M = 16 over 128-dimensional int8 vectors doubles your index memory.

100,000 nodes × 132.3 B = 13.2 MB of graph, on top of 13.2 MB of vectors

Raise M to 32 for better recall and it gets worse: Mmax0 = 64, mL = 1/ln 32, expected upper memberships = 1/31 = 0.0323, so 64 + 0.0323 × 32 = 65.03 links = 260 bytes per node — twice the vector, for 26 MB of graph.

Build cost: why it is 40 seconds and not 3

Inserting a node runs a greedy search down the layers with a candidate list of size efConstruction, then applies the neighbour-selection heuristic. A typical insert at efConstruction = 200 evaluates on the order of 2,000 distances. For 100,000 insertions:

100,000 × 2,000 = 2 × 108 distance evaluations
arithmetic: 2 × 108 × 128 = 2.56 × 1010 MACs ÷ (8 × 109/s) = 3.2 s

Three seconds of math. But every one of those distance evaluations requires fetching a vector at a random address in a 13 MB array — far larger than the last-level cache. Each miss costs about 100 ns to DRAM, and a graph traversal typically incurs a couple per candidate:

2 × 108 × 2 misses × 100 ns = 4 × 1010 ns = 40 seconds

Twelve times the arithmetic time, spent waiting for memory. On a phone this is worse still, because 40 seconds of sustained random-access work heats the SoC, the governor throttles, and the OS may suspend your background task partway through — leaving you with a half-built graph and no clean way to resume.

The reason ANN is a server technique. Graph indexes trade sequential memory access for random memory access, and that trade is good when your bottleneck is arithmetic over hundreds of millions of vectors. On a phone with a hundred thousand vectors, the arithmetic was never the bottleneck — so you have paid the full price of random access and bought nothing that mattered.

Where flat search actually breaks

Push N up and watch which wall you hit first. All at 128-d int8, 132 bytes per vector.

NIndex bytesFlat MACsFlat time (1 core)Verdict
10,0001.3 MB1.28 M0.16 msTrivial. Flat, obviously.
100,00013.2 MB12.8 M1.6 msFlat. An index buys 1.3 ms and costs 13 MB.
1,000,000132 MB128 M16 msMemory is the problem, not time.
10,000,0001.32 GB1.28 G160 msNot a phone workload as posed.

Look carefully at the million-vector row. Sixteen milliseconds is annoying but survivable — four cores brings it to four. What is not survivable is 132 MB resident. Background execution contexts on mobile platforms get a small fraction of a foreground app’s memory allowance, often only tens of megabytes, and exceeding it does not make you slow, it makes you terminated.

The correct escalation order on a phone. When flat search stops fitting, the first move is not “add an index.” It is go back to Chapter 3 and shrink the vectors, because that fixes both the memory wall and the time wall at once, whereas an index fixes only time and makes memory strictly worse. Truncating 1 M vectors from 128-d int8 to 64-d binary takes 132 MB to 8 MB — a 16× improvement — and the scan gets faster too.

The index everybody forgets: partition by something the user already understands

Photo libraries have structure that has nothing to do with vectors. Roughly 80% of searches are about the last year or two. Photos belong to albums, to places, to people.

So: keep one flat array per year. Scan the most recent three years first — say 12,000 of the 40,000 vectors — and only fall through to the rest if the user scrolls past the first page or the best similarity is below a threshold. Expected work:

0.80 × 12,000 + 0.20 × 40,000 = 9,600 + 8,000 = 17,600 vectors scanned on average
versus 40,000 always  ⇒  2.3× less work, exact results, zero extra memory

It is not clever and it will not appear in a paper. It also costs nothing, never loses recall, needs no rebuild, and handles deletion by deleting. When a partition key correlates with query intent, partitioning beats approximation.

Memory-mapping: the index that is not in memory at all

There is a third option between resident and absent. Memory-map the index file and let the operating system page it in. A sequential scan over a 5.28 MB mapped file reads at storage speed — modern phone flash does over 1 GB/s sequentially:

5.28 × 106 ÷ (1.5 × 109) = 3.5 × 10-3 s = 3.5 ms on a cold read

and effectively zero on subsequent queries, because the page cache holds it — memory you did not have to claim, and that the OS can reclaim under pressure without killing you. Your resident set stays tiny and your warm-query latency stays low.

Now do the same for HNSW. Its access pattern is random, so each hop is a potential page fault: a 4 kB page read at roughly 100 microseconds of storage latency, times a few hundred hops.

300 hops × 100 µs = 30,000 µs = 30 ms, versus 3.5 ms for the flat scan

The “fast” index is now ten times slower than the “slow” one, purely because of locality. Sequential access is not a minor implementation detail on constrained hardware; it is the whole game.

Flat vs. HNSW on a phone

Set the library size and vector shape. The chart shows query latency (top bars) and total resident memory (the stacked columns below) for a flat scan and for HNSW at your chosen M. The dashed line is the memory ceiling of a background task. Watch which structure crosses it first.

vectors N100,000
dimensions d128
HNSW M16
memory ceiling (MB)60

Concept → realization: the forty-line scanner

Here is the entire “vector database” for the photo app. Note that it never allocates, never rebuilds, and returns exact results.

c
// coarse: N contiguous uint64 sign-codes.  q64: query's sign-code.
// Stage 1 - 320 kB streamed, ~40 microseconds for N = 40,000.
void stage1(const uint64_t* coarse, int N, uint64_t q64,
            int* out_idx, int K) {
    // keep the K smallest popcounts with a tiny fixed-size max-heap
    for (int i = 0; i < N; ++i) {
        int h = __builtin_popcountll(coarse[i] ^ q64);   // 1 instruction
        heap_offer(out_idx, K, i, h);
    }
}

// Stage 2 - exact int8 dot product over the K survivors only.
// vdot8 is one SDOT-based loop: 16 MACs per instruction.
void stage2(const int8_t* vecs, const int8_t* q, int d,
            const int* cand, int K, float* score) {
    for (int j = 0; j < K; ++j)
        score[j] = vdot8(vecs + (size_t)cand[j] * d, q, d);
}

The design decisions embedded in twenty lines: coarse codes in their own array so stage 1 is a pure sequential stream; a fixed-size heap so there is no allocation in the hot path; size_t on the index multiply so a million-vector library does not overflow a 32-bit product; and stage 2 touching only K vectors so the random-access part is bounded by a number you chose rather than by N.

On a phone with 100,000 128-dim int8 vectors, why is HNSW usually the wrong choice?

Chapter 5: Always On

Everything so far assumed the model runs when asked. Now consider the harder regime: a model that runs forever. A wake word listener. A fall detector. A gesture watcher. These are embedding models too — and their design is dominated by a currency the photo app barely noticed.

Power is not latency

Latency is joules per call. Power is joules per second. They are related by the call rate:

P = Ecall × fcall

A model that takes 2.4 mJ per call — the one from Chapter 1, which we happily ran 40,000 times — becomes, at 50 calls per second:

P = 2.4 mJ × 50 /s = 120 mW, continuously
over 24 h: 0.120 W × 86,400 s = 10,368 J = 10,368 / 60,984 = 17% of a full battery, every day

Seventeen percent of the battery for a background listener is a product that gets deleted. The same model, the same joules per call, and a completely different verdict — because the rate changed.

The shape of a keyword spotter

Keyword spotting (KWS) is the canonical always-on model: detect a small set of words in a continuous audio stream, on a device that may be a microcontroller. The classic reference is Zhang et al., Hello Edge (2017), which evaluated the architecture family under microcontroller memory and compute constraints and found depthwise-separable convolutions to be the sweet spot — DS-CNN reaching 95.4% accuracy, about 10 points above a plain fully-connected network with a similar parameter count.

The front end and the timing:

microphone
16 kHz mono, continuous
↓ frame: 40 ms window, 20 ms hop
feature frames
40 MFCC coefficients per frame, 50 frames per second
↓ stack a 1-second context
input tensor
49 frames × 40 coefficients
↓ small conv stack
embedding
a d-dimensional vector, once every 20 ms
↓ compare to stored keyword prototypes
score
one similarity per keyword, smoothed over time

Check the 49: a 1-second context at a 20 ms hop with a 40 ms window gives (1000 − 40)/20 + 1 = 49 frames. The window slides by one frame every 20 ms, so a naive implementation runs the whole network 50 times a second.

Worked example: the streaming trick, worth 49×

Here is the single most important optimization in always-on inference, and it is a pure engineering win with no accuracy cost.

Naive. Every 20 ms, assemble the last 49 frames and run the whole network. Say the network is 20 M MACs per call:

20 × 106 MACs × 50 /s = 1 × 109 MACs/s = 2 GOPS

At a DSP efficiency of 100 GOPS per watt — a reasonable figure for a low-power audio DSP doing int8 convolutions:

P = 2 ÷ 100 = 0.02 W = 20 mW
daily: 0.02 × 86,400 = 1,728 J = 1,728 / 60,984 = 2.83% of a battery per day

Streaming. Notice that 48 of those 49 frames were already processed on the previous call, and a convolution is translation-equivariant: the activation it computed for frame 17 last time is bit-for-bit the activation it needs for frame 17 this time. So cache the intermediate activations in a ring buffer and compute only the new frame’s column. Incremental work:

20 × 106 ÷ 49 = 4.08 × 105 MACs per call
4.08 × 105 × 50 /s = 2.04 × 107 MACs/s = 0.0408 GOPS
P = 0.0408 ÷ 100 = 4.08 × 10-4 W = 0.41 mW
daily: 0.000408 × 86,400 = 35.3 J = 0.058% of a battery per day

Twenty milliwatts becomes four-tenths of a milliwatt. Forty-nine times, exactly the ratio of receptive field to hop, and the model is numerically identical.

What the cache costs. For each convolution layer with kernel width k and C channels, you must retain the last k−1 columns of its input. For four layers of k = 3 and C = 64, at int8:

4 layers × (3 − 1) × 64 × 1 byte = 512 bytes

Half a kilobyte of state buys a 49× power reduction. This is why streaming or causal convolutions are mandatory, not optional, in always-on designs — and why an architecture with a global operation (a pooling over the whole window, a full self-attention) is disqualified: nothing can be cached across a shift if every output depends on every input.

The design constraint hiding in that sentence. Always-on models must be causal and local, not because of accuracy but because of arithmetic reuse. That excludes several architectures which are otherwise excellent. On the edge, the ability to be computed incrementally is an architectural property with a 50× price tag on it, and it must be a first-class criterion in model selection.

The cascade: why the big model is allowed to exist at all

Even 20 mW is too much for some devices, and no useful embedding model runs at 100 µW. The universal answer is a cascade: a chain of increasingly expensive, increasingly accurate stages, where each stage only wakes the next.

StageWhat it doesPower when activeDuty cycle
1. Voice activity detectionIs there any speech-like energy at all?0.5 mW100%
2. Keyword spotterDoes this second contain the wake word?20 mW5%
3. Application processorFull recognition, intent, response900 mW0.1%

Average power:

P̄ = 1.00(0.5) + 0.05(20) + 0.001(900) = 0.5 + 1.0 + 0.9 = 2.4 mW

Compare with running stage 3 continuously at 900 mW:

900 ÷ 2.4 = 375× saving

And look at how evenly the 2.4 mW is split: 0.5 from the always-on stage, 1.0 from the sometimes stage, 0.9 from the rare stage. A balanced cascade has roughly equal contributions from each tier — if one term dominates, that is where your next engineering hour goes. If the VAD term dominated, you would move it into analog hardware; if the AP term dominated, you would tighten stage 2’s specificity.

Worked example: the false-alarm arithmetic that shocks everyone

The always-on regime imposes a specificity requirement so extreme that most engineers get it wrong on the first try. Count the opportunities.

50 evaluations/s × 86,400 s/day = 4,320,000 windows per day

Suppose your detector has a per-window false-accept rate of 0.5% — which sounds excellent, and would be a fine number for an offline classifier.

4,320,000 × 0.005 = 21,600 false wakes per day

That is a wake every four seconds. Now invert the question: to tolerate one false wake per day, what per-window rate do you need?

1 ÷ 4,320,000 = 2.31 × 10-7 = 0.0000231%

Seven-figure specificity. No single-window classifier gives you that at any useful sensitivity. The solution is not a better model but a better decision rule: require the detector to fire on several consecutive windows.

If false alarms in adjacent windows were independent with rate p, requiring k in a row gives approximately pk. With p = 0.005 and k = 3:

(0.005)3 = 1.25 × 10-7
4,320,000 × 1.25 × 10-7 = 0.54 false wakes per day

From 21,600 to one every two days, and the cost is

3 windows × 20 ms = 60 ms of added latency

Sixty milliseconds is imperceptible on a wake word. That is the trade: latency you cannot feel, bought with four orders of magnitude of specificity.

The caveat you must not skip. The independence assumption is false. Consecutive windows overlap by 980 ms out of 1,000 — they share 98% of their audio — so their errors are strongly correlated, and the true rate is much worse than p3. The honest procedure is to run the detector over many hours of held-out real audio, measure the empirical distribution of consecutive-exceedance run lengths, and pick k from that. Reporting p3 as your false-alarm rate is a textbook way to ship a product that wakes up during television.

Why an embedding rather than a classifier

The obvious KWS design is a classifier with one output per keyword. It works, and it has a fatal product limitation: the keyword set is frozen at training time, so a user cannot invent their own wake word without a round trip to your training infrastructure.

The embedding design fixes this. Train a model that maps a one-second audio window to a vector such that utterances of the same word land near each other, regardless of which word it is. Then a keyword is just a stored vector.

Mazumder et al., Few-Shot Keyword Spotting in Any Language (2021), built exactly this: an automated multilingual keyword bank mined from open speech corpora in nine languages, used to train an embedding model. Their reported results:

SettingResult
5-shot fine-tune, 180 new keywords, 9 seen languagesaverage F1 0.75
5-shot, 260 keywords across 13 unseen languagesaverage F1 0.65
Streaming keyword spotting, 440 keywords, 22 languages87.4% accuracy at a 4.3% false-accept rate

Five examples. In a language the embedding model never saw. That is the payoff of embeddings over classification heads: the taxonomy becomes data instead of architecture, exactly as in the photo app where the query became data instead of a tag list.

Concept → realization: adding a wake word on the device

The whole personalization flow, and how little it costs.

python
# user records their chosen wake word five times
clips = record_five()                        # 5 x float32[16000]
E     = [embed(c) for c in clips]           # 5 x float32[1024]

# the "training" is one line: the prototype is the mean direction
proto = normalize(mean(E, axis=0))            # float32[1024]
store(proto)                                 # 1024 * 4 = 4,096 bytes

# at inference, once every 20 ms, on the streaming embedding
score = dot(proto, z_t)                      # 1024 MACs. That is the whole detector.
if score > tau and run_length >= 3:
    wake()

Four kilobytes per custom wake word, 1,024 MACs per evaluation on top of the embedding, no retraining, no server, no new model download. A better version fits a small logistic regression on the five positives plus a bank of stored negatives — still a handful of kilobytes — but the prototype version already works and is the right thing to ship first.

And note the calibration problem hiding in tau. Five recordings tell you where the positives are but nothing about where the negatives are, so τ cannot be chosen from them. The fix is to ship a fixed bank of, say, 5,000 stored negative embeddings (ordinary speech, television, kitchen noise), score them against the new prototype on the device, and pick τ at the percentile that meets your false-alarm budget. 5,000 × 1,024 × 1 byte at int8 = 5.1 MB shipped once, and it converts an uncalibratable threshold into an arithmetic one.

An always-on detector evaluates 50 windows per second with a per-window false-accept rate of 0.5%. Why is this catastrophic, and what is the standard fix?

Chapter 6: Distance to Normal

A pump in a factory. It has run for four years. You have a vibration sensor on its housing and a microcontroller with 256 kB of SRAM, and management would like to know before the bearing fails.

Try to phrase this as classification and you hit a wall immediately: you have no failures. The pump has never failed. You cannot label what has not happened, you cannot collect a balanced dataset, and if you wait until you have a hundred bearing failures to train on, you have already had a hundred bearing failures.

What you do have is four years of the pump being fine. That is an enormous amount of data about one class. So change the question from “which class is this?” to “how far is this from everything I have ever seen?” — and distance is exactly what an embedding space is for.

The pipeline

3-axis accelerometer
1,600 Hz, continuous
↓ 1-second windows, 50% overlap
window
float32[1600, 3]
↓ FFT per axis, 128 log-magnitude bins
spectrum
float32[128, 3]
↓ tiny conv encoder (~50 k params, int8)
embedding z
float32[32]
↓ compare with the stored model of “normal”
anomaly score
one number, plus a threshold

Everything interesting is in the last arrow. There are three standard ways to represent “normal” and they differ enormously in memory, which is the currency that matters on a microcontroller.

RepresentationScoreMemory at d = 32Catches
Mean + covarianceMahalanobis distance32 + 32×32 = 1,056 floats = 4.2 kBAnything outside one ellipsoid
Memory bankDistance to nearest stored normalK × 32 bytes at int8; K = 200 ⇒ 6.4 kBMulti-mode normals (idle, loaded, starting)
Gaussian mixtureNegative log-likelihoodC components × (32 + 32) floatsMulti-mode, with a probability

The memory-bank approach is the one that scaled best in the image domain: PatchCore (Roth et al., 2021) stores a coreset-subsampled bank of nominal patch features and scores by nearest-neighbour distance, reaching image-level anomaly-detection AUROC up to 99.6% on MVTec AD — more than halving the error of the next best method at the time — with competitive inference times and strong results in the few-sample regime. The technique transfers directly: your “patches” are one-second windows.

Worked example: Mahalanobis, all the way through, by hand

Let us do this with d = 2 so every number is checkable.

Step 1 — the normal set. Five embeddings recorded while the pump was healthy:

z1 = (1, 4),   z2 = (2, 5),   z3 = (3, 4),   z4 = (2, 3),   z5 = (2, 4)

Step 2 — the mean.

μx = (1 + 2 + 3 + 2 + 2) ÷ 5 = 10 / 5 = 2     μy = (4 + 5 + 4 + 3 + 4) ÷ 5 = 20 / 5 = 4
μ = (2, 4)

Step 3 — the deviations.

(−1, 0),   (0, 1),   (1, 0),   (0, −1),   (0, 0)

Step 4 — the covariance. Sum of squared deviations, divided by n − 1 = 4:

Sxx = 1 + 0 + 1 + 0 + 0 = 2 ⇒ 2/4 = 0.5
Syy = 0 + 1 + 0 + 1 + 0 = 2 ⇒ 2/4 = 0.5
Sxy = (−1)(0) + (0)(1) + (1)(0) + (0)(−1) + 0 = 0 ⇒ 0

So the covariance matrix and its inverse are both diagonal and trivially computed:

Σ = [ [0.5, 0], [0, 0.5] ]   ⇒   Σ−1 = [ [2, 0], [0, 2] ]

Step 5 — score a new window. The pump now produces the embedding t = (3.5, 6).

t − μ = (1.5, 2)
D2 = (t − μ)T Σ−1 (t − μ) = 2(1.5)2 + 2(2)2 = 2(2.25) + 2(4) = 4.5 + 8 = 12.5
D = √12.5 = 3.536

Is 12.5 an anomaly? That question has no answer until you supply a budget, which is the point of the next step.

The threshold comes from the alarm budget, not from statistics

If the normal embeddings really were Gaussian, then D2 for a normal window follows a chi-square distribution with d degrees of freedom. For d = 2 this has an unusually clean tail:

P(D2 > t) = e−t/2   ⇒   t = −2 ln( P )

Threshold A — the textbook 1%.

t = −2 ln(0.01) = −2(−4.6052) = 9.21   (D = 3.035)

Our test point scored 12.5 > 9.21. Alarm. But now count what that threshold costs. At one window per second:

0.01 × 86,400 = 864 false alarms per day

Nobody will look at the 864th. The detector is worthless.

Threshold B — one false alarm per day. Invert the requirement:

P = 1 ÷ 86,400 = 1.157 × 10-5
t = −2 ln(1.157 × 10-5) = −2(−11.3668) = 22.73   (D = 4.768)

Our test point scored 12.5 < 22.73. No alarm. The same measurement, the same model, opposite verdicts — and the second one is the correct engineering answer, because a detector that cries wolf 864 times a day is not a detector.

State this out loud on every anomaly project. The threshold is not a property of the model. It is a property of how often you are willing to be wrong, converted into a number through the tail of the score distribution. Two teams with the identical model and identical data should choose different thresholds if one gets paged and the other reads a weekly report. “We used three sigma” is not a justification; it is a number someone remembered from a textbook.

What the budget costs you in sensitivity

Raising the threshold from 9.21 to 22.73 did not come free. Measure the price. A developing fault shifts the embedding away from μ; how far must it shift to be caught?

threshold A: D = √9.21 = 3.035     threshold B: D = √22.73 = 4.768
4.768 ÷ 3.035 = 1.57×

A fault must be 57% further from normal to trigger the day-budget threshold. Faults between those two distances — genuinely abnormal, but subtly — are invisible by construction. That is the sensitivity you sold to buy quiet.

Buying the sensitivity back with time

The same trick as the wake word: require persistence. A real bearing fault does not appear for one second and vanish; a sensor glitch does. So require k consecutive exceedances of the lower threshold.

With per-window false rate α = 0.01 and k = 3, treating windows as independent:

(0.01)3 = 1 × 10-6
86,400 × 1 × 10-6 = 0.086 false alarms per day = one per 11.6 days

So you get a quieter detector than threshold B, at threshold A’s sensitivity of D = 3.035, and the entire cost is three seconds of detection latency on a fault that has been developing for weeks.

Same caveat as Chapter 5, and it is sharper here. With 50% window overlap, consecutive windows share half their samples, so α3 is optimistic. Worse, the physical processes that cause spurious scores — a truck driving past, a temperature transient, the compressor next door starting — last for seconds and produce runs of correlated exceedances by their nature. Measure the empirical run-length distribution on a month of normal operation and choose k from the data. If a truck reliably produces runs of 8, then k = 3 buys you nothing at all.
Edge anomaly-detector playground

A 2-D embedding space. Teal points are the learned normal manifold; the ellipse is the Mahalanobis threshold you set. Orange points are a drifting fault. Move the threshold and watch the two counters that matter: false alarms per day, and how much of the fault trajectory is caught. Add drift to see the normal cloud wander — and turn on adaptation to watch a slow fault get absorbed into “normal”.

threshold D3.0
fault severity45
consecutive k1

Drift: the normal manifold moves, and that is a trap

Machines change. A bearing wears in over its first month. The factory is 8°C colder in January. The lubricant is replaced. If “normal” is frozen at commissioning, your detector will be screaming by March for reasons that have nothing to do with faults.

The standard fix is to update the model continuously with an exponentially weighted moving average:

μt = (1 − α) μt−1 + α zt

which is beautiful on a microcontroller — d multiply-adds per window and no history to store. But choose α carefully, because it defines a time constant:

τ ≈ 1 / α windows   ⇒   α = 0.001 at 1 Hz gives τ = 1,000 s = 16.7 minutes

Now the trap, stated precisely. Anything that changes more slowly than τ is absorbed into “normal” instead of being reported. A bearing that degrades over three weeks moves the mean by an amount per window that is far below what the EWMA will resist, so the detector tracks the fault all the way to failure and never alarms. Meanwhile a fault that appears in ten seconds is caught easily.

Quantify it. If a fault adds a constant drift δ per window to the true embedding, the EWMA mean lags behind by a steady-state offset of about δ/α. The detector effectively sees only that lag:

α = 0.001, δ = 0.002 units/window ⇒ steady-state gap = 0.002 / 0.001 = 2 units
α = 0.01 ⇒ gap = 0.002 / 0.01 = 0.2 units — ten times less visible

So α is not a smoothing convenience. It is a declaration of which fault timescales you are choosing to be blind to. Two mitigations are standard: run two detectors with very different α and alarm on their disagreement, or freeze adaptation whenever the current score is elevated so a developing fault cannot teach the model to accept itself.

Concept → realization: the whole detector in 256 kB

c
// Fixed footprint. No malloc. Runs on a Cortex-M4 at 1 Hz.
//   encoder weights (int8) ......... 50,000 B
//   FFT scratch + window buffer .... 19,200 B
//   mean mu[32] (float) ............    128 B
//   inverse covariance L[32][32] ...  4,096 B
//   memory bank 200 x int8[32] .....  6,400 B
//   ------------------------------------------
//   total .......................... 79,824 B  (31% of 256 kB SRAM)

float score_window(const float* z) {
    float dev[32], acc = 0.f;
    for (int i = 0; i < 32; ++i) dev[i] = z[i] - mu[i];
    // D^2 = dev' * Sinv * dev, with Sinv stored as its Cholesky factor L
    for (int i = 0; i < 32; ++i) {
        float u = 0.f;
        for (int j = 0; j <= i; ++j) u += L[i][j] * dev[j];
        acc += u * u;                     // 32*33/2 = 528 MACs total
    }
    return acc;                           // D^2, compare against t
}

// Adaptation, gated so a developing fault cannot teach the model
// to accept itself - the single most important line in the file.
if (acc < t_freeze)
    for (int i = 0; i < 32; ++i) mu[i] += alpha * (z[i] - mu[i]);

Two details worth naming. Storing the Cholesky factor L of the inverse covariance instead of the full matrix halves the memory and turns the quadratic form into a triangular product — 528 multiply-adds instead of 1,024, and it guarantees the score is non-negative even after numerical drift. And the t_freeze gate on the last line is the difference between a detector that adapts to seasons and a detector that adapts to its own failure.

Finally, note what leaves the machine: one float, once a second. When an alarm fires you might upload the 32-dimensional embedding — 32 bytes — for a human to look at. Not the vibration signal, not the audio. This is the same architecture as the photo app, and it leads directly to the next chapter’s question, which is whether those 32 bytes are as harmless as they look.

You add EWMA adaptation with α = 0.001 at 1 Hz so the detector tracks seasonal drift. What have you also done?

Chapter 7: What Leaves the Device

Six chapters of engineering have produced a system that computes embeddings locally. Now somebody in a planning meeting says the sentence that undoes it: “we should sync the embeddings so search works on the user’s laptop too.”

It is a completely reasonable product request, and the vectors are small — that is why it gets waved through:

40,000 × 132 bytes = 5.28 MB — a single photo’s worth of upload

This chapter is about why that number is a trap, and what the honest options are.

Three postures, stated precisely

PostureWhat crosses the networkWhat you can truthfully claim
A. Nothing leavesModel weights come down; nothing goes up“Your photos never leave your device” — and it is checkable with a packet capture
B. Embeddings leaveVectors, per item“We do not upload your photos.” True. And much weaker than the reader will hear.
C. Raw leavesPixels, audio, sensor tracesWhatever your privacy policy says, honestly

Posture B is where almost every product lands, and it is where the interesting problem is, because the intuition that an embedding is “just numbers” is wrong in a way that has been measured.

Embeddings are lossy, not anonymous

The intuition goes: the encoder throws away enormous amounts of information — 3 MB of JPEG becomes 132 bytes — so surely you cannot get the content back.

That reasoning confuses compression ratio with irreversibility. The encoder discards information that is irrelevant to meaning; it preserves meaning as precisely as it can, because that is the training objective. And meaning is what the private content is.

Morris et al., Text Embeddings Reveal (Almost) As Much As Text (2023), framed inversion as controlled generation: generate text whose re-embedding lands near a target vector, then iteratively correct and re-embed. Their reported results:

FindingNumber
32-token inputs recovered exactly from the embedding alone92%
Naive single-shot decoding conditioned on the embeddingperforms poorly — the multi-step correction is the whole trick
Full names recovered from a dataset of clinical notesdemonstrated

Ninety-two percent, exactly, on two different state-of-the-art embedding models. The mental model to adopt is: an embedding is a compressed encoding of the content, not a hash of it. Hashes are designed to be irreversible. Embeddings are designed to be maximally informative about meaning, which is the opposite design goal.

The image case is less studied but structurally identical: with a generative decoder trained against the encoder, an image embedding yields a reconstruction that preserves the scene, the setting, and often the people. For a photo library, “we only have the embeddings” means “we have a lossy but semantically faithful description of every photo you have ever taken.”

The sentence to strike from your design docs. “We only send embeddings, so the data is anonymized.” Replace it with the accurate version: “We send a representation from which a large fraction of the original content can be reconstructed by anyone holding the encoder.” If the second sentence changes the decision, then the first sentence was doing the work of a lie.

Do the usual mitigations help? Arithmetic, one at a time

Truncation and quantization. Surely 132 bytes leaks less than 3,072? Somewhat — but we computed in Chapter 3 that int8 costs 0.002% of the cosine, meaning the quantized vector is functionally the same vector. And truncation to a matryoshka prefix keeps the most semantically loaded coordinates by construction. You have removed the fine detail and kept the meaning, which is precisely backwards for privacy. Not a defense.

A device-held random rotation. Multiply every vector by a secret orthogonal matrix R. The server can still compute inner products, because

(Rz1) · (Rz2) = z1TRTRz2 = z1Tz2

Which is exactly the problem: it preserves every distance, so it preserves every bit of structure that inversion exploits. It defeats an attacker who has your encoder and no key, and is defeated by anyone who can obtain a few hundred (plaintext, rotated) pairs and solve for R — a linear system, not a cryptographic one. Useful as a speed bump. Not a defense.

Additive noise. This one genuinely trades utility for privacy, so it deserves real arithmetic. Add independent Gaussian noise of per-coordinate standard deviation σ to a unit-norm vector. The expected squared length of the noise is

E||e||2 = d σ2

and the cosine between the vector and its noised version is approximately

cos ≈ 1 ÷ √(1 + dσ2)

Evaluate at d = 128:

σ2cosine to the true vectorVerdict
0.005128(0.000025) = 0.00321/√1.0032 = 0.9984No effect on search. No effect on an attacker either.
0.02128(0.0004) = 0.05121/√1.0512 = 0.9753Search degrades on close calls
0.05128(0.0025) = 0.321/√1.32 = 0.8704Search destroyed

Read the last row against a fact from Chapter 3: in a real photo library, the genuinely good matches for a query sit at cosine similarities around 0.80–0.90 to each other. At σ = 0.05 the vector is only 0.87 similar to itself. The noise is now larger than the signal you were ranking by. There is a narrow band where noise helps meaningfully and does not destroy retrieval, and you must locate it by measurement rather than by hoping.

The mitigation that actually works. Do not send the vectors. Send the derived state the product genuinely needs. If the feature is “search works on my laptop too,” the honest implementations are: sync the vectors end-to-end encrypted with a key the server never sees (the server becomes dumb storage, and search runs on the laptop); or compute the laptop’s own index from the laptop’s own copy of the photos. Both are more work than a plaintext sync. Both preserve the claim you were making.

Federated aggregation, and what it does and does not hide

The other reason to send something up is to improve the model without collecting data. Federated averaging (McMahan et al., 2016) is the base recipe: each device trains locally on its own data and sends the resulting parameter update; the server averages the updates weighted by how much data each device had.

wnew = Σk ( nk / n ) · wk

Worked, on a single parameter, three devices:

Devicenkweight nk/nupdate Δkcontribution
A1000.10+0.20+0.020
B3000.30−0.10−0.030
C6000.60+0.05+0.030
n = 1,000, aggregate+0.020

Check it the other way: (100(0.20) + 300(−0.10) + 600(0.05)) / 1000 = (20 − 30 + 30)/1000 = 20/1000 = 0.020. Consistent.

Now look at the table again as an attacker. The server received ΔB = −0.10 from device B. Federated averaging by itself does not hide anything — it hides data only in the sense that the raw examples were not transmitted, and gradients computed on a small local dataset are famously informative about that dataset. Two additions are needed to make the claim real:

AdditionWhat it fixesWhat it costs
Secure aggregationThe server sees only the sum over many devices, never any individual ΔkA cryptographic protocol, a minimum cohort size, and failure handling when devices drop out mid-round
Differential privacyBounds how much any single device’s data can move the published result, even against the sumClipping each update plus calibrated noise — measurable accuracy loss, tracked as a privacy budget that only ever depletes

On device, federated participation also has to obey Chapter 1’s budget: a local training round is a forward and backward pass, roughly three times the cost of inference per example, and it only runs when charging, idle, and on unmetered network — which means your effective cohort is systematically biased toward users who charge overnight on home Wi-Fi. That bias is a modelling problem, not just a logistics one.

Concept → realization: the data-flow table you should be able to fill in

For any on-device embedding system, write this table before writing the code. If a row is uncomfortable, the architecture is wrong, and it is much cheaper to learn that now.

ArtifactWhere it livesDoes it sync?In a device backup?What it reveals if leaked
Original photosDeviceOnly if the user enabled photo syncYesEverything
Embeddings (132 B each)Device, app containerDecide deliberatelyUsually yes — checkA semantically faithful reconstruction of every photo
Query historyDeviceDecide deliberatelyYesWhat the user was looking for, which is often more sensitive than the photos
Model weightsDeviceDownloaded from youYesNothing about the user — and it is the decoder key for the row above
Aggregate telemetryServerYesDepends entirely on granularity

The row people miss is the fourth one interacting with the second. Your model weights are public — you shipped them in an app anyone can download. So every attacker who obtains the embeddings also has the encoder, which is exactly the setting in which the 92% inversion result was measured. There is no security through the obscurity of your embedding space.

Who actually gets the file: a threat model in one table

“Is this secure?” is unanswerable. “Secure against whom, holding what?” is answerable, and it is the only version of the question that produces engineering decisions. Here are the five parties who realistically end up holding an embedding file, ranked by how often it happens rather than by how dramatic they sound.

WhoHow they get itWhat they also haveWhat the embeddings give them
The device backupAutomatic, unless you opted the file outYour public app, hence the encoderA reconstructable record of the whole library, in a copy the user never thought about
Your own serversYou added sync “because it is only vectors”The encoder, and your logsThe same thing the photos would have given, at 1/23,000 of the storage cost — which is precisely why it feels harmless
Whoever holds the unlocked phoneBorrowed, lost, or handed over at a borderEverything on the device anywayLittle extra — this is the one case where the embeddings are not the weak point
A legal requestServed on whoever has the dataThe encoder, which is publicWhatever you chose to be able to produce. You cannot hand over what you never held.
A future attackerA breach of a store you thought was low-riskBetter inversion models than exist todayMore than today’s numbers suggest — stored data is attacked with tomorrow’s tools

Read the last row twice. Every number in this chapter is a lower bound on what a stored embedding reveals, because the file persists and the attack improves. A design that is exactly at the acceptable threshold today is over it in three years without anyone touching the code.

Worked example: what a leaked index file actually costs

Concrete beats abstract. Suppose the 40,000-photo index — 132 bytes a record, 5.28 MB total — ends up in a breach dump. Price the damage rather than gesturing at it.

The attacker downloads your app and extracts the image encoder; that is an afternoon. They now hold 40,000 vectors and the function that produced them. Two attacks are available, and they differ enormously in cost.

Attack one: matching. Take any photo they already have — from a social account, a previous breach, a public profile — embed it, and check the cosine against all 40,000. This is one forward pass plus a 5.28 MB scan, so milliseconds, and it answers “was this picture in that person’s library?” with high confidence. Nothing is reconstructed; nothing needs to be. Membership alone is often the sensitive fact — whether a specific person, place, document or medical image was present.

Attack two: reconstruction. Train or fine-tune a decoder that maps embeddings back toward inputs. This is the expensive attack, and it is the one the inversion literature measures: 92% of 32-token texts recovered exactly from their embeddings. For images the analogous result is a semantically faithful reconstruction rather than a pixel-exact one — the scene, the setting, the objects, the number of people. Whether that is worse than exact depends entirely on what the photo was of.

The asymmetry that makes this a design problem rather than a security problem. Attack one costs an afternoon and works today. Attack two costs a research project and gets cheaper every year. Both are enabled by the same decision — letting the vectors leave the device — and neither is prevented by anything you do to the vectors afterwards, because the encoder is already in the attacker’s hands. The only mitigation with real leverage is the one taken before any code is written: do not move them.

Saying it out loud: the consent sentence

Every design in this chapter eventually becomes a sentence in a privacy screen, and writing that sentence early is a surprisingly effective engineering tool. Write it honestly, then read it back.

The dishonest version is the one that gets shipped by default: “We sync a compact numerical representation of your photos, not the photos themselves.” Every word is true and the sentence is misleading, because “numerical representation” implies irreversibility that the arithmetic does not support.

The honest version: “We upload a compact vector for each photo. It is not the photo, but anyone holding it and our app can determine whether a given picture is in your library, and can reconstruct a recognisable description of what each photo shows.”

Both sentences describe the same system. If the second one would not survive the design review, the design should not survive it either — and finding that out during the review is roughly a thousand times cheaper than finding it out afterwards.

The engineering value of writing it first. A data-flow table can be argued with. A sentence a real person has to read cannot. Teams that write the consent sentence at design time routinely discover that the sync they were about to build was not actually needed for the feature — and deleting a feature you have not built yet is the cheapest privacy control that exists.
Your team proposes syncing only embeddings, arguing that 3 MB of photo compressed to 132 bytes cannot be reversed. What is wrong with the argument?

Chapter 8: Where to Cut

Eight chapters of parts. Now assemble them into a method, because the actual skill being taught here is not “know about MobileCLIP” — it is being able to sit down with a budget and produce a design that meets it, and to know which knob to turn when it does not.

The four dials and what each one actually costs

DialEffect on index sizeEffect on query timeEffect on qualityCost to change
Architecturenonenone (query uses the text tower only)Large. Sets the ceiling on everything.Weeks. Retrain, re-evaluate, re-ship the app.
Dimension dlinearlinearGentle if matryoshka, catastrophic if notOne line, if the model was trained for it.
Precision blinearlinear — and superlinear via cache effectsNearly free at int8; needs a rescore pass at 1 bitA calibration pass. Hours.
Indexincreases itlarge reduction at large NSilent recall loss you must measureA library, a build step, a rebuild policy.

Read the first column. Three dials shrink memory; one grows it. On a device where memory is the wall that terminates your process, that asymmetry should determine the order in which you reach for them — and it is the reverse of the order most engineers reach in.

One cost model, written once

Before turning any dial, write the whole system as three formulas. Then the dials stop being a list of tricks and become variables you can differentiate by eye. Every term below was derived somewhere in Chapters 1 through 4; this is just the collection.

Memory. Each stored record is d components at b bits, plus a fixed per-record overhead c covering the photo identifier, the dequantization scale, and the stored norm:

M(N, d, b) = N · (d · b ÷ 8 + c)   bytes,    c ≈ 12 B

If you build a graph index, add g bytes per node on top. Chapter 4 derived g ≈ 8M + 4 for HNSW with degree parameter M, which is 132 bytes at the customary M = 16 — almost exactly the same as the 128-dimensional int8 vector it is meant to help you find.

Search time. A flat scan reads every byte exactly once and does one cheap integer operation per byte, so it is bandwidth-bound and its time is simply bytes divided by streaming bandwidth β:

Tscan(N, d, b) = N · d · b ÷ 8 ÷ β

Chapter 4 pinned the constant with one measurement: 100,000 vectors of 128 int8 components is 12.8 MB and took 1.6 ms, so

β = 12.8 × 106 B ÷ 1.6 × 10−3 s = 8 × 109 B/s

Eight gigabytes per second of useful single-core streaming throughput. That one number generates every scan estimate in this lesson, and it is the first thing you should re-measure on your own hardware, because it is the only empirical constant in the model.

Query time, end to end, is the search plus everything around it — and the things around it are usually larger:

Tquery = Ttokenize + Ttext tower + Tsearch + Tthumbnails + Trender

Energy for the initial sweep, from Chapter 1, where epre is the per-photo preprocessing cost and η is the accelerator’s efficiency in operations per joule:

E(N) = N · (epre + 2 · MACs ÷ η)   joules

The factor of 2 is the MAC-to-op conversion from Chapter 1 — one multiply-accumulate is two operations, which is why a vendor’s TOPS figure must be halved before it enters this formula.

Now differentiate by eye, which is the entire reason for writing them down. d and b appear as a product in both the memory formula and the scan formula. Halving d and moving from fp32 to int8 does not add; it multiplies:

(512 → 128 is 4×) × (32-bit → 8-bit is 4×) = 16× on both memory and scan time

N appears linearly everywhere and is the one variable you do not control — the user decides how many photos they have. And the index term is the only one with the opposite sign: it subtracts from Tsearch while adding to M. Two dials move both budgets in the good direction at once; one trades them against each other. That is the whole priority order, visible in the algebra.

The quantity that is missing from all three formulas. Quality. Nothing above knows whether a 128-dimensional int8 vector still finds the right photo. Arithmetic can tell you what a design costs and can never tell you what it is worth, which is why every worked example in this chapter ends with a measurement you have to actually run.
The order of operations for an over-budget on-device system. (1) Cut precision — nearly free, and you probably have int8 sitting unused. (2) Cut dimension — free if the embedding is matryoshka, and add a rescore stage to recover the top-k ordering. (3) Partition on something the user already understands — time, album, place. (4) Only now consider an approximate index. (5) Only now consider a different encoder, because that one costs weeks and re-opens every downstream decision.

Worked example: design the photo app end to end

Requirements, stated as numbers so we can check them:

RequirementTarget
Library size40,000 photos, growing ~5,000/year
Query latency, end to end≤ 100 ms
Resident memory for the index≤ 30 MB
App download increase≤ 60 MB
Battery for the initial sweep≤ 3% of one charge
Recall@10 against exact fp32 search≥ 0.95

Choose the architecture. MobileCLIP-S0: 11.4 M image tower at 1.5 ms, 42.4 M text tower at 1.6 ms, 67.8% zero-shot ImageNet-val. Quantize both to int8 with per-channel scales, and the text token table to int4:

11.4 + (25.3 × 0.5 + 17.1 × 1) = 11.4 + 29.75 = 41.2 MB   ≤ 60 MB  ✓

Choose dimension and precision. Take the 512-dimensional output, truncate the matryoshka prefix to 128, renormalize, quantize to int8, and also store a 64-bit sign code for the coarse pass:

8 (id) + 8 (coarse) + 128 (int8) + 2 (scale) + 2 (norm) = 148 bytes per photo
40,000 × 148 = 5,920,000 B = 5.92 MB   ≤ 30 MB  ✓ (with 5× headroom for growth)

Choose the index. N = 40,000. From Chapter 4’s table, a flat scan at this size is a fraction of a millisecond, and the coarse pass is 40 microseconds. HNSW would add ~5.3 MB of graph and save under a millisecond. No index. Two-stage flat.

Check the latency budget.

0.2 (tokenize) + 1.6 (text tower) + 0.04 (coarse) + 0.3 (rescore 200) + 40 (thumbnails) + 15 (render) = 57.1 ms   ≤ 100 ms  ✓

Check the energy budget. From Chapter 1, using existing thumbnails rather than decoding originals:

40,000 × (1.5 mJ decode + 2.4 mJ model) = 40,000 × 3.9 mJ = 156 J
156 / 60,984 = 0.26% of a charge   ≤ 3%  ✓

Check recall. This one cannot be computed. It must be measured — on device, with the actual quantized vectors, against an exact fp32 baseline, on real user-style queries. Chapter 3 gives the knob: if recall@10 comes in under 0.95, raise the coarse shortlist from 200 until it plateaus, and only if that fails raise the kept dimension from 128 to 256.

Look at the slack. Latency landed at 57 ms against 100. Memory at 5.9 MB against 30. Energy at 0.26% against 3%. Every budget has room, which means the correct next move is to spend some of it on quality: move from MobileCLIP-S0 to S1 or S2 for a few extra accuracy points at 2 ms per photo, or keep 256 dimensions instead of 128. A design that meets every budget with a large margin has under-delivered on quality, and finding that out is exactly what the arithmetic is for.
The budget board

Four budgets, four bars, one design. The dashed line in each track is the ceiling: 60 MB of app growth, 30 MB of resident index, 100 ms end to end, 3% of a charge for the initial sweep. Turn the dials and watch which bar crosses first, then read the verdict line — it names the dial the order of operations says to reach for next. Note what the board refuses to show you: there is no quality bar, because arithmetic cannot produce one.

library N40,000
kept dimension d128
bits per component8
encoderMobileCLIP-S0
search structuretwo-stage

Three experiments worth running on it before reading on. First, drag the encoder to S1. The download bar goes red and nothing else moves, because S1 keeps the same 63.4 M-parameter Base text tower that S0 replaced with the 42.4 M MCt — the accuracy is affordable, the text encoder is not. Second, set the library to a million and the structure to HNSW: the memory bar overshoots while the latency bar barely improves, which is the graph paying 132 bytes a node to save time you were not short of. Third, take dimension to 512 at 32 bits and then fix it in the order the callout prescribed — precision first, then dimension — and watch two bars retreat together on each move.

Worked example two: the design that fails

The first worked example passed on every line, which makes it a poor teacher. Here is the same app under a harder brief, because the method is only visible when something breaks.

A power user with 500,000 photos, on a four-year-old phone. Two things change: the background memory allowance is tighter, call it 25 MB, and the memory system is slower — re-measure β and it comes back at 4 GB/s, half of what the new device gave us. Latency ceiling is still 100 ms, and the fixed pipeline cost around the search is still 0.2 + 1.6 + 40 + 15 = 56.8 ms, leaving 43.2 ms for the search itself.

Start naive: the encoder’s native 512-dimensional fp32 output, stored as is.

per record = 512 × 4 + 12 = 2,060 B  →  500,000 × 2,060 = 1,030 MB   vs 25 MB  ✗ (41× over)
Tscan = 500,000 × 512 × 4 ÷ (4 × 109) = 1.024 × 109 ÷ 4 × 109 = 256 ms   vs 43.2 ms  ✗

Both budgets fail, and they fail by different factors, which already tells you something: memory is 41× over and time is 5.9× over, so any fix that helps only time is irrelevant. Apply the order of operations.

MovePer recordResidentTscanVerdict
naive: 512-d fp322,060 B1,030 MB256 msboth fail
1. precision → int8524 B262 MB64 msboth still fail
2. dimension → 128 (matryoshka)140 B70 MB16 mstime passes, memory fails
3. coarse 128-bit code, vectors memory-mapped24 B resident12 MB2 msboth pass

Check each row by hand rather than trusting the table. Move 1 is 512 × 1 + 12 = 524 B, and 500,000 × 524 = 262 MB; the scan reads 500,000 × 512 = 256 MB at 4 GB/s = 64 ms. Move 2 is 128 × 1 + 12 = 140 B, so 70 MB and 500,000 × 128 ÷ 4 × 109 = 16 ms. Latency now fits: 56.8 + 16 = 72.8 ms against 100. Memory does not: 70 MB against 25.

And here the chapter’s own advice runs out, which is the interesting part. Precision is spent — you are at int8 and the next stop is 1 bit. Dimension is spent — 128 is already the matryoshka prefix you validated. Neither remaining dial goes anywhere good, so the fifth move is the one that was never a dial at all: change what is resident.

resident: 500,000 × (16 B sign code + 8 B id) = 12,000,000 B = 12 MB   ≤ 25 MB  ✓
on disk, memory-mapped: 500,000 × 140 B = 70 MB, resident cost 0

The 128-bit sign codes stay in memory permanently — that is Chapter 3’s binary trick, 16 bytes a photo. The int8 vectors live in a file the operating system pages in on demand, so they cost zero resident bytes and the page cache is reclaimable rather than fatal. The coarse pass streams 500,000 × 16 = 8 MB at 4 GB/s = 2 ms, and stage two touches only the survivors.

The new failure mode this introduces, and how to size it. Stage two now performs K random reads into a memory-mapped file. Flash serves a 4 kB page in roughly 100 µs, so a shortlist of K = 500 could cost 500 × 100 µs = 50 ms if every read misses — and 56.8 + 2 + 50 = 108.8 ms fails the budget you just passed. Two fixes, both cheap. Shrink the shortlist to K = 200 (200 × 100 µs = 20 ms, total 78.8 ms ✓), and issue the reads in ascending file offset order, which lets readahead merge neighbours and turns most of the misses into one sequential stream. You would not have found either fix by making the model smaller. Locality is a dial, and it is invisible in every published benchmark.

Notice what the second example demonstrates that the first could not. The first four dials are finite. When they run out, the remaining moves are all about arrangement — what is resident versus mapped, what is contiguous versus scattered, what is searched by default versus on request. Those moves do not appear in any model card, and on constrained hardware they are frequently the ones that decide whether the product ships.

Concept → realization: the budget as twenty lines of build script

Everything above should live in your repository, not in a notebook someone lost. This runs in continuous integration and fails the build when a design change quietly breaks a budget:

python
# budget.py — run in CI. Numbers, not opinions.
BETA   = 8e9    # B/s streaming, MEASURED on the oldest supported phone
ETA    = 2e12   # ops/joule, vendor TOPS already halved
CHARGE = 60984  # J in a 4400 mAh @ 3.85 V cell

def budget(N, d, bits, img_mb, txt_mb, tok_mb, txt_ms, macs_g,
           coarse_b=8, graph_b=0, pre_mj=1.5, shortlist=200):
    per_vec = d * bits / 8 + 12                 # + id, scale, norm
    resident = N * (per_vec + coarse_b + graph_b)
    app_mb  = img_mb + (txt_mb - tok_mb) + tok_mb * 0.5   # int8, int4 tokens
    scan_ms = (N * coarse_b if coarse_b else N * d * bits / 8) / BETA * 1e3
    rescore = shortlist * per_vec / BETA * 1e3 if coarse_b else 0.0
    query   = 0.2 + txt_ms + scan_ms + rescore + 40 + 15  # + thumbs + render
    joules  = N * (pre_mj + 2 * macs_g * 1e9 / ETA * 1e3) / 1e3
    return {"app_mb": app_mb, "index_mb": resident / 1e6,
            "query_ms": query, "charge_pct": joules / CHARGE * 100}

CAPS = {"app_mb": 60, "index_mb": 30, "query_ms": 100, "charge_pct": 3.0}

def test_photo_app_fits():
    # MobileCLIP-S0, 40k photos, 128-d int8, two-stage
    b = budget(N=40_000, d=128, bits=8, img_mb=11.4, txt_mb=42.4,
               tok_mb=25.3, txt_ms=1.6, macs_g=2.4)
    for k, cap in CAPS.items():
        assert b[k] <= cap, f"{k} = {b[k]:.2f} exceeds {cap}"

Three decisions are worth naming. BETA is a constant you measured, not one you looked up, and it carries a comment saying which device it came from — because the moment someone benchmarks it on a new phone and updates it, every budget in the file silently loosens. The energy term keeps 2 * macs_g explicit rather than folding the factor of two away, so nobody re-derives it wrongly next year. And the whole thing is a test, so an innocent-looking bump from 128 to 192 dimensions fails continuous integration instead of failing a user’s phone.

Failure signatures: which cut went too far?

This is the diagnostic table. When quality is bad, the symptom tells you which dial to blame — and the symptoms are genuinely distinguishable, which is the useful part.

Cut too farWhat the user seesThe measurement that confirms itFix
DimensionResults are on-topic but in a strange order; near-duplicates rank inconsistentlyrecall@50 is fine, recall@1 is poor — a re-ordering failure, not a retrieval failureAdd or widen the rescore stage; the coarse pass is doing its job
Precision (weights)Some queries return nonsense; a subset of photos never match anythingPer-layer output error against the fp32 model spikes at one layerPer-channel scales on that layer, or keep it in fp16
Precision (activations)Works on your test set, fails on 0.3% of real photosActivation range on failing inputs exceeds the calibration range — clippingRecalibrate on a wider set; use dynamic ranges for the offending tensor
ArchitectureSystematic blind spots: small objects, text in images, fine-grained breedsFailures cluster by category, not by query phrasingA larger encoder. Nothing downstream will fix this.
Index (efSearch)Rare, unreproducible misses on unusual queriesRecall against a flat baseline is below 1.0 and varies by queryRaise efSearch, or delete the index

The distinction in the first row is the most valuable one on the page. Recall@50 good, recall@1 bad means the information is present and the ordering is coarse — a cheap fix. Recall@50 bad means the information is gone — an expensive fix. Measure both, always, and never report only one.

The measurement protocol

Everything in this lesson is arithmetic until you validate it on the device. Five measurements, in order, and none of them is optional.

#MeasureAgainstWhy it is the one people skip
1End-to-end query latency, on the oldest phone you supportThe 100 ms wallEveryone benchmarks on the newest device, where everything passes
2Peak resident memory during the sweep and during a queryThe platform’s background limitBecause the failure mode is termination, which looks like a crash, not like slowness
3Recall@1 and recall@50 of the shipped pipelineExact fp32 search on the same deviceBecause the paper’s accuracy number is not your pipeline’s accuracy number
4Total energy for a full sweep3% of a chargeBecause it only shows up in the battery settings screen, days later
5Sustained throughput over 20 minutesYour first-minute throughputBecause thermal throttling can halve it and no unit test runs for 20 minutes

Measurement 3 deserves one more sentence, because it is the one that separates a working product from a demo. The baseline is not the benchmark score in the paper. It is your own model, at fp32, at full dimension, doing an exhaustive scan, on the same device, over the same library. That number is your ceiling. Every cut you made in Chapters 2, 3 and 4 is measured as a loss against it, and if you never computed it you do not know what any of your engineering cost.

Your on-device search is over its memory budget. Which dial do you reach for first, and why?

Chapter 9: Connections

Ten chapters, one repeated move: compute a vector on the device, compare it to something already on the device, and let the comparison be the product. Photo search, wake words, and a failing bearing are the same architecture with the budgets shifted by three orders of magnitude.

This chapter introduces nothing. It collects — the formulas in one place with every symbol named, the numbers with their provenance, the misconceptions the lesson was built to dislodge, and an honest list of what we did not cover. Read it once now and once again the week you actually have to build something.

The three questions, in the order they have to be asked

Strip away the specifics and every chapter was answering one of three questions, always in this order, because each one constrains the next.

#QuestionWhere it was answeredWhat it decides
1What must never leave the device, and what is allowed to?Ch 0 and Ch 7Whether you have an on-device problem at all. If everything may leave, you have a server, and none of this applies.
2What is the regime and therefore the budget?Ch 1Interactive query, live frame, or background sweep. A millisecond figure without a named regime is not a number, it is a mood.
3Which dial buys the most budget per unit of quality surrendered?Ch 2 through Ch 4, assembled in Ch 8Architecture, dimension, precision, index — and, when those run out, arrangement.

Asking them out of order is the standard failure. Picking a model before you know the regime means you optimized latency for a path where memory was the wall. Picking an index before you know what may leave the device means you built a beautiful ANN structure for data that could have gone to a server anyway.

The formula sheet, with every symbol named

Nine chapters produced eleven expressions. Here they are together, so that the next time you need one you do not have to re-derive it — and so that when a number surprises you, you have somewhere to check it against.

What it gives youExpressionSymbolsCh
Compute time when arithmetic-boundT = 2 · MACs ÷ (R · u)R peak ops/s, u achieved utilization1
Compute time when bandwidth-boundT = bytes ÷ ββ memory bandwidth in B/s1
Which of the two you are inI = MACs ÷ bytes  vs  B = R ÷ βI arithmetic intensity, B machine balance1
Symmetric int8 quantizations = max|w| ÷ 127,   q = round(w ÷ s)s scale, q the stored integer2
Precision lost to one outlierΔbits = log2(max|w| ÷ max|wrest|)the ratio of the outlier to its group2
Quantization error on a unit vector‖e‖ ≈ √(d · s2 ÷ 12)d dimension; s2/12 is the uniform-rounding variance3
Bytes per stored recordd · b ÷ 8 + cb bits per component, c ≈ 12 B of id, scale, norm3, 8
Angle from a Hamming distanceθ ≈ π · h ÷ dh differing bits out of d3
HNSW graph bytes per nodeg ≈ 8M + 4M the degree parameter; 132 B at M = 164
Streaming-convolution savingreceptive field ÷ hopthe redundancy a sliding window recomputes5
Threshold from an alarm budgett = −2 ln P  (chi-square, d = 2)P the tolerated tail probability6

Two of these deserve a second look because they are the ones people misuse. The arithmetic-intensity test (I versus B) decides which of the first two formulas applies, and applying the wrong one is how a team spends a month cutting MACs on a model that was waiting for memory the whole time. And the outlier formula is a base-two logarithm, which means the damage is mild for a 2× outlier and severe for a 100× one — a shape you cannot guess and must compute.

The numbers worth carrying out of here

QuantityValueWhere it came from
MAC to op conversion1 MAC = 2 opsCh 1 — and the reason vendor TOPS figures need halving
Machine balance of a phone~16 MACs per byteCh 1 — compare with your model’s arithmetic intensity
MobileCLIP-S011.4 M + 42.4 M params, 1.5 + 1.6 ms, 67.8% zero-shotCh 2 — the reference point for “fits on a phone”
Cost of one outlier in a quantization group~3 bits of precision for everyone elseCh 2 — and why per-channel scales are mandatory
Int8 on a normalized embedding0.99998 cosine — essentially freeCh 3 — errors do not compound after the last layer
128-d int8 vector132 bytesCh 3 — the unit of on-device index arithmetic
HNSW graph at M = 16~132 bytes per node — it doubles your indexCh 4 — derived from P(level ≥ 1) = 1/M
Flat scan, 100k × 128-d int8~1.6 ms on one coreCh 4 — the number that makes most indexes unnecessary
Streaming convolution savingreceptive field ÷ hop — 49× hereCh 5 — mandatory for always-on
Windows per day at 50 Hz4,320,000Ch 5 — the denominator of every false-alarm claim
Chi-square tail for d = 2t = −2 ln PCh 6 — turns an alarm budget into a threshold
Embedding inversion92% of 32-token texts recovered exactlyCh 7 — embeddings are not anonymized data

Every one of those has a chapter reference attached on purpose. A number without a provenance is a rumour, and the failure in Chapter 2 — where a widely repeated loss coefficient turned out to belong to a different model in the same family — is what rumours cost.

The same shape at three scales

The three systems in this lesson look unrelated and are structurally identical. Lay them side by side and the template becomes obvious enough to apply to a fourth thing you meet next month.

Photo search (Ch 0–4)Wake word (Ch 5)Vibration monitor (Ch 6)
What is embeddedAn image, once, at import1 s of audio, every 20 ms, forever1 s of accelerometer, every 1 s, forever
What it is compared toThe text query’s vectorA stored prototype, 4 kB per keywordA running mean and inverse covariance
Vector width512 → 128 after truncation1,02432
The binding constraintResident memoryAverage power — it never stopsSRAM, 256 kB total
The decisive trickPrecision then dimension, then arrangementStreaming convolution: 49× off the redundant workFixed-footprint scoring, 79.8 kB, no allocation
How the threshold is setShortlist size, tuned against exact searchConsecutive-window run length, from real audioChi-square tail, from an alarm budget
What personalizes itThe query is data, not a tag listFive recordings and a meanThe machine’s own first fortnight

Read the last row across. In all three, the thing that used to require retraining became a stored vector. That is the single most valuable structural consequence of using embeddings rather than classifiers, and it is why the same architecture survives a three-order-of-magnitude change in budget: the taxonomy moved out of the weights and into the data, so the weights stopped needing to change.

Misconceptions this lesson was built to dislodge

Each of these is a belief that is common, plausible, and wrong — and each cost a chapter to dismantle.

The beliefWhy it is wrongCh
“On-device means the model is the bottleneck.”For the photo sweep the encoder is 2.4 mJ of a 33 mJ per-photo bill. Decoding the JPEG is 91% of it. Profile the pipeline, not the model.1
“A bigger model is the way to more accuracy.”MCi0 → MCi2 bought 2.3 points for 2.1 ms per photo, forever. Reinforced training bought 10–14 points for zero inference cost.2
“We quantized to int8” is a complete statement.Weights compound through depth and need per-channel scales; a normalized embedding is the last op before a dot product and quantizes to 0.99998 cosine for free. Same words, different risk.2, 3
“Search needs an index.”A flat scan of 100,000 128-d int8 vectors is ~1.6 ms, exact, with zero build time and zero extra memory. HNSW would add 132 B per node to save a millisecond you did not need.4
“An approximate index is faster.”Only if it is resident. Memory-mapped, its random access pattern turns 3.5 ms of sequential scan into ~30 ms of page faults. Locality beats asymptotics at this size.4
“Requiring three consecutive detections cubes the false-alarm rate.”Consecutive windows share 98% of their audio, so their errors are strongly correlated and p3 is badly optimistic. Measure run lengths on real audio.5
“An adaptive baseline makes the detector robust.”It also makes it blind to anything slower than its time constant. α is a declaration of which fault timescales you have chosen not to see.6
“Embeddings are an anonymized form of the input.”92% of 32-token texts were recovered exactly from their embeddings — and you ship the encoder inside the app.7

What this lesson deliberately did not cover

Honesty about the edges is part of the method. Four things sit just outside this lesson and you will meet all of them.

Pruning and sparsity. Chapter 2 named it as a fourth compression knob and then set it aside. Removing weights or whole channels composes with everything here, but it interacts with hardware in ways that need their own treatment — unstructured sparsity often buys nothing at all on a mobile accelerator that has no sparse kernel. The site’s TinyML series covers it properly.

Quantization-aware training. Everything in Chapter 2 was post-training quantization: calibrate, round, ship. When the accuracy loss is unacceptable, the alternative is to simulate the rounding during training so the weights learn to be quantization-friendly. It is strictly more powerful and strictly more expensive.

Runtime and export. The lesson costed models in parameters and milliseconds and never discussed how a trained network actually becomes a mobile binary — Core ML, LiteRT, ExecuTorch, ONNX Runtime Mobile, each with its own operator coverage and its own way of silently falling back to the CPU when an operator is unsupported. That fallback is a common source of a model that benchmarks at 1.5 ms in a paper and 40 ms in your app, and diagnosing it is a per-runtime skill.

Federated and on-device learning. Chapter 7 mentioned federated averaging in passing as one of the things that can leave a device. Actually training on user data locally — secure aggregation, client drift, differential privacy budgets — is a whole discipline sitting behind that one reference.

How to read a paper before you believe its numbers

Chapter 2 contained a small crisis: a coefficient that circulates widely as “MobileCLIP-S0’s loss weights” belongs to a different model in the same paper, and an augmentation count is stated one way in the dataset section and the opposite way in one ablation sentence. That is normal. Papers are written by tired people under deadlines. Four habits make you resistant.

Ask which model the table is about. A hyperparameter table usually documents one run. The sentence naming which one is often in an appendix, and the sentence saying “all other variants use the same except …” is the one that matters to you.
Find a second number that would have to move with it. The augmentation counts were settled not by re-reading the sentence but by two independent cross-checks: the epoch arithmetic and the storage table. A single quoted number can be a typo; three numbers that agree cannot.
Separate the headline from the configuration. “67.8% zero-shot” is a property of a specific input resolution, a specific text tower, a specific training set and a specific evaluation protocol. Copy the number without the configuration and you will not reproduce it.
Check the latency footnote before anything else. Every millisecond in this lesson is an iPhone 12 Pro Max at batch size 1 via Core ML. A latency figure without a device, a batch size and an export path is not comparable to yours, and comparing it anyway is the most common way an on-device project starts with a budget that was never real.

Where to go next on this site

Three directions lead out of here. Downward, into the compression machinery this lesson used as a black box — pruning, quantization-aware training, neural architecture search. Sideways, into the server-side counterpart, where every constraint inverts and it is instructive to watch the same problem produce opposite answers. And upward, into what happens when the thing you are trying to fit on the device is not a 11.4 M-parameter encoder but a language model.

LessonWhy, from here
Vector EmbeddingsThe foundation underneath everything here — what a vector space of meaning is and how it gets trained
Vector DatabasesThe server-side counterpart. Read Chapter 4 of this lesson next to it and notice how completely the constraints invert
Matryoshka Representation LearningThe full paper walkthrough behind Chapter 3’s truncation trick
TinyML 05 — Quantization I and IIChapter 2 in depth: symmetric versus asymmetric, per-channel, post-training versus quantization-aware
TinyML 09 — Knowledge DistillationThe mechanism behind MobileCLIP’s reinforced training, in general form
TinyML 10 — MCUNetWhat happens when the budget shrinks by another three orders of magnitude, to a microcontroller
TinyML 01 — EfficiencyThe parameters-versus-MACs-versus-bytes framing that Chapter 1 leans on throughout
Embedding BenchmarksHow to know whether a smaller embedding is actually worse, rather than assuming it
Audio RepresentationsThe log-mel and MFCC front end that Chapters 5 and 6 both start from
TinyML 03 — Pruning I and IIThe fourth compression knob this lesson named and then set aside, including when sparsity buys nothing on real hardware
TinyML 11 — TinyEngineWhat the runtime is doing underneath the millisecond figures — memory planning, operator fusion, and why an unsupported operator silently costs you 10×
TinyML 13 — LLM DeploymentThe same four dials, three orders of magnitude up, where the weights themselves stop fitting
Similarity MetricsWhy cosine and not Euclidean, and what the normalization in Chapter 3 was quietly buying you

The cheat sheet

Before you write any code. Which regime — interactive query, live frame, or background sweep? They have different budgets and want different designs, and quoting a millisecond figure without naming the regime is meaningless.
Before you pick a model. Compute arithmetic intensity I = MACs/bytes and machine balance B = MAC-rate/bandwidth. If I > B, cut MACs. If I < B, cut bytes. Optimizing on the wrong side of that inequality is the most common wasted month in on-device work.
Before you quantize. Say what. Weights compound through depth and need per-channel scales. Embeddings are the last operation before a dot product and quantize essentially for free. The same word describes two very different risks.
Before you add an index. Time the flat scan first. At 100,000 vectors it is about 1.6 ms on one core, exact, with zero build time and zero extra memory. Then cut precision, then cut dimension, then partition on something the user understands. An index is the fourth idea, not the first.
Before you sync anything. Fill in the data-flow table from Chapter 7, including the backup column. Then say the honest sentence out loud: “we send a representation from which much of the content can be reconstructed by anyone holding our encoder — which we ship in the app.” If that changes the decision, it needed to be said.
Before you ship. Measure recall against your own exact fp32 baseline, on the device, on the oldest phone you support, over twenty sustained minutes. Every other number in this lesson is a prediction. That one is the result.

Ten questions you should now be able to answer from a blank page

Not a quiz — a self-test. Cover the right column. If a row takes more than a minute, the chapter number tells you where to go back to.

QuestionThe answer in one lineCh
Your model does 2.4 G MACs and reads 12 MB of weights. Compute-bound or bandwidth-bound?I = 2.4e9/12e6 = 200 MACs per byte, far above the phone’s ~16. Compute-bound — cut MACs, not bytes.1
A vendor claims 8 TOPS. What number goes into your latency formula?4 T MACs/s, then multiplied by a realistic utilization well under 1.1
Why does one weight of 2.40 in a group with 0.12 hurt?The scale is max|w|/127, so the step becomes 0.0189 instead of 0.00244 — 9.26× the error, log₂(2.40/0.31) ≈ 3 bits.2
Is int8 safe for your embeddings?Yes — 0.99998 cosine, because nothing downstream amplifies the error. For weights, no, not without per-channel scales.2, 3
Can you truncate any embedding to its first 128 components?Only if it was trained to be truncatable. Otherwise the prefix carries no privileged information and you have thrown away a random three quarters.3
How long does a flat scan of 250,000 192-d int8 vectors take?250,000 × 192 = 48 MB ÷ 8 GB/s = 6 ms.4
Your HNSW index doubled your memory. Is that a bug?No — 132 B of graph per node at M = 16 is about the size of a 128-d int8 vector. It is the design.4
Your wake word fires 21,600 times a day. What now?Require consecutive detections, but set the run length from measured run-length statistics, not from pk.5
You want one false alarm per day at 1 Hz. What threshold?P = 1/86,400; for d = 2 the chi-square tail gives t = −2 ln P = 22.7.6
Marketing wants to sync embeddings for “analytics.” What do you say?Say the honest sentence: anyone with the file and our public app can test membership in milliseconds and reconstruct what each photo shows.7

The sixth row is the one worth checking yourself on, because it is pure application of a formula you now own: bytes divided by measured bandwidth, nothing else. If that arrived quickly, the lesson worked.

One last thing

The reason on-device work rewards arithmetic so heavily is that the constraints are real and they are knowable in advance. A server can be given more machines. A phone cannot. Every megabyte, every millisecond and every millijoule in this lesson could have been computed before a single line was written, and almost every failed on-device project failed because nobody did that computation until after the code existed.

So the skill is not knowing MobileCLIP. It is being able to sit down with a blank page, write M = N · (d · b ÷ 8 + c), and know within an hour whether the thing you have been asked to build can exist on the device you have been asked to build it for.

“The first principle is that you must not fool yourself — and you are the easiest person to fool.”
— Richard Feynman

On a phone, the arithmetic is how you stop fooling yourself. It is unglamorous, it takes twenty minutes, and it is the entire difference between a demo and a product.

References

  1. Vasu, Pouransari, Faghri, Vemulapalli, Tuzel. “MobileCLIP: Fast Image-Text Models through Multi-Modal Reinforced Training.” CVPR, 2024. arXiv:2311.17049
  2. Kusupati et al. “Matryoshka Representation Learning.” NeurIPS, 2022. arXiv:2205.13147
  3. Malkov, Yashunin. “Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs.” IEEE TPAMI, 2018. arXiv:1603.09320
  4. Zhang, Suda, Lai, Chandra. “Hello Edge: Keyword Spotting on Microcontrollers.” 2017. arXiv:1711.07128
  5. Mazumder, Banbury, Meyer, Warden, Reddi. “Few-Shot Keyword Spotting in Any Language.” Interspeech, 2021. arXiv:2104.01454
  6. Roth, Pemula, Zepeda, Schölkopf, Brox, Gehler. “Towards Total Recall in Industrial Anomaly Detection” (PatchCore). CVPR, 2022. arXiv:2106.08265
  7. Morris, Kuleshov, Shmatikov, Rush. “Text Embeddings Reveal (Almost) As Much As Text.” EMNLP, 2023. arXiv:2310.06816
  8. McMahan, Moore, Ramage, Hampson, Agüera y Arcas. “Communication-Efficient Learning of Deep Networks from Decentralized Data” (FedAvg). AISTATS, 2017. arXiv:1602.05629
  9. Lin, Chen, Lin, Gan, Han. “MCUNet: Tiny Deep Learning on IoT Devices.” NeurIPS, 2020. arXiv:2007.10319
  10. Radford et al. “Learning Transferable Visual Models From Natural Language Supervision” (CLIP). ICML, 2021. arXiv:2103.00020
What single idea unifies the photo search, the wake word, and the vibration monitor in this lesson?