Repository navigation
examples/megatron_bridge: distill on chat data straight from HuggingFace/JSONL #2587
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ab441f4
0d05131
875aebe
9de33ac
92f80d6
d72fb57
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -48,6 +48,15 @@ | |
| from megatron.bridge.training.post_training.checkpointing import has_modelopt_state | ||
| from megatron.bridge.training.post_training.distillation import ModelOptDistillConfig | ||
| from megatron.bridge.utils.vocab_utils import calculate_padded_vocab_size | ||
|
|
||
| try: | ||
| from megatron.bridge.data.builders import DirectHFSFTDatasetConfig | ||
| from megatron.bridge.data.sft_processing import ChatSFTPreprocessingConfig | ||
| from megatron.bridge.data.sources import HFDatasetSourceConfig | ||
|
|
||
| HAS_DIRECT_HF_SFT = True | ||
| except ImportError: | ||
| HAS_DIRECT_HF_SFT = False | ||
| from megatron.core.datasets.utils import get_blend_from_list | ||
| from megatron.core.distributed import DistributedDataParallelConfig | ||
| from megatron.core.utils import unwrap_model | ||
|
|
@@ -117,6 +126,16 @@ def _nonnegative_int(value: str) -> int: | |
| return parsed | ||
|
|
||
|
|
||
| def _hf_source(spec: str) -> "HFDatasetSourceConfig": | ||
| """``<file>.json[l]`` -> local chat jsonl; otherwise ``<hub_id>[:<split>]`` (split defaults to train).""" | ||
| if spec.endswith((".json", ".jsonl")): | ||
| return HFDatasetSourceConfig( | ||
| path_or_dataset="json", split="train", load_kwargs={"data_files": spec} | ||
| ) | ||
| dataset, _, split = spec.partition(":") | ||
| return HFDatasetSourceConfig(path_or_dataset=dataset, split=split or "train") | ||
|
Comment on lines
+129
to
+136
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [IMPORTANT Compatibility] The combined spec discriminates "local file" from "hub id" solely on a The case I'd most expect in practice: a QAD pool kept as a directory of sharded jsonl (or any local path without the extension). The typo'd-file case is less confusing but still costly: Why this is worth a check rather than left to the builder: the A cheap version that keeps the spec as-is and only disambiguates when the answer is knowable locally: def _hf_source(spec: str) -> "HFDatasetSourceConfig":
"""``<file>.json[l]`` -> local chat jsonl; otherwise ``<hub_id>[:<split>]`` (split defaults to train)."""
if spec.lower().endswith((".json", ".jsonl")):
if not os.path.isfile(spec):
raise ValueError(f"{spec} is not a file.")
return HFDatasetSourceConfig(
path_or_dataset="json", split="train", load_kwargs={"data_files": spec}
)
dataset, _, split = spec.partition(":")
# A local path reaching the Hub branch fails as a malformed repo id deep in the data
# builder; say so here instead.
if os.path.exists(dataset):
raise ValueError(
f"{dataset} is a local path but not a .json/.jsonl file; --sft_hf_dataset takes a "
"single chat jsonl file or a '<hub_id>[:<split>]'."
)
return HFDatasetSourceConfig(path_or_dataset=dataset, split=split or "train")
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Intentional: the flag takes
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Follow-up in |
||
|
|
||
|
|
||
| def get_args(): | ||
| """Parse command-line arguments.""" | ||
| parser = argparse.ArgumentParser(description="Distillation for Megatron-Bridge.") | ||
|
|
@@ -181,8 +200,9 @@ def get_args(): | |
| parser.add_argument( | ||
| "--sft", | ||
| action="store_true", | ||
| help="Distill on prompt-completion jsonl from --sft_dataset_root with the loss masked to " | ||
| "the completion, instead of pre-tokenized --data_paths.", | ||
| help="Distill on SFT data with the loss masked to the responses, instead of pre-tokenized " | ||
| "--data_paths: prompt-completion jsonl from --sft_dataset_root, or chat data from " | ||
| "--sft_hf_dataset.", | ||
| ) | ||
| parser.add_argument( | ||
| "--sft_dataset_root", | ||
|
|
@@ -192,6 +212,32 @@ def get_args(): | |
| '{"input": <prompt>, "output": <response>} records (used with --sft). See the README for ' | ||
| "how the fields are tokenized and truncated.", | ||
| ) | ||
| parser.add_argument( | ||
| "--sft_hf_dataset", | ||
| type=str, | ||
| default=None, | ||
| help="Chat dataset to distill on (used with --sft): a local chat '<file>.jsonl', or " | ||
| "'<hub_id>[:<split>]' (split defaults to train). Only .json/.jsonl are read as local " | ||
| "files; anything else is taken as a Hub id. Each conversation is rendered by the " | ||
| "model's own chat template. Alternative to --sft_dataset_root, which expects " | ||
| '{"input", "output"} records already preprocessed into a dataset root.', | ||
| ) | ||
| parser.add_argument( | ||
| "--sft_hf_validation", | ||
| type=str, | ||
| default=None, | ||
| help="Validation data for --sft_hf_dataset, in the same format (required when " | ||
| "--eval_iters > 0), e.g. '<hub_id>:test' or '<hub_id>:train[:1000]'.", | ||
| ) | ||
| parser.add_argument( | ||
| "--sft_loss_mode", | ||
| type=str, | ||
| default="assistant", | ||
| choices=["assistant", "last_turn", "full"], | ||
| help="Which tokens --sft_hf_dataset trains on: every assistant turn, only the final one, " | ||
| "or the whole conversation. Multi-turn records train all of their responses under " | ||
| "'assistant'.", | ||
|
Comment on lines
+233
to
+239
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [SUGGESTION] Two things about
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Follow-up on 1: in 71b4ec1 I dropped the guard again to keep |
||
| ) | ||
| # Training & Eval arguments | ||
| parser.add_argument( | ||
| "--output_dir", type=str, required=True, help="Folder for logging and checkpoint saving" | ||
|
|
@@ -354,19 +400,32 @@ def get_args(): | |
| if args.validate_only and args.eval_iters == 0: | ||
| raise ValueError("--validate_only requires --eval_iters > 0.") | ||
|
|
||
| if args.sft and not args.sft_dataset_root: | ||
| if args.sft and not (args.sft_dataset_root or args.sft_hf_dataset): | ||
| raise ValueError( | ||
| "--sft requires --sft_dataset_root (a directory with training.jsonl, plus " | ||
| "validation.jsonl when --eval_iters > 0)." | ||
| "validation.jsonl when --eval_iters > 0) or --sft_hf_dataset." | ||
| ) | ||
| if args.sft_dataset_root and args.sft_hf_dataset: | ||
| raise ValueError("--sft_dataset_root and --sft_hf_dataset are mutually exclusive.") | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| if args.sft_hf_dataset: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
if not args.sft_hf_dataset and (args.sft_hf_validation or args.sft_loss_mode != "assistant"):
raise ValueError("--sft_hf_validation / --sft_loss_mode require --sft_hf_dataset.")(If you want to detect an explicit
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in |
||
| if not args.sft: | ||
| raise ValueError("--sft_hf_dataset requires --sft.") | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| if not HAS_DIRECT_HF_SFT: | ||
| raise ValueError( | ||
| "--sft_hf_dataset needs a newer Megatron-Bridge (DirectHFSFTDatasetConfig)." | ||
| ) | ||
| if args.eval_iters > 0 and not args.sft_hf_validation: | ||
| raise ValueError("--sft_hf_dataset with --eval_iters > 0 needs --sft_hf_validation.") | ||
|
Comment on lines
+410
to
+418
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [SUGGESTION] This file is otherwise strict about exactly this class of mistake: the Non-blocking because the wrong-loss-mode run still trains sensibly — it just isn't the masking the user asked for, which on a 400-iteration 8-node job is an expensive thing to discover from a wandb curve. The prior review raised the if args.sft_dataset_root and (args.sft_hf_validation or args.sft_loss_mode != "assistant"):
raise ValueError(
"--sft_hf_validation / --sft_loss_mode only apply to --sft_hf_dataset; the "
"--sft_dataset_root path would silently ignore them."
)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same trade-off as
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reversed in |
||
| elif args.sft_hf_validation or args.sft_loss_mode != "assistant": | ||
| raise ValueError("--sft_hf_validation / --sft_loss_mode require --sft_hf_dataset.") | ||
| if args.sft and (args.data_paths or args.use_mock_data): | ||
| raise ValueError( | ||
| "--sft is mutually exclusive with --data_paths / --use_mock_data: the SFT branch wins " | ||
| "the dataset selection, so those inputs would be silently ignored." | ||
| ) | ||
| if args.sft_dataset_root and not args.sft: | ||
| raise ValueError("--sft_dataset_root requires --sft; without it the SFT path is not used.") | ||
| if args.sft: | ||
| if args.sft and args.sft_dataset_root: | ||
| # Fail on a mistyped root here rather than after both checkpoints have loaded onto GPUs. | ||
| required = ["training.jsonl"] + (["validation.jsonl"] if args.eval_iters > 0 else []) | ||
| absent = [f for f in required if not os.path.isfile(os.path.join(args.sft_dataset_root, f))] | ||
|
|
@@ -557,7 +616,21 @@ def _restore_student_hook(model_chunks): | |
| "dataloader_type": "single", | ||
| "skip_getting_attention_mask_from_dataset": True, | ||
| } | ||
| if args.sft: | ||
| if args.sft and args.sft_hf_dataset: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
No test exercises this branch end to end. Following
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in |
||
| # Chat rows rendered by the model's own chat template, with the loss covering every | ||
| # assistant turn, so multi-round records train all of their responses rather than the last. | ||
|
Comment on lines
+619
to
+621
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [SUGGESTION] The # Default True would make text_to_ids inject a BOS at the answer boundary,
# since "{input}" and "{output}" are tokenized separately.There is no Worth a half-line: note that the chat path wants the same setting because the template emits BOS itself. Since this is the one config in the shared path whose rationale is source-specific, and the chat path hasn't been run against this exact file, it's also the one worth double-checking on the smoke run you offered in the description.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not changed by this PR. |
||
| dataset_config = DirectHFSFTDatasetConfig( | ||
| seq_length=args.seq_length, | ||
| source=_hf_source(args.sft_hf_dataset), | ||
| validation_source=( | ||
| _hf_source(args.sft_hf_validation) if args.sft_hf_validation else None | ||
| ), | ||
| preprocessing=ChatSFTPreprocessingConfig(loss_mode=args.sft_loss_mode), | ||
| dataloader_type="batch", | ||
| do_validation=args.eval_iters > 0, | ||
| do_test=False, | ||
| ) | ||
| elif args.sft: | ||
| # SFT-masked distillation via Bridge's FinetuningDatasetConfig -> NeMo-style GPTSFTDataset, | ||
| # reading {"input", "output"} jsonl. Fields are tokenized as written except that each is | ||
| # ``.strip(" ")``-ed; see --sft_dataset_root help. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please add a small unit test for this parser. The test module already imports from
examples/megatron_bridgethroughsys.path. Coverfoo.jsonl,org/ds,org/ds:testandorg/ds:train[:1000]; the last one depends onpartitionsplitting only on the first:. Note that any other local format (.parquet,.csv, a directory) will be treated as a Hub id. That's worth a line in the help text.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done in
792a8436b:test_hf_source_speccoversdata/foo.jsonl,data/foo.json,org/ds,org/ds:testandorg/ds:train[:1000](first-colon split). The--sft_hf_datasethelp now says only.json/.jsonlare read as local files; anything else is taken as a Hub id.