[feat][SFT] Stream tokenization to arrow, bounding memory for large text datasets - #1971
[feat][SFT] Stream tokenization to arrow, bounding memory for large text datasets#1971avigyabb wants to merge 4 commits into
Conversation
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>
Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
There was a problem hiding this comment.
Code Review
This pull request optimizes the tokenization pipeline by streaming tokenized rows into memory-mapped Arrow files in bounded batches instead of materializing the entire dataset in memory, significantly reducing RAM usage. Feedback on the changes highlights a potential issue in the parallel tokenization path where active references to memory-mapped Arrow files in the temporary shard directory might prevent the directory from being successfully cleaned up in the finally block, especially on Windows, and suggests explicitly deleting these references before returning.
| if self.sft_cfg.disable_cache: | ||
| # No cache directory to serve the mmap from (the shard dir is | ||
| # deleted below), so keep the materialized-list behavior. | ||
| return tokenized_ds.to_list() | ||
|
|
||
| return tokenized | ||
| _save_to_cache(cache_path, tokenized_ds) | ||
| mmapped = _load_from_cache(cache_path) | ||
| if mmapped is None: | ||
| raise RuntimeError(f"Failed to reload tokenized dataset cache at {cache_path}") | ||
| return mmapped |
There was a problem hiding this comment.
In the parallel tokenization path, shards and tokenized_ds hold active references to the memory-mapped Arrow files in shard_dir. Because the finally block is executed before the function returns, these references remain active when shutil.rmtree(shard_dir) is called. On Windows (and potentially some Unix environments), this will cause a PermissionError or prevent the files from being deleted, leading to disk space leaks. Explicitly deleting the references (del shards, tokenized_ds) before returning ensures the file handles are closed and the temporary directory can be successfully cleaned up.
if self.sft_cfg.disable_cache:\n # No cache directory to serve the mmap from (the shard dir is\n # deleted below), so keep the materialized-list behavior.\n res = tokenized_ds.to_list()\n del shards, tokenized_ds\n return res\n\n _save_to_cache(cache_path, tokenized_ds)\n del shards, tokenized_ds\n mmapped = _load_from_cache(cache_path)\n if mmapped is None:\n raise RuntimeError(f\"Failed to reload tokenized dataset cache at {cache_path}\")\n return mmapped
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). With this PR, the tokenize-on-load path never holds the tokenized dataset in memory -- text and VLM 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 = 1000examples):_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)._save_to_cacheaccepts an arrow-backedDatasetdirectly (save_to_diskwrites through in batches) in addition tolist[dict].pixel_values,image_grid_thw) round-trip through the streaming writer with full parity toDataset.from_list(test_vlm_rows_stream_to_arrow, run under thevllmmarker with the fsdp extras like the other VLM tests). VLM rows are the megabyte-sized ones, so they benefit most.disable_cache=Trueonly (no arrow file to stream to or serve from).Behavior change: inconsistent row keys now raise
The VLM validation surfaced a pre-existing hazard: datasets mixing text-only and multimodal rows silently lose their image columns to arrow schema inference (locked from the first example) -- identically under the old
Dataset.from_listpath, so training would proceed without images and no one would know._write_tokenized_arrownow raises an explicit error on inconsistent row keys. Runs that previously trained silently-wrong on such datasets now fail loudly at tokenization.Measured (cache-miss tokenization; peak anonymous heap = the memory that can OOM, vs reclaimable file-backed pages)
The ~720 MB floor is torch/transformers/tokenizer imports; doubling the dataset moved the streamed heap by 2 MB. Streamed growth appears only in file-backed page cache (the arrow file being written/read), which the OS reclaims under pressure.
Memory profile of the tokenize-on-load path, across the stack
Tests
vllm-marked); inconsistent-keys guard test.tests/trainsuite green: 884 (+36 tokenization, +3 vllm-marked).🤖 Generated with Claude Code