gigatoken 0.1.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.
- checksums.yaml +7 -0
- data/Cargo.lock +3016 -0
- data/Cargo.toml +135 -0
- data/LICENSE +21 -0
- data/README.md +141 -0
- data/exe/gigatoken +9 -0
- data/ext/gigatoken/Cargo.toml +24 -0
- data/ext/gigatoken/extconf.rb +19 -0
- data/ext/gigatoken/src/error.rs +20 -0
- data/ext/gigatoken/src/gvl.rs +122 -0
- data/ext/gigatoken/src/lib.rs +50 -0
- data/ext/gigatoken/src/sentencepiece.rs +205 -0
- data/ext/gigatoken/src/sources.rs +207 -0
- data/ext/gigatoken/src/tokenizer.rs +571 -0
- data/lib/gigatoken/cli/bench.rb +64 -0
- data/lib/gigatoken/cli/support.rb +132 -0
- data/lib/gigatoken/cli/validate.rb +55 -0
- data/lib/gigatoken/cli.rb +18 -0
- data/lib/gigatoken/hub.rb +242 -0
- data/lib/gigatoken/packed_result.rb +48 -0
- data/lib/gigatoken/tokenizer.rb +117 -0
- data/lib/gigatoken/version.rb +5 -0
- data/lib/gigatoken.rb +29 -0
- data/rust-toolchain.toml +8 -0
- data/src/batch.rs +1808 -0
- data/src/bindings/bridge.rs +396 -0
- data/src/bindings/hub.rs +42 -0
- data/src/bindings/matcher.rs +114 -0
- data/src/bindings/mod.rs +14 -0
- data/src/bindings/padding.rs +177 -0
- data/src/bindings/pretokenize.rs +53 -0
- data/src/bindings/sources.rs +273 -0
- data/src/bindings/train.rs +125 -0
- data/src/bpe/mod.rs +1217 -0
- data/src/bpe/pretoken_cache.rs +495 -0
- data/src/bpe/sentencepiece.rs +1485 -0
- data/src/bpe/tiktoken.rs +2555 -0
- data/src/bpe_train.rs +351 -0
- data/src/input/decompress.rs +11 -0
- data/src/input/file_source.rs +514 -0
- data/src/input/jsonl.rs +94 -0
- data/src/input/mod.rs +333 -0
- data/src/input/parquet.rs +303 -0
- data/src/lib.rs +578 -0
- data/src/load_tokenizer/hf.rs +1036 -0
- data/src/load_tokenizer/hub.rs +344 -0
- data/src/load_tokenizer/mod.rs +3 -0
- data/src/load_tokenizer/tiktoken.rs +87 -0
- data/src/main.rs +95 -0
- data/src/pretokenize/fast/cl100k.rs +426 -0
- data/src/pretokenize/fast/cl100k_family.rs +891 -0
- data/src/pretokenize/fast/deepseek_v3.rs +605 -0
- data/src/pretokenize/fast/kimi.rs +281 -0
- data/src/pretokenize/fast/mask.rs +1486 -0
- data/src/pretokenize/fast/mod.rs +446 -0
- data/src/pretokenize/fast/nemotron.rs +138 -0
- data/src/pretokenize/fast/o200k.rs +347 -0
- data/src/pretokenize/fast/o200k_family.rs +1734 -0
- data/src/pretokenize/fast/olmo3.rs +505 -0
- data/src/pretokenize/fast/qwen2.rs +429 -0
- data/src/pretokenize/fast/qwen3_5.rs +541 -0
- data/src/pretokenize/fast/r50k.rs +1250 -0
- data/src/pretokenize/mod.rs +1079 -0
- data/src/pretokenize/options.rs +188 -0
- data/src/pretokenize/pretoken.rs +20 -0
- data/src/pretokenize/pretokenize_traits.rs +49 -0
- data/src/pretokenize/reference/avx512.rs +522 -0
- data/src/pretokenize/reference/combinator.rs +572 -0
- data/src/pretokenize/reference/mod.rs +28 -0
- data/src/pretokenize/reference/simd.rs +852 -0
- data/src/pretokenize/reference/state_machine.rs +365 -0
- data/src/pretokenize/unicode.rs +546 -0
- data/src/test_hub.rs +28 -0
- data/src/token.rs +42 -0
- metadata +161 -0
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
use std::collections::HashMap;
|
|
2
|
+
use std::path::{Path, PathBuf};
|
|
3
|
+
|
|
4
|
+
use rayon::prelude::*;
|
|
5
|
+
use rustc_hash::FxBuildHasher;
|
|
6
|
+
|
|
7
|
+
use crate::input::decompress;
|
|
8
|
+
use crate::input::jsonl::{JsonLinesReader, JsonLinesSlice};
|
|
9
|
+
use crate::input::MmappedFile;
|
|
10
|
+
use crate::input::Resource;
|
|
11
|
+
use crate::pretokenize::{pretokenize_as_iter, pretokenize_par_bytes};
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// File format detection: compression and content format are independent
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
#[derive(Debug, Clone, Copy)]
|
|
18
|
+
pub(crate) enum Compression {
|
|
19
|
+
None,
|
|
20
|
+
Gzip,
|
|
21
|
+
Zstd,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#[derive(Debug, Clone, Copy)]
|
|
25
|
+
pub enum ContentFormat {
|
|
26
|
+
PlainText,
|
|
27
|
+
Jsonl,
|
|
28
|
+
Parquet,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// Strip compression extension and detect compression type.
|
|
32
|
+
/// Returns (stem without compression ext, compression).
|
|
33
|
+
fn detect_compression(name: &str) -> (&str, Compression) {
|
|
34
|
+
if let Some(stem) = name.strip_suffix(".zst").or_else(|| name.strip_suffix(".zstd")) {
|
|
35
|
+
(stem, Compression::Zstd)
|
|
36
|
+
} else if let Some(stem) = name.strip_suffix(".gz") {
|
|
37
|
+
(stem, Compression::Gzip)
|
|
38
|
+
} else {
|
|
39
|
+
(name, Compression::None)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/// Detect content format from the (uncompressed) filename stem.
|
|
44
|
+
fn detect_content_format(stem: &str) -> ContentFormat {
|
|
45
|
+
if stem.ends_with(".jsonl") {
|
|
46
|
+
ContentFormat::Jsonl
|
|
47
|
+
} else if stem.ends_with(".parquet") {
|
|
48
|
+
ContentFormat::Parquet
|
|
49
|
+
} else {
|
|
50
|
+
ContentFormat::PlainText
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
fn detect_format(path: &Path) -> (ContentFormat, Compression) {
|
|
55
|
+
let name = path
|
|
56
|
+
.file_name()
|
|
57
|
+
.and_then(|n| n.to_str())
|
|
58
|
+
.unwrap_or("");
|
|
59
|
+
let (stem, compression) = detect_compression(name);
|
|
60
|
+
let content = detect_content_format(stem);
|
|
61
|
+
(content, compression)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// DocFormat: how a file's bytes split into documents
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
/// How to split a file's bytes into documents. Carried by the Python
|
|
69
|
+
/// `TextFileSource` / `JsonlFileSource` classes; compression is orthogonal
|
|
70
|
+
/// and always detected from the file extension.
|
|
71
|
+
#[derive(Debug, Clone)]
|
|
72
|
+
pub enum DocFormat {
|
|
73
|
+
/// Plain text. With a separator, documents are the pieces between
|
|
74
|
+
/// occurrences; without one, the whole file is a single document.
|
|
75
|
+
Text { separator: Option<Vec<u8>> },
|
|
76
|
+
/// JSON Lines: one document per line, text taken from `field`.
|
|
77
|
+
Jsonl { field: String },
|
|
78
|
+
/// Parquet: one document per row, text taken from `column` (a string or
|
|
79
|
+
/// binary column; null rows become empty documents). Unlike the byte-
|
|
80
|
+
/// stream formats, parquet files are materialized into owned documents
|
|
81
|
+
/// up front (see `input::parquet`) and never reach the byte-region
|
|
82
|
+
/// chunking machinery.
|
|
83
|
+
Parquet { column: String },
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
impl DocFormat {
|
|
87
|
+
/// Separator to split text documents on. Empty means "whole input is one
|
|
88
|
+
/// document", matching `DocumentIter`/`SeparatorReader` semantics.
|
|
89
|
+
fn separator(&self) -> &[u8] {
|
|
90
|
+
match self {
|
|
91
|
+
DocFormat::Text { separator } => separator.as_deref().unwrap_or(b""),
|
|
92
|
+
DocFormat::Jsonl { .. } | DocFormat::Parquet { .. } => b"",
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/// Default format for a bare path: JSONL (field "text") if the uncompressed
|
|
98
|
+
/// name ends in .jsonl, parquet (column "text") if it ends in .parquet,
|
|
99
|
+
/// otherwise plain text with the whole file as one document.
|
|
100
|
+
pub fn detect_default_format(path: &Path) -> DocFormat {
|
|
101
|
+
match detect_format(path).0 {
|
|
102
|
+
ContentFormat::Jsonl => DocFormat::Jsonl {
|
|
103
|
+
field: "text".to_string(),
|
|
104
|
+
},
|
|
105
|
+
ContentFormat::Parquet => DocFormat::Parquet {
|
|
106
|
+
column: "text".to_string(),
|
|
107
|
+
},
|
|
108
|
+
ContentFormat::PlainText => DocFormat::Text { separator: None },
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Loading and chunking files for parallel encoding
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
/// A file's full contents: mmapped when stored uncompressed, otherwise
|
|
117
|
+
/// decompressed into memory (parallel chunking needs random access).
|
|
118
|
+
pub enum LoadedFile {
|
|
119
|
+
Mmapped(MmappedFile),
|
|
120
|
+
Owned(Vec<u8>),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
impl LoadedFile {
|
|
124
|
+
pub fn as_bytes(&self) -> &[u8] {
|
|
125
|
+
match self {
|
|
126
|
+
LoadedFile::Mmapped(m) => m.as_bytes(),
|
|
127
|
+
LoadedFile::Owned(v) => v,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/// Open a file for encoding: mmap if uncompressed, else decompress fully.
|
|
133
|
+
pub fn load_file(path: &Path) -> Result<LoadedFile, std::io::Error> {
|
|
134
|
+
use std::io::Read;
|
|
135
|
+
let (_, compression) = detect_format(path);
|
|
136
|
+
Ok(match compression {
|
|
137
|
+
Compression::None => LoadedFile::Mmapped(MmappedFile::open(path)?),
|
|
138
|
+
Compression::Gzip => {
|
|
139
|
+
let mut buf = Vec::new();
|
|
140
|
+
decompress::open_gzip(path)?.read_to_end(&mut buf)?;
|
|
141
|
+
LoadedFile::Owned(buf)
|
|
142
|
+
}
|
|
143
|
+
Compression::Zstd => {
|
|
144
|
+
let mut buf = Vec::new();
|
|
145
|
+
decompress::open_zstd(path)?.read_to_end(&mut buf)?;
|
|
146
|
+
LoadedFile::Owned(buf)
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/// Cut `bytes` into ranges of roughly `target` bytes, each ending on a
|
|
152
|
+
/// document boundary so no document spans two chunks. A single range is
|
|
153
|
+
/// returned when the input is smaller than `target` or has no boundaries
|
|
154
|
+
/// (plain text without a separator is one document, which cannot be split
|
|
155
|
+
/// without changing tokenization).
|
|
156
|
+
pub fn chunk_ranges(
|
|
157
|
+
bytes: &[u8],
|
|
158
|
+
format: &DocFormat,
|
|
159
|
+
target: usize,
|
|
160
|
+
) -> Vec<std::ops::Range<usize>> {
|
|
161
|
+
let len = bytes.len();
|
|
162
|
+
// `next_boundary(probe)` finds the first document boundary at or after
|
|
163
|
+
// `probe` and returns (chunk_end, next_chunk_start).
|
|
164
|
+
let cut = |next_boundary: &dyn Fn(usize) -> Option<(usize, usize)>| {
|
|
165
|
+
let mut out = Vec::new();
|
|
166
|
+
let mut start = 0;
|
|
167
|
+
while start < len {
|
|
168
|
+
let probe = start + target;
|
|
169
|
+
match (probe < len).then(|| next_boundary(probe)).flatten() {
|
|
170
|
+
Some((end, next_start)) => {
|
|
171
|
+
out.push(start..end);
|
|
172
|
+
start = next_start;
|
|
173
|
+
}
|
|
174
|
+
None => {
|
|
175
|
+
out.push(start..len);
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if out.is_empty() {
|
|
181
|
+
out.push(0..0); // empty file: one empty chunk, so files stay 1:1
|
|
182
|
+
}
|
|
183
|
+
out
|
|
184
|
+
};
|
|
185
|
+
match format {
|
|
186
|
+
DocFormat::Jsonl { .. } => cut(&|probe| {
|
|
187
|
+
memchr::memchr(b'\n', &bytes[probe..]).map(|off| (probe + off + 1, probe + off + 1))
|
|
188
|
+
}),
|
|
189
|
+
DocFormat::Text { separator: Some(sep) } if !sep.is_empty() => {
|
|
190
|
+
let finder = memchr::memmem::Finder::new(sep);
|
|
191
|
+
cut(&|probe| {
|
|
192
|
+
finder
|
|
193
|
+
.find(&bytes[probe..])
|
|
194
|
+
.map(|off| (probe + off, probe + off + sep.len()))
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
// One chunk spanning the whole file (a single Range element, not 0..len values).
|
|
198
|
+
DocFormat::Text { .. } => std::iter::once(0..len).collect(),
|
|
199
|
+
// Parquet documents are materialized before any byte-region chunking
|
|
200
|
+
// (encode_files_ragged and pretokenize_file branch on the format
|
|
201
|
+
// first), so parquet bytes must never arrive here.
|
|
202
|
+
DocFormat::Parquet { .. } => {
|
|
203
|
+
unreachable!("parquet files are materialized into documents before chunking")
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// Per-file processing
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
fn pretokenize_plain_text_bytes(
|
|
213
|
+
bytes: &[u8],
|
|
214
|
+
separator: &[u8],
|
|
215
|
+
) -> HashMap<Vec<u8>, usize, FxBuildHasher> {
|
|
216
|
+
let borrowed_counts = pretokenize_par_bytes(bytes, separator);
|
|
217
|
+
borrowed_counts
|
|
218
|
+
.into_iter()
|
|
219
|
+
.map(|(k, v)| (k.as_ref().to_vec(), v))
|
|
220
|
+
.collect()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/// Parallel JSONL pretokenization on a memory-mapped byte slice.
|
|
224
|
+
/// Splits at newline boundaries into N chunks, each chunk processes its
|
|
225
|
+
/// JSONL lines independently.
|
|
226
|
+
fn pretokenize_jsonl_par(
|
|
227
|
+
bytes: &[u8],
|
|
228
|
+
field: &str,
|
|
229
|
+
) -> HashMap<Vec<u8>, usize, FxBuildHasher> {
|
|
230
|
+
let n_threads = rayon::current_num_threads();
|
|
231
|
+
if bytes.is_empty() {
|
|
232
|
+
return HashMap::default();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let boundaries = jsonl_chunk_boundaries(bytes, n_threads);
|
|
236
|
+
|
|
237
|
+
boundaries
|
|
238
|
+
.par_windows(2)
|
|
239
|
+
.map(|w| {
|
|
240
|
+
let chunk = &bytes[w[0]..w[1]];
|
|
241
|
+
let mut counts: HashMap<Vec<u8>, usize, FxBuildHasher> = HashMap::default();
|
|
242
|
+
for doc in JsonLinesSlice::new(chunk, field) {
|
|
243
|
+
for pretoken in pretokenize_as_iter(doc.as_ref()) {
|
|
244
|
+
*counts.entry(pretoken.as_ref().to_vec()).or_default() += 1;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
counts
|
|
248
|
+
})
|
|
249
|
+
.reduce(HashMap::default, |mut acc, counts| {
|
|
250
|
+
if acc.is_empty() {
|
|
251
|
+
return counts;
|
|
252
|
+
}
|
|
253
|
+
for (k, v) in counts {
|
|
254
|
+
*acc.entry(k).or_default() += v;
|
|
255
|
+
}
|
|
256
|
+
acc
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/// Pretokenize documents from a streaming reader.
|
|
261
|
+
/// For JSONL: each line is a document (field extracted from JSON).
|
|
262
|
+
/// For plain text: documents are split on `separator`.
|
|
263
|
+
/// Never buffers the entire decompressed file.
|
|
264
|
+
fn pretokenize_streaming(
|
|
265
|
+
reader: impl std::io::BufRead,
|
|
266
|
+
format: &DocFormat,
|
|
267
|
+
) -> HashMap<Vec<u8>, usize, FxBuildHasher> {
|
|
268
|
+
let mut counts: HashMap<Vec<u8>, usize, FxBuildHasher> = HashMap::default();
|
|
269
|
+
let mut count_pretokens = |doc: &[u8]| {
|
|
270
|
+
for pretoken in pretokenize_as_iter(doc) {
|
|
271
|
+
*counts.entry(pretoken.as_ref().to_vec()).or_default() += 1;
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
match format {
|
|
276
|
+
DocFormat::Jsonl { field } => {
|
|
277
|
+
for doc in JsonLinesReader::new(reader, field) {
|
|
278
|
+
count_pretokens(doc.as_ref());
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
DocFormat::Text { .. } => {
|
|
282
|
+
for doc in SeparatorReader::new(reader, format.separator()) {
|
|
283
|
+
count_pretokens(&doc);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// pretokenize_file handles parquet before any streaming/compression
|
|
287
|
+
// path (parquet compresses internally).
|
|
288
|
+
DocFormat::Parquet { .. } => {
|
|
289
|
+
unreachable!("parquet files are pretokenized by input::parquet, not streamed")
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
counts
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
fn pretokenize_file(
|
|
296
|
+
path: &Path,
|
|
297
|
+
format: &DocFormat,
|
|
298
|
+
compression: Compression,
|
|
299
|
+
) -> Result<HashMap<Vec<u8>, usize, FxBuildHasher>, std::io::Error> {
|
|
300
|
+
eprintln!("Processing {:?} ({:?}, {:?})", path, format, compression);
|
|
301
|
+
|
|
302
|
+
// Parquet handles its own compression and parallelism (per row group);
|
|
303
|
+
// it never goes through the mmap/streaming byte paths below.
|
|
304
|
+
if let DocFormat::Parquet { column } = format {
|
|
305
|
+
return crate::input::parquet::pretokenize_par(path, column);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Uncompressed files: memory-map for parallel processing
|
|
309
|
+
if matches!(compression, Compression::None) {
|
|
310
|
+
let resource = MmappedFile::open(path)?;
|
|
311
|
+
return Ok(match format {
|
|
312
|
+
DocFormat::Text { .. } => {
|
|
313
|
+
pretokenize_plain_text_bytes(resource.as_bytes(), format.separator())
|
|
314
|
+
}
|
|
315
|
+
DocFormat::Jsonl { field } => pretokenize_jsonl_par(resource.as_bytes(), field),
|
|
316
|
+
DocFormat::Parquet { .. } => unreachable!("handled above"),
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Compressed files: stream from reader (never fully in memory)
|
|
321
|
+
Ok(match compression {
|
|
322
|
+
Compression::None => unreachable!(),
|
|
323
|
+
Compression::Gzip => pretokenize_streaming(decompress::open_gzip(path)?, format),
|
|
324
|
+
Compression::Zstd => pretokenize_streaming(decompress::open_zstd(path)?, format),
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
// SeparatorReader: streaming document splitter for plain text
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
/// Reads from a `BufRead` and yields documents split on a byte separator.
|
|
333
|
+
/// Each `next()` returns one document (the bytes between two separators).
|
|
334
|
+
/// Buffers only the current document, not the entire input.
|
|
335
|
+
struct SeparatorReader<R> {
|
|
336
|
+
reader: R,
|
|
337
|
+
separator: Vec<u8>,
|
|
338
|
+
buf: Vec<u8>,
|
|
339
|
+
finished: bool,
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
impl<R: std::io::BufRead> SeparatorReader<R> {
|
|
343
|
+
fn new(reader: R, separator: &[u8]) -> Self {
|
|
344
|
+
Self {
|
|
345
|
+
reader,
|
|
346
|
+
separator: separator.to_vec(),
|
|
347
|
+
buf: Vec::with_capacity(4096),
|
|
348
|
+
finished: false,
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
impl<R: std::io::BufRead> Iterator for SeparatorReader<R> {
|
|
354
|
+
type Item = Vec<u8>;
|
|
355
|
+
|
|
356
|
+
fn next(&mut self) -> Option<Vec<u8>> {
|
|
357
|
+
if self.finished {
|
|
358
|
+
return None;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Empty separator: yield all remaining bytes as one document
|
|
362
|
+
if self.separator.is_empty() {
|
|
363
|
+
self.finished = true;
|
|
364
|
+
let mut all = Vec::new();
|
|
365
|
+
self.reader.read_to_end(&mut all).ok()?;
|
|
366
|
+
return if all.is_empty() { None } else { Some(all) };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
let finder = memchr::memmem::Finder::new(&self.separator);
|
|
370
|
+
self.buf.clear();
|
|
371
|
+
|
|
372
|
+
loop {
|
|
373
|
+
let available = match self.reader.fill_buf() {
|
|
374
|
+
Ok([]) => {
|
|
375
|
+
// EOF
|
|
376
|
+
self.finished = true;
|
|
377
|
+
return if self.buf.is_empty() {
|
|
378
|
+
None
|
|
379
|
+
} else {
|
|
380
|
+
Some(std::mem::take(&mut self.buf))
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
Ok(buf) => buf,
|
|
384
|
+
Err(_) => {
|
|
385
|
+
self.finished = true;
|
|
386
|
+
return None;
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// Search for separator in: [tail of buf where separator could span] + available
|
|
391
|
+
// We need to handle the case where the separator spans the boundary between
|
|
392
|
+
// self.buf and the new `available` data.
|
|
393
|
+
let overlap = self.separator.len().saturating_sub(1).min(self.buf.len());
|
|
394
|
+
let search_start = self.buf.len() - overlap;
|
|
395
|
+
|
|
396
|
+
// Append available to buf, then search from search_start
|
|
397
|
+
self.buf.extend_from_slice(available);
|
|
398
|
+
let consumed = available.len();
|
|
399
|
+
self.reader.consume(consumed);
|
|
400
|
+
|
|
401
|
+
if let Some(pos) = finder.find(&self.buf[search_start..]) {
|
|
402
|
+
let sep_start = search_start + pos;
|
|
403
|
+
let doc = self.buf[..sep_start].to_vec();
|
|
404
|
+
// Keep everything after the separator for the next call
|
|
405
|
+
let remainder_start = sep_start + self.separator.len();
|
|
406
|
+
let remainder = self.buf[remainder_start..].to_vec();
|
|
407
|
+
self.buf = remainder;
|
|
408
|
+
|
|
409
|
+
// Skip empty documents
|
|
410
|
+
if doc.is_empty() {
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
return Some(doc);
|
|
414
|
+
}
|
|
415
|
+
// No separator found yet — continue reading
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
// FileSourceSpec — multi-file parallel pretokenization
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
pub struct FileSourceSpec {
|
|
425
|
+
pub paths: Vec<PathBuf>,
|
|
426
|
+
pub format: DocFormat,
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/// Newline-aligned chunk boundaries for parallel JSONL processing.
|
|
430
|
+
fn jsonl_chunk_boundaries(bytes: &[u8], n_chunks: usize) -> Vec<usize> {
|
|
431
|
+
let mut boundaries = Vec::with_capacity(n_chunks + 1);
|
|
432
|
+
boundaries.push(0usize);
|
|
433
|
+
let chunk_size = bytes.len() / n_chunks;
|
|
434
|
+
for i in 1..n_chunks {
|
|
435
|
+
let target = i * chunk_size;
|
|
436
|
+
match memchr::memchr(b'\n', &bytes[target..]) {
|
|
437
|
+
Some(offset) => boundaries.push(target + offset + 1),
|
|
438
|
+
None => break,
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
boundaries.push(bytes.len());
|
|
442
|
+
boundaries.dedup();
|
|
443
|
+
boundaries
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
impl FileSourceSpec {
|
|
447
|
+
pub fn pretokenize(&self) -> Result<HashMap<Vec<u8>, usize, FxBuildHasher>, std::io::Error> {
|
|
448
|
+
eprintln!(
|
|
449
|
+
"FileSource: processing {} files across {} threads",
|
|
450
|
+
self.paths.len(),
|
|
451
|
+
rayon::current_num_threads()
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
self.paths
|
|
455
|
+
.par_iter()
|
|
456
|
+
.map(|path| {
|
|
457
|
+
let (_, compression) = detect_format(path);
|
|
458
|
+
pretokenize_file(path, &self.format, compression)
|
|
459
|
+
})
|
|
460
|
+
.try_reduce(HashMap::default, |mut acc, counts| {
|
|
461
|
+
if acc.is_empty() {
|
|
462
|
+
return Ok(counts);
|
|
463
|
+
}
|
|
464
|
+
for (k, v) in counts {
|
|
465
|
+
*acc.entry(k).or_default() += v;
|
|
466
|
+
}
|
|
467
|
+
Ok(acc)
|
|
468
|
+
})
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
#[cfg(test)]
|
|
473
|
+
mod tests {
|
|
474
|
+
use super::*;
|
|
475
|
+
|
|
476
|
+
#[test]
|
|
477
|
+
fn test_detect_compression() {
|
|
478
|
+
assert!(matches!(detect_compression("data.jsonl.zst"), (_, Compression::Zstd)));
|
|
479
|
+
assert!(matches!(detect_compression("data.jsonl.zstd"), (_, Compression::Zstd)));
|
|
480
|
+
assert!(matches!(detect_compression("data.txt.gz"), (_, Compression::Gzip)));
|
|
481
|
+
assert!(matches!(detect_compression("data.jsonl"), (_, Compression::None)));
|
|
482
|
+
assert!(matches!(detect_compression("data.txt"), (_, Compression::None)));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
#[test]
|
|
486
|
+
fn test_detect_compression_strips_ext() {
|
|
487
|
+
assert_eq!(detect_compression("data.jsonl.zst").0, "data.jsonl");
|
|
488
|
+
assert_eq!(detect_compression("data.jsonl.zstd").0, "data.jsonl");
|
|
489
|
+
assert_eq!(detect_compression("data.txt.gz").0, "data.txt");
|
|
490
|
+
assert_eq!(detect_compression("data.jsonl.gz").0, "data.jsonl");
|
|
491
|
+
assert_eq!(detect_compression("data.txt").0, "data.txt");
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
#[test]
|
|
495
|
+
fn test_detect_format_combinations() {
|
|
496
|
+
// jsonl + compression
|
|
497
|
+
assert!(matches!(detect_format(Path::new("data.jsonl.zst")), (ContentFormat::Jsonl, Compression::Zstd)));
|
|
498
|
+
assert!(matches!(detect_format(Path::new("data.jsonl.zstd")), (ContentFormat::Jsonl, Compression::Zstd)));
|
|
499
|
+
assert!(matches!(detect_format(Path::new("data.jsonl.gz")), (ContentFormat::Jsonl, Compression::Gzip)));
|
|
500
|
+
assert!(matches!(detect_format(Path::new("data.jsonl")), (ContentFormat::Jsonl, Compression::None)));
|
|
501
|
+
|
|
502
|
+
// txt + compression
|
|
503
|
+
assert!(matches!(detect_format(Path::new("data.txt.zst")), (ContentFormat::PlainText, Compression::Zstd)));
|
|
504
|
+
assert!(matches!(detect_format(Path::new("data.txt.gz")), (ContentFormat::PlainText, Compression::Gzip)));
|
|
505
|
+
assert!(matches!(detect_format(Path::new("data.txt")), (ContentFormat::PlainText, Compression::None)));
|
|
506
|
+
|
|
507
|
+
// bare compression extension → plain text (unknown inner format)
|
|
508
|
+
assert!(matches!(detect_format(Path::new("data.zst")), (ContentFormat::PlainText, Compression::Zstd)));
|
|
509
|
+
assert!(matches!(detect_format(Path::new("data.gz")), (ContentFormat::PlainText, Compression::Gzip)));
|
|
510
|
+
|
|
511
|
+
// unknown → plain text, no compression
|
|
512
|
+
assert!(matches!(detect_format(Path::new("data.csv")), (ContentFormat::PlainText, Compression::None)));
|
|
513
|
+
}
|
|
514
|
+
}
|
data/src/input/jsonl.rs
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
use std::io::BufRead;
|
|
2
|
+
|
|
3
|
+
use crate::input::Document;
|
|
4
|
+
use sonic_rs::JsonValueTrait;
|
|
5
|
+
|
|
6
|
+
/// Zero-copy JSONL iterator over a byte slice (e.g. from mmap).
|
|
7
|
+
/// Used for parallel chunking of uncompressed JSONL files.
|
|
8
|
+
pub struct JsonLinesSlice<'a> {
|
|
9
|
+
slice: &'a [u8],
|
|
10
|
+
position: usize,
|
|
11
|
+
field: &'a str,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
impl<'a> JsonLinesSlice<'a> {
|
|
15
|
+
pub fn new(slice: &'a [u8], field: &'a str) -> Self {
|
|
16
|
+
Self {
|
|
17
|
+
slice,
|
|
18
|
+
position: 0,
|
|
19
|
+
field,
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
impl<'a> Iterator for JsonLinesSlice<'a> {
|
|
25
|
+
type Item = Document<'static>; // Owned: JSON parsing requires extraction
|
|
26
|
+
|
|
27
|
+
fn next(&mut self) -> Option<Self::Item> {
|
|
28
|
+
loop {
|
|
29
|
+
// Skip newlines
|
|
30
|
+
while self.position < self.slice.len() && self.slice[self.position] == b'\n' {
|
|
31
|
+
self.position += 1;
|
|
32
|
+
}
|
|
33
|
+
if self.position >= self.slice.len() {
|
|
34
|
+
return None;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let line_end = memchr::memchr(b'\n', &self.slice[self.position..])
|
|
38
|
+
.map(|i| self.position + i)
|
|
39
|
+
.unwrap_or(self.slice.len());
|
|
40
|
+
let line = &self.slice[self.position..line_end];
|
|
41
|
+
self.position = line_end + 1;
|
|
42
|
+
|
|
43
|
+
if line.is_empty() {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let value = sonic_rs::get_from_slice(line, &[self.field]).ok()?;
|
|
48
|
+
let text = value.as_str()?;
|
|
49
|
+
return Some(Document::from(text.as_bytes().to_vec()));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// Streaming JSONL iterator over a `BufRead` source.
|
|
55
|
+
/// Reads one line at a time — never buffers the entire file.
|
|
56
|
+
pub(crate) struct JsonLinesReader<R> {
|
|
57
|
+
reader: R,
|
|
58
|
+
field: String,
|
|
59
|
+
line_buf: Vec<u8>,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
impl<R: BufRead> JsonLinesReader<R> {
|
|
63
|
+
pub(crate) fn new(reader: R, field: &str) -> Self {
|
|
64
|
+
Self {
|
|
65
|
+
reader,
|
|
66
|
+
field: field.to_string(),
|
|
67
|
+
line_buf: Vec::with_capacity(4096),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
impl<R: BufRead> Iterator for JsonLinesReader<R> {
|
|
73
|
+
type Item = Document<'static>;
|
|
74
|
+
|
|
75
|
+
fn next(&mut self) -> Option<Self::Item> {
|
|
76
|
+
loop {
|
|
77
|
+
self.line_buf.clear();
|
|
78
|
+
let bytes_read = self.reader.read_until(b'\n', &mut self.line_buf).ok()?;
|
|
79
|
+
if bytes_read == 0 {
|
|
80
|
+
return None; // EOF
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let line = self.line_buf.as_slice();
|
|
84
|
+
// Skip empty lines
|
|
85
|
+
if line.iter().all(|&b| b == b'\n' || b == b'\r') {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let value = sonic_rs::get_from_slice(line, &[self.field.as_str()]).ok()?;
|
|
90
|
+
let text = value.as_str()?;
|
|
91
|
+
return Some(Document::from(text.as_bytes().to_vec()));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|