Phase-Technologies commited on
Commit
ca6b633
Β·
verified Β·
1 Parent(s): 78387fe

Add tokenizer component: streaming_tokenizer.py

Browse files
Files changed (1) hide show
  1. tokenizer/streaming_tokenizer.py +288 -0
tokenizer/streaming_tokenizer.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FORGE-3B Streaming Tokenizer.
3
+
4
+ A thin wrapper around ForgeTokenizer that streams a large corpus file and
5
+ writes tokenized output to disk as memory-mapped numpy arrays.
6
+
7
+ Designed for preprocessing pipelines where the full corpus does not fit in RAM.
8
+ Processes documents lazily in chunks, writing each completed shard atomically.
9
+
10
+ Usage:
11
+ python -m tokenizer.streaming_tokenizer \\
12
+ --input /data/raw/fineweb_edu \\
13
+ --output /data/tokenized/fineweb_edu \\
14
+ --profile standard \\
15
+ --seq_len 2048 \\
16
+ --shard_size 100000
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import json
23
+ import time
24
+ import logging
25
+ import argparse
26
+ from pathlib import Path
27
+ from typing import Iterator, List
28
+
29
+ import numpy as np
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ _BOS = 1
34
+ _EOS = 2
35
+ _PAD = 0
36
+ _DTYPE = np.uint32
37
+
38
+
39
+ # ─────────────────────────────────────────────────────────────────────────────
40
+ # Streaming Tokenizer
41
+ # ─────────────────────────────────────────────────────────────────────────────
42
+
43
+ class StreamingTokenizer:
44
+ """
45
+ Tokenizes a corpus of JSONL files in a streaming fashion, packing
46
+ tokens into fixed-length sequences and writing them to numbered .npy shards.
47
+
48
+ Key properties:
49
+ - O(shard_size * seq_len * 4 bytes) peak RAM usage β€” stream-safe
50
+ - Atomic shard writes (temp file β†’ rename)
51
+ - Resumable: detects and skips existing shards
52
+ - Thread-safe per instance (no shared state)
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ tokenizer,
58
+ output_dir: str,
59
+ seq_len: int = 2048,
60
+ shard_size: int = 100_000,
61
+ split: str = "train",
62
+ ):
63
+ self.tok = tokenizer
64
+ self.seq_len = seq_len
65
+ self.shard_size = shard_size
66
+ self.split = split
67
+
68
+ self._out_dir = Path(output_dir)
69
+ self._out_dir.mkdir(parents=True, exist_ok=True)
70
+
71
+ # Rolling packed-token buffer
72
+ self._buffer: List[int] = []
73
+
74
+ # Shard accumulation buffer
75
+ self._shard_seqs: List[np.ndarray] = []
76
+ self._shard_idx = self._detect_next_shard()
77
+
78
+ # Stats
79
+ self._n_docs = 0
80
+ self._n_tokens = 0
81
+ self._n_seqs = 0
82
+ self._t0 = time.perf_counter()
83
+
84
+ # ── Internal helpers ──────────────────────────────────────────────────────
85
+
86
+ def _detect_next_shard(self) -> int:
87
+ """Skip past shards that already exist on disk (resumable run)."""
88
+ existing = sorted(self._out_dir.glob(f"{self.split}_shard_*.npy"))
89
+ if not existing:
90
+ return 0
91
+ idx = int(existing[-1].stem.split("_")[-1])
92
+ logger.info(f"StreamingTokenizer: resuming after shard {idx}")
93
+ return idx + 1
94
+
95
+ def _shard_path(self, idx: int) -> Path:
96
+ return self._out_dir / f"{self.split}_shard_{idx:04d}.npy"
97
+
98
+ def _flush_shard(self):
99
+ """Write the shard buffer to disk atomically via temp→rename."""
100
+ if not self._shard_seqs:
101
+ return
102
+ arr = np.stack(self._shard_seqs, axis=0) # (N, seq_len) uint32
103
+ path = self._shard_path(self._shard_idx)
104
+ tmp = path.with_suffix(".tmp.npy")
105
+ np.save(str(tmp), arr)
106
+ tmp.rename(path)
107
+ logger.info(
108
+ f" Shard {self._shard_idx:04d}: {len(arr)} seqs β†’ {path.name} "
109
+ f"({arr.nbytes / 1e6:.1f} MB)"
110
+ )
111
+ self._shard_idx += 1
112
+ self._shard_seqs = []
113
+
114
+ def _drain_buffer(self):
115
+ """Carve complete sequences from the rolling buffer."""
116
+ while len(self._buffer) >= self.seq_len:
117
+ seq = np.array(self._buffer[: self.seq_len], dtype=_DTYPE)
118
+ self._buffer = self._buffer[self.seq_len :]
119
+ self._shard_seqs.append(seq)
120
+ self._n_seqs += 1
121
+
122
+ if len(self._shard_seqs) >= self.shard_size:
123
+ self._flush_shard()
124
+
125
+ # ── Public API ────────────────────────────────────────────────────────────
126
+
127
+ def add_document(self, text: str):
128
+ """Tokenize one text document and pack its tokens into the buffer."""
129
+ try:
130
+ token_ids = self.tok.encode(
131
+ text,
132
+ add_bos=False,
133
+ add_eos=False,
134
+ truncate=False,
135
+ )
136
+ except Exception as exc:
137
+ logger.warning(f"Tokenization failed: {exc} β€” skipping document")
138
+ return
139
+
140
+ if not token_ids:
141
+ return
142
+
143
+ # BOS + tokens + EOS
144
+ self._buffer.extend([_BOS] + list(token_ids) + [_EOS])
145
+ self._n_tokens += len(token_ids) + 2
146
+ self._n_docs += 1
147
+ self._drain_buffer()
148
+
149
+ def add_token_array(self, token_ids: np.ndarray):
150
+ """Add a pre-tokenized uint32 array directly (bypasses the tokenizer)."""
151
+ self._buffer.extend([_BOS] + token_ids.tolist() + [_EOS])
152
+ self._n_tokens += len(token_ids) + 2
153
+ self._n_docs += 1
154
+ self._drain_buffer()
155
+
156
+ def finalize(self) -> dict:
157
+ """
158
+ Flush all remaining sequences (partial last shard) and write metadata.
159
+ Must be called exactly once after all documents have been added.
160
+ Returns a summary dict.
161
+ """
162
+ # Drain any remaining full sequences
163
+ if len(self._buffer) >= self.seq_len:
164
+ self._drain_buffer()
165
+
166
+ # Write partial shard if there is anything left
167
+ if self._shard_seqs:
168
+ self._flush_shard()
169
+
170
+ elapsed = time.perf_counter() - self._t0
171
+ speed = self._n_tokens / max(elapsed, 1e-6)
172
+
173
+ meta = {
174
+ "n_documents": self._n_docs,
175
+ "n_tokens": self._n_tokens,
176
+ "n_sequences": self._n_seqs,
177
+ "seq_len": self.seq_len,
178
+ "shard_size": self.shard_size,
179
+ "n_shards": self._shard_idx,
180
+ "elapsed_s": round(elapsed, 1),
181
+ "tokens_per_sec": int(speed),
182
+ }
183
+
184
+ meta_path = self._out_dir / f"{self.split}_streaming_meta.json"
185
+ with open(str(meta_path), "w") as f:
186
+ json.dump(meta, f, indent=2)
187
+
188
+ logger.info(
189
+ f"StreamingTokenizer finalized: "
190
+ f"{self._n_seqs:,} seqs | "
191
+ f"{self._n_tokens / 1e9:.2f}B tokens | "
192
+ f"{speed / 1e6:.1f}M tok/s | "
193
+ f"{elapsed:.1f}s"
194
+ )
195
+ return meta
196
+
197
+
198
+ # ─────────────────────────────────────────────────────────────────────────────
199
+ # Corpus Iterator
200
+ # ─────────────────────────────────────────────────────────────────────────────
201
+
202
+ def _iter_texts(input_path: Path, text_key: str = "text") -> Iterator[str]:
203
+ """Yield text strings from all JSONL / plain-text files under input_path."""
204
+ files: list = []
205
+ for ext in (".jsonl", ".json", ".txt"):
206
+ files.extend(sorted(input_path.rglob(f"*{ext}")))
207
+
208
+ if not files:
209
+ raise FileNotFoundError(f"No JSONL/txt files found under {input_path}")
210
+
211
+ logger.info(f"_iter_texts: found {len(files)} files")
212
+
213
+ for path in files:
214
+ try:
215
+ if path.suffix == ".txt":
216
+ text = path.read_text(encoding="utf-8", errors="replace").strip()
217
+ if text:
218
+ yield text
219
+ else:
220
+ with open(str(path), encoding="utf-8", errors="replace") as fh:
221
+ for line in fh:
222
+ line = line.strip()
223
+ if not line:
224
+ continue
225
+ try:
226
+ obj = json.loads(line)
227
+ text = obj.get(text_key, "")
228
+ if text:
229
+ yield text
230
+ except json.JSONDecodeError:
231
+ continue
232
+ except OSError as exc:
233
+ logger.warning(f"Cannot read {path}: {exc}")
234
+
235
+
236
+ # ─────────────────────────────────────────────────────────────────────────────
237
+ # CLI Entry Point
238
+ # ─────────────────────────────────────────────────────────────────────────────
239
+
240
+ if __name__ == "__main__":
241
+ logging.basicConfig(
242
+ level=logging.INFO,
243
+ format="[%(asctime)s][%(levelname)s] %(message)s",
244
+ datefmt="%H:%M:%S",
245
+ )
246
+
247
+ parser = argparse.ArgumentParser(
248
+ description="FORGE-3B Streaming Tokenizer",
249
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
250
+ )
251
+ parser.add_argument("--input", required=True, help="Input dir with JSONL/txt files")
252
+ parser.add_argument("--output", required=True, help="Output dir for .npy shards")
253
+ parser.add_argument("--profile", default="standard", choices=["standard", "lite"])
254
+ parser.add_argument("--seq_len", type=int, default=2048)
255
+ parser.add_argument("--shard_size", type=int, default=100_000)
256
+ parser.add_argument("--split", default="train")
257
+ parser.add_argument("--text_key", default="text")
258
+ parser.add_argument("--n_workers", type=int,
259
+ default=min(8, os.cpu_count() or 1))
260
+ args = parser.parse_args()
261
+
262
+ from tokenizer.crayon_wrapper import ForgeTokenizer
263
+
264
+ tok = ForgeTokenizer(
265
+ profile=args.profile,
266
+ device="cpu",
267
+ n_workers=args.n_workers,
268
+ max_length=args.seq_len * 8,
269
+ )
270
+
271
+ streamer = StreamingTokenizer(
272
+ tokenizer=tok,
273
+ output_dir=args.output,
274
+ seq_len=args.seq_len,
275
+ shard_size=args.shard_size,
276
+ split=args.split,
277
+ )
278
+
279
+ input_path = Path(args.input)
280
+ n = 0
281
+ for text in _iter_texts(input_path, text_key=args.text_key):
282
+ streamer.add_document(text)
283
+ n += 1
284
+ if n % 100_000 == 0:
285
+ logger.info(f"Processed {n:,} documents...")
286
+
287
+ meta = streamer.finalize()
288
+ print(json.dumps(meta, indent=2))