unitoken 0.1.5

Fast BPE tokenizer/trainer with a Rust core and Python bindings
Documentation
from __future__ import annotations

import argparse
import json
import resource
import sys
import time
from collections.abc import Iterator, Sequence
from pathlib import Path
from typing import Any

import pyarrow.parquet as pq

from uni_tokenizer import PreTokenizer


GIB = 1024 ** 3
DEFAULT_WORDS_OUTPUT = (
  Path("out")
  / "data"
  / "fineweb2"
  / "cmn_Hani"
  / "fineweb2_cmn_Hani_5GiB.unicode_bigram_top100k_min16"
  / "_words.json"
)


class ParquetSource:
  def __init__(
    self,
    input_dir: Path,
    *,
    size_bytes: int,
    column: str,
    parquet_batch_size: int,
  ) -> None:
    self.paths = sorted(input_dir.glob("*.parquet"))
    if not self.paths:
      raise FileNotFoundError(f"no Parquet files found in {input_dir}")
    self.size_bytes = size_bytes
    self.column = column
    self.parquet_batch_size = parquet_batch_size
    self.scan_number = 0
    self.last_scan: dict[str, int | float] = {}

  def scan(self) -> Iterator[str]:
    self.scan_number += 1
    started = time.perf_counter()
    bytes_read = 0
    records_read = 0
    files_read = 0
    next_report = GIB

    for path in self.paths:
      files_read += 1
      parquet = pq.ParquetFile(path)
      batches = parquet.iter_batches(
        batch_size=self.parquet_batch_size,
        columns=[self.column],
        use_threads=True,
      )
      for batch in batches:
        for text in batch.column(0).to_pylist():
          if text is None:
            continue
          text_bytes = len(text.encode("utf-8"))
          if records_read > 0 and bytes_read + text_bytes > self.size_bytes:
            self.last_scan = {
              "bytes": bytes_read,
              "records": records_read,
              "files": files_read,
              "elapsed_s": time.perf_counter() - started,
            }
            return
          bytes_read += text_bytes
          records_read += 1
          yield text
          if bytes_read >= next_report:
            elapsed = time.perf_counter() - started
            print(
              f"scan={self.scan_number} read={bytes_read / GIB:.2f} GiB "
              f"records={records_read:,} rate={bytes_read / GIB / elapsed:.3f} GiB/s",
              file=sys.stderr,
              flush=True,
            )
            next_report += GIB

    self.last_scan = {
      "bytes": bytes_read,
      "records": records_read,
      "files": files_read,
      "elapsed_s": time.perf_counter() - started,
    }


def max_rss_bytes() -> int:
  rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
  return rss if sys.platform == "darwin" else rss * 1024


def scan_result(source: ParquetSource, elapsed_s: float) -> dict[str, int | float]:
  result = dict(source.last_scan)
  result["source_elapsed_s"] = result.pop("elapsed_s")
  result["elapsed_s"] = elapsed_s
  result["throughput_mib_s"] = int(result["bytes"]) / 1024 ** 2 / elapsed_s
  result["max_rss_bytes"] = max_rss_bytes()
  return result


def run(args: argparse.Namespace) -> dict[str, Any]:
  source = ParquetSource(
    args.input_dir,
    size_bytes=args.size_bytes,
    column=args.column,
    parquet_batch_size=args.parquet_batch_size,
  )
  pretokenizer = PreTokenizer([])

  bigram_counter = pretokenizer.bigram_counter()
  started = time.perf_counter()
  bigram_counter.add_source(
    source.scan(),
    max_records=args.max_records,
    max_bytes=args.max_bytes,
    prefetch=args.prefetch,
  )
  bigram_pass = scan_result(source, time.perf_counter() - started)

  started = time.perf_counter()
  bigrams = bigram_counter.selected(top_k=args.top_k, min_freq=args.min_freq)
  selection_s = time.perf_counter() - started
  del bigram_counter

  word_counter = pretokenizer.with_unicode_bigrams(bigrams).word_counter()
  started = time.perf_counter()
  word_counter.add_source(
    source.scan(),
    max_records=args.max_records,
    max_bytes=args.max_bytes,
    prefetch=args.prefetch,
  )
  word_pass = scan_result(source, time.perf_counter() - started)

  args.words_output.parent.mkdir(parents=True, exist_ok=True)
  started = time.perf_counter()
  word_counter.save(args.words_output)
  save_s = time.perf_counter() - started

  return {
    "contract": "parquet_source_two_pass_exact_words",
    "input_dir": str(args.input_dir),
    "size_bytes": args.size_bytes,
    "column": args.column,
    "parquet_batch_size": args.parquet_batch_size,
    "source_batch": {
      "max_records": args.max_records,
      "max_bytes": args.max_bytes,
      "prefetch": args.prefetch,
    },
    "unicode_bigrams": {
      "top_k": args.top_k,
      "min_freq": args.min_freq,
      "selected": len(bigrams),
      "selection_s": selection_s,
    },
    "bigram_pass": bigram_pass,
    "word_pass": word_pass,
    "unique_words": word_counter.len,
    "words_output": str(args.words_output),
    "words_output_bytes": args.words_output.stat().st_size,
    "save_words_s": save_s,
    "max_rss_bytes": max_rss_bytes(),
  }


def main(argv: Sequence[str] | None = None) -> int:
  parser = argparse.ArgumentParser(
    description="Count exact words from a replayable Parquet source using the two-pass native API."
  )
  parser.add_argument("--input-dir", type=Path, required=True)
  parser.add_argument("--size-bytes", type=int, default=5 * GIB)
  parser.add_argument("--column", default="text")
  parser.add_argument("--parquet-batch-size", type=int, default=2048)
  parser.add_argument("--max-records", type=int, default=4096)
  parser.add_argument("--max-bytes", type=int, default=64 * 1024 * 1024)
  parser.add_argument("--prefetch", type=int, choices=[0, 1], default=1)
  parser.add_argument("--top-k", type=int, default=100_000)
  parser.add_argument("--min-freq", type=int, default=16)
  parser.add_argument("--words-output", type=Path, default=DEFAULT_WORDS_OUTPUT)
  parser.add_argument("--json", type=Path)
  args = parser.parse_args(argv)

  if args.size_bytes < 1:
    parser.error("--size-bytes must be at least 1")
  if args.parquet_batch_size < 1:
    parser.error("--parquet-batch-size must be at least 1")

  result = run(args)
  rendered = json.dumps(result, indent=2)
  print(rendered)
  if args.json:
    args.json.parent.mkdir(parents=True, exist_ok=True)
    args.json.write_text(rendered + "\n", encoding="utf-8")
  return 0


if __name__ == "__main__":
  raise SystemExit(main(sys.argv[1:]))