Skip to content

v1.0.0-rc0: the train/encode split, repacked into 53 commits - #2384

Open
ArthurZucker wants to merge 57 commits into
mainfrom
tokenizers-rc0
Open

ArthurZucker wants to merge 57 commits into
mainfrom
tokenizers-rc0

Conversation

@ArthurZucker

@ArthurZucker ArthurZucker commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Repacked replacement for feat/train_encode_split. Same content, readable history, credit intact.

Important

Do not squash-merge. The 53 commits are the deliverable. Fast-forward (--ff-only) or --no-ff; both keep them.

What we packed

feat/train_encode_split carried 97 commits (91 + 6 merges). The early era predated our PR discipline, so it held 8 pure-mechanics commits (nit, fmt / lint, lockfiles, post-merge format ×2) and long runs of one-PR-per-tiny-change.

This branch is the same work in 53 commits, fully linear (0 merges), under three rules:

  • Chronology first. Every commit is a contiguous run of the original history — nothing reordered, so the story still matches what happened.
  • One author, one family per commit. Commits were packed only when they shared both the same author and the same area of work. That means no commit contains anyone else's work: 0 co-authored commits, 0 lines credited to the wrong person.
  • Nothing erased. Every squashed body lists its original commits with their PR numbers, and every commit carries an Area: trailer (core-pretokenization, core-memory-allocation, core-parallelism, core-model-bpe, core-splitter, core-serialization, ci-bench, bindings-node, …) so each contribution says what it helped.

Work that was tried and later replaced — kept visible

Exploration that didn't survive into rc0 is not collapsed or hidden. It keeps its own commits, because the design work is part of the record:

atomsplit — 12,633 lines over 6 commits, the split engine that preceded bitsplit. Its arc reads end to end:

commit
add feat(atomsplit): the atomsplit split engine (@ArthurZucker, +14,054)
iterate perf(atomsplit): tekken-style regex fast path (@SBrandeis)
iterate perf(atomsplit): offload literal patterns to atomsplit (@SBrandeis)
retire refactor!: strip the legacy encode engine and its serde layer (@ArthurZucker)
replace feat(bitsplit): replace atomsplit with the bitsplit engine (@ArthurZucker)

The pipeline benchmark programme — 6,694 lines over 26 commits (@SBrandeis 5,487 / @ArthurZucker 1,177), now 9 commits rather than 4, each noting that the harness was later extracted to the standalone tokbench repo.

MultiRegex (472 lines, @McPatate) keeps its own commit, noting it was superseded by atomsplit.

The pipeline pre-tokenizers themselves shipped and are not marked dropped; only the old tokenizer/pipeline.rs trait host was refactored away (into tokenizer/pipeline/pre_tokenizer.rs).

Credit

commits before commits after
@SBrandeis 46 27
@ArthurZucker 23 15
@McPatate 22 11

Author emails were normalised to the GitHub noreply addresses — 7 commits were previously authored under personal addresses and would not have appeared on the contribution graph at all.

Verification

  • git diff tokenizers-rc0 feat/train_encode_split → only the 3 files from main's d5827816, which this branch contains and the old branch never merged. bindings/python/src/trainers.rs was checked line-by-line: 0 unexplained lines.
  • cargo check --workspace --all-targets → clean, 0 errors, 0 warnings.
  • 53 commits, 0 merges, 53/53 Area:-tagged, 0 commits containing another author's work.

Pre-existing issue, not introduced here

cargo check -p tk-train --features parity-aware-bpe fails with E0432: trainers/bpe/parity_trainer.rs does use super::{BPE, …} but trainers::bpe doesn't re-export BPE. The tk-train trees are byte-identical between this branch and feat/train_encode_split, so this is latent on the old branch today. Worth a separate fix before rc0 ships.

McPatate and others added 29 commits September 3, 2026 08:24
Squashed from 2 commits:
  - ci: update CI after encode/train split (#2125)
  - chore: refactor byte_level constants in utils (#2123)

Area: ci-build
Squashed from 2 commits:
  - Fast encode fixes (#2142)
  - plumbing: full pipeline (#2130)

Area: core-encode-pipeline
Part of the pipeline benchmark programme (6,694 lines over 26 commits). The harness was
later extracted to the standalone tokbench repo and is not part of the rc0 tree.

Squashed from 4 commits:
  - ci: Simple bench workflow for PipelineTokenizer (#2141)
  - ci: maintain pipeline-bench report in PR description + comment reactions (#2145)
  - ci: label-triggered pipeline bench into the PR description (#2146)
  - ci: run benches on more tokenizers (#2147)

Area: ci-bench
Squashed from 7 commits:
  - refactor: extract pretok algo (#2134)
  - feat: `impl pipeline::PreTokenizer for FixedLength` (#2137)
  - feat: `impl pipeline::PreTokenizer for Digits` (#2136)
  - feat: `impl pipeline::PreTokenizer for CharDelimiterSplit` (#2135)
  - feat: `impl pipeline::PreTokenizer for UnicodeScripts` (#2138)
  - feat: `impl pipeline::PreTokenizer for WhitespaceSplit` (#2140)
  - feat: handle `Merge*` variants of the `SplitDelimiterBehavior` (#2139)

Area: core-pretokenization
472 lines. Superseded by atomsplit; utils/multi_regex.rs is not in the rc0 tree.

Area: core-pretokenization
Area: core-pretokenization
Area: core-pretokenization
Part of the pipeline benchmark programme (6,694 lines over 26 commits). The harness was
later extracted to the standalone tokbench repo and is not part of the rc0 tree.

Area: ci-bench
Part of the pipeline benchmark programme (6,694 lines over 26 commits). The harness was
later extracted to the standalone tokbench repo and is not part of the rc0 tree.

Area: ci-bench
Squashed from 3 commits:
  - feat: ByteLevel BPE (#2167)
  - Feat/no alloc model (#2173)
  - feat: other data structure for WordPiece (yada) (#2183)

Area: core-model-bpe
Part of the pipeline benchmark programme (6,694 lines over 26 commits). The harness was
later extracted to the standalone tokbench repo and is not part of the rc0 tree.

Area: ci-bench
Part of the pipeline benchmark programme (6,694 lines over 26 commits). The harness was
later extracted to the standalone tokbench repo and is not part of the rc0 tree.

Squashed from 2 commits:
  - bench(pipeline): show per-stage split cost as share% (+ ns), not a crushed absolute bar (#2185)
  - bench(pipeline): multi-thread scaling sweep vs latest release (#2187)

Area: ci-bench
Squashed from 2 commits:
  - feat: bump `ptr_hash` to `2.0.1`
  - fix: account for `FastPtrHash` no remap sparse layout

Area: core-dependencies
Part of the pipeline benchmark programme (6,694 lines over 26 commits). The harness was
later extracted to the standalone tokbench repo and is not part of the rc0 tree.

Area: ci-bench
The atomsplit split engine: 12,633 lines over 6 commits. Iterated on through the
tekken-style regex fast path and literal-pattern offload, then retired in favour of
bitsplit -- see `feat(bitsplit): replace atomsplit with the bitsplit engine`.

Area: core-splitter
Area: core-memory-allocation
Squashed from 5 commits:
  - post-merge fixes
  - post-merge format
  - post-merge format
  - ci: fix Format and README checks
  - chore: bump rust edition to 2024 (#2220)

Area: core-toolchain
Part of the pipeline benchmark programme (6,694 lines over 26 commits). The harness was
later extracted to the standalone tokbench repo and is not part of the rc0 tree.

Squashed from 2 commits:
  - ci: fix unfait and inconsistent pipeline bench (#2226)
  - ci: faster benchmarks (#2228)

Area: ci-bench
Area: core-encode-pipeline
Squashed from 2 commits:
  - feat(pipeline): Decode benches and stub (#2231)
  - fix(bench): use full tokenizer configs, add gemma-4 and mistral-small-4 (#2246)

Area: core-decode
Iteration on the atomsplit engine, later retired in favour of bitsplit.

Area: core-splitter
SBrandeis and others added 24 commits September 3, 2026 08:24
Iteration on the atomsplit engine, later retired in favour of bitsplit.

Area: core-splitter
Squashed from 3 commits:
  - refactor: make sure the pool is fork-safe (#2268)
  - feat: update `encode` signature (#2274)
  - fix: iterate then wait potential out of bounds panic (#2282)

Area: core-parallelism
Area: core-model-bpe
Also reinstates the scratch pool after a rebase mishap.

Squashed from 2 commits:
  - revert: rebase mishap, reintroduce scratch pool
  - feat: gate parallelism (#2295)

Area: core-parallelism
Squashed from 2 commits:
  - Perf/bpe merge (#2241)
  - perf(bpe): wire the word cache into the pipeline model (#2307)

Area: core-model-bpe
Squashed from 4 commits:
  - fmt / lint
  - ci: fetch gpt2.json in make test, stop masking Windows lib-test failures
  - ci: fail on missing fixtures instead of skipping; fetch them on Windows
  - make: add `data` and `models` convenience targets

Area: tooling-tests
Part of the pipeline benchmark programme (6,694 lines over 26 commits). The harness was
later extracted to the standalone tokbench repo and is not part of the rc0 tree.

Area: ci-bench
Squashed from 3 commits:
  - refactor(bpe): decide the fold at load time, drop `fold_by_flag` (#2310)
  - perf(pipeline): give the model a whole chunk of pre-tokens at a time (#2304)
  - fix(bpe): repair `tokenize_spans`, which does not compile (#2314)

Area: core-model-bpe
Part of the pipeline benchmark programme (6,694 lines over 26 commits). The harness was
later extracted to the standalone tokbench repo and is not part of the rc0 tree.

Area: ci-bench
Squashed from 2 commits:
  - cleanup: remove legacy VocabStore (#2321)
  - chore: first round of cleanup in preparation for 1.0.0rc0 (#2328)

Area: core-cleanup
Squashed from 2 commits:
  - feat: wire in the thread pool (#2331)
  - perf: group units into tasks (#2338)

Area: core-parallelism
…de pass

Squashed from 3 commits:
  - tk-encode: the API tk-serialize reads and writes a pipeline through (#2349)
  - tk-serialize: the canonical tokenizer.json reader and writer (#2350)
  - tk-convert: the legacy tokenizer.json upgrade pass (#2351)

Area: core-serialization
Removes 29,834 lines of the legacy encode engine, including atomsplit's serde layer.

Squashed from 2 commits:
  - tk-encode: strip the legacy encode engine and its serde layer (#2352)
  - nit

Area: core-serialization
Retires atomsplit (12,633 lines built over 6 commits) in favour of the bitsplit engine.
atomsplit is removed from the tree here; its design work is recorded in the commits above.

Area: core-splitter
Squashed from 2 commits:
  - chore: remove dead code (#2359)
  - lockfiles

Area: core-cleanup
Squashed from 2 commits:
  - chore(ci): oracle test (#2373)
  - ci: run all tests (#2374)

Area: tooling-tests
@ArthurZucker ArthurZucker changed the title v1.0.0-rc0: the train/encode split, repacked into 31 commits v1.0.0-rc0: the train/encode split, repacked into 53 commits Sep 2, 2026
ArthurZucker and others added 4 commits September 3, 2026 10:57
…2378)

* perf(pipeline): decide the single-sequence serial fallback before planning

`parallel::encode` gated on `inputs.size_bytes() < PARALLEL_MIN_BYTES`, then
planned, then bailed to `encode_serial` when planning produced fewer than
two tasks. For one document between `PARALLEL_MIN_BYTES` and twice it that
bail is guaranteed: `plan_sequence` short-circuits any sequence under
`2 * PARALLEL_MIN_BYTES` into a single chunk, one chunk makes one task, and
one task fails the check. So the allocations were provably discarded, and
the condition is knowable from the length alone.

No measurable throughput effect, and it is worth recording why rather than
implying one. Because `plan_sequence` short-circuits *before* its
added-token scan, the only waste in that window is about six small `Vec`
allocations, against ~22 us of encoding for a 9 kB document. Measured with
single-document encodes at 4 kB / 9 kB / 20 kB (the first and last as
controls, since the guard cannot fire there): every cell within +-1.5%,
including the 9 kB window itself.

Kept because it removes work that cannot ever be used, not because it is
faster.

The expensive version of this waste is at 16 kB and above, where
`plan_sequence` does run the full `SpecialSegmentIterator` pass over the
document, finds no special token, emits one chunk and discards it -- a
duplicated added-token scan. This guard does not cover that, and it cannot
cheaply: whether a sequence splits is only knowable from that scan. The real
fix there is the intra-sequence splitting #2331 left as follow-up, which
would make the planning useful instead of merely cheap to skip.

* perf(pipeline): order chunks by size class with a counting sort

`plan_work` sorted the chunk list with `sort_unstable_by_key(Reverse(len))`
and then folded it a second time for the per-sequence `chunk_count`. Two
things are wrong with that shape:

- `SequenceChunk` is 40 bytes, so a comparison sort does O(n log n) moves of
  40 bytes each. The container was never the problem -- `chunks` is a `Vec`,
  access was already O(1) -- what it sorts is.
- It runs in the serial prologue, on the calling thread, before a single
  worker is spawned. It is pure Amdahl fraction, and worst exactly in the
  many-small-inputs case tasks were introduced for in #2338: one chunk per
  sequence means n is the batch size, so an 80k-document batch sorts 80k
  structs before any encoding starts.

The grouping it feeds never needs a total order -- it walks the chunks
accumulating bytes and cuts a task every `PARALLEL_MIN_BYTES`. Ordering by
power-of-two size class is sufficient, and that is a counting sort:
histogram, exclusive prefix sums largest-class-first, scatter. O(n), moving
4-byte indices instead of 40-byte structs, with `chunk_count` coming off the
same histogram pass rather than a second fold.

`tasks` now index `order` rather than `chunks`, so the worker pays one extra
`u32` load per chunk -- against at least `PARALLEL_MIN_BYTES` of tokenizing
per task.

Batched `encode` of a 5 MB corpus, min of 5 passes with the corpus repeated
to fill 250 ms per pass, 3 interleaved rounds, separately built binaries:

    64 B documents          n_docs   base MB/s   this commit
    gpt2/chinese             80457       362.7        +17.8%
    llama-3/chinese          80457       344.4        +11.0%
    llama-3/code             32691       709.0         +6.8%
    gpt2/english             81983       668.4         +4.0%
    gpt2/code                32691       683.9         +2.9%
    llama-3/english          81983       663.8         +1.5%

    1024 B documents        n_docs   base MB/s   this commit
    llama-3/chinese           5118       600.9         +2.1%
    gpt2/chinese              5118       846.5         +1.8%
    others                                     -1.0% .. +0.9%

Positive in every cell where n is large and gone by 1 kB documents, which is
the shape an O(n log n) -> O(n) change in a serial prefix should have. The
noise floor for this setup is about +-4%, measured from a commit that cannot
affect the batch path at all, so the two chinese cells are the solid ones and
the sub-3% entries are marginal.

Token ids are unaffected: chunks are encoded independently and reassembled by
`(seq, idx)`, so visiting order cannot change the result. Verified identical
across all four builds in every cell. Only the task boundaries move.

* perf(pipeline): partition the chunks rather than counting-sort them

Replaces the counting sort from the previous commit with a single partition
pass, on review against #2361 which reached the same conclusion
independently and expressed it in eight lines.

The task grouping never needed the ordering the counting sort provided.
Tasks are byte-balanced -- each grown until it holds `PARALLEL_MIN_BYTES` --
so how the chunks below that threshold sit among themselves cannot change a
task's size. Exactly one property matters: a chunk big enough to be a task
on its own must not be picked last and become the straggler the batch waits
on. Partitioning those to the front is O(n) in place, and drops the
`Vec<u32>` permutation, the `order` field, the size-class table, and the
extra load per chunk in `encode_task`.

Same measurement as before -- batched `encode`, 64 B documents, corpus
repeated to fill 250 ms per pass, 3 interleaved rounds -- shows the two are
equivalent in throughput, so the precision the counting sort bought was not
worth its code:

                     counting sort   partition
    gpt2/chinese            +17.8%      +16.9%
    llama-3/chinese         +11.0%      +12.9%
    llama-3/code             +6.8%       +6.3%
    llama-3/english          +1.5%       +5.4%
    gpt2/code                +2.9%       +3.0%
    gpt2/english             +4.0%       +2.6%

Net effect on this PR: 152 insertions become 88, and the worker's inner loop
goes back to indexing `chunks` directly.

* Apply suggestion from @McPatate

Co-authored-by: Luc Georges <McPatate@users.noreply.github.com>

* Apply suggestion from @McPatate

Co-authored-by: Luc Georges <McPatate@users.noreply.github.com>

* Apply suggestion from @ArthurZucker

---------

Co-authored-by: Luc Georges <McPatate@users.noreply.github.com>
(cherry picked from commit cbd94b8)
* implement padding

* axe legacy pad()

* padding can be a property of the tokenizer

* read padding from json config

* fmt

* fix

* to_json

* allow disabling padding

* test attention mask, type_ids, sentence_transformers configs

* rm maybe_par_iter

* test(convert): oracle the two sentence-transformers configs with truncation and padding

all-MiniLM-L6-v2 and all-mpnet-base-v2 ship truncation (max_length 128) and
padding (Fixed 128) in tokenizer.json, so they are where the padded attention
mask and type ids are compared against the released crate. Their fixtures are
pinned at hf-internal-testing/tokenizers-test-data 943303b (#11), one commit
past the previous pin, so no json_oracle digest changes.

(cherry picked from commit de4c7c1)
* perf(pipeline): reuse sequence A's buffer in post_process

Extracted from #2361, which bundled it with the flat batch path; it is an
independent change to the general path and reviews better on its own.

The `batch_alloc` example the original commit added is left out: it imports
`tk_encode::Tokenizer`, which #2352 removed, so it no longer compiles. #2361
deleted it two commits later for the same reason.

* lol

* remove bloat

* Apply suggestion from @ArthurZucker

* move post processor code where it belongs

* more cleanup

* Apply batched suggestions from code review

Co-authored-by: Arthur <48595927+ArthurZucker@users.noreply.github.com>

* remove stuff that belongs in convert

* update

* nit

* cleanup again

* fix

* fromat

* nit

* update

* remove bloat shit

* nit

* Apply batched suggestions from code review

Co-authored-by: Arthur <48595927+ArthurZucker@users.noreply.github.com>

* weave is ai

* simple nit

* fix(deps): bump chacha20 to 0.10.2, the yanked 0.10.1 fails cargo audit

chacha20 0.10.1 and 0.10.0 are both yanked from crates.io. The audit jobs run
`cargo audit -D warnings`, and a yanked crate is a denied warning, so the node
audit failed. The `--ignore RUSTSEC-*` flags cannot suppress it: "yanked" is an
index state, not an advisory ID.

Pulled in transitively by rand 0.10.2. tokenizers/Cargo.lock was already on
0.10.2; the node and python lockfiles were missed.

Verified with the exact CI command against all three lockfiles:
  node       exit 0
  python     exit 0 (was 1: "1 denied warning found")
  tokenizers exit 0

(cherry picked from commit 0743ac0)
* perf(pipeline): give each thread its own scratch sub-pool

`ScratchPool` is one `Mutex<Vec<EncodeScratch>>` and every `encode` locks it
twice: once to pop a scratch, once to push it back when `ScratchGuard` drops.
Every encoding thread therefore serialises on one lock, which is what the note
in the source predicted:

    TODO @McPatate : The Mutex can create contention, to be replaced by a
    better access pattern

Measured with N threads encoding a 12 kB prompt of 30k distinct words through
one shared `PipelineTokenizer`, throughput stops improving between 16 and 32
threads and then falls back (encodes/s):

    threads   Intel GNR 128c   NVIDIA Vera 88c   AMD Turin 96c
          1           52,474            67,399          56,076
          8          205,768           308,619         408,580
         16          298,086           327,975         640,830
         32          226,496           609,208         675,155
         64          191,749           475,348         668,325
       full          223,578           314,715         661,471

Scaling efficiency at full width is 3.3%, 5.3% and 12.3% of linear.

This gives each thread one of 64 sub-pools, picked once per thread from a
counter and kept in a thread-local. A scratch returns to the sub-pool it came
from, so a thread reuses its own buffers and the `WordCache` inside each scratch
stays warm per thread instead of being passed around. A thread whose own
sub-pool is empty takes from another before building -- `try_lock`, so it skips
a busy sub-pool rather than queueing behind it, which would reintroduce what the
sharding removes. That keeps the property the pool exists for: it hands out what
it has rather than allocating alongside it. `keeps_at_most_one_scratch_per_
concurrent_holder` is what makes this non-optional; without the borrow it fails.

Same machines, same inputs, with the sharded pool:

    threads   Intel GNR 128c   NVIDIA Vera 88c   AMD Turin 96c
          1           52,557            67,927          56,054
          8          401,004           538,663         438,338
         16          798,554         1,074,139         871,056
         32        1,568,314         2,135,057       1,698,626
         64        2,858,405         4,265,264       2,832,353
       full        4,623,304         4,904,611       3,719,211

20.7x, 15.6x and 5.6x at full width, at 69%, 82% and 69% efficiency. Single
thread is unchanged on all three (1.00x), so the serial path pays nothing.

Measured with the `scratch_scaling` example added in this series, 5 s per point,
on otherwise idle machines. `cargo test -p tk-encode` goes from 131 to 132
tests passing, the new one being the borrow path above.

Reproducibility differs by arm. Three sweeps on the same idle GNR node read
4.62M, 4.80M and 4.94M encodes/s at 128 threads with the sharded pool, against
223,578, 245,339 and 539,356 with the single mutex: a contended lock is sensitive
to where the scheduler puts the threads, and 128 threads have 512 of them to
choose from there. The full-width multiplier on that machine is therefore 9x to
21x depending on which baseline sample it is divided by. The sharded arm is the
reproducible one.

One caveat on scope: an HTTP serving frontend that encodes one request at a time
per connection was measured before and after this change and is unaffected
(0.88-0.98x). Its 60 kB prompts cost about five of the 12 kB encodes tabulated
above, so at 8,500 requests/s it asks the pool for roughly 43,000 encodes/s,
against the unsharded pool's peak of 609,208 on the same machine -- 7%, short of
where this mutex begins to bind. The win here is for callers driving `encode`
from many threads at once.

* example: measure how encode scales with thread count

N threads calling `encode` on one shared `PipelineTokenizer` for a fixed wall
time, reporting encodes/s, speedup and scaling efficiency per thread count. This
is what the preceding commit's numbers come from, and what makes a claim about
pool contention checkable rather than argued.

In tk-convert because loading a released `tokenizer.json` now needs both
`tk_convert::canonicalize_file` and `tk_serialize::from_json`, and this is the
crate that has both.

Vocabulary size is a knob for a reason. The scratch holds a `WordCache`, so a
prompt cycling through a handful of words is a near-100% cache hit: absolute
throughput is inflated, and so is the share of each encode spent on the pool
lock, which is the quantity being measured. `--vocab 30000` is nearer natural
text; `--vocab 15` reproduces the artefact deliberately.

* bench: move the scaling sweep to tk-serialize

Review feedback: a bench, not an example with its own arg parser. Thread counts
from available_parallelism, prompts from big.txt. Two rows: distinct text and a
repeated line, since a WordCache hit is a real regime, not a void measurement.
Drops the Dynamo notes and the TODO this PR fixes.

---------

Co-authored-by: Arthur <arthur.zucker@gmail.com>
Co-authored-by: Arthur <48595927+ArthurZucker@users.noreply.github.com>
(cherry picked from commit 1f1602d)
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.

4 participants