ffbpe 0.1.8

Unicode-aware, streaming BPE training and tiktoken-compatible encoding
Documentation
from __future__ import annotations

import argparse
import gc
import json
import sys
import time
from collections.abc import Sequence
from pathlib import Path
from typing import Any, cast

from ffbpe import BpeTrainer
from ffbpe import BoundaryMode
from ffbpe import PreTokenizer

from common import DEFAULT_CHUNK_SIZE
from common import SPECIAL_TOKENS
from common import add_report_args
from common import benchmark_metadata
from common import bucket_steps
from common import duration_summary
from common import resolve_report_path
from common import write_report


SCRIPT_NAME = "profile_trainer"


def pretokenize(path: Path, chunk_size: int, boundary: BoundaryMode) -> tuple[list[tuple[str, int]], dict[str, Any]]:
  gc.collect()
  started = time.perf_counter()
  pretokenizer = PreTokenizer(SPECIAL_TOKENS, SPECIAL_TOKENS[0])
  words = pretokenizer.get_words_from_file(path, chunk_size=chunk_size, boundary=boundary)
  pretokenize_s = time.perf_counter() - started
  return list(words.items()), {
    "pretokenize_s": pretokenize_s,
    "unique_words": len(words),
    "occurrences": sum(words.values()),
  }


def train(words: Sequence[tuple[str, int]], vocab_size: int, bucket_size: int) -> dict[str, Any]:
  gc.collect()
  trainer = BpeTrainer(SPECIAL_TOKENS, unit="byte", initial_alphabet="byte_level")

  started = time.perf_counter()
  trainer.add_words(words)
  add_words_s = time.perf_counter() - started
  initial_vocab_size = trainer.vocab_size

  started = time.perf_counter()
  trainer.init_training()
  init_training_s = time.perf_counter() - started

  step_times = []
  while trainer.vocab_size < vocab_size:
    started = time.perf_counter()
    next_vocab_size = trainer.step()
    step_times.append(time.perf_counter() - started)
    if next_vocab_size is None:
      break

  train_s = add_words_s + init_training_s + sum(step_times)
  return {
    "vocab_size": trainer.vocab_size,
    "final_merge_freq": trainer.last_merge_freq,
    "initial_vocab_size": initial_vocab_size,
    "add_words_s": add_words_s,
    "init_training_s": init_training_s,
    "step_summary": duration_summary(step_times),
    "step_buckets": bucket_steps(step_times, bucket_size),
    "train_s": train_s,
  }


def main(argv: Sequence[str] | None = None) -> int:
  parser = argparse.ArgumentParser(description="Profile end-to-end FFBPE training from raw UTF-8 text.")
  parser.add_argument("--text", type=Path, required=True, help="Raw UTF-8 text file.")
  parser.add_argument("--vocab-size", type=int, default=10000)
  parser.add_argument("--chunk-size", type=int, default=DEFAULT_CHUNK_SIZE)
  parser.add_argument("--boundary", choices=["auto", "eot", "line", "utf8"], default="auto")
  parser.add_argument("--bucket-size", type=int, default=500)
  add_report_args(parser)
  args = parser.parse_args(argv)

  if args.vocab_size < 1:
    parser.error("--vocab-size must be at least 1")
  if args.chunk_size < 1:
    parser.error("--chunk-size must be at least 1")
  if args.bucket_size < 1:
    parser.error("--bucket-size must be at least 1")

  boundary = cast(BoundaryMode, args.boundary)
  words, pretokenizer_result = pretokenize(args.text, args.chunk_size, boundary)
  trainer_result = train(words, args.vocab_size, args.bucket_size)

  result = {
    "metadata": benchmark_metadata(
      contract="raw_text_unitoken_trainer_profile",
      script_name=SCRIPT_NAME,
      dataset_name=args.dataset_name,
      config_name=args.config_name,
      experiment_name=args.experiment_name,
      notes=[
        "This is an end-to-end FFBPE training profile from raw text.",
        "Timing includes pretokenization, add_words, init_training, and BPE steps.",
      ],
    ),
    "source": {
      "input_kind": "raw_text",
      "text": str(args.text),
      "text_bytes": args.text.stat().st_size,
      "boundary": boundary,
      "chunk_size": args.chunk_size,
    },
    "target_vocab_size": args.vocab_size,
    "pretokenizer": pretokenizer_result,
    "unitoken": {
      **trainer_result,
      "total_s": pretokenizer_result["pretokenize_s"] + trainer_result["train_s"],
    },
  }

  rendered = json.dumps(result, indent=2)
  if not args.quiet:
    print(rendered)
  write_report(resolve_report_path(args, script_name=SCRIPT_NAME, vocab_size=args.vocab_size), rendered)
  return 0


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