Coverage for / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / data / tools / offline_preparation.py: 0%
417 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-10-25 05:45 +0800
« prev ^ index » next coverage.py v7.13.1, created at 2026-10-25 05:45 +0800
1# Copyright 2026 Huawei Technologies Co., Ltd
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14# ============================================================================
15# modified from https://github.com/NVIDIA/Megatron-LM/blob/main/tools/preprocess_data.py
17"""Processing large data for pretraining."""
19import argparse
20import glob
21import gzip
22import json
23import logging
24import math
25import multiprocessing
26import os
27import time
28import traceback
29import warnings
30from pathlib import Path
31from typing import Any
33import numpy as np
34from transformers import AutoTokenizer
36try:
37 import nltk
38 from nltk.tokenize.punkt import PunktLanguageVars
39except ImportError:
40 PunktLanguageVars = object
41 NLTK_AVAILABLE = False
42else:
43 NLTK_AVAILABLE = True
45# Store generated samples in the indexed ``.bin/.idx`` format.
46from hyper_parallel.data.tools import io as indexed_dataset
47from hyper_parallel.data.dataset_logging import get_dataset_logger
49logger = get_dataset_logger(__name__)
51logger = logging.getLogger(__name__)
54class CustomLanguageVars(PunktLanguageVars):
55 """Preserve newline runs when Punkt detects sentence boundaries."""
57 _period_context_fmt = r"""
58 \S*
59 %(SentEndChars)s
60 \s*
61 (?=(?P<after_tok>
62 %(NonWord)s
63 |
64 (?P<next_tok>\S+)
65 ))"""
68def build_tokenizer(args: argparse.Namespace) -> Any:
69 """Build the tokenizer configured for offline preprocessing."""
70 warnings.warn(
71 "Only Hugging Face tokenizers loaded through AutoTokenizer are currently supported; custom tokenizer "
72 "classes are not supported.",
73 UserWarning,
74 stacklevel=2,
75 )
76 return build_huggingface_tokenizer(args)
79def build_huggingface_tokenizer(args: argparse.Namespace) -> Any:
80 """Build a Hugging Face tokenizer from command-line arguments."""
81 tokenizer_path = args.tokenizer_name_or_path
82 if not tokenizer_path:
83 raise ValueError("tokenizer_name_or_path must be provided for HuggingFaceTokenizer")
85 tokenizer_kwargs = {
86 "use_fast": args.tokenizer_use_fast,
87 "trust_remote_code": args.trust_remote_code,
88 }
90 tokenizer = AutoTokenizer.from_pretrained(
91 tokenizer_path,
92 **tokenizer_kwargs,
93 )
95 if args.chat_template:
96 tokenizer.chat_template = args.chat_template
98 if args.add_special_tokens:
99 tokenizer.add_special_tokens(
100 {
101 "additional_special_tokens": args.add_special_tokens,
102 }
103 )
105 return tokenizer
108def append_eod(args: argparse.Namespace, tokenizer: Any) -> int | None:
109 """Resolve the document-ending token requested for preprocessing."""
110 if not args.append_eod:
111 return None
112 eod_id = getattr(tokenizer, "eos_token_id", None)
113 if eod_id is None:
114 # Prefer EOS as the document boundary and fall back to SEP for tokenizers
115 # that do not define an EOS token.
116 eod_id = getattr(tokenizer, "sep_token_id", None)
117 if eod_id is None:
118 raise ValueError("append_eod requires the tokenizer to define eos_token_id or sep_token_id")
119 return int(eod_id)
122class Encoder:
123 """Encode JSON text fields into token ids and sentence lengths."""
125 def __init__(self, args: argparse.Namespace) -> None:
126 """Store the preprocessing arguments shared with worker processes.
128 Args:
129 args: Parsed offline preprocessing arguments.
130 """
131 self.args = args
133 def initializer(self) -> None:
134 """Build the tokenizer and sentence splitter in each worker process."""
135 # Use Encoder class as a container for global data
136 Encoder.tokenizer = build_tokenizer(self.args)
137 if self.args.split_sentences:
138 if not NLTK_AVAILABLE:
139 raise ImportError("NLTK is required when --split-sentences is enabled")
140 if os.environ.get("NLTK_DATA"):
141 library = os.path.join(
142 os.environ["NLTK_DATA"],
143 "tokenizers",
144 "punkt",
145 f"{self.args.lang}.pickle",
146 )
147 splitter = nltk.load(f"file:{library}")
148 else:
149 splitter = nltk.load(f"tokenizers/punkt/{self.args.lang}.pickle")
150 if self.args.keep_newlines:
151 Encoder.splitter = nltk.tokenize.punkt.PunktSentenceTokenizer(
152 train_text=splitter._params, # pylint: disable=protected-access
153 lang_vars=CustomLanguageVars(),
154 )
155 else:
156 Encoder.splitter = splitter
158 def split(self, json_line: str) -> tuple[str, int]:
159 """Split configured JSON text fields into sentence lists."""
160 data = json.loads(json_line)
161 output = {}
162 max_chunk_length = 1_000_000
163 for key in self.args.json_keys:
164 text = data[key]
165 sentences_by_chunk = [
166 Encoder.splitter.tokenize(text[start : start + max_chunk_length])
167 for start in range(0, len(text), max_chunk_length)
168 ]
169 output[key] = [sentence for chunk in sentences_by_chunk for sentence in chunk]
170 return json.dumps(output), len(json_line)
172 def encode(
173 self,
174 json_line: str,
175 ) -> tuple[dict[str, list[int]], dict[str, list[int]], int]:
176 """Tokenize one JSON record into per-key token ids and sentence lengths.
178 Args:
179 json_line: One serialized JSON record.
181 Returns:
182 Per-key token ids, per-key sentence lengths, and the input byte length.
183 """
184 data = json.loads(json_line)
185 ids = {}
186 lens = {}
187 keys = self.args.json_keys
188 for key in keys:
189 text = data[key]
190 if isinstance(text, list):
191 sentences = text
192 else:
193 sentences = [text]
194 doc_ids = []
195 sentence_lens = []
196 for sentence in sentences:
197 sentence_ids = Encoder.tokenizer.encode(sentence, add_special_tokens=False)
198 if len(sentence_ids) > 0:
199 doc_ids.extend(sentence_ids)
200 sentence_lens.append(len(sentence_ids))
201 eod = append_eod(self.args, Encoder.tokenizer)
202 if len(doc_ids) > 0 and eod is not None:
203 doc_ids.append(eod)
204 sentence_lens[-1] += 1
205 ids[key] = doc_ids
206 lens[key] = sentence_lens
207 return ids, lens, len(json_line)
210class Partition:
211 """Process one dataset partition with a pool of worker processes."""
213 def __init__(self, args: argparse.Namespace, workers: int) -> None:
214 """Store the partition configuration.
216 Args:
217 args: Parsed offline preprocessing arguments.
218 workers: Number of worker processes used by this partition.
219 """
220 self.args = args
221 self.workers = workers
222 self.performance: list[float] = []
224 def print_processing_stats(
225 self,
226 count: int,
227 proc_start: float,
228 total_bytes_processed: int,
229 ) -> None:
230 """Report encoding throughput at the configured log interval.
232 Args:
233 count: Number of documents processed so far.
234 proc_start: Timestamp when processing started.
235 total_bytes_processed: Total input bytes processed so far.
236 """
237 if count % self.args.log_interval != 0:
238 return
239 elapsed = time.time() - proc_start
240 docs_per_second = count / elapsed
241 megabytes_per_second = total_bytes_processed / elapsed / 1024 / 1024
242 logger.info(
243 "Processed %d documents (%s docs/s, %s MB/s).",
244 count,
245 docs_per_second,
246 megabytes_per_second,
247 )
248 if self.args.find_optimal_num_workers:
249 self.performance.append(docs_per_second)
251 def split_sentences(self, file_name: tuple[str, str]) -> None:
252 """Split every document in one JSONL partition into sentences."""
253 input_file_name, output_file_name = file_name
254 logger.info("Opening %s", input_file_name)
255 encoder = Encoder(self.args)
256 with open(input_file_name, "r", encoding="utf-8") as input_file, open(
257 output_file_name,
258 "w",
259 encoding="utf-8",
260 ) as output_file:
261 # multiprocessing.Pool must be shut down with close()+join() so
262 # in-flight tasks finish; a 'with' block would terminate() them.
263 pool = multiprocessing.Pool(self.workers, initializer=encoder.initializer) # pylint: disable=R1732
264 try:
265 split_docs = pool.imap(encoder.split, input_file, 32)
266 proc_start = time.time()
267 total_bytes_processed = 0
268 for count, (document, bytes_processed) in enumerate(split_docs, start=1):
269 total_bytes_processed += bytes_processed
270 output_file.write(document + "\n")
271 self.print_processing_stats(count, proc_start, total_bytes_processed)
272 finally:
273 pool.close()
274 pool.join()
276 def _create_builders(self, output_prefix: str, tokenizer: Any) -> tuple[dict[str, str], dict[str, Any]]:
277 """Create indexed dataset builders for every configured JSON key."""
278 level = "sentence" if self.args.split_sentences else "document"
279 output_idx_files = {}
280 builders = {}
281 for key in self.args.json_keys:
282 output_bin_file = f"{output_prefix}_{key}_{level}.bin"
283 output_idx_files[key] = f"{output_prefix}_{key}_{level}.idx"
284 builders[key] = indexed_dataset.IndexedDatasetBuilder(
285 output_bin_file,
286 dtype=indexed_dataset.DType.optimal_dtype(len(tokenizer)),
287 )
288 return output_idx_files, builders
290 def _get_chunk_size(self) -> int | None:
291 """Return the packed chunk size including the shifted target token."""
292 pack_to_seq_len = getattr(self.args, "pack_to_seq_len", None)
293 return pack_to_seq_len + 1 if pack_to_seq_len is not None else None
295 def _write_encoded_document(
296 self,
297 builders: dict[str, Any],
298 document: dict[str, list[int]],
299 sentence_lengths: dict[str, list[int]],
300 token_buffers: dict[str, list[int]],
301 chunk_size: int | None,
302 ) -> None:
303 """Write one encoded document, optionally packing fixed-size chunks."""
304 for key in self.args.json_keys:
305 if chunk_size is None:
306 builders[key].add_document(document[key], sentence_lengths[key])
307 continue
308 token_buffers[key].extend(document[key])
309 complete_length = len(token_buffers[key]) // chunk_size * chunk_size
310 for offset in range(0, complete_length, chunk_size):
311 chunk = token_buffers[key][offset : offset + chunk_size]
312 builders[key].add_document(chunk, [chunk_size])
313 del token_buffers[key][:complete_length]
315 def _process_encoded_documents(
316 self,
317 pool: Any,
318 encoder: Encoder,
319 input_file_name: str,
320 builders: dict[str, Any],
321 token_buffers: dict[str, list[int]],
322 chunk_size: int | None,
323 proc_start: float,
324 ) -> None:
325 """Encode input records and write them to the indexed dataset builders."""
326 total_bytes_processed = 0
327 with open(input_file_name, "r", encoding="utf-8") as input_file:
328 encoded_docs = pool.imap(encoder.encode, input_file, 32)
329 for count, (document, sentence_lengths, bytes_processed) in enumerate(encoded_docs, start=1):
330 if self.args.find_optimal_num_workers and count > self.args.max_documents:
331 break
332 total_bytes_processed += bytes_processed
333 self._write_encoded_document(
334 builders, document, sentence_lengths, token_buffers, chunk_size
335 )
336 self.print_processing_stats(count, proc_start, total_bytes_processed)
338 def process_json_file(self, file_name: tuple[str, str]) -> list[float]:
339 """Tokenize one JSONL partition into indexed dataset .bin/.idx files.
341 Args:
342 file_name: Input JSONL path and output prefix pair.
344 Returns:
345 Throughput measurements collected while benchmarking workers.
346 """
347 input_file_name, output_prefix = file_name
348 logger.info("Opening %s", input_file_name)
350 startup_start = time.time()
351 encoder = Encoder(self.args)
352 tokenizer = build_tokenizer(self.args)
353 # multiprocessing.Pool must be shut down with close()+join() so
354 # in-flight tasks finish; a 'with' block would terminate() them.
355 pool = multiprocessing.Pool(self.workers, initializer=encoder.initializer) # pylint: disable=R1732
357 output_idx_files, builders = self._create_builders(output_prefix, tokenizer)
358 timing = (time.time() - startup_start, time.time())
359 logger.info("Time to startup: %s", timing[0])
360 chunk_size = self._get_chunk_size()
361 token_buffers = {key: [] for key in self.args.json_keys}
362 try:
363 self._process_encoded_documents(
364 pool, encoder, input_file_name, builders, token_buffers, chunk_size, timing[1]
365 )
366 for key in self.args.json_keys:
367 builders[key].finalize(output_idx_files[key])
368 finally:
369 for builder in builders.values():
370 builder.data_file.close()
371 pool.close()
372 pool.join()
374 return self.performance
377def _parse_bool(value: str) -> bool:
378 """Parse an explicit command-line boolean value."""
379 normalized = value.strip().lower()
380 if normalized in {"true", "1", "yes", "on"}:
381 return True
382 if normalized in {"false", "0", "no", "off"}:
383 return False
384 raise argparse.ArgumentTypeError(f"Expected a boolean value, but received {value!r}")
387def _add_offline_source_arguments(group: Any) -> None:
388 """Register source, tokenizer, and tokenization arguments."""
389 arguments = (
390 (("--dataset-name-or-path",), {"required": True, "help": "Input JSON/JSONL file, directory, or glob."}),
391 (("--output-prefix",), {"required": True, "help": "Path prefix for generated .bin/.idx files."}),
392 (("--json-keys",), {"nargs": "+", "default": ["text"], "help": "JSON fields to tokenize."}),
393 (("--tokenizer-name-or-path",), {"required": True, "help": "Tokenizer name or local path."}),
394 (("--chat-template",), {"default": None, "help": "Optional tokenizer chat template."}),
395 (("--add-special-tokens",), {"nargs": "+", "default": None, "help": "Additional special tokens."}),
396 (("--tokenizer-use-fast",), {"type": _parse_bool, "default": True, "help": "Use the fast tokenizer."}),
397 (("--trust-remote-code",), {"action": "store_true", "help": "Allow tokenizer remote code."}),
398 (("--split-sentences",), {"action": "store_true", "help": "Split text into sentences."}),
399 (("--keep-newlines",), {"action": "store_true", "help": "Preserve newlines while splitting."}),
400 (("--lang",), {"default": "english", "help": "Punkt language for sentence splitting."}),
401 (("--append-eod",), {"type": _parse_bool, "default": True, "help": "Append an EOD token."}),
402 (("--pack-to-seq-len",), {"type": int, "default": None, "help": "Fixed packed sequence length."}),
403 )
404 for flags, options in arguments:
405 group.add_argument(*flags, **options)
408def _add_offline_worker_arguments(group: Any) -> None:
409 """Register partitioning and worker arguments."""
410 arguments = (
411 (("--keep-sequential-samples",), {"action": "store_true", "help": "Keep sequential samples."}),
412 (("--keep-partition-files",), {"action": "store_true", "help": "Keep temporary partition files."}),
413 (("--workers",), {"type": int, "default": 8, "help": "Number of worker processes."}),
414 (("--partitions",), {"type": int, "default": 1, "help": "Number of data partitions."}),
415 (("--find-optimal-num-workers",), {"action": "store_true", "help": "Benchmark worker counts."}),
416 (
417 ("--workers-to-check",),
418 {"nargs": "+", "type": int, "default": [16, 32, 64], "help": "Candidate worker counts."},
419 ),
420 (("--max-documents",), {"type": int, "default": 100_000, "help": "Benchmark document limit."}),
421 (("--log-interval",), {"type": int, "default": 1000, "help": "Progress-report interval."}),
422 )
423 for flags, options in arguments:
424 group.add_argument(*flags, **options)
427def get_args() -> argparse.Namespace:
428 """Parse offline dataset preparation arguments."""
429 parser = argparse.ArgumentParser(description="Prepare an offline dataset.")
430 group = parser.add_argument_group(title="input data")
431 _add_offline_source_arguments(group)
432 _add_offline_worker_arguments(group)
433 return parser.parse_args()
436def get_file_name(args: argparse.Namespace, file_id: int) -> dict[str, str]:
437 """Build input and output paths for one dataset partition.
439 Args:
440 args: Parsed preprocessing arguments.
441 file_id: Zero-based partition identifier.
443 Returns:
444 Input, sentence-split, and output-prefix paths for the partition.
445 """
446 output_directory = os.path.dirname(args.output_prefix)
447 output_name = os.path.basename(args.output_prefix)
448 work_directory = os.path.join(output_directory, f".{output_name}_preprocess")
449 input_directory = os.path.join(work_directory, f"partition{file_id}")
450 partition_directory = os.path.join(output_directory, f"partition{file_id}")
451 os.makedirs(input_directory, exist_ok=True)
452 os.makedirs(partition_directory, exist_ok=True)
453 input_file_name = os.path.join(input_directory, "input.jsonl")
454 sentence_split_file = os.path.join(input_directory, "input_ss.jsonl")
455 output_prefix = os.path.join(partition_directory, output_name)
456 file_names = {"partition": input_file_name, "sentence_split": sentence_split_file, "output_prefix": output_prefix}
457 return file_names
460def check_files_exist(
461 in_ss_out_names: list[dict[str, str]],
462 key: str,
463 num_partitions: int,
464) -> bool:
465 """Check whether every partition contains the requested file type."""
466 for i in range(num_partitions):
467 if not os.path.exists(in_ss_out_names[i][key]):
468 return False
469 return True
472def find_optimal_num_workers(
473 performance: dict[int, list[float]],
474 partitions: int,
475) -> None:
476 """Print candidate worker counts ordered by average throughput."""
477 results = []
478 for workers, measurements in performance.items():
479 if measurements:
480 results.append((workers, float(np.mean(measurements))))
481 if not results:
482 raise ValueError("No worker performance measurements were collected")
483 results.sort(key=lambda item: item[1], reverse=True)
484 logger.info("Worker performance results:")
485 for position, (workers, average_rate) in enumerate(results, start=1):
486 logger.info("%d. %d workers: %.4f docs/s", position, workers, average_rate)
487 best_workers, best_rate = results[0]
488 logger.info(
489 "Best configuration: %d total workers (%d per partition), %.4f docs/s.",
490 best_workers,
491 best_workers // partitions,
492 best_rate,
493 )
496def _process_json_file_worker(
497 args: argparse.Namespace,
498 workers: int,
499 name: dict[str, str],
500 input_key: str,
501 queue: Any,
502) -> None:
503 """Encode one partition and return performance or an error to the parent."""
504 try:
505 partition = Partition(args, workers)
506 measurements = partition.process_json_file((name[input_key], name["output_prefix"]))
507 queue.put((True, measurements))
508 except Exception: # pylint: disable=W0718
509 queue.put((False, traceback.format_exc()))
512def _split_sentences_worker(
513 args: argparse.Namespace,
514 workers: int,
515 name: dict[str, str],
516 queue: Any,
517) -> None:
518 """Split one partition and report completion or an error to the parent."""
519 try:
520 partition = Partition(args, workers)
521 partition.split_sentences((name["partition"], name["sentence_split"]))
522 queue.put((True, None))
523 except Exception: # pylint: disable=W0718
524 queue.put((False, traceback.format_exc()))
527def _wait_for_processes(processes: list[Any], queue: Any) -> list[Any]:
528 """Collect child results, join processes, and surface child failures."""
529 results = [queue.get() for _ in processes]
530 for process in processes:
531 process.join()
532 queue.close()
533 queue.join_thread()
534 errors = [payload for succeeded, payload in results if not succeeded]
535 if errors:
536 raise RuntimeError("Child preprocessing process failed:\n" + "\n".join(errors))
537 return [payload for succeeded, payload in results if succeeded]
540def _count_jsonl_lines(file_name: str) -> int:
541 """Count records in a plain or gzip-compressed JSONL file."""
542 open_file = gzip.open if file_name.endswith(".gz") else open
543 open_kwargs = {"mode": "rt", "encoding": "utf-8"}
544 with open_file(file_name, **open_kwargs) as input_file:
545 return sum(1 for _ in input_file)
548def _resolve_input_files(dataset_name_or_path: str) -> list[str]:
549 """Resolve a JSON input file, directory, or glob into stable file order.
551 Args:
552 dataset_name_or_path: Input file, directory, or glob expression.
554 Returns:
555 Sorted input JSON/JSONL file paths.
557 Raises:
558 ValueError: If the input does not resolve to supported files.
559 """
560 input_path = Path(dataset_name_or_path).expanduser()
561 supported_suffixes = (".json", ".jsonl", ".json.gz", ".jsonl.gz")
562 if input_path.is_dir():
563 input_files = [
564 str(path.resolve())
565 for path in input_path.iterdir()
566 if path.is_file() and path.name.lower().endswith(supported_suffixes)
567 ]
568 elif input_path.is_file():
569 input_files = [str(input_path.resolve())]
570 else:
571 input_files = [
572 str(Path(file_name).resolve())
573 for file_name in glob.glob(dataset_name_or_path)
574 if Path(file_name).is_file() and file_name.lower().endswith(supported_suffixes)
575 ]
576 input_files.sort()
577 if not input_files:
578 raise ValueError(
579 "dataset_name_or_path must be a JSON/JSONL file, a directory containing "
580 f"JSON/JSONL files, or a matching glob; received {dataset_name_or_path!r}"
581 )
582 return input_files
585def _merge_partition_outputs(
586 args: argparse.Namespace,
587 in_ss_out_names: list[dict[str, str]],
588) -> None:
589 """Merge all encoded partition datasets into final output files."""
590 level = "sentence" if args.split_sentences else "document"
591 tokenizer = build_tokenizer(args)
592 for key in args.json_keys:
593 output_bin_file = f"{args.output_prefix}_{key}_{level}.bin"
594 output_idx_file = f"{args.output_prefix}_{key}_{level}.idx"
595 builder = indexed_dataset.IndexedDatasetBuilder(
596 output_bin_file,
597 dtype=indexed_dataset.DType.optimal_dtype(len(tokenizer)),
598 )
599 for name in in_ss_out_names:
600 partition_prefix = f"{name['output_prefix']}_{key}_{level}"
601 builder.add_index(partition_prefix)
602 builder.finalize(output_idx_file)
605def _cleanup_intermediate_files(
606 args: argparse.Namespace,
607 in_ss_out_names: list[dict[str, str]],
608 input_files: list[str],
609) -> None:
610 """Remove generated JSON partition inputs after successful processing."""
611 source_files = {str(Path(file_name).resolve()) for file_name in input_files}
612 for name in in_ss_out_names:
613 generated_files = [name["sentence_split"]]
614 if str(Path(name["partition"]).resolve()) not in source_files:
615 generated_files.append(name["partition"])
616 for file_name in generated_files:
617 if os.path.isfile(file_name):
618 os.remove(file_name)
620 output_path = Path(args.output_prefix).expanduser().resolve()
621 work_directory = output_path.parent / f".{output_path.name}_preprocess"
622 if work_directory.is_dir():
623 for directory, _, _ in os.walk(work_directory, topdown=False):
624 try:
625 os.rmdir(directory)
626 except OSError:
627 pass
630def _validate_preparation_args(args: argparse.Namespace) -> list[int]:
631 """Validate preprocessing options and return worker candidates."""
632 if args.partitions <= 0:
633 raise ValueError("partitions must be greater than zero")
634 if args.log_interval <= 0:
635 raise ValueError("log_interval must be greater than zero")
636 if args.max_documents <= 0:
637 raise ValueError("max_documents must be greater than zero")
638 pack_to_seq_len = getattr(args, "pack_to_seq_len", None)
639 if pack_to_seq_len is not None and pack_to_seq_len <= 0:
640 raise ValueError("pack_to_seq_len must be greater than zero")
641 if pack_to_seq_len is not None and not args.append_eod:
642 warnings.warn(
643 "pack_to_seq_len is enabled while append_eod is disabled; packed samples will not contain EOD tokens "
644 "at original document boundaries.",
645 UserWarning,
646 stacklevel=2,
647 )
648 if not args.output_prefix:
649 raise ValueError("output_prefix must be provided")
650 if not args.tokenizer_name_or_path:
651 raise ValueError("tokenizer_name_or_path must be provided")
652 output_parent = Path(args.output_prefix).expanduser().resolve().parent
653 output_parent.mkdir(parents=True, exist_ok=True)
654 worker_candidates = list(args.workers_to_check) if args.find_optimal_num_workers else [args.workers]
655 if not worker_candidates or any(workers <= 0 for workers in worker_candidates):
656 raise ValueError("worker counts must be greater than zero")
657 invalid_workers = [workers for workers in worker_candidates if workers % args.partitions != 0]
658 if invalid_workers:
659 raise ValueError(f"Worker counts {invalid_workers} must be divisible by partitions ({args.partitions})")
661 if args.split_sentences and not NLTK_AVAILABLE:
662 raise ImportError("NLTK is required when --split-sentences is enabled")
663 return worker_candidates
666def _partition_input_data(
667 args: argparse.Namespace,
668 input_files: list[str],
669) -> list[dict[str, str]]:
670 """Create or reuse input partitions and return their path descriptions."""
671 if args.partitions == 1 and len(input_files) == 1:
672 output_path = Path(args.output_prefix).expanduser().resolve()
673 work_directory = output_path.parent / f".{output_path.name}_preprocess"
674 work_directory.mkdir(parents=True, exist_ok=True)
675 return [
676 {
677 "partition": input_files[0],
678 "sentence_split": str(work_directory / "input_ss.jsonl"),
679 "output_prefix": args.output_prefix,
680 }
681 ]
683 partition_size = 1
684 if args.keep_sequential_samples:
685 total_sample_count = sum(_count_jsonl_lines(filename) for filename in input_files)
686 if total_sample_count == 0:
687 raise ValueError("Input JSON/JSONL files must contain at least one record")
688 partition_size = math.ceil(total_sample_count / args.partitions)
689 if args.partitions == 1:
690 output_path = Path(args.output_prefix).expanduser().resolve()
691 directory = output_path.parent / f".{output_path.name}_preprocess" / "partition0"
692 directory.mkdir(parents=True, exist_ok=True)
693 names = [
694 {
695 "partition": str(directory / "input.jsonl"),
696 "sentence_split": str(directory / "input_ss.jsonl"),
697 "output_prefix": args.output_prefix,
698 }
699 ]
700 else:
701 names = [get_file_name(args, file_id) for file_id in range(args.partitions)]
702 keep_files = getattr(args, "keep_partition_files", False)
703 partitions_present = keep_files and check_files_exist(names, "partition", args.partitions)
704 split_files_present = keep_files and check_files_exist(names, "sentence_split", args.partitions)
705 if not partitions_present and not split_files_present:
706 _write_input_partitions(args, names, input_files, partition_size)
707 return names
710def _write_input_partitions(
711 args: argparse.Namespace,
712 names: list[dict[str, str]],
713 input_files: list[str],
714 partition_size: int | None,
715) -> None:
716 """Distribute input records across partition files."""
717 outputs = [open(name["partition"], "w", encoding="utf-8") for name in names] # pylint: disable=R1732
718 partition_index = 0
719 line_count = 0
720 try:
721 for input_file_name in input_files:
722 open_file = gzip.open if input_file_name.endswith(".gz") else open
723 with open_file(input_file_name, "rt", encoding="utf-8") as input_file:
724 for line in input_file:
725 outputs[partition_index].write(line)
726 if args.keep_sequential_samples:
727 line_count += 1
728 if line_count % partition_size == 0 and partition_index < args.partitions - 1:
729 partition_index += 1
730 else:
731 partition_index = (partition_index + 1) % args.partitions
732 finally:
733 for output in outputs:
734 output.close()
737def _split_partitions(args: argparse.Namespace, workers: int, names: list[dict[str, str]]) -> None:
738 """Run sentence splitting for partitions that lack cached outputs."""
739 split_files_present = getattr(args, "keep_partition_files", False) and check_files_exist(
740 names, "sentence_split", args.partitions
741 )
742 if not args.split_sentences or split_files_present:
743 return
744 queue = multiprocessing.Queue()
745 processes = []
746 for name in names:
747 process = multiprocessing.Process(target=_split_sentences_worker, args=(args, workers, name, queue))
748 process.start()
749 processes.append(process)
750 split_results = _wait_for_processes(processes, queue)
751 if any(result is not None for result in split_results):
752 raise RuntimeError("Sentence-splitting workers returned unexpected results")
755def _encode_partitions(
756 args: argparse.Namespace,
757 workers: int,
758 names: list[dict[str, str]],
759) -> list[float]:
760 """Encode every partition and flatten its performance measurements."""
761 input_key = "sentence_split" if args.split_sentences else "partition"
762 queue = multiprocessing.Queue()
763 processes = []
764 for name in names:
765 process = multiprocessing.Process(
766 target=_process_json_file_worker,
767 args=(args, workers, name, input_key, queue),
768 )
769 process.start()
770 processes.append(process)
771 results = _wait_for_processes(processes, queue)
772 return [measurement for measurements in results for measurement in (measurements or [])]
775def prepare_offline_dataset(args: argparse.Namespace) -> None:
776 """Prepare an indexed offline dataset from a parsed configuration.
778 Args:
779 args: Offline dataset preprocessing configuration.
780 """
781 worker_candidates = _validate_preparation_args(args)
783 performance = {}
784 input_files = _resolve_input_files(args.dataset_name_or_path)
785 for workers in worker_candidates:
786 logger.info("Processing data with %s workers", workers)
787 workers_per_partition = workers // args.partitions
789 if args.split_sentences:
790 nltk.download("punkt", quiet=True, download_dir=os.environ.get("NLTK_DATA"))
791 in_ss_out_names = _partition_input_data(args, input_files)
792 _split_partitions(args, workers_per_partition, in_ss_out_names)
793 performance[workers] = _encode_partitions(args, workers_per_partition, in_ss_out_names)
795 if args.partitions > 1:
796 _merge_partition_outputs(args, in_ss_out_names)
797 if not getattr(args, "keep_partition_files", False):
798 _cleanup_intermediate_files(args, in_ss_out_names, input_files)
800 if args.find_optimal_num_workers:
801 find_optimal_num_workers(performance, args.partitions)
804def main() -> None:
805 """Parse command-line arguments and prepare the offline dataset."""
806 prepare_offline_dataset(get_args())
809if __name__ == "__main__":
810 main()