~/satyajit

gpu-lexer: how a 28 KB model lexes in parallel, and where the 3.8x came from

mdjsonmcp

2026-10-23 · 17 min · webgpu · small-models · browser-ml · performance · benchmarks · developer-tools

A 1:59 narrated explainer, drawn in code. Every number and picture in it is this article's own; the sources are below.

› transcript

Hi, I'm Dovetail! Today: a highlighter with no grammar that colours a whole file at once. Treat code as a one dimensional image, and label every part in parallel. A normal lexer walks one character at a time, with a grammar per language. gpu-lexer is never told the language, and labels every part at once. One CPU pass splits the source into words, spaces and symbols. On the GPU, each part becomes thirty two learned channels. A learned scan carries context in both directions. A tree merges blocks into one file summary, then sends it back down. Every part gets one of nine classes, and equal neighbours merge into spans. Here is the formula. The gate a decides how much old state survives. b is what the current part writes in. Two of these maps in a row are still one map, so any stretch of code shrinks to two numbers per channel. Cut the parts into blocks of thirty two. Fold every block into one pair, all at once. A short scan tells each block where it starts. Each block replays from its true start. Here is the tree from Shu Ding's animation. Whole file context comes back down to every part. On five and a half million characters, gpu-lexer takes about two hundred and twenty milliseconds. Highlights takes about seventy five. And the three point eight times? Measured the same way before and after, the code is worth one point three seven. These maps compose into maps of the same size, so the scan never grows. Code as an image, one scan, one tree. Every source is in the full article. I'm Dovetail. Bye!

Why read this

Hightop 30%

Reads gpu-lexer's scan kernels, shows its 3.8x is 1.37x of code plus a re-measured baseline, and runs a parallel-prefix lexer against a sequential one.

  • Checked against the source
  • Runs in a browser
  • A lasting reference

GPUs, kernels & systemsMITPractitioner teardown

How this was scored
Is it new?
2 of 3: A real new idea, method or capability
Can I trust it?
3 of 3: Reproduces the headline result, or shows from primary files it is wrong
Can I run it?
2 of 3: Open code or weights with real limits
Will I understand it?
2 of 3: Mechanism from first principles with figures
Can I act on it?
2 of 3: A concrete recipe, numbers or comparison
Will it last?
2 of 3: A reference for a year or more
Does it affect many?
1 of 3: A specialist community
Only here?
2 of 3: A teardown or measurement few others did

Score 73 of 100, ranked 77 of 607 rated articles. Each question is answered 0–3 by hand, and a 3 is rare. How articles are scored

On 22 October Shu Ding posted that he had made gpu-lexer "3.8x faster: 850ms → 221ms", added that it is "still far behind" Pierre's Highlights, and linked an essay, Ideas Behind GPU Lexer. I covered gpu-lexer once already, as the seed of a cluster of tiny browser models. This piece is about the two things that article did not take apart: how a lexer runs in parallel at all, and what the 3.8x actually measures.

Short version, each part labelled:

The workload: ten copies of three.min.js

Every number in the post is the same stress test. The input is three.min.js from three.js r97, concatenated ten times: 555,650 characters a copy, 5,556,500 in total. I downloaded it from the URL in the benchmark report and its SHA-256 matches the report's 3ed7c931… (measured). The time is one warmed parse() call in a dedicated worker, DOM rendering excluded, on an Apple M4 Pro with a 20-core GPU (reported, packages/benchmark/reports/browser-performance-2026-10-22.json).

It is a large minified file: lots of independent work for one GPU dispatch. Asked on X why lexing takes 220 ms at all, Shu Ding replied that "Running just a RegExp on it would take 10ms" (reported).

How gpu-lexer works

The essay frames the problem as image segmentation in one dimension. Split code into its smallest parts, treat each part as a pixel, and label every pixel. The architecture doc fills in the rest:

  1. One CPU pass splits the string into word runs, whitespace runs, newlines and single symbols, and packs each part's features into two u32s: kind, a log length bucket, first and last character, two word hashes, line-start and shape flags, and neighbouring-symbol hashes. No grammar, no regex table.
  2. On the GPU, sparse features are summed into a 32-channel vector per part, mixed with a five-part window and the nearest non-whitespace neighbours.
  3. Learned affine state updates scan the sequence in both directions.
  4. A binary tree merges 32-part blocks bottom-up into one whole-file summary, then pushes context back down.
  5. A classifier with 72 hidden units gives each part one of nine classes, and adjacent equal labels merge into spans.
vercel-labs/gpu-lexer@4f36e39 · snapshot 2026-10-23
tracked files
181
license
MIT
branch
main
tests
43 files
source
1.2 MB
commit date
2026-10-22
source by language
JavaScript899.3 kB(106)Python149.7 kB(6)TypeScript98.0 kB(17)WGSL39.5 kB(1)CSS16.8 kB(1)HTML2.5 kB(4)Makefile0.1 kB(1)

by size of tracked source at this commit, file counts in brackets; docs, data and vendored trees excluded

local clone, 2026-10-23 at 4f36e39 — branch, commit, commitDate, fileCount, hasTests, languages, license, licenseFile, testFileCount

The weights are 41,321 six-bit integers, and the whole package is about 28.6 KB with Brotli after this week's change: 28,580 bytes in the README, 28,599 in the size benchmark (reported).

Dark animation frame titled 'Scan context both directions'. A row of source parts (const, space, message, =, quote, hello, comma, world, and so on) sits under a column of 32 blue channel cells per part, with a highlighted window around five parts. Labels read '32 learned channels' and '5-part window + nearest non-space'.
Each part becomes 32 learned channels; a five-part window and a bidirectional scan add ordered context (Shu Ding, gpu-lexer pipeline video, Ideas Behind GPU Lexer).
Dark animation frame titled 'Pass 2: top-down'. A binary tree of blue channel blocks rises from the row of source parts to a single root, with the level counts 21, 11, 6, 3, 2, 1 in the corner.
The tree pass: blocks merge up to one root for the whole file, then context flows back down to every part (Shu Ding, gpu-lexer pipeline video, Ideas Behind GPU Lexer).

Step 3 is the part that answers "how do you lex in parallel", so it gets its own section.

Why a lexer looks sequential

A conventional lexer is a loop with a state variable: inside a string, inside a comment, in code. Whether the * at byte 4,000,000 is multiplication or the end of a comment depends on everything before it. Each state needs the previous one, which is a dependency chain the length of the file.

The classical escape is to stop carrying a state and carry a function instead. For a DFA with states QQ, each chunk of input defines a transition table f:Q→Qf: Q \to Q, "if you enter in state qq, you leave in state f(q)f(q)". Function composition is associative, so the state at every chunk boundary is a prefix scan over those tables, and a scan finishes in O(log⁡n)O(\log n) parallel rounds (Hillis and Steele, 1986). Once each chunk knows its start state, every chunk re-lexes itself independently.

The widget runs that algorithm exactly on a six-state toy grammar: code, a pending /, a block comment, a pending * inside it, and two kinds of string. Each chunk becomes a table of six arrows. The scan composes them in ⌈log⁡2chunks⌉\lceil \log_2 \text{chunks} \rceil rounds and reads off each chunk's real start state. Then every chunk re-lexes from that state. Press "skip the scan" and each chunk assumes it starts in code. That is what chunked lexing without a scan does, and a quote or a /* in an earlier chunk then colours later characters wrong.

I wrote about the cost of doing this for real grammars in Lexing on the GPU. Pareas, a GPU compiler, does exactly this, and its precomputed composition table grows from 7.4 MB to 126.5 MB when a single string rule is added (measured there). The trouble is that composed tables do not stay small. A string introduces a mode, compositions stop collapsing, and the number of distinct functions explodes.

What the DFA scan costs on CPU

I ran the same algorithm on the benchmark input, in JavaScript on Node 22, single-threaded, on a shared 4-core cloud VM (dfa-scan.mjs, an 11-state DFA covering code, // and /* */ comments, three quote styles and escapes; regex literals ignored, so it is a toy, not a JavaScript lexer). Medians of five runs, measured:

Phase, 5,556,500 charactersTime
Sequential walk, one state18.3 ms
Phase 1: each of 4,096 chunks run from all 11 start states193 ms
Phase 1, merging start states once they converge80.1 ms
Phase 2: scan over 4,096 tables1.33 ms
Phase 3: re-lex every chunk from its true start16.9 ms

The output matched the sequential walk on all 5,556,500 characters, and the converged tables matched the full ones exactly (measured, zero mismatches). The scan itself is nearly free. The cost is phase 1: running every start state costs about 10.5x the sequential work (reasoned, 193 / 18.3), or 4.4x with merging, and that is what a parallel machine has to amortize before it wins anything.

Merging start states helps, but less than I expected. I sampled 2,000 random offsets and ran all 11 states forward. After 4,096 characters, an average of 3.05 distinct states were still alive (measured). Start states that differ only in whether you are inside a string never merge: every " swaps "in a string" and "in code", so a chunk cannot know which side of a quote it is on without its prefix. Quote parity is the part of lexing that refuses to be local.

gpu-lexer's scan: an affine map instead of a table

gpu-lexer sidesteps the table problem by not scanning a DFA at all. Its state is 32 floating-point channels, and each part updates each channel with a gated linear step. From affine_at in packages/core/src/tree-shader.js:

let a = sigmoid(gate);                       // in (0, 1)
return vec2<f32>(a, (1.0 - a) * tanh(input)); // the pair (a_t, b_t)

and the state update is ht=atht−1+bth_t = a_t h_{t-1} + b_t. Both ata_t and btb_t are computed from the part's own local features, not from ht−1h_{t-1}. That is what makes the step an affine map of the previous state, and affine maps have the property DFA tables lack: composing two gives another affine map of exactly the same size,

(a2,b2)∘(a1,b1)=(a2a1,  a2b1+b2).(a_2, b_2) \circ (a_1, b_1) = (a_2 a_1,\; a_2 b_1 + b_2).

A block of 32 parts, or a million, summarizes to one pair (A,B)(A, B) per channel. There is no closure to precompute and nothing that grows with the grammar. This is the same trick as the selective scan in Mamba, applied to lexing.

The kernels split it into three passes, which I read off the shader:

The architecture doc puts it in one line: "block boundaries do not reset context".

The widget runs that on one channel with hand-picked gates: a quote that looks like an opening (after =, ( or a space) writes +1, one that looks like a closing writes −1, everything else leaks the state slowly. Change the block size and the block pairs change, but the replayed states do not.

I checked the exactness claim numerically (affine-scan.mjs, measured). On 1,000,000 random gated steps in float32, the blocked scan with gpu-lexer's block size and lane count differs from a plain serial loop by at most 1.19×10−71.19 \times 10^{-7}, one float32 rounding step near 1. Both sit within 1.12×10−71.12 \times 10^{-7} of a float64 reference. Because at<1a_t < 1 the recurrence is contractive, so re-associating it does not let errors grow. That agrees with the repository's own parity test: the new kernels produced zero label mismatches across 5,028 corpus files and 5,979,352 spans, in both f16 and f32 (reported).

What the affine scan gives up

There is a catch, and it is the same quote parity that kept my DFA from converging. With at∈(0,1)a_t \in (0, 1) and ata_t independent of the state, a channel cannot flip its sign at a quote. It can only overwrite itself ("I just saw something that looks like an opening quote") or decay. So the scan cannot count quotes. Whether a " opens or closes a string has to come from local evidence, like the character before it, plus whatever the tree pass adds (reasoned). That is the price of a scan that stays two numbers wide. It is also consistent with the accuracy being high but not perfect: 86.8% agreement with Shiki on the site's popularity-weighted measure, against 84.1% for Prism.js and 70.1% for Highlight.js (reported, essay), and 83.02% per-part on the held-out corpus (reported, README). I have not checked which classes the errors land on.

Where the 3.8x came from

Here is what the two numbers in the post are:

16 September22 October
gpu-lexer850 ms220.5 ms
Samples1 warm-up, 1 measured callmedian of 3 fresh workers
BrowserChrome 152headless Chrome 156
Prism.js8,391.9 ms1,238.1 ms
Highlight.js15,906.7 ms1,364.0 ms
Sugar High4,829.6 ms820.0 ms
Starry Night72,312.8 ms11,356.1 ms
Shiki33,096.7 ms30,438.8 ms

All reported, from the two browser-performance JSON files in the repository. The ratio 850 / 220.5 is 3.85 (reasoned), so the headline arithmetic holds. But four of the five comparison libraries also got 5.9x (Sugar High) to 11.7x (Highlight.js) faster between those runs, with the same versions and no code change; only Shiki barely moved, at 1.09x (reasoned). The October report says so itself: "Re-measured comparison libraries were 4-12x faster than that run under the same versions … so the earlier figures were not representative."

The controlled comparison is in the same file. The same runner, run on the old commit 07f3e56 and the new tree, five fresh-worker samples per series, gives medians of 289.6 and 290.2 ms before and 212.1 and 212.8 ms after (reported). That is about 1.37x (reasoned, 290 / 212). So of the 3.85x, the code accounts for roughly 1.37x and the measurement change for roughly 2.8x (reasoned, 3.85 / 1.37).

Horizontal bar chart titled 'Highlight 10× three.min.js, warmed browser time, lower is better'. Highlights 75.3ms, gpu-lexer 220.5ms (highlighted), Sugar High 820.0ms, Prism.js 1.24s, Highlight.js 1.36s, Starry Night 11.4s, Shiki 30.4s, on an axis from 0 to 40 seconds.
The chart attached to the post: the October 22 run, median of three fresh-worker samples on an Apple M4 Pro. These are the author's numbers, not mine (Shu Ding, gpu-lexer benchmark, posted on X).

The 1.37x is still a real optimization, and the commit (c82583f) says where it came from:

Nothing changed in the model; the checkpoint is the same 20260916T055435.785Z in both reports. Someone in the replies asked how the 221 ms splits between the CPU split and the GPU. The repository does not publish that split for this input, so I cannot say.

One more data point shows how noisy these numbers are. The pre-optimization runtime has been timed on this input at 557.85 ms (the architecture doc's repeated benchmark on 16 September, 18 calls with ABBA ordering, Chrome 152), at 400.8 ms (Pierre's run of 0.0.2 on 21 October, Chromium 153, diffs.com/highlights/benchmark-browser.json), and at about 290 ms (the October before/after, commit 07f3e56, Chrome 156). All reported, all on an M4 Pro, and none of them is the 850 ms single sample. Browser timing of a GPU workload moves with the browser build and the harness.

How Pierre's Highlights differs

pierrecomputer/pierre@99c8881 · snapshot 2026-10-23
tracked files
1,756
license
Apache-2.0
branch
main
tests
424 files
source
11.9 MB
commit date
2026-10-23
source by language
TypeScript11.4 MB(1203)CSS176.5 kB(13)HTML159.5 kB(32)JavaScript113.6 kB(21)Shell4.8 kB(3)

by size of tracked source at this commit, file counts in brackets; docs, data and vendored trees excluded

local clone, 2026-10-23 at 99c8881 — branch, commit, commitDate, fileCount, hasTests, languages, license, licenseFile, testFileCount

Highlights is close to the opposite design. From its repository and its ARCHITECTURE.md:

On the benchmark input, Highlights took 75.3 ms in gpu-lexer's run and 81.6 ms in Pierre's (reported). That makes it about 2.9x faster than gpu-lexer (reasoned, 220.5 / 75.3) on a workload chosen to favour the GPU. It is also larger: 98,187 bytes with Brotli for all its languages, against gpu-lexer's 28,599 (reported, gpu-lexer's size benchmark). That is the trade in one line. Highlights spends bytes on per-language lexers and gets determinism and speed; gpu-lexer spends accuracy and gets one 28 KB model for every language, including ones nobody wrote a grammar for.

The comparison is not quite like for like. Highlights is told the language (lang: "javascript") and returns themed tokens. gpu-lexer is told nothing and returns nine-class spans (the benchmark notes say so).

What I ran

Everything below ran on a shared 4-core cloud VM with no GPU, CPU only, at low priority behind other jobs.

The two scans on CPU. dfa-scan.mjs (about 100 lines) and affine-scan.mjs (about 40) are plain JavaScript on Node 22.22.0; their numbers are in the sections above.

Both gpu-lexer releases, and Highlights, in headless Chromium. I loaded the published npm builds of gpu-lexer 0.0.3 (before the optimization) and 0.0.4 (after), plus @pierre/highlights 0.2.0, into module workers in Playwright's headless Chromium 141 with WebGPU enabled. The adapter it handed out was SwiftShader: architecture: "swiftshader", isFallbackAdapter: true, no shader-f16 (measured). That is Google's software Vulkan, running the compute shaders on the CPU. The input was one copy of three.min.js, 555,650 characters, timed the way gpu-lexer's runner times it: one warm-up call, then one timed call, in each of three fresh workers.

One copy of three.min.js, SwiftShaderMedian of 3Samples
gpu-lexer 0.0.371,561 ms64,095 / 78,940 / 71,561
gpu-lexer 0.0.472,426 ms66,595 / 74,794 / 72,426
Highlights 0.2.063.7 ms122.2 / 57.2 / 63.7

All measured. Three things in that table:

I did not run ten copies; at this rate one call would take more than ten minutes. I also could not reproduce 220.5 ms or 75.3 ms, because I have no Apple M4 Pro. Those stay reported.

The take

The interesting idea in gpu-lexer is not "neural network replaces grammar". It is that the network's sequence model is scannable. A state update that is affine in the previous state has a property a DFA table lacks: any run of steps composes into one step of the same size. That is exactly what a GPU needs: fold blocks independently, scan a few pairs, replay. A DFA has the scan but not the fixed size, and Pareas shows what happens to its tables. Highlights keeps the hand-written lexer's determinism and gives up the scan, then makes the sequential pass fast with SIMD and the incremental pass cheap with saved states.

The 3.8x is honest arithmetic on two published numbers, but it is mostly a better benchmark. The engineering improvement is about 1.37x, with a 4x drop in GPU memory, and it is well documented in the commit. If you are choosing a highlighter today: Highlights is faster, and rule-based, for the 84 languages it knows. gpu-lexer is the one that needs no grammar and no language id, and accepts some wrong colours for that.

Cite this article

For attribution, please use the following reference or BibTeX:

Satyajit Ghana, "gpu-lexer: how a 28 KB model lexes in parallel, and where the 3.8x came from", ai.thesatyajit.com, October 2026.

bibtex
@misc{ghana2026gpulexer,
  author = {Satyajit Ghana},
  title  = {gpu-lexer: how a 28 KB model lexes in parallel, and where the 3.8x came from},
  url    = {https://ai.thesatyajit.com/articles/gpu-lexer},
  year   = {2026}
}
share