Skip to content

perf(fast): batch ingestion in Rust with gigatoken's O200k pretokenizer#55

Draft
AmitMY wants to merge 2 commits into
mainfrom
perf/rust-batch-ingestion
Draft

perf(fast): batch ingestion in Rust with gigatoken's O200k pretokenizer#55
AmitMY wants to merge 2 commits into
mainfrom
perf/rust-batch-ingestion

Conversation

@AmitMY

@AmitMY AmitMY commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

The last structural piece of the gigatoken playbook: one boundary crossing. trainer_from_texts (new fast/src/ingest.rs) takes the raw text list and does pretokenization → unique-word dedup → graph building → doc assembly → Trainer construction entirely in Rust, GIL released. Previously text crossed Python↔Rust three times (pretokenizer round-trip, per-word units calls, PyObject→GraphV conversion).

The Python shim gates the fast path on exactly the default configuration (utf8_clusters, default GPT pretokenizer, no registered script handlers); custom pretokenizers/units/scripts take the unchanged fallback (covered by test_custom_pretokenizer_*, test_callable_units). Pre-existing merge replay (SuperBPE phase 2, add_merges) composes through the same premerge-registry path — digests confirm.

⚠️ Deviation from plan: vendored, not a git dependency

A cargo git dependency on gigatoken is impossible on stable: its manifest carries a nightly-only [profile.profiling] rustflags key that stable cargo refuses to parse even for dependencies, and the O200k scheme exists in no rev without it. So the O200k subset (~3.3k lines: o200k.rs, o200k_family.rs, mask.rs, unicode.rs) is vendored under fast/src/pretok/, every file headed with the repo URL, pinned rev 542367a, and MIT attribution; mod.rs documents what was trimmed. Only new crate dep: icu_properties (slimmed from the icu umbrella). Build time unchanged, .so +0.17 MB. Follow-up: a small upstream PR making gigatoken's manifest stable-consumable would let us replace the vendored copy with a real dependency.

Our GPT_PATTERN is byte-for-byte gigatoken's O200k regex (PretokenizerType::from_split_regex matches it verbatim) — and per the maintainer's call there's no dedicated split-parity test: the digest gates are the check, and all digests match everywhere (20k / 2000 / 500 rows, all four tokenizers, identical token counts).

The pretokenize ladder (20k rows, 1.19M tokens)

Pretokenizer Time
Python-side HF pre_tokenize_str (old setup path) 576 ms
HF tokenizers crate, serial 464 ms
HF tokenizers crate, parallel 72 ms
gigatoken O200k, serial 14.3 ms
gigatoken O200k, parallel 5 ms

Results (20k rows / 100 merges, best of 3, digests = reference)

Case before after Δ vs reference
BPE 1.73s 0.36s 4.8× 6.6×
BNE n=4 2.13s 0.69s 3.1× 7.6×
Boundless 3.36s 2.20s 1.5× 31×
SuperBPE 4.91s 2.07s 2.4× 21×

Setup collapsed 0.95s → 0.04s; loop times also dropped (BPE 0.78→0.32s) because ingestion Arc-shares each unique word graph across its occurrences (1.19M occurrence trees → 74k unique + Arc clones).

Default benchmark (2,000 rows): fast Boundless 1.40→0.32s, SuperBPE 1.08→0.35s, BPE 0.25→0.10s, BNE 0.47→0.21s, peak RSS down across the board.

Verification

141 passed + 2 skipped from both suite entry points; clippy warning set identical to main (one scoped, commented allow in vendored code); digests byte-identical to the reference at every scale tested.

🤖 Generated with Claude Code

trainer_from_texts (fast/src/ingest.rs) takes the raw text list once and
does pretokenization, unique-word dedup, graph building, doc assembly, and
Trainer construction entirely in Rust with the GIL released. The Python
shim gates it on the default configuration (utf8_clusters units, default
GPT pretokenizer, no registered script handlers); anything else falls back
to the unchanged per-document path.

Pretokenization uses gigatoken's O200k implementation (our GPT_PATTERN is
byte-for-byte the gpt-oss/O200k pattern), vendored under fast/src/pretok/
with MIT attribution pinned to rev 542367a3: a cargo git dependency is
impossible on stable (gigatoken's manifest uses a nightly-only profile
rustflags key), so the O200k subset (~3.3k lines) is vendored with the
trimmed pieces documented. Only new crate dep: icu_properties.

Pretokenize ladder at 20k rows (1.19M tokens, identical counts everywhere):
Python HF pre_tokenize_str 576ms -> HF tokenizers crate parallel 72ms ->
gigatoken O200k parallel 5ms.

20k rows / 100 merges, best of 3, digests byte-identical to the reference:
- BPE:       1.73s -> 0.36s (4.8x)
- BNE n=4:   2.13s -> 0.69s (3.1x)
- Boundless: 3.36s -> 2.20s (1.5x; reference 68.2s -> 31x)
- SuperBPE:  4.91s -> 2.07s (2.4x; reference 42.9s -> 21x)

Setup collapsed 0.95s -> 0.04s, and loop times also dropped because
ingestion Arc-shares each unique word graph across occurrences (1.19M
occurrence trees -> 74k unique + Arc clones).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AmitMY
AmitMY marked this pull request as draft July 23, 2026 11:29
@AmitMY

AmitMY commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

On hold by maintainer decision — do not merge. The vendored fast/src/pretok/ copy is a stopgap; this PR waits until gigatoken is consumable as a proper cargo dependency. Plan: (1) upstream PR exposing model-aware pretokenization bindings (in preparation), (2) upstream PR making gigatoken's manifest stable-cargo-consumable (the nightly-only [profile.profiling] rustflags key currently blocks git dependencies). Once upstream merges, the vendored directory gets replaced with a pinned dependency and this PR is re-benched and un-drafted.

…red word deltas

Profiled on the entire wikitext-2 train split (23.8k docs, 500 merges).
Five kept changes, each digest-verified in isolation:

- Inverted-index values Vec<u32> -> FxHashSet<u32>: removal was a linear
  scan of up to ~23k doc ids per affected doc per step.
- The per-step registry scan emits only words that actually merge; staying
  in the registry is the known-unchanged signal (kills a serial ~74k-entry
  map rebuild per step).
- apply_premerge rewrite: changed-only map, scan-first without cloning
  untouched docs, in-place slot writes via Arc::make_mut (SuperBPE replay
  4.65s -> 1.58s).
- try_apply_incremental writes (position, replacement) in place instead of
  rebuilding each affected doc's children Vec (~2.4M Arc clones/step early).
- Each changed word's candidate delta list is computed once per step and
  docs combine the pre-built lists weighted by occurrence count (delta
  emission 17.1s -> 8.3s thread-summed).

Full scale, cold, best of 3: Boundless 10.90s -> 5.64s (1.9x), SuperBPE
8.36s -> 5.05s (1.7x), BPE/BNE flat (bottlenecks untouched). Digests
unchanged at full scale and at the 2000-row default config.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AmitMY

AmitMY commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Second commit on this branch: the full-scale profiling round (entire wikitext-2 train split, 500 merges). Five changes, each digest-verified in isolation — details in the commit message.

Case full scale before after Δ 2000-row default
BPE 0.95s 0.99s flat 0.106s ✓
BNE n=4 1.69s 1.82s flat 0.212s ✓
Boundless 10.90s 5.64s 1.9× 0.243s ✓
SuperBPE 8.36s 5.05s 1.7× 0.235s ✓

Cumulative vs the original full-scale baselines: Boundless 179s → 5.6s (32×), SuperBPE 216s → 5.1s (43×), and the reference comparison at this scale is now roughly 120× (Boundless).

⚠️ Methodology finding for anyone benchmarking: full-scale digests are cluster-cache-state dependent — training mutates the global cluster cache (apply_merge_to_cluster_cache), so a second train in the same process starts from mutated cluster graphs and produces a different digest. Invisible at small scale, deterministic per cache state. All numbers above are cold subprocess-per-case runs (same methodology as bench_tokenizers.py). This statefulness is a reference-divergence risk worth fixing separately — filing an issue.

Ranked remaining (evidence in the profiling report): incremental handling of cross-word top-match steps (~0.5–1s Boundless), a pair→words index for the registry scan (~1s Boundless, ~0.4s SuperBPE), u32 token interning (full-round redesign; only if sub-4s Boundless matters). BNE/BPE are spread thin — no single lever left.

@AmitMY

AmitMY commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Status update: #65 now delivers this PR's ingestion structure using the HF tokenizers crate for pretokenization (identical splits by construction — no vendored code, all digests preserved). Once #65 merges, this draft's only remaining value is the gigatoken SIMD pretokenizer swap (~72ms → 5ms pretokenize at 1×), still parked on upstream consumability (marcelroed/gigatoken#34). Will rework or close accordingly when upstream moves.

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