~/satyajit

Lexing on the GPU: the scan is the easy part

mdjsonmcp

2026-09-18 · 19 min · gpu · compilers · parsing · cuda · futhark · performance · explainer

Pareas is a compiler that runs on the GPU. Not a compiler for GPUs — a compiler whose lexer, parser, semantic analysis and RISC-V code generation all execute as Futhark kernels, with the CPU doing little more than reading the file and writing the ELF. It came out of two 2021 Leiden master's theses, Robin Voetter's Parallel Lexing, Parsing and Semantic Analysis on the GPU and Marcel Huijben's Parallel Code Generation on the GPU, and a 2022 paper at ACM Computing Frontiers, Compilation on the GPU? A Feasibility Study (DOI 10.1145/3528416.3530249 — it is not on arXiv; I looked, including the authors' own publication list, and there is no preprint).

I cloned the repository, rebuilt its lexer generator, and measured the parts of the design that decide whether any of it scales. The front end is the interesting half: the code-generation backend is a different thesis, and lexing is where the "can this even be data-parallel?" question is sharpest.

What it isa GPU compiler front end (lex → parse → semantic analysis) in Futhark, plus a JSON parser built the same way
The lexera parallel DFA: every byte becomes a transition function, composed by a prefix scan
Hardware in the paperone RTX 3090 (24 GB, 936 GB/s) for GPU runs; a separate 2× EPYC 7601 box for CPU and simdjson runs. Two GPUs installed, one used. No cluster anywhere
The headlineon the two largest JSON files, the GPU parser beats simdjson
The baseline it's againstsimdjson, one thread, on the other machine · PAPAGENO, a parallel CPU lexer/parser · its own Futhark multicore back end at 1–64 threads
Is the transfer counted?No. Appendix C's JSON table reports Upload as a separate column and leaves it out of Total
Are there tests?None in the repository — no token-stream comparison against any reference
Last commitMarch 2024 (a Futhark-HEAD compatibility fix), 383 stars. The research is finished, not abandoned mid-flight

A lexer is a prefix scan

Start from why this is hard. A DFA is the definition of sequential: state qi+1=δ(qi,ai)q_{i+1} = \delta(q_i, a_i), and you cannot know qi+1q_{i+1} without qiq_i. Every classic lexer — flex, re2c, ragel, the hand-rolled switch in your favourite parser — is a loop over bytes with a state variable, and that loop is a dependency chain.

The escape is the Hillis & Steele reformulation the thesis builds on. Instead of carrying a state, carry a function. Partially apply the transition function to the symbol: δa(q)=δ(q,a)\delta_{a}(q) = \delta(q, a). Then the state after the whole input is

δanδa2δa1(q0)\delta_{a_n} \circ \cdots \circ \delta_{a_2} \circ \delta_{a_1}\,(q_0)

and function composition is associative, so the running state after every prefix is an inclusive prefix scan. Parallel time drops from O(n)O(n) to O(logn)O(\log n).

The obvious problem is that a transition function is a table of Q|Q| entries, so a naive scan stores O(Qn)O(|Q|n) intermediate state — the thesis works this out and rejects it. Pareas's answer is to precompute, ahead of time, every composition of transition functions that is actually reachable, assign each one an integer identifier, and reduce composition to a two-dimensional table lookup. Call the number of distinct reachable functions kk. The scan then carries a single u16 per position and the merge operator is one load from a k×kk \times k table.

lexing as a prefix scan — a five-symbol toy
input
1+"ab"
fn id
1d23+24"5a5a4"
round 3stride 4
scan
12324101012
state
NWPWQQQT
token
intwsplusws
grammar those three, plus one "..." rule
final token str
k 20 distinct transition functions
merge table 20² × 2 B = 800 B
tree scan = sequential fold yes, at every position — that equality is the only reason any of this parallelises
A five-symbol miniature of src/compiler/lexer/lexer.fut, computed live in the page rather than read off the real tables. Drag the round slider to watch Hillis–Steele fill in the prefixes with stride 1, 2, 4. Switch the grammar and watch k move: one string rule takes this toy from 5 to 20, and takes the real pareas.lex from 1,927 to 7,952. Under “int + ws” the quote has no rule at all, so the automaton drops into R and the reject state absorbs the rest of the input.

That is the whole kernel. src/compiler/lexer/lexer.fut, which is what actually runs on the GPU:

let lex [n] [m] 'token (input: [n]u8) (table: lex_table [m] token): [](token, i32, i32) =
    let merge (a: state) (b: state) =
        let a = a & !produces_token_mask
        let b = b & !produces_token_mask
        in table.merge_table[state.to_i64 a, state.to_i64 b]
    let states =
        input
        |> map (\x -> table.initial_state[u8.to_i64 x])
        |> scan merge table.identity_state

Three lines of real work: one map from bytes to function identifiers, one scan whose operator is a table lookup, then a filter and two gathers to turn states into (token, start, length) triples. No if, no divergence, no shared-memory choreography. Compared to the shared-memory ballet in a hand-tuned CUDA kernel, this is almost embarrassingly clean — which is exactly why it is worth asking what it costs.

Two schematic diagrams from the thesis. Left: a template nondeterministic finite automaton, drawn as a start circle with three epsilon-labelled arrows fanning out to boxes reading 'automaton for pattern 1', 'automaton for pattern 2' and 'automaton for pattern n'. Right: the same thing collapsed to a single box labelled 'combined DFA' with one incoming arrow from 'start'.
How the lexical grammar becomes one automaton: Thompson-construct an NFA per rule, join them with epsilon edges, then subset-construct. The merge table is built from the DFA on the right (Parallel Lexing, Parsing and Semantic Analysis on the GPU, Figure 3.2a-b).

The merge table is the entire cost model

The thesis reports its two tables in passing — "about 8 MB of precomputed data" for the language, "about 35 MB" for JSON — and then moves on. That number is the design, so I wanted it measured rather than quoted.

You do not need a GPU for this. pareas-lpg, the lexer and parser generator, is plain C++20 that depends only on {fmt}, and it has a --verbose-lexer flag that prints the table sizes. I built it against a header-only fmt 7.1.3 and ran it on the two shipped grammars:

$ pareas-lpg --lexer src/json/json.lex --check --verbose-lexer
Initial states table: 256 element
Merge table: 4176² elements = 17438976 elements
Final states table: 4176 elements
 
$ pareas-lpg --lexer src/compiler/lexer/pareas.lex --check --verbose-lexer
Merge table: 1927² elements = 3713329 elements

lexer.fut declares merge_table: [n][n]state with module state = u16, so those are 34,877,952 and 7,426,658 bytes. The thesis's "4 176 unique unary transition functions ... about 34.9 MB" and "1 927 ... about 7.5 MB" are exactly right. First check: the thesis's own arithmetic holds.

The interesting question is what moves kk. Section 6.1.1 of the thesis predicts the failure mode itself — "when more of these structures are introduced, for example in a programming language which also supports strings, the amount of entries in the merge table blow up drastically, and makes the entire system infeasible" — but never quantifies it. So I edited the grammars, one rule at a time, and re-ran the generator.

merge table size vs lexical grammarmeasured · pareas-lpg --verbose-lexer · log scale
100 KB · shared memory per SM6 MB · L224 GB · VRAM
json.lex − stringk = 642 · 0.82 MB

the shipped JSON grammar with the string rule deleted

pareas.lex − commentk = 798 · 1.3 MB

the shipped language grammar with // comments deleted

json.lex − numberk = 1,482 · 4.4 MB

the shipped JSON grammar with the number rule deleted

pareas.lexk = 1,927 · 7.4 MB

shipped: 48 rules, // comments, no string literals

json.lexk = 4,176 · 34.9 MB

shipped: 12 rules, RFC 8259 strings and numbers

json.lex, uppercase-hex fixk = 4,249 · 36.1 MB

[0-9a-f] → [0-9a-fA-F] inside the \uXXXX escape

pareas.lex + string literalk = 7,952 · 126.5 MB

one added rule: "..." with backslash escapes

pareas.lex + block commentk = > 38,000 · > 2.8 GB

one added rule: /* ... */ — generator dies with std::bad_alloc

k is the number of distinct unary transition functions reachable by composition; the table is k² entries of u16. Nothing here needs a GPU — k falls out of the grammar, and pareas-lpg --verbose-lexer prints it. The last bar is a floor: the stock generator aborts on that grammar, and a patched build that counts states without allocating the square table was still past 38,000 and climbing when I stopped it.

Deleting the string rule from json.lex takes kk from 4,176 to 642 and the table from 34.9 MB to 0.82 MB. One rule is 97.6% of the JSON lexer's precomputed data. Deleting // comments from pareas.lex takes it from 7.4 MB to 1.3 MB. Going the other way, adding a single C-style string literal to pareas.lex — one line, string_literal = /"([^"\\\x00-\x1F]|\\["\\nrt])*"/ — takes kk from 1,927 to 7,952 and the table from 7.4 MB to 126.5 MB, and takes the generator from 4 seconds to 139.

Adding a block comment instead does not finish at all. The stock generator aborts after about six seconds:

$ pareas-lpg --lexer pareas-blockcomment.lex --check --verbose-lexer
terminate called after throwing an instance of 'std::bad_alloc'
  what():  std::bad_alloc

MergeTable::resize in src/lpg/lexer/parallel_lexer.cpp allocates capacity² × sizeof(Transition) and doubles the capacity, so the step past 16,384 asks the allocator for 17 GB. I rebuilt it with resize() patched to count states instead of allocating; the count went past 38,000 and was still climbing inside the merge closure when I stopped it. One /* … */ rule, on a 48-rule grammar, and the table goes from 7.4 MB to somewhere north of 2.8 GB.

The mechanism is easy to see once you look at the transition functions. Outside a string, they are constant: a digit puts you in the "in a number" state regardless of where you were. Constants compose to constants, so the closure barely grows. A string introduces a mode — a digit means "number" outside it and "string body" inside it — so δdigit\delta_{\text{digit}} stops being constant, compositions stop collapsing, and kk grows toward its combinatorial bound. Flip the grammar toggle in the widget above and watch the toy do it: 5 functions become 20, a 4.0× growth, against the 4.1× I measured on the real grammar. Same mechanism; the toy's whole merge table is 800 bytes and the real one is 126.5 MB.

receiptscaptured 2026-09-18

Pareas's parallel lexer turns every input byte into an identifier for a DFA transition function, and composes them with a k×k merge table. k is the number of distinct transition functions reachable by composition, and it is decided entirely by the lexical grammar. I rebuilt pareas-lpg and measured k for the two shipped grammars and six variants.

lexical grammarchange from shippedk (states)u16 tablegenerator
src/json/json.lexshipped, unmodified4,17634.9 MB15 s
json.lex − stringdeleted the string rule6420.82 MB0.3 s
json.lex − numberdeleted the number rule1,4824.4 MB1.5 s
json.lex, \u00E9 fix[0-9a-f] → [0-9a-fA-F] in \uXXXX4,24936.1 MB17 s
src/compiler/lexer/pareas.lexshipped, unmodified1,9277.4 MB4 s
pareas.lex − commentdeleted the // comment rule7981.3 MB0.9 s
pareas.lex + string literaladded one C-style "..." rule7,952126.5 MB139 s
pareas.lex + block commentadded one /* ... */ rule> 38,000> 2.8 GBdid not finish

The last row is the thesis's own predicted failure, measured. The stock generator dies on it with std::bad_alloc after about six seconds — MergeTable::resize allocates capacity² × sizeof(Transition) and the doubling step past 16,384 asks for 17 GB. I rebuilt it with resize() patched to count states instead of allocating; the count passed 38,000 and was still in the merge closure when I stopped it, so the k and byte figures on that row are lower bounds, not the answer.

method g++ -std=c++20 -O2 on src/lpg (fmt 7.1.3 header-only, no Futhark needed), then `pareas-lpg --lexer <g.lex> --check --verbose-lexer`, which prints the merge table size. Bytes = k² × 2, because src/compiler/lexer/lexer.fut declares the table as `merge_table: [n][n]state` with `module state = u16`. Ubuntu 24.04, 4 cores, 15 GB RAM.
data /articles/lexing-on-the-gpu/data/merge-tables.json (8 rows, 2.9 KB)

This matters beyond tidiness, because the table lives in global memory and is read with a data-dependent access pattern. The thesis says so itself, and names the fix it could not reach: if the table were tens of kilobytes it would fit in shared memory and "lexical analysis speed could be improved drastically". At 34.9 MB it does not fit in the RTX 3090's 6 MB L2 either. The merge operator — the innermost operation of the whole kernel — is a random gather into a 35 MB array in VRAM.

What the throughput number is actually against

Here is the claim, from the thesis's own conclusions: "When the input is sufficiently large, however, the GPU-powered JSON parser is able to outperform simdjson."

Grouped bar chart with a logarithmic runtime axis from 0.1 to 100000 milliseconds, across five JSON files from twitter_api_response up to refsnp-other-100K. Nine bars per file: the GPU in yellow, the Futhark multicore back end at 1, 4, 16 and 64 threads in greens, simdjson single-threaded in teal, and PAPAGENO at 4, 16 and 64 threads in blues and purples. simdjson is the shortest bar on the three smallest files; on the two largest the yellow GPU bar is shortest.
The comparison the headline rests on. Note the two largest files, where the yellow GPU bar drops below the teal simdjson bar (Parallel Lexing, Parsing and Semantic Analysis on the GPU, Figure 5.4b).

The figure is a log-scale bar chart, so I went to Appendix C for the milliseconds. Table C.6 breaks the GPU JSON run into five columns — Upload, Lexical Analysis, Parsing, Building parse tree, Restructuring — plus a Total. For the largest file, refsnp-other-100K at 442 MB:

                  Upload    Lexical A.   Parsing    Building   Restruct.   Total
refsnp-other-100K 107.43      58.41        56.86      12.20      42.11     169.59

Add the four processing stages: 58.41 + 56.86 + 12.20 + 42.11 = 169.58. The printed Total is 169.59. The Total excludes the 107.43 ms upload. This is not a rounding artefact — it holds on every row of that table, and the cpu (1 thread) row for twitter_api_response makes it unambiguous: its four stages sum to 1.10 ms, its Total is 1.10 ms, and its Upload is a separate 9.80 ms. Table C.5, the compiler table three pages earlier in the same appendix, does the opposite: there Total equals Upload plus every stage (pareas-4: 40.74 + 345.34 = 386.08 against a printed 386.10). Two tables, same appendix, two different definitions of Total, and the one the simdjson comparison uses is the one that drops the transfer.

That transfer is not incidental. It carries the document and the 34.9 MB merge table, and it is the flattest number in the whole appendix: across a 29,000× range of input sizes the upload rate sits between 3.8 and 4.4 GB/s. That is what a bus looks like, not what a kernel looks like — and the GPU host is a pair of Xeon Silver 4214R, which is PCIe 3.0, capping at 15.75 GB/s theoretical and considerably less for pageable memory.

JSON, whole document to parse tree — RTX 3090 vs simdjson, 1 threadthesis Appendix C, mean of 30 runs
GPU wins 2 of 5
twitter_api_response15.2 KB · simdjson 197.40×
GPU9.87 ms · 0.002 GB/s
simdjson0.05 ms · 0.30 GB/s
spirv.core.grammar423 KB · simdjson 8.44×
GPU11 ms · 0.038 GB/s
simdjson1.31 ms · 0.32 GB/s
gsoc-20183.33 MB · simdjson 2.34×
GPU12 ms · 0.28 GB/s
simdjson4.99 ms · 0.67 GB/s
refsnp-chrMT66.0 MB · GPU 1.55×
GPU49 ms · 1.34 GB/s
simdjson76 ms · 0.87 GB/s
refsnp-other-100K442 MB · GPU 1.86×
GPU277 ms · 1.60 GB/s
simdjson515 ms · 0.86 GB/s
GPU = lexical analysis + parsing + parse-tree construction + restructuring, the four stages Table C.6 sums into its Total. The upload row is a separate column in that table and is excluded from it; it carries the document and the 34.9 MB merge table. simdjson parses from memory on the other benchmark machine (2× EPYC 7601), so this is a cross-machine comparison in both directions.

With the upload counted, the GPU still wins on the two largest files, so the thesis's claim survives — but the margin on refsnp-other-100K falls from 3.03× to 1.86×, and gsoc-2018 flips from a 2.48× GPU win to a 2.34× simdjson win. Turn on the second switch and it gets worse: Futhark hands kernel source to the driver at context creation, and section 5.3.3 measures that at 2.0 seconds for the JSON parser. Against a 442 MB file that simdjson finishes in 515 ms, a one-shot pareas-json invocation pays 2,277 ms. The GPU parser is a server, not a CLI tool, and nothing in the paper claims otherwise — it is just not a distinction the bar chart can draw.

Three more things the denominators are worth saying out loud:

Does it produce the same tokens?

The repository has no test suite. git ls-files turns up a test_parser.cpp for the LLP generator and the vendored Futhark library's own tests, and nothing that compares a token stream or a parse tree against a reference. The paper measures runtime; correctness is asserted, not checked. For a lexer that reaches every byte through a precomputed composition table, that is the part I most wanted to poke at — string escapes and comment terminators are exactly where a data-parallel scan gets interesting.

There is a LexerInterpreter in src/lpg/lexer/interpreter.cpp that walks the same merge table sequentially, but nothing calls it; it is dead code. So I wrote a harness around it. It builds the real ParallelLexer from src/json/json.lex the way src/lpg/main.cpp does, then steps the merge operator one pair at a time — which is the same function lexer.fut hands to scan, so the token stream it prints is the token stream the kernel produces:

// lexcheck.cpp — transcription of LexerInterpreter::lex_linear, with one change:
// the byte is widened through `unsigned char`. The repo's version writes
// `for (auto c : input)` and indexes `initial_states[c]` with a *signed* char,
// which is out of bounds for every byte >= 0x80. lexer.fut does the same lookup
// as `table.initial_state[u8.to_i64 x]`, i.e. unsigned.
for (unsigned char c : input) {
    auto st = lx.initial_states[c];
    states.push_back(st.result_state);
    if (st.produces_lexeme) emit(lx.final_states[PL::START]);
}
for (size_t i = 1; i < input.size(); ++i) {
    auto prev = states[i - 1];
    auto st = lx.merge_table(states[i - 1], states[i]);
    states[i] = st.result_state;
    if (st.produces_lexeme) emit(lx.final_states[prev]);
}

That signed-char indexing is a real bug, though a harmless one — the interpreter it lives in is never called, and the generator itself is uint8_t throughout, so the tables the GPU gets are correct. It cost me one confusing run before I spotted it.

Fourteen inputs, against CPython's json module as the reference decoder:

receiptscaptured 2026-09-18

Pareas ships no test that compares its token stream against a reference. I built one. Fourteen inputs through the generated JSON lexer (the repo's own tables, driven one pair at a time through the same merge operator the GPU scan uses), against Python 3's json module as the reference decoder. Five of the fourteen disagree, which is three distinct defects.

inputpareas json.lexpython jsonverdict
{"a":1}lbrace string colon number rbraceacceptagree
[1.5e+10,-0,0.25]lbracket number comma number comma number rbracketacceptagree
["a\tb\u0041\/\\"]lbracket string rbracketacceptagree
"\u00e9" (lowercase hex)stringacceptagree
"\u00E9" (uppercase hex)(input error)acceptDISAGREE
"\u00Ff" (mixed hex)(input error)acceptDISAGREE
"é" (C3 A9, valid UTF-8)stringacceptagree
"ÿþ" (FF FE, invalid UTF-8)stringrejectDISAGREE
"€" (lone continuation byte)stringrejectDISAGREE
"ab" (raw DEL, 0x7F)(input error)acceptDISAGREE
"a\tb" (raw TAB, 0x09)(input error)rejectagree
"abc (unterminated)(input error)rejectagree
01number numberrejectdeferred to parser
+1(input error)rejectagree

Five rows are marked DISAGREE and one is deferred. Two of the five are the same bug counted twice (uppercase hex in a \uXXXX escape), so it is four distinct defects: uppercase hex escapes rejected, raw 0x7F rejected, and invalid UTF-8 accepted without complaint. RFC 8259 §7 defines the escape as %x75 4HEXDIG, and ABNF terminals are case-insensitive; §7 also permits unescaped %x20-21 / %x23-5B / %x5D-10FFFF, which includes 0x7F. The `01` row is not a lexer defect — the token stream `number number` is not a valid JSON document, so the parser rejects it downstream.

method lexcheck.cpp — my harness, not in the repo. It constructs pareas::lexer::ParallelLexer from src/json/json.lex exactly as src/lpg/main.cpp does, then walks the merge table sequentially, which is the same function src/compiler/lexer/lexer.fut hands to Futhark's `scan`. Input bytes are widened through `unsigned char`, matching lexer.fut's `u8.to_i64 x`; the repo's own LexerInterpreter uses a signed char and indexes out of bounds on any byte ≥ 0x80. Reference column is `json.loads` on CPython 3.11.
data /articles/lexing-on-the-gpu/data/json-conformance.json (14 rows, 3.7 KB)

Three distinct defects in that table, plus a fourth in the language grammar, and nothing in the repository catches any of them:

  1. Uppercase hex in a \uXXXX escape is rejected. The grammar writes the escape as u[0-9a-f][0-9a-f][0-9a-f][0-9a-f]. RFC 8259 §7 defines it as %x75 4HEXDIG, and ABNF terminals are case-insensitive, so "\u00E9" is valid JSON. Pareas lexes it to (input error). This one is cheap to fix: changing the class to [0-9a-fA-F] takes kk from 4,176 to 4,249 — 1.2 MB more table, 3.5%.
  2. A raw DEL (0x7F) inside a string is rejected. RFC 8259 forbids only %x00-1F unescaped; 0x7F is explicitly allowed by the %x5D-10FFFF range.
  3. Invalid UTF-8 is accepted silently. "\xFF\xFE" and a lone continuation byte both lex to a clean string token. The grammar's negated class lets every byte from 0x80 to 0xFF through unchecked. simdjson, the comparison baseline, validates UTF-8 as part of its parse — so the two programs in that bar chart are not doing the same amount of work.
  4. A // comment at end of file without a trailing newline fails. pareas.lex writes comment = /\/\/[^\n]*\n/, and the newline is mandatory. fn f(): int { return 1; }\n// trailing comment lexes fine right up to the comment and then ends in (input error).

None of these are fatal to the research — the thesis is a feasibility study, not a conformance claim, and it never says otherwise. They are just what "correctness is asserted, not verified" looks like when you go and check.

What I could not check

What would change my mind

4 claims above, and what would falsify each

  1. The merge table, not the scan, is what limits this design.

    A grammar with block comments and string literals that generates a table small enough to fit in an RTX 3090's 6 MB L2 — or a sparse encoding of the table that keeps the merge operator O(1). The thesis proposes both; neither is implemented. Either would move the ceiling.

  2. The GPU-beats-simdjson result shrinks from 3.03× to 1.86× once the PCIe upload is counted.

    A statement from the authors that the JSON Total in Table C.6 does include Upload and the columns are labelled misleadingly. The arithmetic says otherwise on all five rows, but arithmetic on a published table is an inference about intent, not a measurement.

  3. Pareas's JSON lexer disagrees with a reference decoder on five of fourteen inputs.

    A demonstration that my harness diverges from the Futhark scan — most plausibly if produces_token_mask handling in lexer.fut changes which token a state reports. Running pareas-json on those fourteen inputs on real hardware would settle it in a minute, and I cannot.

  4. Adding one block-comment rule to pareas.lex makes the table unbuildable.

    A completed run of the patched generator landing at some modest k — my number is a lower bound taken while the closure was still growing, not a final count. I stopped it; somebody with more RAM and more patience should not.

The take

The reformulation is the good part and it is genuinely good. Turning "simulate a DFA" into "scan an associative operator" is the kind of move that makes a sequential problem disappear, and lexer.fut is a map, a scan, a filter and two gathers, with no divergence, no barriers and no shared-memory choreography — the opposite of the register-and-barrier hand-scheduling that FlashAttention-3 style kernels live on. Read it once and the idea is yours.

What the measurements say is that the cost moved rather than vanished. It went into kk, the number of distinct transition functions, and kk is quadratic in the table and superlinear in the grammar's complexity. JSON — twelve token rules, one of them a string — already needs 34.9 MB. One block comment is enough to make the generator run out of memory building the thing. That is not a tuning problem; it is the representation. Every real language the approach would want next (C, Rust, anything with nested comments, raw strings or a preprocessor) sits on the wrong side of that curve, and the thesis's own future-work section names the two fixes it would need — shrink the table into shared memory, or store it sparsely — without implementing either.

And the throughput headline is real but narrower than the chart suggests: a 1.86× win over one simdjson thread on a 442 MB document, once the transfer is counted, on a card with 5.5× the memory bandwidth of the CPU it is being compared against, after a 2.0-second startup, with no UTF-8 validation and no tests. All of which the thesis is more honest about than most papers would be — it writes "the lack of a proper baseline makes it hard to draw a final conclusion" in its own conclusions. The right reading of Pareas is the one its title claims: a feasibility study that answers yes, and here is precisely which part will stop you. Five years on, the part that stops you is still the table.

Sources: Snektron/pareas (commit c0105fd) · Voetter 2021, MSc thesis · Huijben 2021, MSc thesis · Voetter, Huijben & Rietveld, CF '22 · RFC 8259 · simdjson

Cite this article

For attribution, please use the following reference or BibTeX:

Satyajit Ghana, "Lexing on the GPU: the scan is the easy part", ai.thesatyajit.com, September 2026.

bibtex
@misc{ghana2026lexingonthegpu,
  author = {Satyajit Ghana},
  title  = {Lexing on the GPU: the scan is the easy part},
  url    = {https://ai.thesatyajit.com/articles/lexing-on-the-gpu},
  year   = {2026}
}
share