Skip to content

Instantly share code, notes, and snippets.

@mizchi
Last active July 19, 2026 19:43
Show Gist options
  • Select an option

  • Save mizchi/1ba06e646dbc6396a50798a2f9678d15 to your computer and use it in GitHub Desktop.

Select an option

Save mizchi/1ba06e646dbc6396a50798a2f9678d15 to your computer and use it in GitHub Desktop.
SIMD Lexer Pipeline: classify, context carving, coalesce, compress (日本語 / English)

SIMD Lexer Pipelines: Classify, Carve, Coalesce, Compress

I prototyped a v128-based lexer optimization using the JSON parser in moonbitlang/core as the target. This note distills the design into a SIMD lexer pipeline that can be applied to JSON, JavaScript, and other languages.

The short version is that bitmasks are indeed the key abstraction. However, the main opportunity is not matching CST node kinds after construction. It is classifying input bytes and extracting only the positions where lexical context can change.

The pipeline

A typical pipeline can be decomposed as follows:

UTF-8 bytes
    ↓
1. classify
    ↓
character-class bitmaps
    ↓
2. context carving
    ↓
context-corrected bitmaps
    ↓
3. coalesce
    ↓
token-start / token-kind bitmaps
    ↓
4. compress
    ↓
flat token positions + kinds
    ↓
5. unfused parser

The goal is not to interpret the entire grammar directly with SIMD.

SIMD broadly enumerates what each byte might be. A small scalar state machine then processes only the sparse positions that can change context. Finally, mask operations eliminate invalid candidates in bulk.

1. Classify

Load 16 bytes at a time and classify every lane in parallel.

For JSON, the minimum useful set of masks is:

quote       : "
backslash   : \
structural  : { } [ ] : ,
whitespace  : space tab LF CR
control     : byte < 0x20

The result is not yet a token stream. It is a bitmap for each character class.

input:       { "x": [1, 2] }
quote:         1 1
structural:  1    1 1  1 1 1
whitespace:   1    1   1  1

ASCII punctuation can be classified with a two-table nibble lookup: use i8x16.swizzle on a 16-entry low-nibble table and a 16-entry high-nibble table, then intersect the results.

class(byte) = lo_table[byte & 0x0f]
            & hi_table[byte >> 4]

This scales better than adding more independent comparisons as the number of character classes grows.

2. Context carving

Classification is context-free. In the following JSON, it therefore marks ] } , inside the string as structural candidates too:

{"text": "not ] } , structural"}

Context carving takes these broad candidate masks, cuts out spans such as strings, comments, and regular expressions, then removes or reclassifies candidates that are impossible in that context.

For JSON, this consists of:

  1. Enumerating only the events in quote | backslash
  2. Tracking the parity of each backslash run
  3. Retaining only unescaped quotes
  4. Computing inside_string with a prefix XOR over the quote mask
  5. Removing structural candidates inside strings

Conceptually:

inside_string   = prefix_xor(unescaped_quotes)
real_structural = structural & ~inside_string

A standard bit-enumeration loop is useful here:

while mask != 0:
    lane = ctz(mask)
    process(lane)
    mask &= mask - 1

Ordinary bytes do not branch one by one. Only characters capable of changing state, such as quotes and backslashes, are processed.

Why call it carving?

Instead of tokenizing the whole input sequentially, the algorithm cuts exceptional lexical contexts out of the complete set of candidates as masks:

all candidates
  ├─ ordinary code
  ├─ string spans
  ├─ line/block comment spans
  ├─ regex spans
  ├─ template literal spans
  └─ JSX spans

In JavaScript, for example, classification can only identify / as a slash candidate:

a / b         // division
/foo/.test(x) // regexp
// comment
/* comment */

Context carving uses the lexer mode and surrounding state to determine the span, then invalidates operator, identifier, or punctuation candidates inside it, or replaces them with context-specific kinds.

This does not mean parsing a context-sensitive language entirely with SIMD. It means performing broad context-free candidate generation with SIMD, then reducing context-sensitive decisions to sparse event processing.

3. Coalesce

Immediately after classification, we only know that individual bytes resemble digits, word characters, or operator characters.

Coalescing groups adjacent candidates:

1 2 3 . 4 5  → number candidate
f o o _ 1    → identifier candidate
> > =        → operator candidate
t r u e      → keyword candidate

For JSON, this stage finds the starts of numbers and the literals true, false, and null. JavaScript adds identifiers, keywords, and multi-character operators.

4. Compress

The final bitmap representation is compressed into flat arrays suitable for the parser:

positions : Array[UInt32]
kinds     : Array[Byte]

Storing positions and kinds in contiguous arrays is more cache-friendly than allocating a boxed object for every token. Lookahead is cheap as well.

The parser no longer has to restart the lexer each time it requests a token. It can inspect kinds[i + 1] or kinds[i + 2] directly. This unfused lexer/parser contract can be as important as SIMD classification itself.

How is this different from bitmasking CST node kinds?

Bitmasking CST node kinds can still help with tree traversal or set-membership checks inside a parser.

But once a CST exists, the system has already paid for:

  • Sequentially scanning the input
  • Branching on tokens
  • Allocating nodes
  • Constructing source ranges and child arrays

That is too late to provide the main lexer speedup.

The more useful design is to represent kinds as small integers in a flat token stream before CST construction, reducing parser branches and making lookahead cheap. The most valuable bitmasks describe input positions, character classes, and token candidates—not already constructed nodes.

Results from the MoonBit JSON prototype

The existing API in moonbitlang/core/json accepts parse(StringView), which means UTF-16 input. The current v128_load operates on byte arrays, so the UTF-16 prototype had to gather eight scalar lanes to construct each vector.

Under this constraint, a fully SIMD-classified UTF-16 lexer pipeline could not recover its classification overhead. A narrower scanner, however, worked well: it searched long JSON strings only for quotes, backslashes, and control characters.

The production-oriented prototype therefore uses a hybrid scanner:

short string:
    scalar scan

long ordinary string:
    scalar prefix
    → v128 special-character scan
    → scalar tail

escape/control encountered:
    existing slow path

The following are preliminary measurements from one machine. Runtime load causes noticeable variation, so the trend matters more than the absolute values.

Workload Scalar SIMD Speedup
Special-character scan over 65,536 UTF-16 code units / native 71.6 µs 12.7 µs 5.6x
Same / wasm 85.6 µs 32.5 µs 2.6x
Parsing a JSON array of 1,000 long strings / native 1.28 ms 0.399 ms 3.2x
UTF-8 token-start pipeline / native ~182 µs 102–118 µs 1.5–1.8x

For typical JSON dominated by short strings, performance was roughly neutral, with small regressions of a few percent in some runs. The full UTF-8 pipeline was also about 11% slower than scalar on wasm at this stage, so it was not connected to the production parser.

What a complete implementation would require

To make a simdjson/Oxc-style pipeline effective for MoonBit JSON, the input contract should change as well:

  1. Add parse_utf8(Bytes/BytesView)
  2. Generate structural positions and a flat token tape in stage 1
  3. Build Json in stage 2 while retaining slices into the original bytes
  4. Decode and unescape only strings that require it
  5. Optionally expose a lazy or tape-based API alongside the tree API

The current Json API ultimately allocates String, Array, and Map values. Once lexing becomes faster, allocation, string materialization, and number conversion become dominant. A large end-to-end improvement therefore requires a combination of SIMD classification, a flat token contract, lazy decoding, and a low-allocation representation—not a single SIMD trick.

Summary

The core SIMD lexer pattern is:

  1. Classify broadly: generate candidates without context
  2. Carve sparsely: process only the few events that change state
  3. Filter with masks: eliminate contextually impossible candidates in bulk
  4. Compress flatly: pass cache-friendly position and kind arrays to the parser

Bitmasks are not merely a faster way to switch on node kinds. Their more important role is to represent the input so that most of it can be discarded as “nothing interesting happens here,” reducing the amount of information that must be handled sequentially.

References

SIMD Lexer Pipeline: classify, carve, coalesce, compress

moonbitlang/core の JSON parser を題材に、v128 を使った lexer 高速化を試作した。その過程で見えてきた、JSON・JavaScript・その他の言語へ一般化できる SIMD lexer pipeline を整理する。

結論を先に書くと、bitmask を使うという発想は正しい。ただし主戦場は CST 構築後の node kind 判定ではなく、入力 byte の分類と、文脈が変化する位置の抽出である。

全体像

典型的な pipeline は次のように分解できる。

UTF-8 bytes
    ↓
1. classify
    ↓
character-class bitmaps
    ↓
2. context carving
    ↓
context-corrected bitmaps
    ↓
3. coalesce
    ↓
token-start / token-kind bitmaps
    ↓
4. compress
    ↓
flat token positions + kinds
    ↓
5. unfused parser

ポイントは、SIMD だけで文法を直接解釈しようとしないことにある。

SIMD は「各文字が何に見えるか」を大量に列挙する。その後、文脈が変化する少数の位置だけを scalar state machine で処理し、最後に mask 演算で不要な候補をまとめて消す。

1. Classify

入力を16 byteずつロードし、各laneを並列に分類する。

JSONなら最低限、次のmaskがあればよい。

quote       : "
backslash   : \
structural  : { } [ ] : ,
whitespace  : space tab LF CR
control     : byte < 0x20

結果はtoken列ではなく、各位置に対応するbitmaskになる。

input:       { "x": [1, 2] }
quote:         1 1
structural:  1    1 1  1 1 1
whitespace:   1    1   1  1

ASCII punctuationは、low nibbleとhigh nibbleの2つの16-entry tableを i8x16.swizzle し、その結果をANDする nibble lookup で一括分類できる。

class(byte) = lo_table[byte & 0x0f]
            & hi_table[byte >> 4]

単純な文字比較を何本も並べる方法より、分類対象が増えたときに拡張しやすい。

2. Context carving

classifyは文脈を見ない。したがって、次のJSONでは文字列内の ] } , までstructural候補になる。

{"text": "not ] } , structural"}

context carvingは、粗く作った候補maskから、string・comment・regexなどの領域を切り出し、文脈上あり得ない候補を除外または再分類する段階である。

JSONでは次の処理になる。

  1. quote | backslash のeventだけを列挙する
  2. backslash runの奇偶を追跡する
  3. escapeされていないquoteだけを残す
  4. quote maskのprefix XORで inside_string を作る
  5. 文字列内部のstructural候補を削る

概念的には次の式になる。

inside_string   = prefix_xor(unescaped_quotes)
real_structural = structural & ~inside_string

bit列のevent列挙には次の定石を使える。

while mask != 0:
    lane = ctz(mask)
    process(lane)
    mask &= mask - 1

これにより普通の文字を1文字ずつ分岐せず、quoteやbackslashなど、状態を変える文字だけを処理できる。

なぜ carving と呼ぶのか

入力を逐次tokenizeするのではなく、全入力から特殊な文脈のspanをmaskとして切り分けるイメージだからである。

all candidates
  ├─ ordinary code
  ├─ string spans
  ├─ line/block comment spans
  ├─ regex spans
  ├─ template literal spans
  └─ JSX spans

JavaScriptなら / はclassify時点ではslash候補にしかできない。

a / b         // division
/foo/.test(x) // regexp
// comment
/* comment */

context carvingはlexer modeや周辺状態を使ってspanを確定し、その中のoperator・identifier・punctuation候補を無効化したり、専用kindへ置き換えたりする。

つまり「context-sensitive languageをすべてSIMDで解析する」のではない。context-freeな候補生成をSIMDで広く行い、context-sensitiveな判断を疎なevent処理へ縮約する

3. Coalesce

classify直後は、各byteがdigit、word character、operator characterに見えることしか分からない。

coalesceでは連続する候補をまとめる。

1 2 3 . 4 5  → number candidate
f o o _ 1    → identifier candidate
> > =        → operator candidate
t r u e      → keyword candidate

JSONならnumber、truefalsenull の開始位置を作る段階に相当する。JavaScriptではidentifier、keyword、multi-character operatorなどが増える。

4. Compress

最後にbitmaskを、parserが扱いやすい平坦な列へ変換する。

positions : Array[UInt32]
kinds     : Array[Byte]

各tokenをboxed objectとして確保するより、位置とkindを別々の連続領域に置いた方がcache効率とlookahead効率がよい。

parserは次のtokenを得るたびにlexerを再起動する必要がなく、kinds[i + 1]kinds[i + 2] のような先読みも安価になる。この unfused lexer/parser contract 自体が、SIMD分類と同じくらい重要になる。

CST node kind のbitmask判定とは違うのか

CST node kindをbitmask化すること自体は、構築後のtree traversalやparser内部の集合判定には有効である。

ただし、CSTを構築した後ではすでに次のコストを支払っている。

  • 全入力の逐次走査
  • token分岐
  • node allocation
  • source rangeやchild arrayの構築

そのためlexer高速化としては遅すぎる。

より有効なのは、CST構築前のflat token streamでkindを小さな整数として持ち、parserの分岐とlookaheadを軽くすること。bitmaskの対象は「完成したnode」よりも、「入力位置・文字クラス・token candidate」である。

MoonBit JSONでの試作結果

moonbitlang/core/json の既存APIは parse(StringView)、つまりUTF-16入力を受け取る。現在の v128_load はbyte array向けなので、UTF-16では8 laneをscalar gatherしてvectorを組み立てる必要があった。

この制約下では、全lexer pipelineをUTF-16でSIMD化しても分類コストを回収できなかった。一方、長いJSON文字列からquote・backslash・control characterだけを探す限定的なscannerは有効だった。

production向けには次のhybrid scannerを採用した。

short string:
    scalar scan

long ordinary string:
    scalar prefix
    → v128 special-character scan
    → scalar tail

escape/control encountered:
    existing slow path

手元の暫定値では次の結果になった。マシン負荷による揺れがあるため、絶対値より傾向を見るべきである。

対象 scalar SIMD 比率
65,536 UTF-16 code unitsのspecial scan / native 71.6 µs 12.7 µs 5.6x
同 / wasm 85.6 µs 32.5 µs 2.6x
長い文字列1000個のJSON parse / native 1.28 ms 0.399 ms 3.2x
UTF-8 token-start pipeline / native 約182 µs 102–118 µs 1.5–1.8x

典型的な短い文字列中心のJSONではほぼ同等で、runによって数%の微退行もあった。wasmのfull UTF-8 pipelineもこの段階ではscalarより約11%遅かったため、production parserへは接続していない。

本格的に進めるなら

MoonBit JSONでsimdjson/Oxc型のpipelineを成立させるには、入力契約から変える必要がある。

  1. parse_utf8(Bytes/BytesView) を追加する
  2. stage 1でstructural位置とflat token tapeを生成する
  3. stage 2は元byte sliceを参照しながら Json を構築する
  4. escapeを含む文字列だけUTF-8 decode/unescapeする
  5. 必要ならtreeとは別にlazy/tape APIを提供する

現在の Json APIは最終的に StringArrayMap を確保する。lexerだけを高速化すると、次はallocation、文字列materialization、number conversionが支配的になる。end-to-endで大きく伸ばすには、SIMD classifyだけでなく、flat token contract、遅延decode、低allocation設計を組み合わせる必要がある。

まとめ

SIMD lexer pipelineの本質は次の4点にある。

  1. classify broadly: 文脈を無視して候補を大量に作る
  2. carve sparsely: 状態を変える少数のeventだけscalar処理する
  3. filter with masks: 文脈上あり得ない候補をmask演算で一括削除する
  4. compress flatly: parserへcache-friendlyな位置・kind列を渡す

bitmaskはnode kindを高速にswitchするためだけの道具ではない。入力の大部分を「何も起きない領域」として一括消去し、逐次処理すべき情報量そのものを減らすための中間表現として使うのが重要である。

References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment