feat: multi threaded pipeline - #2213
Conversation
|
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. |
Signed-off-by: Luc Georges <luc.sydney.georges@gmail.com>
Signed-off-by: Luc Georges <luc.sydney.georges@gmail.com>
Signed-off-by: Luc Georges <luc.sydney.georges@gmail.com>
b5c7c18 to
61a82cd
Compare
Signed-off-by: Luc Georges <luc.sydney.georges@gmail.com>
Signed-off-by: Luc Georges <luc.sydney.georges@gmail.com>
Signed-off-by: Luc Georges <luc.sydney.georges@gmail.com>
Signed-off-by: Luc Georges <luc.sydney.georges@gmail.com>
Signed-off-by: Luc Georges <luc.sydney.georges@gmail.com>
Signed-off-by: Luc Georges <luc.sydney.georges@gmail.com>
Signed-off-by: Luc Georges <luc.sydney.georges@gmail.com>
Signed-off-by: Luc Georges <luc.sydney.georges@gmail.com>
Signed-off-by: Luc Georges <luc.sydney.georges@gmail.com>
new `UnsafeCell` with atomic impl should allow us to scale linearly with core count Signed-off-by: Luc Georges <luc.sydney.georges@gmail.com>
Signed-off-by: Luc Georges <luc.sydney.georges@gmail.com>
ArthurZucker
left a comment
There was a problem hiding this comment.
As a reviewer I am missing a lot of help on what is happening at a global scope. So I reviewed smaller scopes and some stuff here and there but we really need to do file split
| views: Vec<PyStrView>, | ||
| } | ||
|
|
||
| // SAFETY: the raw pointers reference CPython's cached UTF-8 buffers, which are |
There was a problem hiding this comment.
| // SAFETY: the raw pointers reference CPython's cached UTF-8 buffers, which are | |
| // SAFETY: the raw pointers reference CPython's cached UTF-8 encoded buffers, which are |
There was a problem hiding this comment.
not encoded, this is only the input type!
| // SAFETY: ptr is kept alive by PyStrBatch::_owners, and the underlying buffer is immutable UTF-8 | ||
| unsafe { std::str::from_utf8_unchecked(std::slice::from_raw_parts(ptr, len)) } |
There was a problem hiding this comment.
| // SAFETY: ptr is kept alive by PyStrBatch::_owners, and the underlying buffer is immutable UTF-8 | |
| unsafe { std::str::from_utf8_unchecked(std::slice::from_raw_parts(ptr, len)) } | |
| // SAFETY: the pointer given by view.as_ptr() is non null even for empty str. The memory range is not freed as PyStrBatch::_owners protects it. | |
| let slice =unsafe { std::slice::from_raw_parts(ptr, len)} | |
| // SAFETY: the pointed string IS a utf8 string it does not require another validation (O(len)). | |
| unsafe { std::str::from_utf8_unchecked(slice) }; |
I think its important to explain why we would go through unsafe when safe can work just as well + not hide to unsafe calls but split them?
There was a problem hiding this comment.
Safe would require a clone here, when it's not necessary because python guarantees the buffer won't be gc-ed as long as we have a reference (this is what _owners is for).
Python also guarantees immutability of the buffer, so we can safely create a &str from it. Also the .to_str call we do in PyStrBatch::new guarantees it'll be utf8. So unsafe is needed here, because we're accessing the string's buffer via raw ptr mechanics (we have to if we want to avoid cloning the strings, which is not needed and thanks to this we're 0-copy from python input to rust, we won't be able to achieve something similar without unsafe), but we know the safety guarantees are upheld in this context.
let view = s.to_str()?; // -> guarantees the string buffer's bytes are utf8, then cached and never mutated
views.push(PyStrView {
ptr: view.as_ptr(),
len: view.len(),
});
owners.push(s.unbind());| tokens.iter().map(|t| t.id).collect() | ||
| } | ||
|
|
||
| struct PyStrView { |
There was a problem hiding this comment.
| struct PyStrView { | |
| // A pointer to a utf8-encoded PyString that comes/lives in python world. | |
| struct PyStrView { |
There was a problem hiding this comment.
PyString is necessarily a python string from python world 😄
Not sure a comment is needed here, imo quite explicit what this is?
| impl IntoInputs for PyStrBatch { | ||
| type Inputs = PyStrBatch; | ||
| fn into_inputs(self) -> PyStrBatch { | ||
| self | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
as this is gonna change with the updates to support pairs of inputs, makes sense to have just a file for input?
There was a problem hiding this comment.
erm, not sure, I think we can extend this struct to support pairs of inputs rather than replacing it. Not sure what you mean by "file for input"
| /// to place it). The consuming thread *assists* the pool while it waits (all | ||
| /// with the GIL released). Dropping the job cancels unclaimed work. |
There was a problem hiding this comment.
how does it assist? why does it matter?
There was a problem hiding this comment.
canceling return claimed and finished work in a wait?
There was a problem hiding this comment.
how does it assist? why does it matter?
impl Iterator for EncodeHandle {
type Item = (usize, Result<Vec<PipelineToken>>);
fn next(&mut self) -> Option<Self::Item> {
match &mut self.inner {
HandleInner::Ready(it) => it.next(),
HandleInner::Streaming { core, state } => state.next_completed(core),
}
}
}
impl StreamState {
fn next_completed(&mut self, core: &JobCore) -> Option<(usize, Result<Vec<PipelineToken>>)> {
if self.next_k >= self.n {
return None;
}
loop {
let seq = core.completed_order[self.next_k].load(Ordering::Acquire);
if seq != NOT_DONE {
self.next_k += 1;
return Some((seq, core.take_result(seq)));
}
// <------ HERE IS THE ASSIST LOGIC ------>
if !self.assist_done {
if SCRATCH.with(|st| core.run_one(&mut st.borrow_mut())) {
continue;
}
self.assist_done = true;
}
std::hint::spin_loop();
}
}
}I've added a comment to show where the assist logic is.
Why:
sleepis out of the question, too unpredictable and adds too many syscalls- spin (
loop { if has_result() { break; } }) burns a core for nothing, although it reacts fast - condvar/futex (park) is the only viable alternative imo, reacts in the µs, but if we do implement it over assist, it has the following downsides:
- latency: encode can start straight away, even before the pool has initialised
- no context switching, the thread is always alive, no need to pay for µs wake
- no need for the workers to signal the main thread that results are ready
- overall more complicated, needs quite some code to implement, whereas assist is simply "call the work function we already have each worker do"
There was a problem hiding this comment.
canceling return claimed and finished work in a wait?
not sure I understand the question, but if the EncodeHandle is dropped for wtv reason, the ongoing encode jobs are cancelled
| self.iter().map(|s| (*s).to_owned()).collect() | ||
| } | ||
| } | ||
| /// One owned encode call's worth of work, shared with the pool workers via |
There was a problem hiding this comment.
this is also a new file!
| /// Encode one or many sequences, returning an [`EncodeHandle`]: | ||
| /// workers keep encoding in the background while the caller | ||
| /// holds it. | ||
| /// | ||
| /// todo: wire the post-processing | ||
| pub fn encode(&self, input: &str, _add_special_tokens: bool) -> Result<Vec<PipelineToken>> { | ||
| let mut output = Vec::new(); | ||
| let mut pre_tokens = Vec::new(); | ||
| let mut scratch = self.model.init_scratch(); | ||
| /// The job is `'static`: it holds the input storage and a cheap clone of this | ||
| /// tokenizer handle. Dropping the job cancels unclaimed | ||
| /// work, and the last worker releases the storage. |
There was a problem hiding this comment.
this is key / core. Needs a proper doc explaining what is happening or this should be in a readme.
| assert_eq!(by_ref, serial, "Normalized (borrowed) != serial"); | ||
| let owned = ids(tok.encode(big.clone()).into_single().unwrap()); | ||
| assert_eq!(owned, serial, "Normalized (owned) != serial"); | ||
| } |
There was a problem hiding this comment.
we need to put the test somewhere else, this file is super hard to read
| //! Process-global worker pool for the parallel encode path: a lazily-built, | ||
| //! library-private `rayon::ThreadPool`. | ||
| //! |
There was a problem hiding this comment.
why? why do we need this etc!
| pub fn set_num_threads(num_threads: usize) { | ||
| NUM_THREADS.store(num_threads, Ordering::SeqCst); | ||
| crate::tokenizer::pool::invalidate(); | ||
| } | ||
|
|
||
| /// The programmatic worker-count override, if one was set. | ||
| pub(crate) fn num_threads_override() -> Option<usize> { | ||
| match NUM_THREADS.load(Ordering::SeqCst) { | ||
| 0 => None, | ||
| n => Some(n), | ||
| } | ||
| } |
There was a problem hiding this comment.
mega long due in this lib!
SBrandeis
left a comment
There was a problem hiding this comment.
Partial review will continue later
| ptr: *const u8, | ||
| len: usize, |
There was a problem hiding this comment.
Can't we use PyBackedString from pyo3 instead?
It seems to be doing exactly what you've re-implemented here, and provide safe APIs to get &str from the Python-owned object
There was a problem hiding this comment.
| let job = self.take()?; | ||
| let res = py.detach(|| job.wait_for_completion()); | ||
| let out = PyResult::from(ToPyResult(res))?; | ||
| Ok(out.into_iter().map(ids).collect()) |
There was a problem hiding this comment.
Ideally we should be able to send data to python without allocating (maybe through numpy array?)
Probably as a follow-up
There was a problem hiding this comment.
yes agree, will tackle later
| enum Stage { | ||
| Frame, | ||
| Normalize, | ||
| Split, | ||
| Model, | ||
| } |
There was a problem hiding this comment.
Missing the post-process stage (reusing the other const would catch this)
| /// Cumulative pipeline stage levels for the ablation ladder, in execution | ||
| /// order: each level runs every stage up to and including itself; `Model` is a | ||
| /// full encode, `Frame` is the special-token scan + iteration only. | ||
| #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] | ||
| enum Stage { | ||
| Frame, | ||
| Normalize, | ||
| Split, | ||
| Model, | ||
| } |
There was a problem hiding this comment.
The issue with enum is that they cannot be used in const generics
| /// ladder). A runtime branch per segment is fine in a bench; the subtraction | ||
| /// cancels its cost. |
There was a problem hiding this comment.
Why scrapping encode_generic?
| let mut scratch = model.init_scratch(); | ||
| let mut run = |out: &mut Vec<PipelineToken>, pre_tokens: &mut Vec<_>| { | ||
| for chunk in chunks { | ||
| out.clear(); | ||
| let _ = | ||
| pipeline.encode_generic::<STAGE>(chunk, &mut pre_tokens, &mut scratch, &mut out); | ||
| for segment in SpecialSegmentIterator::new(chunk, added_vocabulary, false) { | ||
| match segment { | ||
| Segment::SpecialToken(id) => out.push(PipelineToken { id }), | ||
| Segment::Text(text) => { | ||
| let normalized: Cow<str> = if stage >= Stage::Normalize { | ||
| match normalizer { | ||
| Some(n) => n.normalize(text).unwrap(), | ||
| None => Cow::Borrowed(text), | ||
| } | ||
| } else { | ||
| Cow::Borrowed(text) | ||
| }; | ||
| for seg in SpecialSegmentIterator::new(&normalized, added_vocabulary, true) | ||
| { | ||
| match seg { | ||
| Segment::SpecialToken(id) => out.push(PipelineToken { id }), | ||
| Segment::Text(normalized_chunk) => { | ||
| if stage >= Stage::Split { | ||
| pre_tokens.clear(); | ||
| pre_tokenizer | ||
| .pre_tokenize(normalized_chunk, pre_tokens) | ||
| .unwrap(); | ||
| if stage >= Stage::Model { | ||
| for pre_token in pre_tokens.iter() { | ||
| model | ||
| .tokenize_pipeline( | ||
| &normalized_chunk[pre_token.range()], | ||
| &mut scratch, | ||
| out, | ||
| ) | ||
| .unwrap(); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Very brittle, needs an update every time we change the encode logic to be faithful
| thread_local! { | ||
| /// Per-thread reusable encode scratch: pool drainers, inline callers and the | ||
| /// caller-assist path all keep their buffers warm across encode calls | ||
| /// (reset, never realloc'd) — pool threads persist for the process, so this | ||
| /// is the cache-warmth contract of [`EncodeState`]. | ||
| static SCRATCH: RefCell<EncodeState> = RefCell::new(EncodeState::new()); | ||
| } |
There was a problem hiding this comment.
Is there a way to get rid of thread_local!?
For example by having each worker own its copy of EncodeState
See #2223 (scratch pool pattern)
There was a problem hiding this comment.
thread_local! avoids having to use Arc<Mutex<T>>, it is already owned per thread
There was a problem hiding this comment.
it's just not shareable, which is not a problem in our case
Signed-off-by: Luc Georges <luc.sydney.georges@gmail.com>
Brings in the parallel encode runtime from #2213: the `ParallelPlan` ladder, the claim-cursor job core, `EncodeHandle`, and the fork-safe pool. Both branches had rewritten `tokenizer/pipeline.rs`, so #2213's runtime is taken whole and this branch's work is grafted back onto it: - `PipelineInner` carries `normalizers: Vec<_>` (metaspace-as-normalizer) and the wired `PipelinePostProcessor` instead of the single normalizer and the unused `_post_processor`. `stride_boundary` now asks every normalizer; `PipelineNormalizer::Metaspace` answers no, so t5/albert take the `Normalized` plan rather than a raw cut. - The model kernels call the batched `tokenize_spans` instead of looping `tokenize_pipeline` per span -- the point of this branch. - Special-token framing is applied per *input*, where an input's chunks are joined (`take_result` / `encode_serial`), never per chunk. `encode` frames by default; `encode_with` takes the flag. `encode_one` is the single-sequence synchronous path, and `encode_generic::<STAGE>` keeps the bench ablation ladder. - The thread-local `EncodeState` replaces this branch's per-tokenizer `ScratchPool`, but the scratch holds a word cache keyed on word bytes alone -- so it is now rebuilt when the thread's last encode used a *different* tokenizer, not merely a different model kind. Two BPE tokenizers sharing a thread traded ids without this. Also: - `BucketVocabStore::build` returns the empty store instead of panicking on an empty vocab; `BPE::default()` is one, and the plan tests build from it. - #2213's oracle keeps its own file (`pipeline_parallel_oracle.rs`) next to the per-model one, and compares against `add_special_tokens = true` now that the post-processor runs -- which also checks the framing lands once per input. - `bpe_pipeline_oracle` skips a model whose *legacy* reference can't encode without `fancy-regex` (deepseek): with the backend on, all four are exact.
* fix(pipeline): don't stride an input that has no cut points `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. * bench(ab_giga_mt): multi-thread arm of the gigatoken A/B 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. * Delete tokenizers/tk-encode/examples/ab_giga_mt.rs
Status: ai generated iteration, will trim down and review thoroughly to cut the BS and verify for correctness in more depth