perf(bpe): hand the splitter's spans to the model in tiles - #2323
Open
ArthurZucker wants to merge 1 commit into
Open
perf(bpe): hand the splitter's spans to the model in tiles#2323ArthurZucker wants to merge 1 commit into
ArthurZucker wants to merge 1 commit into
Conversation
The pre-tokenizer wrote every span of a chunk into one array and the model then read it back. For a 1 MB chunk of english that is ~229k spans x 8 bytes = 1.8 MB written and 1.8 MB read, or 3.5 bytes of span traffic per input byte, none of which stays in cache. Now the emit hands the model small tiles (1024 spans, 8 KB) as it finds them, and no per-document span array exists. In `bitsplit`, a `SpanSink` trait with two implementations -- `SliceSink` for the whole-chunk API and `TileSink`, which flushes to a consumer whenever it fills -- over ONE generic `emit_contr`. Flushing from inside the emit's own control flow is what makes this need no resumable state: a contraction chain (`y'all'd've`) can emit an unbounded run of spans from a single block, so a caller-driven "emit N spans then return" API would have to resume mid-chain, and getting that wrong changes tokenization. The mask build is untouched and still one pass, so the grammar -- contractions, the `\s+(?!\S)` steal, the block-edge carry -- is exactly as it was. `Grammar::supports_tiled` is asked *before* classifying, so a grammar without a tiled emit does not classify twice. Two details are load-bearing, and both were found by profiling rather than by reasoning: - The consumer is resolved to `&PipelineBPE` **once**, outside the tile loop. Handing tiles to `PipelineModel::tokenize_spans` instead put the enum dispatch on the per-tile path and kept the probe loop from inlining: a profile showed that symbol newly *outlined* at 58% of self time, called once per tile rather than once per chunk. That alone made the first version 4% SLOWER than the array it replaced. - `tokenize_spans` reserves the fast path's worst case for the batch up front, so its capacity test almost never fires. Sized by batch shape: `spans.len() * MAX_INLINE_IDS` is right for an 8 KB tile but a 3x over-allocation for a whole chunk (2.7 MB of buffer for 0.94 MB of tokens), which cost 3-5% on the models that have no tiled emit and therefore still see whole chunks. Measured, tokbench gpt2, 29 corpora, 3 interleaved rounds, medians: **1.0241x with 22/29 corpora faster**, and the geomean against gigatoken crosses from 0.9914x to **1.0165x**. All 30 corpora stay byte-exact against the reference tokenizer; 370 tk-encode tests and bitsplit's parity suite pass. Deliberately not included, both measured and rejected: a `#[cold] #[inline(never)]` fallback in gigatoken's `probe_emit_slow` shape (-4% on gpt2 and **llama-3 0.4157x, chinese 14x slower** -- their slow path takes 3.8% of pretokens and calls out to merge, whereas every cache miss reaches ours and `merge_word` inlines into it, so `#[cold]` has LLVM optimise the merge engine for size); and a one-deep probe prefetch (0.9363x, and 0.9015x on ASCII alone -- the table is L1-resident at inference sizes, so there is no latency to hide).
|
The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The pre-tokenizer wrote every span of a chunk into one array and the model read it back. For a 1 MB chunk of english that is ~229k spans × 8 B = 1.8 MB written and 1.8 MB read — 3.5 bytes of span traffic per input byte, none of which stays in cache. The emit now hands the model small tiles (1024 spans, 8 KB) as it finds them, and no per-document span array exists.
Result
tokbench, gpt2, 29 corpora, 3 interleaved rounds, medians:
poc/target-encode-clean)1.0241x ours-vs-ours, 22/29 corpora faster. All 30 corpora byte-exact against the reference tokenizer; 370
tk-encodetests andbitsplit's parity suite pass.How
In
bitsplit, aSpanSinktrait with two implementations —SliceSinkfor the existing whole-chunk API andTileSink, which flushes to a consumer when it fills — over one genericemit_contr.Flushing from inside the emit's own control flow is what lets this need no resumable state: a contraction chain (
y'all'd've) can emit an unbounded run of spans from a single block, so a caller-driven "emit N spans then return" API would have to resume mid-chain, and getting that wrong changes tokenization. The mask build is untouched and still one pass, so the grammar — contractions, the\s+(?!\S)steal, the block-edge carry — is exactly as before.Grammar::supports_tiledis asked before classifying, so a grammar without a tiled emit does not classify twice.Two details are load-bearing, both found by profiling rather than reasoning:
&PipelineBPEonce, outside the tile loop. Handing tiles toPipelineModel::tokenize_spansput the enum dispatch on the per-tile path and kept the probe loop from inlining — a profile showed that symbol newly outlined at 58% of self time, called once per tile instead of once per chunk. That alone made the first version 4% slower than the array it replaced.tokenize_spansreserves the fast path's worst case up front, so its capacity test almost never fires — but sized by batch shape.spans.len() * MAX_INLINE_IDSis right for an 8 KB tile and a 3× over-allocation for a whole chunk (2.7 MB of buffer for 0.94 MB of tokens), which cost 3–5% on the models that have no tiled emit and so still see whole chunks.Measured and deliberately not included
#[cold] #[inline(never)]fallback, in gigatoken'sprobe_emit_slowshape: −4% on gpt2 and llama-3 0.4157x with chinese 14× slower. Their slow path takes 3.8% of pretokens and calls out to merge; every cache miss reaches ours andmerge_wordinlines into it, so#[cold]has LLVM optimise the merge engine for size.prfm pldl1keepon the next span's home line): 0.9363x, and 0.9015x on ASCII cells alone. The table is L1-resident at inference sizes, so there is no latency to hide and the duplicated hash costs real cycles. gigatoken affordsD = 16only because its span batch already holds every key.Review notes
TK_SPAN_TILE-overridable; a sweep from 64 to 65536 spans could not distinguish sizes above noise, so 1024 (8 KB) is a comfortable-in-L1 default rather than a tuned optimum.bitsplit'semit_contris now generic over the sink.cl100kgoes throughSliceSinkand is unchanged in behaviour.