Skip to content

perf(pipeline): don't stride an input that has no cut points - #2283

Merged
ArthurZucker merged 3 commits into
feat/multi_threaded_pipelinefrom
perf/parallel-uncuttable-input
Aug 6, 2026
Merged

perf(pipeline): don't stride an input that has no cut points#2283
ArthurZucker merged 3 commits into
feat/multi_threaded_pipelinefrom
perf/parallel-uncuttable-input

Conversation

@ArthurZucker

Copy link
Copy Markdown
Collaborator

plan() chose Raw whenever the config exposed a stride boundary, without asking whether the input contains one. Punctuation-terminated CJK contains none: Chinese prose has no space anywhere, and a newline after is not a legal cut because the punct rule ?[…]+[\r\n]* absorbs it into the punct token — NEWLINE_PREV excludes punctuation for exactly that reason. data/corpora/chinese.txt is 100% such newlines: 722 of them, every one preceded by , and zero spaces.

Striding then did what stride_range's own doc comment warns about — "text with no boundaries at all degrades to stride 0 owning the whole input". On a 4 MB doc: 513 strides tiled, 1 resolved, covering all 4199874 bytes, after scanning the document twice for cuts that don't exist. 0.28× the plain serial encode, and flat at every thread count because there is only ever one work unit.

plan() now probes the longest input for a real cut (bounded 32 KB, once per encode) and falls through to Pretokenized when there is none. That prefix runs the normal pre-tokenizer, so it's bitsplit where bitsplit is wired (gpt2, cl100k) — which is what makes it cheap enough to prefer over a degenerate stride.

14 × 4 MB docs, MB/s ours/gigatoken, interleaved best-of-3, quiet box:

gpt2 ours giga ratio
english 6447 9185 0.70×
code 4290 3158 1.36×
korean 3774 1075 3.51×
russian 4422 1213 3.65×
chinese 4928 1289 3.82×
geomean 2.16×
llama-3 ours giga ratio
english 5529 8430 0.66×
code 3327 4209 0.79×
korean 4776 1529 3.12×
russian 5093 1617 3.15×
chinese 2844 1875 1.52×
geomean 1.51×

gpt2 chinese went 0.94× → 3.82×. Single doc, one thread: 260 → 732 MB/s. Token counts unchanged (23531424) — every plan was already byte-exact, so plan choice only trades throughput. Spaced text still probes as cuttable and takes Raw on the identical path; english measured 0.68× → 0.70×.

cut_exists passes lo = 1, not 0: boundary_in_window reads one byte of left context via block_lo - 1, so 0 underflows to usize::MAX and spins forever in the char-boundary walk-back — it hangs rather than panicking in release. Byte 0 is never a cut anyway.


Stacked on feat/multi_threaded_pipeline because the plan ladder it fixes lands with #2213. Retarget to feat/train_encode_split once #2213 merges.

Two notes on the port from poc/target-encode, where this was developed and measured:

  • This branch is edition 2018, so plan() is written with nested if let instead of the let-chains the original used. Same ladder, same order.
  • The measurements above were taken on the merged stack (Perf/bpe merge #2241 + perf(merge bpe): improvement suggestions #2275 + feat: multi threaded pipeline #2213). The fix is to plan selection only and is independent of the merge engine, but the absolute MB/s on this branch alone will be lower — the ratios between plans are what this PR changes. examples/ab_giga_mt.rs is included so the numbers are reproducible: it mirrors gigatoken's own hf_mt bench (one 4 MB doc per thread, encode_docs_ragged vs our batch encode, same MiB/s convention).

292 lib tests + 35 integration tests pass, including a new uncuttable_input_skips_striding that pins all three cases: +newline is not a cut, uncuttable input is not strided, and Han-letter+newline and ordinary spaced text still take Raw.

`plan()` chose `Raw` whenever the *config* exposed a stride boundary, without asking whether
the *input* contains one. Punctuation-terminated CJK contains none at all: Chinese prose has
no space anywhere, and a newline after `。` is not a legal cut because the punct rule
` ?[…]+[\r\n]*` absorbs it into the punct token (`NEWLINE_PREV` excludes punctuation for
exactly that reason). `data/corpora/chinese.txt` is 100% such newlines -- 722 of them, every
one preceded by `。`, and zero spaces.

Striding then did what `stride_range`'s own doc comment warns about: "text with no boundaries
at all degrades to stride 0 owning the whole input". Measured on a 4 MB doc: 513 strides
tiled, **1 resolved, covering all 4199874 bytes**, after scanning the document twice looking
for cuts that do not exist. 0.28x the plain serial encode, and flat across thread counts
because there is only ever one work unit.

`plan()` now probes the longest input for an actual cut (bounded at 32 KB, once per encode)
and falls through to `Pretokenized` when there is none -- which pre-tokenizes serially and
parallelises the model over span groups. That prefix runs the normal pre-tokenizer, so it is
`bitsplit` wherever bitsplit is wired (gpt2, cl100k), which is what makes it cheap enough to
prefer over a degenerate stride.

gpt2 chinese, 14 x 4 MB docs, MB/s ours/giga -- was 1193/1243 (0.94x), now 3004/1120 (2.68x);
on a cool box the same fix measured 4798/1243 (3.86x). Single thread on one doc: 260 -> 732.
Token counts unchanged (23531424), and every plan was already byte-exact, so plan choice only
ever trades throughput. Spaced text still probes as cuttable and takes `Raw` unchanged.

`cut_exists` passes `lo = 1`, not 0: `boundary_in_window` reads one byte of left context via
`block_lo - 1`, so 0 underflows to `usize::MAX` and spins forever in the char-boundary
walk-back (it hangs, it does not panic, in release). Byte 0 is never a cut anyway.

Written with nested `if let` rather than let-chains: this branch is edition 2018.
One 4 MB document per thread through the parallel `encode`, matching gigatoken's `hf_mt`
(`encode_docs_ragged` over the same docs) in thread count, work per thread and MiB/s.

14 threads, best-of-3 interleaved, ours/giga:
  gpt2     english 6240/9229 .68x | code 4252/3056 1.39x | chinese 1181/1251 .94x | russian 4463/1185 3.77x
  llama-3  english 5075/8766 .58x | code 3380/4071 .83x  | chinese 1380/1926 .72x  | russian 4894/1638 2.99x
Geomean: gpt2 1.35x, llama-3 1.01x.

Scaling against each side's own 1-thread encode is the more useful read: english 5.4x/4.5x
(giga 6.2x/6.5x), code 6.8x/4.9x (3.9x/5.2x), russian 6.4x/4.9x (2.0x/2.6x), but
**chinese only 1.6x on both models** (giga 2.5x/3.8x) -- the whitespace-boundary striding
limit already noted for single-document CJK, which is why gigatoken overtakes us at 14
threads on chinese despite our 1.5-1.7x single-thread lead there.
@HuggingFaceDocBuilderDev

Copy link
Copy Markdown

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.

return false;
}
let hi = text.len().min(PROBE);
let hi = (1..=hi).rev().find(|&i| text.is_char_boundary(i)).unwrap_or(1);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this can probably be improved as well btw, find is not really great

@ArthurZucker
ArthurZucker merged commit 6610330 into feat/multi_threaded_pipeline Aug 6, 2026
31 of 42 checks passed
@ArthurZucker
ArthurZucker deleted the perf/parallel-uncuttable-input branch August 6, 2026 06:59
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.

3 participants