perf(parallel): a flat batch path that shares nothing between workers - #2361
Open
ArthurZucker wants to merge 10 commits into
Open
ArthurZucker wants to merge 10 commits into
ArthurZucker wants to merge 10 commits into
Conversation
`num_threads()` defaulted to `available_parallelism()`, which counts SMT siblings. Filling them costs throughput on the encode path: on medium documents (~8 KiB) throughput peaks at the physical core count and then falls off, -20% going 88 -> 176 threads on aarch64 and -47% going 128 -> 512 on a two-socket Granite Rapids. Two workers on one physical core share a front-end and an L1, and this path is branchy and L1i-hungry, so it feels that more than most workloads do. Default to physical cores instead, read from sysfs on Linux and sysctl on macOS, and clamp to `available_parallelism()` so a cgroup quota or an affinity mask still wins -- this only ever lowers the count. Platforms that will not report a physical count keep the old behaviour. `set_num_threads()` still overrides. The sibling-list parser is compiled and tested on every platform, not just Linux: a misparse there would divide the pool size by the wrong number everywhere, and it is the only real logic here. (cherry picked from commit e1cd411)
Encoding one document allocated twice: `encode_sequence` built the ids, then
`post_process` allocated a second buffer of its own and copied them in, dropping
the first. For a batch of short documents that pair is a large share of the
work, and it is worse than it looks -- a worker thread allocates the buffer and
the consumer thread frees it, so every document costs a cross-thread free.
For a single-sequence template, A's buffer already holds almost the whole
answer, so use it as the output and splice the specials around it: the suffix
appends, the prefix costs one memmove. Multi-sequence templates and templates
carrying type ids keep the old path.
Measured on 26k short documents (gpt2, 5 MiB of english, one document per line):
allocations per document 2.28 -> 1.28 single-threaded
3.28 -> 2.28 on the pool
throughput 152 -> 171 MiB/s single-threaded
Byte-exact: the pipeline oracle passes on all 9 models -- including llama2,
llama3, gemma4, t5 and bert, whose templates add BOS/CLS/SEP and so exercise the
splice -- over 15 corpora at two window sizes, for add_special_tokens both false
and true.
`examples/batch_alloc.rs` is how those allocation counts were taken; it also
reports the mdoc/batch split, which is where the remaining work is.
(cherry picked from commit db9d5cc)
Batches of short documents did not scale: the pool was there, the workers were
free, and the throughput stopped climbing at four threads. The work the threads
do is fine -- what caps them is everything the *calling* thread does before any
of them starts. Measured on 20k-line batches of `big.txt` at 8 threads, that was
about 35% of a batch encode, which by Amdahl caps the speedup near 2.9x however
many cores are idle.
Three pieces of it, none of which had to be serial:
**The chunk sort.** `plan_work` sorted every chunk longest-first, 18k of them in
a 20k-line batch, for scheduling. Tasks are already byte-balanced -- they are
grown until they hold `PARALLEL_MIN_BYTES` -- so a total order bought almost
nothing; what matters is only that a chunk big enough to be its own task is not
picked last. Partitioning those to the front is O(n) against the sort's
O(n log n), and was worth ~14% of the batch on its own.
**A `Vec` per sequence.** The per-sequence results were a `Vec<Vec<ChunkResult>>`,
so a batch of 20k short lines asked the allocator for 20k vectors before any
encoding began. They are now one flat buffer with per-sequence offsets, which
took planning from 2.30ms to 0.91ms.
**The input copy.** `Input` owns its text, so `From<&[&str]>` clones every line,
on the calling thread, ahead of every worker -- 19% of the batch. The copies are
independent, so they go to the pool.
threads 1 2 4 8
before 177 261 286 305 MiB/s
after 171 303 442 509
2.98x at eight threads, from 1.72x. Not linear yet: what remains is the
per-document `Encoding` allocation, which `perf/batch-shape-scaling` addresses
with a flat `BatchEncoding` and worker-side reconstruction. That branch forked
before #2338 and #2352 and rewrites the same lock-free code a different way, so
it wants a real rebase rather than a merge -- see the PR description.
`tk-encode/examples/batch_alloc.rs` came in with the cherry-picked
`post_process` commit and uses `tk_encode::Tokenizer`, which #2352 removed; it is
dropped rather than half-ported.
247/247 tests pass, fmt and clippy clean.
The general batch path exists to hand back a `Vec<Encoding>`, one per document, and
almost all of its machinery is there to make that safe: a plan, a chunk table, a task
list, a completion queue, per-sequence result slots. On a handful of long documents
that costs nothing. On twenty thousand short ones it is most of the work, and none of
it is work the threads can help with.
`encode_batch_flat` takes the documents borrowed and returns one contiguous id buffer
with row offsets. Each worker takes a run of documents, encodes them into an arena of
its own with a scratch of its own, and records how long each came out; the arenas are
concatenated in input order at the end. There is no plan, no completion queue, and
nothing shared between workers -- which is the property the POC write-up puts at the
centre of its scaling result, and the reason this scales where the general path
plateaus.
deepseek-v4, 6.5 MiB of `big.txt` as 20k-line batches, timed loop repeated so the
fixed costs are amortised:
threads 1 2 4 8
general 174 319 449 497 MiB/s
flat 223 407 716 1111
5.0x at eight threads against 1.7x when this branch started, and the flat path is
1.3x faster even on one thread because it stops allocating per document.
The serial tail is small: concatenating the arenas measured 0.05ms against 0.9ms of
parallel work, about 5%. Chunk size is two times `PARALLEL_MIN_BYTES` worth of
documents, which measured best over a sweep from 64 to 1250; below that rayon's
per-task cost shows, above it a thread can end up with a single run.
A template the flat layout cannot model -- a pair template, or one carrying type ids
-- falls back to `encode` and copies, so the entry point is total.
`flat_batch.rs` checks the two paths agree document for document on 10k documents with
`add_special_tokens` both ways, including empty inputs, CJK and literal special
tokens. It is a second implementation of the same contract, not a wrapper, so it needs
a test that pins them together.
Idea and shape taken from `perf/batch-shape-scaling`, ported rather than merged: that
branch forked before #2338 and #2352 and rewrites the same lock-free code a different
way.
249/249 tests pass, fmt and clippy clean.
Concatenating the per-worker arenas was the flat path's serial tail, and it was
worth measuring rather than assuming: with the concatenation removed entirely the
eight-thread number goes from 1652 to 1805 MiB/s, so it was costing 9%.
Each part's destination is known once the lengths are, so the copy does not have to
be serial. `split_at_mut` hands each job a disjoint run of the output and the pool
fills them at once. The first version of this allocated with `vec![PipelineToken(0); n]`
and recovered almost nothing -- a zero-fill of three megabytes the workers are about
to overwrite is itself serial, and cost about what the parallel copy saved. Handing
out `spare_capacity_mut` instead skips the memset:
threads 1 2 4 8 10
zero-fill 308 581 1040 1646 1807 MiB/s
uninit 317 586 1068 1712 1852
1712 against the 1805 ceiling: the tail is down from 9% to 5%.
Eight threads give 5.4x here, not 8x, and the reason is not this code. Profiling the
pool shows the eight workers taking 388-389 samples each -- perfectly balanced, ~100%
busy, no idle and no stealing tail. Three candidate causes were measured and all came
back flat: one scratch per worker instead of one per chunk (`map_init`) 0.996x,
jemalloc in place of the system allocator 0.992x, and QoS_USER_INTERACTIVE on the
workers to keep them off the E-cores 0.999x. Chunk granularity is already at its
optimum -- a sweep from 4 to 2500 documents peaks flat across 64-128, which is where
the computed default lands.
What is left is the word cache, and it inflates the baseline rather than slowing the
workers. A benchmark that encodes the same corpus repeatedly lets one thread's 2 MB
cache go warm over all of it; eight workers each cover an eighth. Shrinking the cache
so warmth cannot accumulate makes the apparent scaling jump:
cache slots 1 thread 8 threads speedup
256 167.1 1145.5 6.85x
4096 186.1 1238.1 6.65x
65536 (default) 304.1 1676.2 5.51x
Absolute throughput is still best at the default -- 1676 beats 1238 -- so this is the
baseline being generous, not the pool being slow. The same effect explains why eight
independent processes reach 7.82x: each one has its own fully warm cache over the
whole corpus.
249/249 workspace tests and 37/37 with the parallel feature on; `flat_batch` still
checks the flat path document for document against `encode`. fmt and clippy clean.
ArthurZucker
added a commit
that referenced
this pull request
Sep 2, 2026
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.
ArthurZucker
added a commit
that referenced
this pull request
Sep 2, 2026
…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>
#2378 landed the sort -> partition swap that this branch's "take the batch planning off the critical path" commit also made, so `plan_work` conflicted. Both sides had the identical partition loop; the resolution keeps the comment that landed on the base branch and this branch's `seq_start.push(outputs.len())`, which is the other half of that commit and is not on the base. `encode_flat` conflicted only because both sides append to the end of `parallel.rs`; kept whole.
This branch carried three unrelated changes besides the flat batch path. One (the sort -> partition swap) landed as #2378. The other two are now their own PRs and are reverted here so this branch is the flat path alone: * `perf(parallelism): default the pool to physical cores, not SMT siblings` -- 166 lines of pool sizing and Linux `/sys` topology parsing, nothing to do with the batch path. Was #2335. * `perf(pipeline): reuse sequence A's buffer in post_process` -- a change to the general path. Was #2336. Both revert cleanly and the flat path's tests still pass, which is the point of splitting them: each can be reviewed and measured against the base on its own, and a problem with one no longer blocks the other two.
This was referenced Sep 2, 2026
ArthurZucker
added a commit
that referenced
this pull request
Sep 2, 2026
* 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
ArthurZucker
added a commit
that referenced
this pull request
Sep 3, 2026
…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)
ArthurZucker
added a commit
that referenced
this pull request
Sep 3, 2026
* 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)
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.
encodehands back aVec<Encoding>, one per document, and the machinery for that — achunk table, a task list, a completion queue, an
Encodingand itsVecper input — ismost of the cost when the documents are short.
encode_batch_flatreturns the ids as onecontiguous run with row offsets, and lets each worker fill an arena of its own: nothing
shared between workers but the immutable tokenizer.
A template the flat layout cannot model (a pair template, or one carrying type ids) falls
back to
encodeand copies, so the entry point is total.Concatenation back to the pool. Joining the arenas was the remaining serial tail.
split_at_muthands each job a disjoint run of the output and the pool fills them at once,and the output is handed out as
spare_capacity_mutrather thanvec![PipelineToken(0); n]so a zero-fill of three megabytes the workers are about to overwrite does not happen serially.
Now scoped to the flat path only
This PR bundled four changes. Three are gone:
plan_workpost_processbuffer reuseWhat is left is the flat path alone: +378/−20 across 5 files, down from +593/−42. Each of
the others can be reviewed and measured on its own, and a problem with one no longer blocks
the other two.
Measured on top of #2365
deepseek-v4, 3.5 MiB of english as 20k documents, min of 7 passes, best of 3 interleaved
rounds,
ids_hashidentical across every config and thread count:encodeencode+ #2365encodeencode_batch_flat1.50× at 8 threads, 1.66× at 10, +13% at one thread. #2365 is orthogonal, and for a
concrete reason: the batch path takes a scratch once per worker per batch, not once per
document, so the sharded pool has no contention there to remove. Its own 20× is in the
serving shape — N application threads each calling
encode. Both are worth having.Two caveats on the numbers:
1.4–1.5× at 8 threads, because the
encodebaseline here (1198 MiB/s @8) is far above the497 the table quotes. Different machine and corpus; the table above supersedes it.
so read the 1.5× — well outside it — and not the smaller gaps.
The concat-to-pool commit earns its keep: a version of this path without it measures
17% slower at 8 threads and 13% at 10 (1491 vs 1796 MiB/s), because the serial
memcpyjoin only becomes the bottleneck once the workers are fast enough. It is invisible below 4
threads, which is why it reads as a micro-optimisation and is not one.
One simplification still on the table
flat_specialsandencode_into'sreproduces_sequenceask the same question of thetemplate in two places. Folding them into one predicate deletes
flat_via_encodeentirely— including the
Stringit allocates per input — and would make existingencode_intocallers faster on BERT/RoBERTa-style templates, which currently assemble an
Encodingandcopy even though extending by a prefix and a suffix would do. Not in this PR; happy to do it
as a follow-up.