Skip to content

perf(decode): index the vocab slab by id, not through the MPHF slot - #2387

Open
ArthurZucker wants to merge 9 commits into
feat/train_encode_splitfrom
perf/decode-capacity
Open

perf(decode): index the vocab slab by id, not through the MPHF slot#2387
ArthurZucker wants to merge 9 commits into
feat/train_encode_splitfrom
perf/decode-capacity

Conversation

@ArthurZucker

Copy link
Copy Markdown
Collaborator

Closes the decode gap against tokie, which was the fastest decoder in tokbench and 1.19× ahead of us on english.

What was slow

decode_byte_level reached every token through id_to_token_bytes:

id_to_slot[id]  →  spans[slot]  →  bytes[start .. start+len]

Three dependent loads, and the middle hop is a random access — slot order is the MPHF's, not id order. cargo asm on the loop shows that chase plus three bounds checks per token, and the vocabulary base pointers spilled to the stack and reloaded every iteration. A flat-arena decoder does one load of an adjacent offset pair and a copy.

What changed

build now lays the slab out in ascending id order and records decode_off, so bytes[decode_off[id] .. decode_off[id + 1]] is the token. The two offsets are adjacent, usually in the same cache line.

The reorder is free: nothing below the placement loop depends on iteration order — entries, spans and id_to_slot are written by slot or by id, and keys only feeds an order-independent HashSet. An id the vocabulary does not hold gets equal offsets, i.e. an empty slice, which is exactly what a decoder appends for it.

Cost: 4 bytes per id in the id space — ~513 kB on a 128k-id vocabulary — and no duplicated token bytes.

Measured

tk-serialize's decode bench, llama-3, 10 kB chunks, criterion, p = 0.00 on both:

corpus before after
english 367.4 428.0 MiB/s +16.5%
japanese 449.2 490.1 MiB/s +9.1%

And in tokbench, which hashes every engine's decoded text and compares it against the released crate — all cells byte-exact:

corpus before after tokie was now
english 380.8 464.4 466.3 0.841× 0.996×
chinese 353.3 401.3 386.0 0.924× 1.04×
japanese 390.0 442.6 425.2 0.954× 1.04×

The bench is new, and that matters

The existing decode benches call decode once per line. big.txt lines are short, so one allocation and one full from_utf8 validation per line dominate, and nothing happening in the token loop is visible. Concretely: a change that cut the loop from 420 to 348 instructions moved those benches not at all.

The added decode, 10 kB chunks bench uses tokbench's chunking, so it is both loop-sensitive and directly comparable with that matrix.

Two things that did not work

Recorded so nobody burns the time again:

  1. Raising the output Vec estimate from ids.len() * 4 to * 5. English output is 4.55 B/token so the 4× guess does under-allocate — but english came out flat (p = 0.12) and japanese regressed 4.9%. Over-reserving 26% costs more than the grow it avoids.
  2. Outlining the added-token arm as #[inline(never)] #[cold]. It worked structurally — confirmed in asm, the base pointers moved out of the stack into registers and the loop went 420 → 348 instructions, 37 → 30 calls — and the clock did not move. Those spills were L1 hits.

Tests

decode_index_matches_the_pointer_chase asserts the new index agrees with the chase it replaces for every id in the id space, on a deliberately sparse vocabulary. Sparse ids are the real risk: a hole is encoded as decode_off[id] == decode_off[id + 1], and an off-by-one in that walk would hand a hole its neighbour's bytes — a silently wrong decode, not a crash.

cargo test -p tk-encode -p tk-serialize is green (176 + 38 + 2 + 2). clippy and fmt --check clean. The now-dead PipelineBPE::id_to_token_bytes passthrough is removed; the store's own id_to_token_bytes stays, still used by serialization and id_to_token.

`decode_byte_level` reached every token through `id_to_token_bytes`, which is a
three-load pointer chase -- `id_to_slot[id]` -> `spans[slot]` -> `bytes` -- and
whose middle hop is a *random* access, because slot order is the MPHF's and not
id order. `cargo asm` on the loop shows that chase plus three bounds checks per
token, against a flat-arena decoder's one load and one copy.

`build` now lays the slab out in ascending id order (free: nothing below the
placement loop depends on iteration order -- `entries`, `spans` and `id_to_slot`
are written by slot or by id, and `keys` only feeds an order-independent
`HashSet`) and records `decode_off`, so a decoder reads an adjacent `u32` pair,
usually from one cache line, and slices. An id the vocabulary does not hold gets
equal offsets, i.e. an empty slice, which is what a decoder appends for it
anyway.

Costs 4 bytes per id in the id space, ~513 kB on a 128k-id vocabulary, and
duplicates no token bytes.

Measured, llama-3, `tk-serialize`'s decode bench in 10 kB chunks (p = 0.00):

  english   367.4 -> 428.0 MiB/s   +16.5%
  japanese  449.2 -> 490.1 MiB/s    +9.1%

And in huggingface/tokbench, where every engine's decoded text is hashed and
compared against the released crate -- all cells byte-exact:

  corpus     before   after   tokie    was     now
  english     380.8   464.4   466.3   0.841x  0.996x
  chinese     353.3   401.3   386.0   0.924x  1.04x
  japanese    390.0   442.6   425.2   0.954x  1.04x

Two things that did NOT work, recorded so they are not retried: raising the
output `Vec` estimate from 4 to 5 bytes per token (english flat, japanese -4.9%
-- over-reserving costs more than the grow it avoids), and outlining the
added-token arm as `#[inline(never)] #[cold]` (freed the spilled base pointers
into registers and cut the loop from 420 to 348 instructions, and moved the
clock not at all -- those spills were L1 hits).

The bench that found this is new. The existing decode benches call `decode`
once per *line*, where one allocation and one `from_utf8` validation per short
line dominate and nothing in the token loop is visible; the 10 kB chunk bench
matches the regime tokbench measures.
Two phases instead of one loop: phase 1 walks the ids collecting each token's
byte slice, phase 2 concatenates them into an exactly-sized buffer.

The one-pass loop interleaved a random probe into the decode index with an
append to the output, so every token's copy waited behind its own lookup.
Separated, phase 1 issues nothing but independent random loads with no output
cursor to serialise on, and phase 2 is a sequential walk. Phase 1 also yields
the exact output length, retiring the `ids.len() * 4` guess and the realloc it
caused on Latin text.

llama-3, 10 kB chunks, criterion:

  english   428 -> 501 MiB/s   +17.2%
  japanese  490 -> 504 MiB/s    +2.8%

In huggingface/tokbench, every engine's decoded text hashed and compared
against the released crate -- all cells byte-exact:

  corpus     before   after   tokie
  english     464.4   533.1   456.3    now 1.17x ahead
  japanese    442.6   452.5   423.3
  chinese     401.3   395.1   386.4

Three variants of this lost, recorded in the doc comment so they are not
retried: 4- and 16-lane splits of the id stream with per-lane output buffers
(-16% and -21%, overhead scaling with lane count -- the parallelism was already
being extracted by the out-of-order engine, the bookkeeping was not free);
parking `(start, end)` in a reused thread-local scratch rather than `&[u8]` in a
fresh `Vec` (-3.3%, because phase 2 then re-slices the slab with a bounds check
per token); and prefetching the index eight tokens ahead (+3.1% english, flat
japanese, not worth arch-gated inline asm).

Sequences containing an added token keep the one-pass walk: those yield an owned
`String` rather than a borrow into the vocabulary, so they cannot be gathered as
slices.
Two changes to the two-phase path, measured separately.

Phase 1 already computes the exact output length, so `extend_from_slice`'s
per-token capacity check and length store can only ever pass: write straight
through the pointer and `set_len` once. English +1.8%, japanese +0.8%.

Phase 1's gather buffer becomes thread-local, allocated on a thread's first
decode and reused after, retiring a malloc plus a first touch of every page of
it on every call -- on a ~2250-token chunk producing 10 kB of text, a 36 kB
allocation written and then read straight back to move 10 kB. English +1.3%,
japanese +0.5%.

The payload stays `(ptr, len)` rather than `(start, end)` into the slab, and
that is the whole reason this works now: an earlier attempt paired buffer reuse
with offsets and came out 3.3% DOWN, because phase 2 then re-slices the slab
with a bounds check per token. Reuse was worth +1.3% all along; the offsets were
costing more than the malloc. One change at a time.

Cumulative, llama-3, 10 kB chunks, criterion:

  english   501 -> 517 MiB/s
  japanese  504 -> 511 MiB/s

And in huggingface/tokbench, all cells byte-exact against the released crate:

  corpus     decode   tokie
  english     537.3   465.3   1.15x
  japanese    457.3   396.6   1.15x
  chinese     419.1   376.0   1.11x

`decode` still allocates its returned `String`, which is the one allocation
left in the path.
Decode's cost is one random probe per token into an index sized by the id
space, so vocabulary size is a first-order variable that a single model cannot
show. Three configs, one bench:

  llama-3   ByteLevel, 128k ids  ->  ~513 kB index
  gpt-oss   ByteLevel, 200k ids  ->  ~800 kB index
  gemma     BPE + byte_fallback, Sequence[Replace, ByteFallback, Fuse]

The first two answer the residency question: 511 and 514 MiB/s on english,
statistically identical, so a 1.56x larger index costs nothing at these sizes.
That also retires the idea that prefetching the index would pay on a bigger
vocabulary -- it does not, because the index was never the miss it looked like.

The third is why this is worth committing. gemma measures **34 MiB/s against
514**, ~15x, because it is not byte-level: `decode` falls through to a route
that calls `id_to_token` per id, which allocates a `String` every time, collects
a `Vec<String>`, and only then runs the decoder chain -- 1.5M allocations for one
pass over big.txt. Every SPM-family config is on that path (gemma, llama-2,
mistral, T5), and no bench in the tree showed it.

Left as a standing measurement rather than fixed here: precomputing the chain
per vocabulary entry at load, the way `byte_level::transform_vocab` already
does, gets gemma to 349 MiB/s but is NOT correct. `ByteFallback` is run-based
rather than per-token, and beyond that SPM's leading `▁` makes the decoder's
prefix handling position-dependent, which a per-token table cannot express.
Verified against the released crate: fixing the byte-fallback half repairs
gemma's CJK cells, and gemma/english plus all three llama-2 cells still differ.
Every SentencePiece-family config -- gemma, llama-2, mistral, T5 -- decoded
through a route that allocates roughly three `String`s per token: one in the
gather (`id_to_token` is `from_utf8_lossy(..).into_owned()`), one in `Replace`
(`"".to_string()` per token), a fresh `Vec<String>` plus a
`previous_byte_tokens.clone()` per byte run in `ByteFallback`, then `join("")`
in `Fuse`. About 4.5M allocations for one pass over big.txt, and 34 MiB/s
against 514 for a byte-level model on the same corpus.

`Sequence[Replace{literal}, ByteFallback, Fuse]`, optionally followed by
`Strip`, is now recognised at load and run as a single pass into a reused
buffer: `Replace` inline per token, `ByteFallback` accumulating a run and
resolving it at the end, `Fuse` being the concatenation the loop already does,
`Strip` applied to the finished output. Nothing is allocated per token. An
unrecognised chain declines and keeps the generic route.

Measured, verified byte-exact against the released crate in
huggingface/tokbench (`decode_verified` on all 9 cells, encode ids too):

  gemma-3    chinese   39.4 -> 190.6 MB/s   4.8x
             english   37.2 -> 177.9        4.8x
             japanese  44.1 -> 218.4        5.0x
  llama-2    now 198-273 MB/s on the same path
  llama-3    unchanged (byte-level, 401-553)

And on the larger gemma-4 config in `tk-serialize`'s bench: english
34.4 -> 197.8 MiB/s, japanese 45.1 -> 303.4.

Two things this gets right that a cheaper version did not, both caught by that
gate rather than by reasoning:

  * `ByteFallback` is run-based, not per-token. Precomputing the chain per
    vocabulary entry asks it to validate one byte alone; a lone `0xE4` is not
    UTF-8 and comes back U+FFFD, so every three-token CJK character became
    three replacement characters. An **invalid** run must yield one U+FFFD per
    byte of the run, which "append raw bytes and let `from_utf8_lossy` sort it
    out" also does not reproduce.
  * `Strip` runs *after* `Fuse`, so it applies to the whole output, not to each
    token. That position-dependence is why a per-token table cannot express
    this chain, and it broke every llama-2 cell before being handled here.

Membership in the added vocabulary is a binary search over the added ids rather
than `id >= added_id_min`: that short-circuit never fires for these configs,
because gemma's `<pad>` is id 0 and so every token clears the threshold. Worth
knowing for #2178, which proposes exactly that short-circuit.
The fused SPM pass still ran `Replace` against every token *occurrence*. A
byte-level model does not: `byte_level::transform_vocab` decodes its whole
vocabulary at load, which is most of why that path is a gather and a `memcpy`
and this one was not. Same treatment here -- transform once per vocabulary
*entry*, at load.

A byte-fallback entry cannot be pre-transformed, since it has to rejoin a run
at decode time, and its length does not identify it -- plenty of ordinary
tokens are also one byte. The flag therefore lives in **bit 31 of the start
offset**, so the adjacent-pair load a decoder already performs yields the span
and the flag together: no second array, no second cache line, no extra probe.
Slabs are far below 2 GB, so 31 bits of offset costs nothing.

The decode loop's ordinary arm is now `extend_from_slice` and nothing else.

Measured, `tk-serialize`'s bench on gemma-4, 10 kB chunks:

  english   206.7 -> 310.8 MiB/s   +50%   (34.4 at the start of this work, 9.0x)
  japanese  290.3 -> 383.3         +32%   (45.1 at the start, 8.5x)

And in huggingface/tokbench, byte-exact against the released crate on all nine
cells (`decode_verified` and encode `verified` both true):

  gemma-3    chinese  187.8 -> 214.6 MB/s   (39.4 baseline, 5.4x)
             english  178.2 -> 229.0        (37.2 baseline, 6.2x)
             japanese 213.8 -> 254.6        (44.1 baseline, 5.8x)
  llama-2    chinese  208.7 -> 262.3
             english  188.0 -> 328.3
             japanese 260.3 -> 339.3
  llama-3    unchanged (418, 562, 476)

Costs the transformed vocabulary: 4 bytes per id in the id space plus a copy of
the token bytes, so roughly 3 MB on gemma's 262k ids. That is the same trade
`transform_vocab` already makes for byte-level models.

Also measured and NOT taken, on the way here: giving the fused pass an owned
output `Vec` so `String::from_utf8` could take ownership instead of
`from_utf8_lossy(..).into_owned()` copying a reused buffer. The copy is real,
but the malloc that replaces it costs the same -- english +4.5%, japanese
-4.3%. It is a wash here and not for the byte-level path because that one knows
its exact output length from its gather phase, while this loop cannot: `Replace`
changes byte counts and byte-fallback runs collapse.
Profiling said the SPM loop was 12.59 ns/token against a byte-level model's
~8.7, and the token mix said why it was NOT what I had assumed: byte-fallback
tokens are 0% of english and 1.6% of japanese, added tokens 0% of both, so the
run machinery was never the cost. The cost was `is_added`, a binary search over
the added ids, run on *every* token -- 1.53M times for one pass over big.txt,
always returning false.

So the flags move into the high bits of the start offset the decoder already
loads: bit 31 byte-fallback, bit 30 special. One adjacent-pair load per token
now yields the span and every flag, and the ordinary arm is `extend_from_slice`
and nothing else. No binary search, no second array, no second probe, and
`simple_id_to_token` is gone from the loop entirely -- added tokens are ordinary
entries in the table, with their own decoded bytes.

`SPECIAL` is set from `is_special_token`, which tests the token *string*, so a
model-vocabulary entry that happens to spell a special token is treated exactly
as the generic route treated it.

Offsets keep the low 29 bits, capping a slab at 512 MB and asserted at build.
Vocabulary slabs are three orders of magnitude below that.

Measured, byte-exact against the released crate on all nine tokbench cells:

  gemma-3    chinese  214.6 -> 374.9 MB/s   (39.4 baseline, 9.5x)
             english  229.0 -> 372.8        (37.2 baseline, 10.0x)
             japanese 254.6 -> 411.0        (44.1 baseline, 9.3x)
  llama-2    chinese  262.3 -> 293.1
             english  328.3 -> 345.4
             japanese 339.3 -> 397.0
  llama-3    unchanged (413, 536, 444)

gemma now decodes within 10-30% of a byte-level model on the same corpora,
against 15x slower when this started.

`tk-serialize`'s bench on the larger gemma-4 config agrees: english
310.8 -> 347.4 MiB/s. Note one criterion run reported 149.9 for that cell with
a 121-179 confidence interval; it was contended, and a clean re-run gave 347.4.
A wide interval is the tell.

Also drops the fallback arm that transformed per occurrence. It was unreachable
-- the dispatch already requires BPE and the table is built for every BPE model
-- and an unreachable second implementation of these semantics is exactly the
thing that rots. `recognise` now builds the table itself and declines when it
cannot, so there is one path.
`FusedSpmDecoder` kept `from`/`to` -- the `Replace` pattern -- for the life of
the tokenizer, but the only reader was `push_replaced`, and the only caller of
that is `SpmDecodeTable::build`. Construction-only state does not belong on a
struct that outlives construction.

`push_replaced` becomes a free function taking the pattern, the table's `build`
takes it directly, and the decoder is constructed in one expression instead of
being built half-empty and patched. That also retires the `Default` impl the
two-step construction needed.

`FusedSpmDecoder` is now `{ strip, table }`.

No behaviour change: tests green, clippy and fmt clean, and tokbench reports
zero pipeline mismatches across all nine cells.
Doc comments had accumulated measurement logs and lists of approaches that
lost. That belongs in commit messages, which have it; in the source it is prose
that goes stale the next time anyone touches the loop.

Kept: the bit layout and the 29-bit offset cap, why a byte-fallback entry
cannot be pre-transformed, why the slab is in id order, why the added-token
path falls back, and every SAFETY comment. Dropped: throughput figures,
instruction counts, allocation counts, percentages, and the rejected-variant
lists.

Also fixes a real defect from an earlier edit of mine: the two-phase
explanation had ended up stacked on top of `decode_fused_spm`'s own doc
comment, leaving `decode_byte_level` -- the function it describes -- with none.

No code change. Tests green, clippy and fmt clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant