[feat][SFT] Ingest pretokenized datasets from local or object storage (S3/GCS)#1927
[feat][SFT] Ingest pretokenized datasets from local or object storage (S3/GCS)#1927avigyabb wants to merge 7 commits into
Conversation
… (S3/GCS) Adds a pretokenized data path to the SFT trainer so offline-tokenized datasets can be trained on directly, skipping apply_chat_template: - New skyrl/train/dataset/pretokenized.py: loads parquet/JSONL/arrow files or HF save_to_disk directories from a local path, s3://, gs://, or gcs:// URI (downloads via the shared fsspec io layer with S3 retry/credential-refresh handling; cached locally under cache_dir). - Rows carry input_ids plus a loss target in one of three schemas (native num_actions [+ window loss_mask], full-sequence loss_mask, or HF-style labels with -100) and are normalized to the trainer's internal format; max_length truncation matches the online path. - New SFTConfig fields pretokenized_dataset_path and eval_pretokenized_dataset_path, taking precedence over dataset_name / eval_dataset_name; eval validation accepts a pretokenized eval store. - CPU tests for format detection, schema normalization, truncation, cloud download/caching, trainer routing, and config validation. 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>
…add VLM ingestion Reworks the pretokenized ingestion input schema per design review: - Rows now carry exactly input_ids + a full-sequence 0/1 loss_mask; num_actions is inferred from the first nonzero mask entry (window-form masks, num_actions columns, and HF-style labels are rejected with errors explaining how to convert offline). attention_mask remains optional and must be all-ones; padding stays collation-internal. - VLM rows (pixel_values + image_grid_thw) pass through to the collator's TensorList path. Over-length VLM rows are dropped rather than truncated (mirrors the online VLM path); mixed text+VLM stores drop the null image columns parquet materializes on text rows. - Rename load_pretokenized_dataset -> load_from_pretokenized (and the trainer method to _load_from_pretokenized) to mirror _load_and_tokenize, which returns the same list[dict] shape. - Update tests (33 passing), README schema docs, and config docstrings. Also reword docs to reference the tokenize functions being skipped instead of apply_chat_template. Text path validated e2e on GPU from an S3 parquet store (wandb run sft-pretokenized-s3-e2e-fullseq-mask); VLM path covered at the unit/collation level only. 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>
…ovaSky-AI#1883) Resolves conflicts with the multi-dataset PR and extends pretokenized ingestion to match its list-based workflow: - pretokenized_dataset_path -> pretokenized_dataset_paths (List[str]): multiple stores are concatenated and mixed per train_dataset_weights via DataMixingSampler, exactly like train_datasets. Exclusive with train_datasets (explicit error instead of silent precedence). - eval_pretokenized_dataset_path -> eval_pretokenized_dataset_paths: each store becomes one eval set logged under eval/{name}/, named by eval_dataset_names (default: path basenames, collision -> error). - Config normalization validates weights/names against the pretokenized lists; eval_interval/eval_before_train accept pretokenized eval. - load_dataset() returns (tokenized, dataset_lengths) per the new API; load_eval_datasets() emits (name, rows) pairs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
…ct check Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
There was a problem hiding this comment.
Code Review
This pull request introduces support for pretokenized datasets (local or S3/GCS) in SFT training, allowing users to bypass online tokenization by pointing directly to pretokenized stores in Parquet, JSONL, Arrow, or HuggingFace formats. The changes include a new dataset loading module, updated configuration options in SFTConfig, integration in SFTTrainer, and comprehensive unit tests. The review feedback highlights two important improvements: addressing a potential race condition in multi-node environments during concurrent dataset downloads by using unique temporary directories, and skipping hidden files and directories during dataset file collection to prevent loading errors.
| def _download_to(path: str, target_dir: str) -> None: | ||
| """Download a cloud file or directory into ``target_dir``. | ||
|
|
||
| A cloud directory's contents land directly in ``target_dir``; a single | ||
| cloud file lands at ``target_dir/<basename>``. The download goes to a | ||
| sibling ``<target_dir>.tmp`` first and is atomically renamed so concurrent | ||
| readers (e.g. multi-node runs sharing an NFS cache) never see a partial | ||
| download. | ||
| """ | ||
| temp_dir = target_dir + ".tmp" | ||
| if os.path.isdir(temp_dir): | ||
| shutil.rmtree(temp_dir) | ||
| os.makedirs(temp_dir, exist_ok=True) | ||
|
|
||
| if io.isdir(path): | ||
| io.download_directory(path, temp_dir) | ||
| else: | ||
| io.download_file(path, os.path.join(temp_dir, os.path.basename(path.rstrip("/")))) | ||
|
|
||
| if os.path.isdir(target_dir): | ||
| shutil.rmtree(target_dir) | ||
| os.makedirs(os.path.dirname(target_dir), exist_ok=True) | ||
| os.rename(temp_dir, target_dir) |
There was a problem hiding this comment.
In multi-node or multi-process distributed training environments, concurrent processes/ranks will attempt to download the same dataset at the same time. Using a deterministic temporary directory name like target_dir + ".tmp" causes a severe race condition where one process can delete or overwrite files currently being downloaded by another process. Using a unique temporary directory via tempfile.mkdtemp in the same parent directory, followed by an atomic os.rename, completely avoids this conflict and ensures safe concurrent downloads.
def _download_to(path: str, target_dir: str) -> None:
"""Download a cloud file or directory into ``target_dir``.
A cloud directory's contents land directly in ``target_dir``; a single
cloud file lands at ``target_dir/<basename>``. The download goes to a
unique temporary directory in the same parent directory first and is atomically
renamed so concurrent readers (e.g. multi-node runs sharing an NFS cache)
never see a partial download or conflict with each other.
"""
parent_dir = os.path.dirname(target_dir)
os.makedirs(parent_dir, exist_ok=True)
temp_dir = tempfile.mkdtemp(prefix=".download_tmp_", dir=parent_dir)
try:
if io.isdir(path):
io.download_directory(path, temp_dir)
else:
io.download_file(path, os.path.join(temp_dir, os.path.basename(path.rstrip("/"))))
try:
os.rename(temp_dir, target_dir)
except OSError:
if os.path.isdir(target_dir):
# Another process completed the download and renamed first
shutil.rmtree(temp_dir, ignore_errors=True)
else:
raise
except Exception:
if os.path.isdir(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)
raise| def _collect_data_files(root: str) -> dict[str, list[str]]: | ||
| """Recursively collect data files under ``root``, grouped by format.""" | ||
| groups: dict[str, list[str]] = {"parquet": [], "json": [], "arrow": []} | ||
| for dirpath, _, filenames in os.walk(root): | ||
| for name in sorted(filenames): | ||
| full = os.path.join(dirpath, name) | ||
| lower = name.lower() | ||
| if lower.endswith(_PARQUET_EXTS): | ||
| groups["parquet"].append(full) | ||
| elif lower.endswith(_JSON_EXTS): | ||
| groups["json"].append(full) | ||
| elif lower.endswith(_ARROW_EXTS): | ||
| groups["arrow"].append(full) | ||
| return groups |
There was a problem hiding this comment.
When recursively collecting data files under a directory, os.walk will traverse hidden directories (such as .ipynb_checkpoints or .git) and collect hidden or temporary files (such as macOS AppleDouble ._* files). This can lead to unexpected files being loaded or causing parsing/loading errors. It is safer to explicitly skip hidden files and directories during traversal.
| def _collect_data_files(root: str) -> dict[str, list[str]]: | |
| """Recursively collect data files under ``root``, grouped by format.""" | |
| groups: dict[str, list[str]] = {"parquet": [], "json": [], "arrow": []} | |
| for dirpath, _, filenames in os.walk(root): | |
| for name in sorted(filenames): | |
| full = os.path.join(dirpath, name) | |
| lower = name.lower() | |
| if lower.endswith(_PARQUET_EXTS): | |
| groups["parquet"].append(full) | |
| elif lower.endswith(_JSON_EXTS): | |
| groups["json"].append(full) | |
| elif lower.endswith(_ARROW_EXTS): | |
| groups["arrow"].append(full) | |
| return groups | |
| def _collect_data_files(root: str) -> dict[str, list[str]]: | |
| """Recursively collect data files under ``root``, grouped by format.""" | |
| groups: dict[str, list[str]] = {"parquet": [], "json": [], "arrow": []} | |
| for dirpath, dirnames, filenames in os.walk(root): | |
| # Skip hidden directories in-place | |
| dirnames[:] = [d for d in dirnames if not d.startswith(".")] | |
| for name in sorted(filenames): | |
| # Skip hidden or temporary files | |
| if name.startswith("."): | |
| continue | |
| full = os.path.join(dirpath, name) | |
| lower = name.lower() | |
| if lower.endswith(_PARQUET_EXTS): | |
| groups["parquet"].append(full) | |
| elif lower.endswith(_JSON_EXTS): | |
| groups["json"].append(full) | |
| elif lower.endswith(_ARROW_EXTS): | |
| groups["arrow"].append(full) | |
| return groups |
What
Adds a pretokenized data path to the SFT trainer: point
pretokenized_dataset_pathsat store(s) of already-tokenized rows — on local disk, S3, or GCS — and train directly, skipping online tokenization (tokenize_chat_example/tokenize_sft_example) entirely. Useful when a data pipeline tokenizes offline (e.g. on a Spark/Ray data cluster) and publishes the result to an object store.How it works
New module
skyrl/train/dataset/pretokenized.py—load_from_pretokenized(path, ...), the pretokenized counterpart ofSFTTrainer._load_and_tokenize(samelist[dict]return shape, so everything downstream — collators, sequence packing, samplers, checkpoint/resume — works unchanged):s3://,gs://, orgcs://URI, resolved through the shared fsspec io layer (skyrl.backends.skyrl_train.utils.io) with its S3 retry/credential-refresh handling. Cloud downloads are cached undercache_dirkeyed by remote path (atomic tmp-dir-then-rename, NFS-safe);disable_cache/force_recacheare honored.Dataset.save_to_diskdirectory.input_idsplus a full-sequence 0/1loss_mask(same length).num_actionsis inferred from the first nonzero mask entry; window-form masks,num_actionscolumns, and HF-stylelabelsare rejected with actionable errors.attention_maskis optional and must be all-ones (padding stays collation-internal). The mask form covers instruction-following (1s on the response) and multi-turn conversational data (1s on every assistant turn).pixel_values+image_grid_thwpass through to the collator'sTensorListpath. Over-length VLM rows are dropped rather than truncated (mirrors the online VLM path); mixed text+VLM stores handle the null image columns parquet materializes on text rows.max_lengthtruncation mirrors the online path (prompt prefix kept, action window shrinks, empty-loss rows dropped).Config (
SFTConfig) — integrated with multi-dataset SFT (#1883):pretokenized_dataset_paths: List[str]— multiple stores are concatenated and mixed pertrain_dataset_weightsviaDataMixingSampler, exactly liketrain_datasets. Exclusive withtrain_datasets/train_dataset_splits(explicit error, no silent precedence).eval_pretokenized_dataset_paths: List[str]— each store becomes one eval set logged undereval/{name}/, named byeval_dataset_names(default: path basenames; collisions error).eval_interval/eval_before_trainaccept pretokenized eval stores.Testing
tests/train/test_sft_pretokenized.pycovering format detection, schema validation/normalization, truncation, VLM pass-through + collation, S3 download/cache/force-redownload (monkeypatched io), multi-store concatenation + mixing-sampler wiring, and config validation. Full SFT suite (test_sft_config/test_sft_dataloader/test_sft_callbacks/test_max_training_steps): 170 passed.Notes / limitations
force_recache=trueonce.🤖 Generated with Claude Code