gigatoken 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/src/batch.rs CHANGED
@@ -12,7 +12,7 @@ use crate::input::DocumentIter;
12
12
  use crate::input::file_source::{DocFormat, chunk_ranges};
13
13
  use std::ops::Range;
14
14
  use std::cell::UnsafeCell;
15
- use std::sync::atomic::{AtomicUsize, Ordering};
15
+ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
16
16
  use std::sync::{Mutex, OnceLock, TryLockError};
17
17
 
18
18
  /// Parallel chunks must hold at least this many bytes: a chunk this size
@@ -123,7 +123,17 @@ pub(crate) struct ChunkTokens {
123
123
  pub(crate) continues: bool,
124
124
  }
125
125
 
126
- fn encode_chunk(tokenizer: &mut Tokenizer, chunk: &EncodeChunk) -> ChunkTokens {
126
+ /// Encode one chunk. `cancel` is polled per document rather than only
127
+ /// between chunks: a chunk is a megabyte or more of input, and the caller's
128
+ /// interrupt should not wait out the one already in flight. What is left is
129
+ /// a partial `ChunkTokens`, which is safe because a cancelled run discards
130
+ /// every chunk it encoded — including a run of one chunk (see
131
+ /// `encode_chunks_gathered`).
132
+ fn encode_chunk(
133
+ tokenizer: &mut Tokenizer,
134
+ chunk: &EncodeChunk,
135
+ cancel: Option<&AtomicBool>,
136
+ ) -> ChunkTokens {
127
137
  // Reserve the output once, from a bytes-per-token estimate on the low
128
138
  // side of natural language (~4.4 on OWT/GPT-2). Growing from empty
129
139
  // instead re-copies roughly the final size in doublings — per chunk,
@@ -141,12 +151,20 @@ fn encode_chunk(tokenizer: &mut Tokenizer, chunk: &EncodeChunk) -> ChunkTokens {
141
151
  match chunk {
142
152
  EncodeChunk::Docs(docs) => {
143
153
  for doc in docs {
154
+ if cancelled(cancel) {
155
+ break;
156
+ }
144
157
  encode_into(tokenizer, doc, &mut ids, &mut lens);
145
158
  }
146
159
  }
147
160
  EncodeChunk::Region { bytes, format } => {
148
161
  for_each_doc(bytes, format, |doc| {
149
- encode_into(tokenizer, doc, &mut ids, &mut lens)
162
+ // `for_each_doc` has no break; once cancelled the rest of
163
+ // the region is walked but not encoded (see the sequential
164
+ // path in `encode_files_docs_serial_with`).
165
+ if !cancelled(cancel) {
166
+ encode_into(tokenizer, doc, &mut ids, &mut lens)
167
+ }
150
168
  })
151
169
  }
152
170
  EncodeChunk::Fragment { bytes, first } => {
@@ -621,9 +639,29 @@ impl Committer {
621
639
  }
622
640
  }
623
641
 
642
+ /// Whether the caller's cancellation token has been set — polled between
643
+ /// chunks by the loops below and per document inside one (`encode_chunk`),
644
+ /// so an interrupt waits out a document rather than a whole chunk.
645
+ /// `None` is a run that cannot be cancelled: what the free functions this
646
+ /// module exports (the pyo3 bindings' and the benches' entry points) pass,
647
+ /// and why they may `expect` a result.
648
+ ///
649
+ /// The flag carries no data, only the decision to stop, so `Relaxed` is all
650
+ /// the ordering it needs. What sets it is the Ruby extension's unblock
651
+ /// function, from whichever thread Ruby delivers the interrupt on — see
652
+ /// `ext/gigatoken/src/gvl.rs`.
653
+ fn cancelled(cancel: Option<&AtomicBool>) -> bool {
654
+ cancel.is_some_and(|flag| flag.load(Ordering::Relaxed))
655
+ }
656
+
657
+ /// What an uncancellable entry point `expect`s of the cancellable core.
658
+ const UNCANCELLABLE: &str = "a run with no cancellation token encodes every chunk";
659
+
624
660
  /// Encode all chunks with pooled workers and gather them into one flat id
625
661
  /// buffer plus per-document row counts — in parallel when there is more
626
- /// than one chunk, serially otherwise. Each worker's caches are pre-sized
662
+ /// than one chunk, serially otherwise. `None` means `cancel` was set part
663
+ /// way through: the chunks encoded so far are dropped, since a batch missing
664
+ /// its tail is nothing a caller can use. Each worker's caches are pre-sized
627
665
  /// for its share of `total_bytes` (capacity hints only — see
628
666
  /// `Tokenizer::fork_sized`; workers already forked on an earlier call keep
629
667
  /// their warm caches).
@@ -642,10 +680,11 @@ pub(crate) fn encode_chunks_gathered(
642
680
  proto: &Tokenizer,
643
681
  chunks: &[EncodeChunk],
644
682
  total_bytes: usize,
645
- ) -> (Vec<u32>, Vec<i64>) {
683
+ cancel: Option<&AtomicBool>,
684
+ ) -> Option<(Vec<u32>, Vec<i64>)> {
646
685
  // A token consumes >= 1 input byte, so total_bytes tokens is the
647
686
  // reservation bound (NFC expansion is caught by the overflow escape).
648
- encode_chunks_gathered_with_cap(workers, proto, chunks, total_bytes, total_bytes)
687
+ encode_chunks_gathered_with_cap(workers, proto, chunks, total_bytes, total_bytes, cancel)
649
688
  }
650
689
 
651
690
  /// `encode_chunks_gathered` with the committer's reservation bound passed
@@ -656,20 +695,23 @@ fn encode_chunks_gathered_with_cap(
656
695
  chunks: &[EncodeChunk],
657
696
  total_bytes: usize,
658
697
  cap_tokens: usize,
659
- ) -> (Vec<u32>, Vec<i64>) {
698
+ cancel: Option<&AtomicBool>,
699
+ ) -> Option<(Vec<u32>, Vec<i64>)> {
660
700
  let share = total_bytes / rayon::current_num_threads().max(1);
661
- let encode = |c: &EncodeChunk| workers.with_worker(proto, share, |tok| encode_chunk(tok, c));
701
+ let encode =
702
+ |c: &EncodeChunk| workers.with_worker(proto, share, |tok| encode_chunk(tok, c, cancel));
662
703
  if chunks.len() <= 1 {
663
704
  // Small inputs skip the thread fan-out — and a lone chunk's id
664
- // buffer IS the flat result, no gather copy at all.
665
- return match chunks.first() {
666
- Some(chunk) => {
667
- let out = encode(chunk);
668
- let counts = row_counts(std::slice::from_ref(&out));
669
- (out.ids, counts)
670
- }
671
- None => (Vec::new(), Vec::new()),
705
+ // buffer IS the flat result, no gather copy at all. `encode_chunk`
706
+ // polls the token per document, so even a lone chunk can come back
707
+ // short; report that as a cancelled run like every other path
708
+ // rather than returning the truncated prefix.
709
+ let Some(chunk) = chunks.first() else {
710
+ return Some((Vec::new(), Vec::new()));
672
711
  };
712
+ let out = encode(chunk);
713
+ let counts = row_counts(std::slice::from_ref(&out));
714
+ return (!cancelled(cancel)).then(|| (out.ids, counts));
673
715
  }
674
716
  let next = AtomicUsize::new(0);
675
717
  let outs: Vec<OnceLock<ChunkTokens>> = (0..chunks.len()).map(|_| OnceLock::new()).collect();
@@ -678,7 +720,10 @@ fn encode_chunks_gathered_with_cap(
678
720
  rayon::scope(|s| {
679
721
  for _ in 0..tasks {
680
722
  s.spawn(|_| {
681
- loop {
723
+ // A cancelled run is discarded whole, so a worker that sees
724
+ // the token set just stops claiming chunks — no drain, no
725
+ // commit, nothing to salvage.
726
+ while !cancelled(cancel) {
682
727
  let i = next.fetch_add(1, Ordering::Relaxed);
683
728
  let Some(chunk) = chunks.get(i) else {
684
729
  // One last opportunistic drain on the way out: this
@@ -699,20 +744,23 @@ fn encode_chunks_gathered_with_cap(
699
744
  });
700
745
  }
701
746
  });
747
+ if cancelled(cancel) {
748
+ return None;
749
+ }
702
750
  let outs: Vec<ChunkTokens> = outs
703
751
  .into_iter()
704
752
  .map(|slot| slot.into_inner().expect("every claimed chunk was encoded"))
705
753
  .collect();
706
754
  let counts = row_counts(&outs);
707
755
  let total: usize = outs.iter().map(|c| c.ids.len()).sum();
708
- match committer.and_then(|c| c.finish(&outs, total)) {
756
+ Some(match committer.and_then(|c| c.finish(&outs, total)) {
709
757
  Some(flat) => {
710
758
  // The copies are done; the spent chunk buffers are dead weight.
711
759
  defer_drop(outs);
712
760
  (flat, counts)
713
761
  }
714
762
  None => (gather_flat(outs), counts),
715
- }
763
+ })
716
764
  }
717
765
 
718
766
  /// `encode_chunks_gathered_with_cap`, but gathering directly into a
@@ -721,34 +769,41 @@ fn encode_chunks_gathered_with_cap(
721
769
  /// `encode_docs_into` and `ext/gigatoken/src/tokenizer.rs`). Every chunk is
722
770
  /// encoded either way, so an overrun `GatherOutcome::Fallback` carries the
723
771
  /// classic gathered result rather than asking the caller to re-run the
724
- /// encode.
772
+ /// encode. `None` means `cancel` was set part way through (see
773
+ /// `encode_chunks_gathered`), leaving `dest` partly written and unusable.
725
774
  pub(crate) fn encode_chunks_into(
726
775
  workers: &WorkerPool,
727
776
  proto: &Tokenizer,
728
777
  chunks: &[EncodeChunk],
729
778
  total_bytes: usize,
730
779
  dest: GatherBuf,
731
- ) -> GatherOutcome {
780
+ cancel: Option<&AtomicBool>,
781
+ ) -> Option<GatherOutcome> {
732
782
  let share = total_bytes / rayon::current_num_threads().max(1);
733
- let encode = |c: &EncodeChunk| workers.with_worker(proto, share, |tok| encode_chunk(tok, c));
783
+ let encode =
784
+ |c: &EncodeChunk| workers.with_worker(proto, share, |tok| encode_chunk(tok, c, cancel));
734
785
  if chunks.len() <= 1 {
735
- return match chunks.first() {
736
- Some(chunk) => {
737
- let out = encode(chunk);
738
- let counts = row_counts(std::slice::from_ref(&out));
739
- if out.ids.len() <= dest.cap {
740
- // SAFETY: the only chunk, so the only write; in bounds
741
- // per the check above.
742
- unsafe {
743
- std::ptr::copy_nonoverlapping(out.ids.as_ptr(), dest.ptr, out.ids.len());
744
- }
745
- GatherOutcome::Committed(out.ids.len(), counts)
746
- } else {
747
- GatherOutcome::Fallback(out.ids, counts)
748
- }
749
- }
750
- None => GatherOutcome::Committed(0, Vec::new()),
786
+ // A lone chunk can still be cancelled part way through (see
787
+ // `encode_chunks_gathered_with_cap`); `dest` is then left partly
788
+ // written, exactly as on the parallel path.
789
+ let Some(chunk) = chunks.first() else {
790
+ return Some(GatherOutcome::Committed(0, Vec::new()));
751
791
  };
792
+ let out = encode(chunk);
793
+ let counts = row_counts(std::slice::from_ref(&out));
794
+ if cancelled(cancel) {
795
+ return None;
796
+ }
797
+ return Some(if out.ids.len() <= dest.cap {
798
+ // SAFETY: the only chunk, so the only write; in bounds per the
799
+ // check above.
800
+ unsafe {
801
+ std::ptr::copy_nonoverlapping(out.ids.as_ptr(), dest.ptr, out.ids.len());
802
+ }
803
+ GatherOutcome::Committed(out.ids.len(), counts)
804
+ } else {
805
+ GatherOutcome::Fallback(out.ids, counts)
806
+ });
752
807
  }
753
808
  let next = AtomicUsize::new(0);
754
809
  let outs: Vec<OnceLock<ChunkTokens>> = (0..chunks.len()).map(|_| OnceLock::new()).collect();
@@ -757,7 +812,10 @@ pub(crate) fn encode_chunks_into(
757
812
  rayon::scope(|s| {
758
813
  for _ in 0..tasks {
759
814
  s.spawn(|_| {
760
- loop {
815
+ // Stop claiming chunks once cancelled (see
816
+ // `encode_chunks_gathered_with_cap`); the destination is
817
+ // left half-written for the caller to discard or reuse.
818
+ while !cancelled(cancel) {
761
819
  let i = next.fetch_add(1, Ordering::Relaxed);
762
820
  let Some(chunk) = chunks.get(i) else {
763
821
  // One last opportunistic drain on the way out: this
@@ -774,19 +832,22 @@ pub(crate) fn encode_chunks_into(
774
832
  });
775
833
  }
776
834
  });
835
+ if cancelled(cancel) {
836
+ return None;
837
+ }
777
838
  let outs: Vec<ChunkTokens> = outs
778
839
  .into_iter()
779
840
  .map(|slot| slot.into_inner().expect("every claimed chunk was encoded"))
780
841
  .collect();
781
842
  let counts = row_counts(&outs);
782
843
  let total: usize = outs.iter().map(|c| c.ids.len()).sum();
783
- if committer.finish_external(&outs, total) {
844
+ Some(if committer.finish_external(&outs, total) {
784
845
  // The copies are done; the spent chunk buffers are dead weight.
785
846
  defer_drop(outs);
786
847
  GatherOutcome::Committed(total, counts)
787
848
  } else {
788
849
  GatherOutcome::Fallback(gather_flat(outs), counts)
789
- }
850
+ })
790
851
  }
791
852
 
792
853
  /// Merge per-chunk outputs into one flat id buffer and per-document row
@@ -922,6 +983,59 @@ impl WorkerPool {
922
983
  });
923
984
  f(guard.get_or_insert_with(|| proto.fork_sized(expected_bytes)))
924
985
  }
986
+
987
+ /// `encode_docs_ragged` with a cancellation token: the same encode, plus
988
+ /// a flag polled at chunk granularity, and `None` when it was set part
989
+ /// way through (see `encode_chunks_gathered`).
990
+ ///
991
+ /// This and the three below are methods rather than free functions like
992
+ /// their uncancellable twins: the crate's free-function set is the
993
+ /// surface the pyo3 bindings and the Rust benches are written against,
994
+ /// and the pool is the receiver either way. They are what the Ruby
995
+ /// extension calls, so that an interrupt cancels a batch instead of
996
+ /// waiting it out (`ext/gigatoken/src/gvl.rs`).
997
+ pub fn encode_docs_ragged_cancellable(
998
+ &self,
999
+ proto: &Tokenizer,
1000
+ docs: &[&[u8]],
1001
+ cancel: &AtomicBool,
1002
+ ) -> Option<(Vec<u32>, Vec<i64>)> {
1003
+ encode_docs_ragged_with(self, proto, docs, lpt_from_env(), Some(cancel))
1004
+ }
1005
+
1006
+ /// `encode_docs_into` with a cancellation token.
1007
+ pub fn encode_docs_into_cancellable(
1008
+ &self,
1009
+ proto: &Tokenizer,
1010
+ docs: &[&[u8]],
1011
+ dest: GatherBuf,
1012
+ cancel: &AtomicBool,
1013
+ ) -> Option<GatherOutcome> {
1014
+ encode_docs_into_with(self, proto, docs, dest, Some(cancel))
1015
+ }
1016
+
1017
+ /// `encode_files_docs` with a cancellation token.
1018
+ pub fn encode_files_docs_cancellable(
1019
+ &self,
1020
+ proto: &Tokenizer,
1021
+ files: &[&[u8]],
1022
+ format: &DocFormat,
1023
+ cancel: &AtomicBool,
1024
+ ) -> Option<(Vec<u32>, Vec<i64>)> {
1025
+ encode_files_docs_with(self, proto, files, format, Some(cancel))
1026
+ }
1027
+
1028
+ /// `encode_files_docs_serial` with a cancellation token, polled per
1029
+ /// document.
1030
+ pub fn encode_files_docs_serial_cancellable(
1031
+ &self,
1032
+ proto: &Tokenizer,
1033
+ files: &[&[u8]],
1034
+ format: &DocFormat,
1035
+ cancel: &AtomicBool,
1036
+ ) -> Option<(Vec<u32>, Vec<i64>)> {
1037
+ encode_files_docs_serial_with(self, proto, files, format, Some(cancel))
1038
+ }
925
1039
  }
926
1040
 
927
1041
  /// Shared core of encode_batch / encode_files for pre-resolved document
@@ -939,7 +1053,7 @@ pub fn encode_docs_ragged(
939
1053
  proto: &Tokenizer,
940
1054
  docs: &[&[u8]],
941
1055
  ) -> (Vec<u32>, Vec<i64>) {
942
- encode_docs_ragged_with(workers, proto, docs, lpt_from_env())
1056
+ encode_docs_ragged_with(workers, proto, docs, lpt_from_env(), None).expect(UNCANCELLABLE)
943
1057
  }
944
1058
 
945
1059
  /// `encode_docs_ragged` with the LPT switch passed explicitly instead of
@@ -950,11 +1064,12 @@ pub(crate) fn encode_docs_ragged_with(
950
1064
  proto: &Tokenizer,
951
1065
  docs: &[&[u8]],
952
1066
  lpt: bool,
953
- ) -> (Vec<u32>, Vec<i64>) {
1067
+ cancel: Option<&AtomicBool>,
1068
+ ) -> Option<(Vec<u32>, Vec<i64>)> {
954
1069
  let total: usize = docs.iter().map(|d| d.len()).sum();
955
1070
  let added = proto.added_token_split_blockers();
956
1071
  let chunks = build_doc_chunks(docs, total, chunk_target_bytes(total), &added, lpt);
957
- encode_chunks_gathered(workers, proto, &chunks, total)
1072
+ encode_chunks_gathered(workers, proto, &chunks, total, cancel)
958
1073
  }
959
1074
 
960
1075
  /// `encode_docs_ragged`, but gathering directly into a caller-supplied
@@ -969,10 +1084,21 @@ pub fn encode_docs_into(
969
1084
  docs: &[&[u8]],
970
1085
  dest: GatherBuf,
971
1086
  ) -> GatherOutcome {
1087
+ encode_docs_into_with(workers, proto, docs, dest, None).expect(UNCANCELLABLE)
1088
+ }
1089
+
1090
+ /// `encode_docs_into` with a cancellation token (see `encode_chunks_gathered`).
1091
+ fn encode_docs_into_with(
1092
+ workers: &WorkerPool,
1093
+ proto: &Tokenizer,
1094
+ docs: &[&[u8]],
1095
+ dest: GatherBuf,
1096
+ cancel: Option<&AtomicBool>,
1097
+ ) -> Option<GatherOutcome> {
972
1098
  let total: usize = docs.iter().map(|d| d.len()).sum();
973
1099
  let added = proto.added_token_split_blockers();
974
1100
  let chunks = build_doc_chunks(docs, total, chunk_target_bytes(total), &added, lpt_from_env());
975
- encode_chunks_into(workers, proto, &chunks, total, dest)
1101
+ encode_chunks_into(workers, proto, &chunks, total, dest, cancel)
976
1102
  }
977
1103
 
978
1104
  /// Sequential `encode_docs_ragged`: encode every document in order on the
@@ -1136,8 +1262,19 @@ pub fn encode_files_docs(
1136
1262
  files: &[&[u8]],
1137
1263
  format: &DocFormat,
1138
1264
  ) -> (Vec<u32>, Vec<i64>) {
1265
+ encode_files_docs_with(workers, proto, files, format, None).expect(UNCANCELLABLE)
1266
+ }
1267
+
1268
+ /// `encode_files_docs` with a cancellation token (see `encode_chunks_gathered`).
1269
+ fn encode_files_docs_with(
1270
+ workers: &WorkerPool,
1271
+ proto: &Tokenizer,
1272
+ files: &[&[u8]],
1273
+ format: &DocFormat,
1274
+ cancel: Option<&AtomicBool>,
1275
+ ) -> Option<(Vec<u32>, Vec<i64>)> {
1139
1276
  if matches!(format, DocFormat::Text { separator: None }) {
1140
- return encode_docs_ragged(workers, proto, files);
1277
+ return encode_docs_ragged_with(workers, proto, files, lpt_from_env(), cancel);
1141
1278
  }
1142
1279
  let total: usize = files.iter().map(|f| f.len()).sum();
1143
1280
  let target = chunk_target_bytes(total);
@@ -1152,7 +1289,7 @@ pub fn encode_files_docs(
1152
1289
  })
1153
1290
  })
1154
1291
  .collect();
1155
- encode_chunks_gathered(workers, proto, &chunks, total)
1292
+ encode_chunks_gathered(workers, proto, &chunks, total, cancel)
1156
1293
  }
1157
1294
 
1158
1295
  /// Sequential `encode_files_docs`: extract and encode every document in
@@ -1165,6 +1302,19 @@ pub fn encode_files_docs_serial(
1165
1302
  files: &[&[u8]],
1166
1303
  format: &DocFormat,
1167
1304
  ) -> (Vec<u32>, Vec<i64>) {
1305
+ encode_files_docs_serial_with(workers, proto, files, format, None).expect(UNCANCELLABLE)
1306
+ }
1307
+
1308
+ /// `encode_files_docs_serial` with a cancellation token, polled per
1309
+ /// document — this path has no chunks to stop between (see
1310
+ /// `encode_chunks_gathered`).
1311
+ fn encode_files_docs_serial_with(
1312
+ workers: &WorkerPool,
1313
+ proto: &Tokenizer,
1314
+ files: &[&[u8]],
1315
+ format: &DocFormat,
1316
+ cancel: Option<&AtomicBool>,
1317
+ ) -> Option<(Vec<u32>, Vec<i64>)> {
1168
1318
  let total: usize = files.iter().map(|f| f.len()).sum();
1169
1319
  workers.with_serial_worker(proto, total, |tok| {
1170
1320
  let mut ids: Vec<u32> = Vec::with_capacity(total / 4 + 16);
@@ -1173,9 +1323,16 @@ pub fn encode_files_docs_serial(
1173
1323
  madvise_hugepage(ids.as_mut_ptr() as *mut u8, ids.capacity() * 4);
1174
1324
  let mut lens = Vec::new();
1175
1325
  for &bytes in files {
1176
- for_each_doc(bytes, format, |doc| encode_into(tok, doc, &mut ids, &mut lens));
1326
+ for_each_doc(bytes, format, |doc| {
1327
+ // Once cancelled the result is discarded, so stop encoding;
1328
+ // the walk itself is a cheap scan and runs out rather than
1329
+ // threading a break through `for_each_doc`'s callback.
1330
+ if !cancelled(cancel) {
1331
+ encode_into(tok, doc, &mut ids, &mut lens);
1332
+ }
1333
+ });
1177
1334
  }
1178
- (ids, lens)
1335
+ (!cancelled(cancel)).then_some((ids, lens))
1179
1336
  })
1180
1337
  }
1181
1338
 
@@ -1306,7 +1463,8 @@ mod tests {
1306
1463
  // A fresh pool per shape so each run exercises the pre-sized
1307
1464
  // fork (slots fork lazily on first use).
1308
1465
  let workers = WorkerPool::new();
1309
- let (flat, lens) = encode_docs_ragged_with(&workers, &proto, &docs, lpt);
1466
+ let (flat, lens) =
1467
+ encode_docs_ragged_with(&workers, &proto, &docs, lpt, None).expect(UNCANCELLABLE);
1310
1468
  assert_eq!(lens, lens_ref, "lens mismatch (lpt={lpt})");
1311
1469
  assert_eq!(flat, ids_ref, "ids mismatch (lpt={lpt})");
1312
1470
  }
@@ -1673,7 +1831,8 @@ mod tests {
1673
1831
 
1674
1832
  for lpt in [true, false] {
1675
1833
  let workers = WorkerPool::new();
1676
- let (flat, lens) = encode_docs_ragged_with(&workers, &proto, &docs, lpt);
1834
+ let (flat, lens) =
1835
+ encode_docs_ragged_with(&workers, &proto, &docs, lpt, None).expect(UNCANCELLABLE);
1677
1836
  assert_eq!(lens, lens_ref, "lens mismatch (lpt={lpt})");
1678
1837
  if flat != ids_ref {
1679
1838
  let i = ids_ref
@@ -1723,14 +1882,16 @@ mod tests {
1723
1882
  assert!(chunks.len() > 1, "test must exercise the parallel path");
1724
1883
 
1725
1884
  let workers = WorkerPool::new();
1726
- let (flat_ref, lens_ref) = encode_chunks_gathered(&workers, &proto, &chunks, total);
1885
+ let (flat_ref, lens_ref) =
1886
+ encode_chunks_gathered(&workers, &proto, &chunks, total, None).expect(UNCANCELLABLE);
1727
1887
  // Byte-level vocab: one token per byte, so any cap below `total`
1728
1888
  // overflows; total / 3 overflows mid-flight with a committed
1729
1889
  // prefix behind it.
1730
1890
  for cap in [0, 1, total / 3] {
1731
1891
  let workers = WorkerPool::new();
1732
1892
  let (flat, lens) =
1733
- encode_chunks_gathered_with_cap(&workers, &proto, &chunks, total, cap);
1893
+ encode_chunks_gathered_with_cap(&workers, &proto, &chunks, total, cap, None)
1894
+ .expect(UNCANCELLABLE);
1734
1895
  assert_eq!(lens, lens_ref, "lens mismatch (cap={cap})");
1735
1896
  assert_eq!(flat, flat_ref, "ids mismatch (cap={cap})");
1736
1897
  }
@@ -1767,7 +1928,8 @@ mod tests {
1767
1928
  assert!(chunks.len() > 1, "test must exercise the parallel path");
1768
1929
 
1769
1930
  let workers = WorkerPool::new();
1770
- let (flat_ref, lens_ref) = encode_chunks_gathered(&workers, &proto, &chunks, total);
1931
+ let (flat_ref, lens_ref) =
1932
+ encode_chunks_gathered(&workers, &proto, &chunks, total, None).expect(UNCANCELLABLE);
1771
1933
 
1772
1934
  // A full-size destination: every chunk commits straight into it.
1773
1935
  let mut dest_buf = vec![0u32; total];
@@ -1775,7 +1937,8 @@ mod tests {
1775
1937
  // SAFETY: `dest_buf` is `total` tokens, exclusively owned for the
1776
1938
  // duration of this call.
1777
1939
  let dest = unsafe { GatherBuf::new(dest_buf.as_mut_ptr(), total) };
1778
- match encode_chunks_into(&workers, &proto, &chunks, total, dest) {
1940
+ let gathered = encode_chunks_into(&workers, &proto, &chunks, total, dest, None);
1941
+ match gathered.expect(UNCANCELLABLE) {
1779
1942
  GatherOutcome::Committed(n, lens) => {
1780
1943
  assert_eq!(lens, lens_ref, "lens mismatch (committed)");
1781
1944
  assert_eq!(&dest_buf[..n], &flat_ref[..], "ids mismatch (committed)");
@@ -1792,7 +1955,8 @@ mod tests {
1792
1955
  // SAFETY: `small_buf` is `cap` tokens, exclusively owned for the
1793
1956
  // duration of this call.
1794
1957
  let dest = unsafe { GatherBuf::new(small_buf.as_mut_ptr(), cap) };
1795
- match encode_chunks_into(&workers, &proto, &chunks, total, dest) {
1958
+ let gathered = encode_chunks_into(&workers, &proto, &chunks, total, dest, None);
1959
+ match gathered.expect(UNCANCELLABLE) {
1796
1960
  GatherOutcome::Fallback(flat, lens) => {
1797
1961
  assert_eq!(lens, lens_ref, "lens mismatch (fallback)");
1798
1962
  assert_eq!(flat, flat_ref, "ids mismatch (fallback)");
@@ -1800,4 +1964,78 @@ mod tests {
1800
1964
  GatherOutcome::Committed(..) => panic!("expected a too-small dest to overflow"),
1801
1965
  }
1802
1966
  }
1967
+
1968
+ /// Every entry point the Ruby extension cancels must report a cancelled
1969
+ /// run rather than hand back a batch missing its tail — and, with the
1970
+ /// token clear, must be identical to its uncancellable twin.
1971
+ #[test]
1972
+ fn cancelled_runs_return_nothing() {
1973
+ let merges = HashMap::with_hasher(rustc_hash::FxBuildHasher {});
1974
+ let vocab = (0..=u8::MAX).map(|b| vec![b]).collect();
1975
+ let proto = Tokenizer::new(merges, vocab, None);
1976
+ // Eight chunk-sized documents, so the parallel paths really do have
1977
+ // chunk boundaries to stop at (the lone-chunk shape every small
1978
+ // input takes is `cancelled_lone_chunk_returns_nothing`).
1979
+ let owned: Vec<Vec<u8>> = (0..8).map(|_| vec![b'a'; MIN_CHUNK_BYTES]).collect();
1980
+ let docs: Vec<&[u8]> = owned.iter().map(|d| d.as_slice()).collect();
1981
+ let total: usize = docs.iter().map(|d| d.len()).sum();
1982
+ let format = DocFormat::Text { separator: None };
1983
+ let workers = WorkerPool::new();
1984
+
1985
+ let reference = encode_docs_ragged(&workers, &proto, &docs);
1986
+ let cancel = AtomicBool::new(false);
1987
+ let uncancelled = [
1988
+ workers.encode_docs_ragged_cancellable(&proto, &docs, &cancel),
1989
+ workers.encode_files_docs_cancellable(&proto, &docs, &format, &cancel),
1990
+ workers.encode_files_docs_serial_cancellable(&proto, &docs, &format, &cancel),
1991
+ ];
1992
+ for got in &uncancelled {
1993
+ assert!(got.as_ref() == Some(&reference), "uncancelled run differs");
1994
+ }
1995
+
1996
+ cancel.store(true, Ordering::Relaxed);
1997
+ let mut buf = vec![0u32; total];
1998
+ // SAFETY: `buf` holds `total` tokens and is exclusively owned here.
1999
+ let dest = unsafe { GatherBuf::new(buf.as_mut_ptr(), total) };
2000
+ let ragged = workers.encode_docs_ragged_cancellable(&proto, &docs, &cancel);
2001
+ let into = workers.encode_docs_into_cancellable(&proto, &docs, dest, &cancel);
2002
+ let files = workers.encode_files_docs_cancellable(&proto, &docs, &format, &cancel);
2003
+ let serial = workers.encode_files_docs_serial_cancellable(&proto, &docs, &format, &cancel);
2004
+ assert!(ragged.is_none() && into.is_none() && files.is_none() && serial.is_none());
2005
+ }
2006
+
2007
+ /// The same contract for an input small enough to be one chunk — the
2008
+ /// shape every sub-MiB batch takes, and the one the cancellable entry
2009
+ /// points used to answer with a truncated result (I01 G1). The token is
2010
+ /// polled per document inside the chunk, so the run really does stop
2011
+ /// short; what it must not do is report that prefix as the batch.
2012
+ #[test]
2013
+ fn cancelled_lone_chunk_returns_nothing() {
2014
+ let merges = HashMap::with_hasher(rustc_hash::FxBuildHasher {});
2015
+ let vocab = (0..=u8::MAX).map(|b| vec![b]).collect();
2016
+ let proto = Tokenizer::new(merges, vocab, None);
2017
+ let owned: Vec<Vec<u8>> = (0..64).map(|i| vec![b'a' + (i % 26) as u8; 512]).collect();
2018
+ let docs: Vec<&[u8]> = owned.iter().map(|d| d.as_slice()).collect();
2019
+ let total: usize = docs.iter().map(|d| d.len()).sum();
2020
+ assert!(total < MIN_CHUNK_BYTES, "the input must be a single chunk");
2021
+ let format = DocFormat::Text { separator: None };
2022
+ let workers = WorkerPool::new();
2023
+
2024
+ let cancel = AtomicBool::new(true);
2025
+ let mut buf = vec![0u32; total];
2026
+ // SAFETY: `buf` holds `total` tokens and is exclusively owned here.
2027
+ let dest = unsafe { GatherBuf::new(buf.as_mut_ptr(), total) };
2028
+ let ragged = workers.encode_docs_ragged_cancellable(&proto, &docs, &cancel);
2029
+ let into = workers.encode_docs_into_cancellable(&proto, &docs, dest, &cancel);
2030
+ let files = workers.encode_files_docs_cancellable(&proto, &docs, &format, &cancel);
2031
+ let serial = workers.encode_files_docs_serial_cancellable(&proto, &docs, &format, &cancel);
2032
+ assert!(ragged.is_none() && into.is_none() && files.is_none() && serial.is_none());
2033
+
2034
+ // With the token clear, the same lone chunk is the uncancellable
2035
+ // path's result exactly.
2036
+ let cancel = AtomicBool::new(false);
2037
+ let reference = encode_docs_ragged(&workers, &proto, &docs);
2038
+ let got = workers.encode_docs_ragged_cancellable(&proto, &docs, &cancel);
2039
+ assert!(got.as_ref() == Some(&reference), "lone chunk differs");
2040
+ }
1803
2041
  }
data/src/bpe/tiktoken.rs CHANGED
@@ -12,7 +12,7 @@ use crate::pretokenize::{
12
12
  Pretoken, PretokenSpans, PretokenizerType, SpanBatch, pack_pretoken_key, pretoken_key_hash,
13
13
  };
14
14
  use crate::token::TokenId;
15
- use eyre::Result;
15
+ use eyre::{Result, ensure, eyre};
16
16
  use std::collections::HashMap;
17
17
  use std::fmt::{Debug, Formatter};
18
18
  use std::sync::Arc;
@@ -708,6 +708,12 @@ impl Tokenizer {
708
708
  /// merges map and returns a Tokenizer.
709
709
  ///
710
710
  /// This process is necessary to load some tokenizers found in tiktoken.
711
+ ///
712
+ /// A rank file is untrusted input, so the two shapes that make the
713
+ /// reconstruction impossible are errors rather than panics: a multi-byte
714
+ /// token whose bytes have no single-byte tokens to build from, and one
715
+ /// that does not reduce to a pair of tokens already seen (the merge it
716
+ /// would record has no two operands).
711
717
  pub fn from_ranks(vocab: Vec<Vec<u8>>) -> Result<Self> {
712
718
  let mut merges: HashMap<(TokenId, TokenId), TokenId, rustc_hash::FxBuildHasher> =
713
719
  HashMap::with_hasher(rustc_hash::FxBuildHasher {});
@@ -727,10 +733,21 @@ impl Tokenizer {
727
733
  }
728
734
  let byte_symbols: Vec<u8> = token_bytes
729
735
  .iter()
730
- .map(|b| vocab_inv.get(std::slice::from_ref(b)).unwrap().0 as u8)
731
- .collect();
736
+ .map(|b| {
737
+ vocab_inv
738
+ .get(std::slice::from_ref(b))
739
+ .map(|id| id.0 as u8)
740
+ .ok_or_else(|| {
741
+ eyre!("rank {token_idx}: no single-byte token for byte {b:#04x}")
742
+ })
743
+ })
744
+ .collect::<Result<_>>()?;
732
745
  let tokenized = simple_bpe_merge(&merges, &byte_symbols);
733
- assert_eq!(tokenized.len(), 2);
746
+ ensure!(
747
+ tokenized.len() == 2,
748
+ "rank {token_idx}: reduces to {} tokens, not a mergeable pair",
749
+ tokenized.len()
750
+ );
734
751
  merges.insert((tokenized[0], tokenized[1]), TokenId::from(token_idx));
735
752
  }
736
753
 
@@ -1694,9 +1711,17 @@ impl Tokenizer {
1694
1711
  }
1695
1712
  }
1696
1713
 
1714
+ /// Concatenate the vocabulary entries of `v`. An id at or past
1715
+ /// [`Self::vocab_size`] contributes nothing rather than indexing out of
1716
+ /// bounds — a boundary that accepts ids from outside (the Ruby
1717
+ /// extension's `decode`) rejects them before getting here, and this is
1718
+ /// what keeps an unchecked one from taking the process down with it.
1697
1719
  pub fn decode(&self, v: &[TokenId]) -> impl Iterator<Item = u8> {
1698
1720
  v.iter()
1699
- .flat_map(|&token| self.vocab[token.0 as usize].as_ref())
1721
+ .flat_map(|&token| match self.vocab.get(token.0 as usize) {
1722
+ Some(bytes) => bytes.as_ref(),
1723
+ None => &[],
1724
+ })
1700
1725
  .copied()
1701
1726
  }
1702
1727