Skip to content

[feat][SFT] Stream tokenization to arrow, bounding memory for large text datasets - #1971

Draft
avigyabb wants to merge 3 commits into
NovaSky-AI:mainfrom
avigyabb:sft-stream-tokenize
Draft

[feat][SFT] Stream tokenization to arrow, bounding memory for large text datasets#1971
avigyabb wants to merge 3 commits into
NovaSky-AI:mainfrom
avigyabb:sft-stream-tokenize

Conversation

@avigyabb

@avigyabb avigyabb commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #1970 — builds on the memory-mapped cache serving introduced there; the diff collapses to the last commit once #1970 merges.

What

Closes the transient flagged in #1970's known-limitations note: cache-miss tokenization accumulated every tokenized row in a Python list before writing the arrow cache (and on the parallel path, pickled each worker's slice back to the controller), so first-run peak memory was still O(dataset) even though training then served the cache memory-mapped. With this PR, the tokenize-on-load path never holds the tokenized dataset in memory — text datasets are bounded by disk, not RAM, end to end.

How

Tokenized rows stream into arrow files in bounded batches via HF's ArrowWriter (flush every _TOKENIZE_WRITER_BATCH_ROWS = 1000 examples):

  • Sequential path: tokenization becomes a generator feeding _write_tokenized_arrow (tokenize → bounded arrow writes to a temp shard → _save_to_cache(Dataset.from_file(shard)) → serve memory-mapped). Peak memory during tokenization is O(writer batch).
  • Parallel path: each spawn worker streams its slice into a per-worker arrow shard and returns only its row count; the controller concatenates the memory-mapped shards (a view, nothing loaded) into the cache. This also removes the old pickle-the-results round-trip from workers to controller.
  • _save_to_cache now accepts an arrow-backed Dataset directly (save_to_disk writes through in batches) in addition to list[dict].

Materializing fallbacks, documented: disable_cache=True (no arrow file to stream to or serve from — keeps the in-memory list behavior) and VLM datasets (image tensors only round-trip through Dataset.from_list; VLM already forces sequential tokenization).

Memory profile of the text path, before → after this stack

Phase main (pre #1961) after #1970 after this PR
Tokenization (cache miss) O(dataset) O(dataset), transient O(writer batch)
Training (serving) O(dataset) × (workers+1) O(page cache) O(page cache)

Tests

  • New: streamed-vs-materialized row parity with _TOKENIZE_WRITER_BATCH_ROWS monkeypatched to 2, forcing multiple flush boundaries.
  • Existing parallel-vs-serial parity test now exercises the shard-writing workers; existing cache round-trip tests unchanged.
  • Full tests/train suite green: 884 passed (+35 tokenization tests).

🤖 Generated with Claude Code

avigyabb and others added 3 commits August 3, 2026 03:56
The tokenized cache is already an arrow-backed HF Dataset on disk in the
trainer's internal row form, but _load_from_cache materialized it back
into a list[dict] (O(dataset) RAM, re-pickled into every spawn dataloader
worker). Serve it through the same map-style mmap wrapper as pretokenized
stores instead, with no transform attached (cached rows are already
normalized):

- _load_from_cache returns PretokenizedDataset(load_from_disk(...),
  lengths), with per-row lengths from arrow offsets via the new
  sequence_lengths_from_arrow helper (chunked, no row materialization).
- Fresh tokenization round-trips through the cache in both the
  sequential and parallel paths, so cold runs also train memory-mapped.
- disable_cache=True keeps the in-memory list (wrapped in TextDataset);
  with no arrow file on disk there is nothing to map.

Tokenization cost is unchanged; only the residency of the results
changes: the text path now has the same memory profile as pretokenized
stores (O(page cache), workers pickle a file reference).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
Since the tokenized-dataset cache is now served through the same class,
the old name was wrong for one of its two roles: the class is a
map-style view over any validated arrow store in (or normalizable to)
the trainer's internal row form. Rename before anything external
depends on the name (NovaSky-AI#1961 merged it one release ago).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
…ext datasets

Cache-miss tokenization accumulated every tokenized row in a Python list
before writing the arrow cache (and on the parallel path, pickled each
worker's slice back to the controller), so first-run peak memory was
O(dataset) even though training then served the cache memory-mapped.

Tokenized rows now stream into arrow files in bounded batches via HF's
ArrowWriter (flush granularity: _TOKENIZE_WRITER_BATCH_ROWS = 1000):

- Sequential path: tokenize as a generator -> _write_tokenized_arrow to
  a temp shard -> _save_to_cache(Dataset.from_file(shard)) -> serve
  memory-mapped. The tokenized dataset is never resident; size is
  bounded by disk, not RAM.
- Parallel path: each spawn worker streams its slice into a per-worker
  arrow shard and returns only its row count; the controller
  concatenates the memory-mapped shards (a view) into the cache. This
  also removes the pickle-results round-trip.
- _save_to_cache accepts an arrow-backed Dataset directly (save_to_disk
  writes through in batches) in addition to list[dict].
- Materializing fallbacks, documented: disable_cache=True (no arrow
  file to stream to or serve from) and VLM datasets (image tensors only
  round-trip through Dataset.from_list).

New test forces multiple ArrowWriter flush boundaries and checks row
parity against the materialized path; the existing parallel-vs-serial
parity test now exercises the shard-writing workers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant