Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ Changelog

*Megatron Framework (M-LM / M-Bridge)*

- Add chat-data distillation to ``examples/megatron_bridge/distill.py``: ``--sft --sft_hf_dataset`` takes a local chat ``.jsonl`` or a Hugging Face Hub dataset, renders each conversation with the model's chat template, and ``--sft_loss_mode`` selects which turns are trained. Requires a Megatron-Bridge with ``DirectHFSFTDatasetConfig``.
- Add mergeable LoRA QAD for fake-quantized dense Megatron/Transformer Engine linear layers. Enable it with ``--lora_rank`` in Megatron-Bridge PTQ, train through the normal QAD flow, and export a standalone quantized checkpoint without deployment adapters.
- Add optional Top-P (nucleus) truncation to the Megatron top-k logits KD loss (now ``TopLogitsKLLoss``) via ``logit_kl_top_p`` and ``logit_kl_top_p_min_k`` in ``DistillationConfig``: after the global Top-K selection, only the smallest prefix whose cumulative teacher probability reaches ``top_p`` (with a floor of ``min_k`` entries) contributes to the KL, mirroring ``--logits-save-top-p`` / ``--logits-save-top-p-min-k`` in Megatron-LM's logits saver.
- Add an end-to-end W4A4 NVFP4 PTQ and QAD tutorial for Qwen3.6-35B-A3B also covering evaluation and vLLM throughput benchmarking. See `examples/megatron_bridge/tutorials/Qwen3.6-35B-A3B/README.md <https://github.com/NVIDIA/Model-Optimizer/tree/main/examples/megatron_bridge/tutorials/Qwen3.6-35B-A3B/>`_ for details.
Expand Down
9 changes: 9 additions & 0 deletions examples/megatron_bridge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,15 @@ token is appended after the response. A record longer than `--seq_length` is tru
**start** of `"input"`, which drops any system prompt or opening role marker baked in there, so
pre-filter or pre-truncate the corpus if that matters.

To distill on **chat data** instead, pass `--sft --sft_hf_dataset <spec>`, where `<spec>` is a
local `<file>.jsonl` of `{"messages": [{"role": ..., "content": ...}, ...]}` records or a Hugging
Face Hub dataset `<hub_id>[:<split>]` (split defaults to `train`, and may be a slice such as
`train[:1000]`). Each conversation is rendered with the model's own chat template, and
`--sft_loss_mode` picks the trained tokens: every assistant turn (`assistant`, the default), only
the last one (`last_turn`), or the whole conversation (`full`). With `--eval_iters > 0`, also pass
`--sft_hf_validation` in the same format. This path needs a Megatron-Bridge with
`DirectHFSFTDatasetConfig`.

Teacher and student must share a tokenizer — distillation scores the teacher on the student's
token ids, and the KD losses compare the two models' logits elementwise over the vocab dimension.

Expand Down
85 changes: 79 additions & 6 deletions examples/megatron_bridge/distill.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -117,6 +126,16 @@ def _nonnegative_int(value: str) -> int:
return parsed


def _hf_source(spec: str) -> "HFDatasetSourceConfig":

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bot comment.

Please add a small unit test for this parser. The test module already imports from examples/megatron_bridge through sys.path. Cover foo.jsonl, org/ds, org/ds:test and org/ds:train[:1000]; the last one depends on partition splitting 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.

Copy link
Copy Markdown
Contributor Author

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_spec covers data/foo.jsonl, data/foo.json, org/ds, org/ds:test and org/ds:train[:1000] (first-colon split). The --sft_hf_dataset help now says only .json/.jsonl are read as local files; anything else is taken as a Hub id.

"""``<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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 .json/.jsonl suffix, and nothing validates either branch before the checkpoints load — so a plausible user input fails late and with a misleading error.

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). --sft_hf_dataset /lustre/pool/train has no .json/.jsonl suffix, so it falls through to spec.partition(":") and becomes HFDatasetSourceConfig(path_or_dataset="/lustre/pool/train", split="train"). The user then gets an HF HFValidationError about repo-id format for a path that exists on disk — which points them at the wrong thing entirely. Two narrower variants land the same way: a case-mismatched suffix (train.JSONL), and .jsonl.gz/.parquet shards.

The typo'd-file case is less confusing but still costly: load_kwargs={"data_files": "<typo>"} raises inside the Bridge data builder, i.e. after both the teacher and the student checkpoint are resident on GPU. On the 8-node runs this flag exists to support, that is a real amount of wasted allocation for a one-character mistake.

Why this is worth a check rather than left to the builder: the --sft_dataset_root branch at line 409 front-loads exactly this validation, with the rationale spelled out in its own comment — "Fail on a mistyped root here rather than after both checkpoints have loaded onto GPUs." The convention is already established in this file; the new branch is the one that doesn't follow it. I see the last commit deliberately dropped the file-existence check, so this is a judgment call you've already made once — but I don't think the earlier review covered the directory/hub-id ambiguity, which is specific to the newer single-spec form and is the misleading failure rather than merely the late one.

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")

os is already imported. Calling it from get_args (next to the line-409 check) rather than at config-build time would move both errors ahead of checkpoint load; as written in _hf_source they at least precede the data builder.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional: the flag takes <file>.json[l] or <hub_id>[:<split>] as documented, and 71b4ec130 deliberately leaves path validation to the Bridge builder to keep get_args() small.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up in 792a8436b: the --sft_hf_dataset help now states that only .json/.jsonl are read as local files and anything else is taken as a Hub id, and test_hf_source_spec pins the spec parsing. No existence check added.



def get_args():
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(description="Distillation for Megatron-Bridge.")
Expand Down Expand Up @@ -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",
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] Two things about --sft_loss_mode:

  1. It is silently ignored on the --sft_dataset_root path. That branch hardcodes answer_only_loss=True with a "{input}{output}" template, so its masking is fixed regardless of this flag. A user who passes --sft_dataset_root ... --sft_loss_mode full gets answer-only masking with no warning. The help text says "Which tokens --sft_hf_dataset trains on", which is accurate, but the file raises for other mis-paired flags (--sft_dataset_root without --sft) — a matching guard would be more consistent than relying on help text.

  2. Only assistant is exercised. Per the PR description the 16 QAD runs all used assistant; last_turn and full are asserted here via choices but never validated against ChatSFTPreprocessingConfig.loss_mode's accepted values. Worth confirming those two strings are what Bridge actually accepts — an argparse choices list that disagrees with the downstream config turns a clean CLI rejection into a late crash (or, worse, a silently-accepted-but-unintended mode).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Addressed in 68e99af: a non-default --sft_loss_mode without --sft_hf_dataset now raises (distill.py:418), so it can't be silently ignored on the --sft_dataset_root path.
  2. Checked against Megatron-Bridge. ChatSFTPreprocessingConfig.loss_mode is Literal["assistant", "last_turn", "full"] and is validated in __post_init__ (megatron/bridge/data/sft_processing.py:55-60, on main and on the 0.6.0 we run). That matches the argparse choices exactly, so no change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 get_args() small. --sft_loss_mode's help already scopes it to --sft_hf_dataset. Point 2 stands.

)
# Training & Eval arguments
parser.add_argument(
"--output_dir", type=str, required=True, help="Folder for logging and checkpoint saving"
Expand Down Expand Up @@ -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.")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if args.sft_hf_dataset:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bot comment.

--sft_hf_validation and --sft_loss_mode are only read on the --sft_hf_dataset branch. With --sft_dataset_root, --sft_loss_mode last_turn or --sft_hf_validation x are silently dropped. That's the same silent-ignore case the --sft vs --data_paths check below was added to prevent. Suggest:

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 assistant, use default=None and resolve it later.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 792a8436b: --sft_hf_validation or a non-default --sft_loss_mode without --sft_hf_dataset now raises --sft_hf_validation / --sft_loss_mode require --sft_hf_dataset.

if not args.sft:
raise ValueError("--sft_hf_dataset requires --sft.")
Comment thread
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] --sft_loss_mode and --sft_hf_validation are silently inert on the --sft_dataset_root path — both are read only inside the args.sft and args.sft_hf_dataset branch at line 599. A user who passes --sft --sft_dataset_root <dir> --sft_loss_mode full gets assistant-only masking (answer_only_loss=True) with no indication the flag did nothing, and --sft_hf_validation likewise vanishes.

This file is otherwise strict about exactly this class of mistake: the --sft / --data_paths check at line 402 rejects the combination rather than letting the loser be ignored, and gives the reason ("those inputs would be silently ignored"). Line 407 does the same for --sft_dataset_root without --sft.

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 --sft_loss_mode half of this and the last commit dropped the guard, so you may have already decided against it; flagging that --sft_hf_validation has the same shape in case that part was unintentional. If you want it back, it's two lines next to the existing mutual-exclusion check:

    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."
        )

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same trade-off as 71b4ec130: both flags are documented as --sft_hf_dataset-only; not adding a guard.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reversed in 792a8436b at a maintainer's request: --sft_hf_validation or a non-default --sft_loss_mode without --sft_hf_dataset now raises.

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))]
Expand Down Expand Up @@ -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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bot comment.

No test exercises this branch end to end. Following test_distill_llm_sft, a tiny-Qwen3 run with a few {"messages": [...]} rows (including one multi-turn record), --sft_hf_validation and eval_iters=1 would catch Bridge API drift in DirectHFSFTDatasetConfig and ChatSFTPreprocessingConfig. Also, if the CI Bridge lacks these symbols, decide whether the test should skip on HAS_DIRECT_HF_SFT.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 792a8436b: test_distill_llm_sft_hf_chat runs distill.py --sft --sft_hf_dataset ... --sft_hf_validation ... with eval_iters=1 on tiny Qwen3 with chat jsonl (every 4th record multi-turn), and skips when HAS_DIRECT_HF_SFT is false. It passed inside nemo:26.08 with the bundled Megatron-Bridge, so CI runs it rather than skipping. Also in this commit: README section, CHANGELOG entry, and --sft help naming both data sources.

# 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] The TokenizerConfig at line ~704 is still gated on bare args.sft, which is now true for both SFT sources, but its include_special_tokens: False comment only explains the --sft_dataset_root case:

# Default True would make text_to_ids inject a BOS at the answer boundary,
# since "{input}" and "{output}" are tokenized separately.

There is no {input}/{output} boundary on this path — the chat template renders one string per conversation. As far as I can tell the setting is still correct here (a chat template emits its own BOS/control tokens, so suppressing an extra prepended BOS is what you want, and add_special_tokens=False does not stop HF from recognizing special tokens already present in the rendered text), so this is not a bug — but the comment now sits on a shared branch and justifies itself with a reason that applies to only one of the two consumers. A reader debugging chat tokenization will bounce off it.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changed by this PR. include_special_tokens is existing --sft_dataset_root code on main. It also has no effect on the --sft_hf_dataset path: Bridge's chat builder unwraps to the HF tokenizer and calls its apply_chat_template directly (conversation_processing.py, tokenize_chat_example), so text_to_ids, the only reader of that kwarg, is never called there.

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.
Expand Down
70 changes: 70 additions & 0 deletions tests/examples/megatron_bridge/test_distill.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@

sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "examples" / "megatron_bridge"))

from distill import HAS_DIRECT_HF_SFT, _hf_source
from export_distilled_megatron_to_hf import _get_checkpoint_export_paths


Expand Down Expand Up @@ -120,6 +121,75 @@ def test_distill_llm_sft(tmp_path, num_gpus):
assert (distill_output_dir / f"checkpoints/iter_{train_iters:07d}").exists()


@pytest.mark.skipif(not HAS_DIRECT_HF_SFT, reason="Megatron-Bridge lacks DirectHFSFTDatasetConfig")
def test_distill_llm_sft_hf_chat(tmp_path, num_gpus):
"""--sft_hf_dataset distills on chat jsonl rendered by the model's chat template."""
teacher_hf_path = create_tiny_qwen3_dir(tmp_path, with_tokenizer=True)
train_iters = 2
gbs = 4
# More records than train_iters * gbs so the sampler does not run dry; every 4th is multi-turn.
records = []
for i in range(64):
messages = [
{"role": "user", "content": f"what follows {i}?"},
{"role": "assistant", "content": f"{i + 1}"},
]
if i % 4 == 0:
messages += [
{"role": "user", "content": "and then?"},
{"role": "assistant", "content": f"{i + 2}"},
]
records.append({"messages": messages})
data_files = {}
for split in ("train", "validation"):
data_files[split] = tmp_path / f"chat_{split}.jsonl"
data_files[split].write_text("\n".join(json.dumps(r) for r in records) + "\n")

distill_output_dir = tmp_path / "distill_output"
distill_cmd_parts = extend_cmd_parts(
["torchrun", f"--nproc_per_node={num_gpus}", "distill.py", "--sft"],
student_hf_path=teacher_hf_path,
teacher_hf_path=teacher_hf_path,
sft_hf_dataset=data_files["train"],
sft_hf_validation=data_files["validation"],
output_dir=distill_output_dir,
tp_size=num_gpus,
pp_size=1,
seq_length=64,
mbs=1,
gbs=gbs,
train_iters=train_iters,
lr_warmup_iters=1,
eval_interval=train_iters,
eval_iters=1,
log_interval=1,
)
run_example_command(distill_cmd_parts, example_path="megatron_bridge")

assert (distill_output_dir / f"checkpoints/iter_{train_iters:07d}").exists()


@pytest.mark.skipif(not HAS_DIRECT_HF_SFT, reason="Megatron-Bridge lacks DirectHFSFTDatasetConfig")
@pytest.mark.parametrize(
("spec", "path_or_dataset", "split", "load_kwargs"),
[
("data/foo.jsonl", "json", "train", {"data_files": "data/foo.jsonl"}),
("data/foo.json", "json", "train", {"data_files": "data/foo.json"}),
("org/ds", "org/ds", "train", None),
("org/ds:test", "org/ds", "test", None),
# Only the first ':' separates the split, so slice syntax survives.
("org/ds:train[:1000]", "org/ds", "train[:1000]", None),
],
)
def test_hf_source_spec(spec, path_or_dataset, split, load_kwargs):
source = _hf_source(spec)
assert (source.path_or_dataset, source.split, source.load_kwargs) == (
path_or_dataset,
split,
load_kwargs,
)


def test_distill_validate_only(tmp_path, num_gpus):
teacher_hf_path = create_tiny_qwen3_dir(tmp_path, with_tokenizer=True)
train_iters = 2
Expand Down
Loading