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 @@ -12,6 +12,7 @@ Changelog

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

- Add SFT-masked data support to ``examples/megatron_bridge/distill.py``: ``--sft --sft_dataset_root <dir>`` distills on raw prompt-completion JSONL (``{"input", "output"}`` records) with the loss masked to the response tokens, using Megatron-Bridge's ``FinetuningDatasetConfig`` and the model's own HuggingFace tokenizer instead of the pretraining ``GPTDataset`` and ``NullTokenizer``.
- Add per-expert weight quantization for Transformer Engine ``TEGroupedMLP`` (fused MoE experts): each expert now has its own ``weight_quantizer`` (a ``GroupedQuantizer`` holding one ``TensorQuantizer`` per expert) with an independent ``amax``, instead of a single shared ``amax`` across all experts. Applies to ``mtq.quantize`` calibration, HF / Megatron export, and QAD.
- Add opt-in ``torch.compile`` execution for Transformer Engine grouped-linear per-expert weight quantizers while preserving their native checkpoint amax shapes. Set ``MODELOPT_TEGROUPED_COMPILE_WEIGHT_LOOP=1`` before quantized-module conversion; the default path remains eager.

Expand Down
15 changes: 15 additions & 0 deletions examples/megatron_bridge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,21 @@ The distillation script expects pre-tokenized data in Megatron's binary format (
See the **[Dataset Preparation README](../dataset/README.md#tokenizing-for-megatron-frameworks)**
for full instructions on tokenizing JSONL files and Hugging Face datasets and get the list of output prefixes that you can use for `--data_paths` argument.

Alternatively, pass `--sft --sft_dataset_root <dir>` to distill on **raw prompt-completion JSONL**
with the loss masked to the completion. The directory must hold `training.jsonl` (and
`validation.jsonl` when `--eval_iters > 0`) of `{"input": <prompt>, "output": <response>}` records, which are tokenized with
the model's own HuggingFace tokenizer. Both fields are tokenized **as written**, except that
leading and trailing spaces on each field are stripped — no chat template is applied. So if your
model expects role/turn markers, include them in the `"input"` field yourself, and express any
significant separator as a newline rather than a trailing space. A BOS token is prepended
automatically when the tokenizer prepends one at inference, so do not add it yourself; an EOS
token is appended after the response. A record longer than `--seq_length` is truncated from the
**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.

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.

### Distillation with Real Data

Example usage to distill a 4B student (HF) from an 8B teacher (HF) on 8 GPUs (TP=8, PP=1):
Expand Down
142 changes: 132 additions & 10 deletions examples/megatron_bridge/distill.py
Comment thread
kevalmorabia97 marked this conversation as resolved.
Comment thread
kevalmorabia97 marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from megatron.bridge.training.config import (
CheckpointConfig,
ConfigContainer,
FinetuningDatasetConfig,
GPTDatasetConfig,
LoggerConfig,
MockGPTDatasetConfig,
Expand All @@ -45,10 +46,11 @@
from megatron.bridge.training.distill import distill
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
from megatron.core.datasets.utils import get_blend_from_list
from megatron.core.distributed import DistributedDataParallelConfig
from megatron.core.utils import unwrap_model
from transformers import AutoConfig
from transformers import AutoConfig, AutoTokenizer

import modelopt.torch.distill as mtd
import modelopt.torch.utils.distributed as dist
Expand Down Expand Up @@ -125,6 +127,20 @@ def get_args():
parser.add_argument(
"--use_mock_data", action="store_true", help="Use mock data instead of --data_paths"
)
parser.add_argument(
Comment thread
kevalmorabia97 marked this conversation as resolved.
"--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.",
)
parser.add_argument(
"--sft_dataset_root",

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.

do we forsee a use case of providing HF SFT dataset name instead of a local SFT dataset root?

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.

Yes, and it's written — --sft_hf_dataset reading a chat dataset straight from the Hub via Megatron-Bridge's DirectHFSFTDatasetConfig, no pre-tokenization, loss masked to every assistant turn.

I've pulled it out of this PR to keep the scope to one thing (local prompt-completion JSONL) so this can land quickly, and will send it as a separate PR. Two reasons it isn't ready to review yet:

  • DirectHFSFTDatasetConfig only exists in Megatron-Bridge after the 2026-07-09 data-builder refactor, and no released NeMo container ships it yet (nemo:26.06 was rebuilt 2026-07-18 but pins an older Bridge), so it can't run on a stock image today.
  • The pre-refactor HFDatasetConfig isn't a usable fallback for this model family: its chat path needs a {% generation %} block in the tokenizer's chat template to build assistant-only masks, which Nemotron's template doesn't have.

type=str,
default=None,
help="Directory holding training.jsonl (and validation.jsonl when --eval_iters > 0) of "
'{"input": <prompt>, "output": <response>} records (used with --sft). See the README for '
"how the fields are tokenized and truncated.",
)
# Training & Eval arguments
parser.add_argument(
"--output_dir", type=str, required=True, help="Folder for logging and checkpoint saving"
Expand Down Expand Up @@ -246,7 +262,7 @@ def get_args():
args = parser.parse_args()

# Sanity checks
if not args.use_mock_data and not args.data_paths:
if not args.sft and not args.use_mock_data and not args.data_paths:
raise ValueError("Must provide either --data_paths or set --use_mock_data.")

if args.student_hf_model is None:
Expand All @@ -256,11 +272,59 @@ 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:
raise ValueError(
"--sft requires --sft_dataset_root (a directory with training.jsonl, plus "
"validation.jsonl when --eval_iters > 0)."
)
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:
# 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))]
if absent:
raise ValueError(f"--sft_dataset_root {args.sft_dataset_root} is missing: {absent}.")
# Decided once here so it reaches print_args and costs a single tokenizer load.
args.sft_add_bos = _tokenizer_prepends_bos(args)

_check_shared_vocabulary(args)

Comment on lines +280 to +297

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION] The mutual-exclusion check covers --sft + --data_paths/--use_mock_data, but not the mirror case: --sft_dataset_root <dir> without --sft is accepted and silently ignored — the run falls through to the --data_paths branch (or fails the "Must provide either --data_paths or set --use_mock_data" check with a message that doesn't mention the flag the user actually passed). Given the reasoning already in the message below ("would be silently ignored"), the same argument applies in this direction.

    if args.sft_dataset_root and not args.sft:
        raise ValueError("--sft_dataset_root requires --sft; without it the SFT data path is not used.")

print_args(args)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return args


def _check_shared_vocabulary(args) -> None:
Comment thread
AAnoosheh marked this conversation as resolved.
"""Raise unless teacher and student use the same tokenizer."""
_tok = {"trust_remote_code": args.trust_remote_code}
student_vocab = AutoTokenizer.from_pretrained(args.student_hf_path, **_tok).get_vocab()
teacher_vocab = AutoTokenizer.from_pretrained(args.teacher_hf_path, **_tok).get_vocab()
if student_vocab != teacher_vocab:
raise ValueError(
"Distillation scores the teacher on the student's token ids, so teacher and student "
"must use the same tokenizer."
)


def _tokenizer_prepends_bos(args) -> bool:
"""True when the student tokenizer prepends a BOS at inference.

Probes an encode: fast tokenizers prepend via a post-processor that exposes no attribute.
"""
tokenizer = AutoTokenizer.from_pretrained(
args.student_hf_path, trust_remote_code=args.trust_remote_code
)
if not getattr(tokenizer, "bos_token", None):
return False
return tokenizer("x").input_ids[:1] == [tokenizer.bos_token_id]


def main(args: argparse.Namespace):
checkpoint_dir = os.path.join(args.output_dir, "checkpoints")
tensorboard_dir = os.path.join(args.output_dir, "tb_logs")
Expand All @@ -279,6 +343,10 @@ def _build_model_provider(hf_path, load_weights=True):
provider.expert_model_parallel_size = args.ep_size
provider.expert_tensor_parallel_size = 1 # Expert tensor parallelism is not supported
provider.seq_length = args.seq_length
if args.sft:
# A response-only loss mask needs per-token reduction to combine across CP ranks.
# Must stay in sync with ``average_in_collective=not args.sft`` on the DDP config.
provider.calculate_per_token_loss = True
Comment on lines +346 to +349

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION] _build_model_provider is called for both student and teacher, so calculate_per_token_loss = True lands on both providers. That's harmless (the teacher's LM loss is zeroed out in adjust_distillation_model_for_mcore, and the flag has to agree with the average_in_collective=not args.sft setting on the single shared DistributedDataParallelConfig anyway), but it reads as if it were a student-only knob.

Since this and line 450 are two halves of one decision that must stay in sync, a one-line note here pointing at the DDP setting — or moving the comment to mention both — would keep a future edit from flipping one without the other.

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.

Accepted — 8c19f38.

Kept the flag where it is (it does have to agree with the single shared DistributedDataParallelConfig, so scoping it to the student would be wrong) and made the coupling explicit instead: the comment now says it lands on both providers, why that is harmless, and that it must stay in sync with average_in_collective=not args.sft below.

if args.recompute_granularity is not None:
provider.recompute_granularity = args.recompute_granularity
provider.recompute_method = args.recompute_method
Expand All @@ -302,14 +370,26 @@ def _build_model_provider(hf_path, load_weights=True):
student_provider.gradient_accumulation_fusion = False
teacher_provider = _build_model_provider(args.teacher_hf_path)

# The KD losses compare logits elementwise over the vocab dim, so both output layers must have
# the same padded width. A shared tokenizer does not imply it: the HF configs can disagree.
padded = {
name: calculate_padded_vocab_size(
p.vocab_size, p.make_vocab_size_divisible_by, p.tensor_model_parallel_size
)
for name, p in (("student", student_provider), ("teacher", teacher_provider))
}
if padded["student"] != padded["teacher"]:
raise ValueError(
"Distillation needs student and teacher logits of equal width, but their padded vocab "
f"sizes differ ({padded['student']} vs {padded['teacher']})."
)

kd_config = ModelOptDistillConfig(
skip_lm_loss=not args.no_skip_lm_loss, kd_loss_scale=args.kd_loss_scale
)

# VLM detection convention: HF VLM configs expose a ``vision_config``, and Megatron-Bridge nests
# the text model under the ``language_model`` submodule (used as ``distill_submodule`` below). If a
# future model breaks either convention, the ``getattr(model, "language_model")`` in the provider
# will error loudly rather than silently distilling the wrong module.
# HF VLM configs expose ``vision_config``; Megatron-Bridge nests the text model under
# ``language_model`` (used as ``distill_submodule`` below).
is_vlm = hasattr(
AutoConfig.from_pretrained(args.student_hf_path, trust_remote_code=args.trust_remote_code),
"vision_config",
Expand Down Expand Up @@ -368,7 +448,33 @@ def _restore_student_hook(model_chunks):
"dataloader_type": "single",
"skip_getting_attention_mask_from_dataset": True,
}
if args.use_mock_data:
if 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.
dataset_config = FinetuningDatasetConfig(
seq_length=args.seq_length,
dataset_root=args.sft_dataset_root,
seed=args.seed,
dataloader_type="batch",
# Honour --eval_iters 0 so a training-only dataset_root does not have to carry a
# dummy validation.jsonl just to satisfy the builder.
do_validation=args.eval_iters > 0,
do_test=False,
Comment thread
kevalmorabia97 marked this conversation as resolved.
dataset_kwargs={
"prompt_template": "{input}{output}",
"label_key": "output",
"truncation_field": "input",
Comment thread
kevalmorabia97 marked this conversation as resolved.
# Drop the oldest context. The default "right" would cut the prompt/answer
# boundary and then "output" itself, the only span the loss is computed on.
"truncation_method": "left",
Comment on lines +466 to +470

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[IMPORTANT Compatibility] truncation_method="left" silently deletes exactly the token _warn_if_bos_missing tells the user to add.

Two decisions in this same diff work against each other:

  • _warn_if_bos_missing (line 299) instructs the user to "Include <bos> at the start of the 'input' field", and the README repeats it: "if your model expects role/turn markers or a BOS token, include them in the 'input' field yourself."
  • truncation_field="input" + truncation_method="left" drops tokens from the beginning of that same field. So for any record whose input+output exceeds seq_length, the first thing removed is the baked-in BOS — plus the system prompt and the opening role marker that follow it.

Why it matters. The result is the same train/inference skew the BOS warning exists to prevent, except now it is per-record and invisible: short records train with the BOS, long ones train without it, and nothing in the logs distinguishes them. At seq_length=32768 (the tested configuration) truncation is rare, so a reported-clean run tells you nothing about a corpus with long contexts. Note also that right is not simply the better choice — it would truncate the answer boundary instead, which is why you moved off it. Both ends are load-bearing; the fix is visibility, not a different default.

Suggestion. Say so where the user is told to bake in the BOS. Extend the --sft_dataset_root help and the README with one sentence, e.g.:

Records longer than --seq_length are truncated from the start of "input", which drops any BOS / system prompt / opening role marker you baked in. Pre-filter or pre-truncate your corpus if that matters.

If you want a runtime signal rather than only docs, _warn_if_bos_missing is already the natural place: it has the tokenizer, so it could additionally warn when the first record's input+output already exceeds seq_length.

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.

@yueshen2016 Can you take a look at this?

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.

add_bos now comes from a tokenizer probe instead of being hardcoded False, so Bridge prepends the BOS after truncation and budgets for it in total_ids .

"answer_only_loss": True,
# Prepended after truncation, so it survives a record that had to be cut.
"add_bos": args.sft_add_bos,
"add_eos": True,
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines +464 to +475

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[IMPORTANT Compatibility] add_bos: False is hardcoded, and prompt_template="{input}{output}" applies no chat template. Together these mean every training sequence starts directly at the first token of input — no BOS, no role markers.

For a model family whose tokenizer/chat template always prepends BOS (Llama <|begin_of_text|>, Gemma <bos>, Mistral <s>), that is a train/inference skew: distillation runs on sequences the model never sees at serving time, and nothing warns about it. The PR body's stated intent is the opposite — "matching how the model was fine-tuned" — and this silently doesn't for those models. It happens to be correct for the Nemotron-Nano-3 run in the Testing section, which is why the run looked clean.

The escape hatch (bake the full chat-formatted prompt, including BOS and role markers, into the "input" field) is real but undocumented — neither the --sft_dataset_root help text nor the README says the text is fed verbatim, so the natural reading of {"input": <prompt>, "output": <response>} is plain instruction text.

Two options, either is fine:

  1. Derive it from the tokenizer instead of hardcoding, so BOS-requiring models get BOS:
    "add_bos": AutoTokenizer.from_pretrained(
        args.student_hf_path, trust_remote_code=args.trust_remote_code
    ).bos_token is not None,
  2. Keep add_bos=False (verbatim is a defensible contract) but state the requirement where users will read it — in the --sft_dataset_root help string and the README: input must contain the fully templated prompt, including any BOS and role/turn markers the model expects; no chat template or BOS is added.

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.

Accepted — took option 2, in 080f4de.

Deriving add_bos from the tokenizer is the more automatic fix, but it would silently change tokenization for the runs already validated against this path, and it only covers BOS while leaving the role/turn-marker half of the skew unaddressed. "The fields are tokenized verbatim" is the contract I actually want; it was just undocumented.

So the requirement is now stated in all three places a user could look:

  • the --sft_dataset_root help text,
  • a comment next to the dataset_kwargs that implement it,
  • the Data Preparation section of examples/megatron_bridge/README.md.

Each says the same thing: both fields are tokenized verbatim, no chat template is applied and no BOS is prepended, so bake in any role/turn markers and BOS the model expects.

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.

Following up on this thread: I took option 2 (document the contract), and after digging further I think option 1 as written would have been actively wrong for this model.

bos_token is not None tests whether a BOS exists in the vocab, not whether the tokenizer uses one. Nemotron-3.5's config is:

bos_token     : <s>        # exists
add_bos_token : False      # but is never prepended

and its chat_template.jinja never emits one either — the first thing it produces is <|im_start|>system. So option 1 would have set add_bos=True and trained the model with a BOS it never sees at serving, creating exactly the skew the suggestion was meant to prevent.

Even the corrected predicate (tokenizer.add_bos_token) conflicts with the contract, because a fully-templated input may already contain a BOS and would then get a second one. The two designs are mutually exclusive: either the caller owns the whole prompt (verbatim, what this PR does) or the framework does (apply_chat_template).

What your comment did expose is a real gap the documentation alone does not close: a model with add_bos_token=True whose data lacks a BOS is silently skewed. That case is now detected (6160a69):

def _warn_if_bos_missing(tokenizer, dataset_root: str) -> None:
    bos = getattr(tokenizer, "bos_token", None)
    if not bos or not getattr(tokenizer, "add_bos_token", False):
        return
    ...
    if not first_input.startswith(bos):
        warn_rank_0(f"This tokenizer prepends {bos!r} at inference, but the first record ... ")

Verified across five cases: silent for add_bos_token=False, silent when no BOS token exists, silent when the caller already baked the BOS in, fires when the tokenizer adds one and the data lacks it, and silent on a missing/malformed file (left to the dataset builder to report).

For data that is not pre-templated at all, the right answer is Bridge's GPTSFTChatDataset with use_hf_tokenizer_chat_template=True, which handles BOS, role markers and multi-turn together. Tracking that as a follow-up rather than expanding this PR.

Comment thread
kevalmorabia97 marked this conversation as resolved.
)
elif args.use_mock_data:
dataset_config = MockGPTDatasetConfig(**dataset_kwargs)
else:
# Convert flat CLI list (e.g. ["1.0", "/path/data"]) to Megatron blend format
Expand Down Expand Up @@ -399,7 +505,7 @@ def _restore_student_hook(model_chunks):
grad_reduce_in_fp32=True,
overlap_grad_reduce=True,
overlap_param_gather=True,
average_in_collective=True,
average_in_collective=not args.sft, # per-token loss must not be pre-averaged
use_distributed_optimizer=True,
),
dataset=dataset_config,
Expand All @@ -412,8 +518,24 @@ def _restore_student_hook(model_chunks):
wandb_entity=args.wandb_entity, # optional
wandb_exp_name=args.wandb_exp_name,
),
tokenizer=TokenizerConfig(
tokenizer_type="NullTokenizer", vocab_size=distill_provider.vocab_size
tokenizer=(
# SFT reads raw text, so it needs the model's real tokenizer; the pretraining path
# consumes pre-tokenized data and keeps NullTokenizer.
TokenizerConfig(
tokenizer_type="HuggingFaceTokenizer",
tokenizer_model=args.student_hf_path,
hf_tokenizer_kwargs={
"trust_remote_code": args.trust_remote_code,
# Default True would make text_to_ids inject a BOS at the answer boundary,
# since "{input}" and "{output}" are tokenized separately. Consumed by Bridge
# in training/tokenizers/config.py.
"include_special_tokens": False,
},
)
Comment thread
kevalmorabia97 marked this conversation as resolved.
if args.sft
else TokenizerConfig(
tokenizer_type="NullTokenizer", vocab_size=distill_provider.vocab_size
)
),
checkpoint=CheckpointConfig(
save_interval=(
Expand Down
38 changes: 38 additions & 0 deletions tests/examples/megatron_bridge/test_distill.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
# limitations under the License.
"""Tests for prune_minitron.py and distill.py scripts."""

import json
from pathlib import Path

import pytest
Expand Down Expand Up @@ -58,6 +59,43 @@ def test_distill_llm(tmp_path, num_gpus):
assert (distilled_hf_path / "config.json").exists()


def test_distill_llm_sft(tmp_path, num_gpus):
"""--sft distills from prompt-completion jsonl instead of pre-tokenized --data_paths."""
teacher_hf_path = create_tiny_qwen3_dir(tmp_path, with_tokenizer=True)
train_iters = 2
gbs = 4
dataset_root = tmp_path / "sft_data"
dataset_root.mkdir()
# More records than train_iters * gbs so the sampler does not run dry.
records = [{"input": f"Q: what follows {i}?\nA:", "output": f" {i + 1}"} for i in range(64)]
for split in ("training", "validation"):
(dataset_root / f"{split}.jsonl").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_dataset_root=dataset_root,
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()


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