Skip to content

Commit 38eea70

Browse files
authored
Merge branch 'main' into codex/refactor-component-builders
2 parents 00c659c + dca7a3a commit 38eea70

15 files changed

Lines changed: 700 additions & 140 deletions

File tree

examples/llm_finetune/agent/qwen2_5_3b_function_calling.yaml

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,17 +114,23 @@ validation_dataloader:
114114
_target_: torchdata.stateful_dataloader.StatefulDataLoader
115115
collate_fn: nemo_automodel.components.datasets.utils.default_collater
116116

117-
# Generation-based tool-call accuracy eval. Runs at every val step alongside
118-
# val_loss. Catches the case where loss is decreasing but the model is
119-
# emitting wrong tool names or malformed argument JSON — a regression
120-
# loss alone cannot detect. Remove or comment out this block to disable.
117+
# Generation-based tool-call accuracy eval. Catches the case where loss is
118+
# decreasing but the model emits wrong tool names or malformed argument JSON —
119+
# something loss alone cannot detect. Remove or comment out this block to disable.
120+
#
121+
# NOTE: this recipe uses `distributed.strategy: fsdp2`, which SKIPS in-loop
122+
# generation eval by default — running generate() on the sharded training model
123+
# repeatedly unshards params and can OOM the next step. It runs every val step
124+
# under DDP. To force it under FSDP2, set `run_on_fsdp2: true` below (slower,
125+
# higher memory); or run the evaluator offline against a saved checkpoint.
121126
tool_call_eval:
122127
_target_: nemo_automodel.components.eval.tool_call_evaluator.ToolCallAccuracyEvaluator
123128
dataset_name: llamafactory/glaive_toolcall_en
124129
split: train[:128]
125130
max_eval_samples: 128
126131
max_new_tokens: 256
127132
max_prompt_tokens: 3584
133+
# run_on_fsdp2: true # opt in to in-loop eval under FSDP2 (otherwise skipped)
128134

129135
optimizer:
130136
_target_: torch.optim.AdamW

nemo_automodel/components/datasets/llm/agent_chat.py

Lines changed: 1 addition & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -275,38 +275,6 @@ def _truncate_messages_to_fit(
275275
return system + history[boundaries[-1] :]
276276

277277

278-
def _mask_labels_to_last_turn(labels: List[int], ignore_index: int = -100) -> List[int]:
279-
"""Restrict the loss to the final assistant turn (``mask_history``).
280-
281-
``labels`` come from :func:`format_chat_template` with every assistant
282-
turn supervised; non-assistant tokens are already ``ignore_index``.
283-
Because the chat template renders each assistant message as a single
284-
contiguous span, supervised tokens form one maximal run per assistant
285-
turn separated by ``ignore_index`` runs. This keeps only the last such
286-
run and masks every earlier supervised token in place.
287-
288-
Args:
289-
labels: per-token labels (``ignore_index`` marks unsupervised tokens).
290-
ignore_index: the value marking unsupervised tokens.
291-
292-
Returns:
293-
The same list, mutated so only the final supervised run is kept.
294-
"""
295-
last = -1
296-
for i in range(len(labels) - 1, -1, -1):
297-
if labels[i] != ignore_index:
298-
last = i
299-
break
300-
if last < 0:
301-
return labels
302-
start = last
303-
while start - 1 >= 0 and labels[start - 1] != ignore_index:
304-
start -= 1
305-
for i in range(start):
306-
labels[i] = ignore_index
307-
return labels
308-
309-
310278
def _format_example(
311279
example: Dict[str, Any],
312280
tokenizer,
@@ -400,10 +368,8 @@ def _format_example_impl(
400368
truncation=truncation,
401369
answer_only_loss_mask=True,
402370
mask_reasoning_content=mask_reasoning_content,
371+
train_on_last_turn_only=train_on_last_turn_only,
403372
)
404-
if train_on_last_turn_only:
405-
_mask_labels_to_last_turn(tokenized["labels"])
406-
407373
# Truncation (or over-aggressive last-turn masking) can leave a sample with no
408374
# supervised tokens at all — every label is ``ignore_index`` (-100). A single
409375
# such sample is harmless: the loss normalizes by the batch's supervised-token

nemo_automodel/components/datasets/llm/formatting_utils.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,41 @@ def _build_reasoning_mask(
279279
return reasoning_mask
280280

281281

282+
def _mask_labels_to_last_turn(mask: List[int], ignore_index: int = -100) -> List[int]:
283+
"""Restrict supervision to the final assistant turn (``mask_history``).
284+
285+
Operates on any per-token sequence where ``ignore_index`` marks
286+
unsupervised positions: a label list (``ignore_index=-100``) or a 0/1
287+
assistant mask (``ignore_index=0``). Each assistant turn renders as a
288+
single contiguous supervised span, so this keeps only the last such run
289+
and rewrites every earlier supervised position to ``ignore_index``.
290+
291+
Apply this to the assistant mask **before** any reasoning_content holes are
292+
punched into it; running it on already-holed labels would treat the
293+
reasoning gap as a turn boundary and drop in-turn content before the hole.
294+
295+
Args:
296+
mask: per-token labels or 0/1 mask (``ignore_index`` marks unsupervised).
297+
ignore_index: the value marking unsupervised positions.
298+
299+
Returns:
300+
The same list, mutated so only the final supervised run is kept.
301+
"""
302+
last = -1
303+
for i in range(len(mask) - 1, -1, -1):
304+
if mask[i] != ignore_index:
305+
last = i
306+
break
307+
if last < 0:
308+
return mask
309+
start = last
310+
while start - 1 >= 0 and mask[start - 1] != ignore_index:
311+
start -= 1
312+
for i in range(start):
313+
mask[i] = ignore_index
314+
return mask
315+
316+
282317
@torch.no_grad()
283318
def _get_right_trailing_pad_mask(
284319
sequence: torch.Tensor,
@@ -583,6 +618,7 @@ def format_chat_template(
583618
tools: Optional[List[Dict]] = None,
584619
answer_only_loss_mask: bool = True,
585620
mask_reasoning_content: bool = False,
621+
train_on_last_turn_only: bool = False,
586622
unshifted: bool = False,
587623
) -> Dict[str, List[int]]:
588624
"""
@@ -597,6 +633,9 @@ def format_chat_template(
597633
tools: Optional list of tool definitions for function calling.
598634
answer_only_loss_mask: Whether to compute the loss mask only on the answer tokens.
599635
mask_reasoning_content: Whether to exclude rendered reasoning_content tokens from loss.
636+
train_on_last_turn_only: Whether to supervise only the final assistant turn,
637+
masking every earlier assistant turn (``mask_history``). Applied to the
638+
assistant mask before reasoning_content is masked out.
600639
601640
Returns:
602641
A dictionary with the formatted example.
@@ -667,6 +706,11 @@ def format_chat_template(
667706
if not tokenizer_attn_mask[i]:
668707
mask[i] = 0
669708

709+
# Restrict to the last assistant turn before reasoning is masked, so the
710+
# contiguous-run heuristic sees a hole-free mask (one run per turn).
711+
if train_on_last_turn_only:
712+
_mask_labels_to_last_turn(mask, ignore_index=0)
713+
670714
if mask_reasoning_content and has_reasoning_content:
671715
reasoning_mask = _build_reasoning_mask(
672716
tokenizer,

nemo_automodel/components/distributed/utils.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@
1616
import logging
1717
from contextlib import ContextDecorator, nullcontext
1818
from datetime import datetime, timedelta
19-
from typing import Optional
19+
from typing import Any, Optional
2020

2121
import torch
2222
import torch.distributed
2323
import torch.distributed as dist
2424

25+
from nemo_automodel.components.distributed.config import DDPConfig
26+
2527
logger = logging.getLogger(__name__)
2628

2729

@@ -245,3 +247,17 @@ def get_sync_ctx(model, is_optim_step, defer_fsdp_grad_sync: bool):
245247
elif isinstance(model, torch.nn.parallel.DistributedDataParallel) and not is_optim_step:
246248
sync_ctx = model.no_sync()
247249
return sync_ctx
250+
251+
252+
def dp_eval_sample_shard(distributed_config: Any, dp_rank: int, dp_size: int) -> tuple | None:
253+
"""Return ``(dp_rank, dp_size)`` to shard eval samples across DP ranks, else ``None``.
254+
255+
Only DDP replicates the full model on every rank, so only there can each rank
256+
score a disjoint subset independently. Under FSDP2 / MegatronFSDP the model is
257+
sharded and ``generate()`` issues per-layer all-gather collectives that must
258+
stay in lockstep across the group — different samples per rank would desync
259+
them and hang — so every rank scores the same samples (``None``).
260+
"""
261+
if isinstance(distributed_config, DDPConfig) and dp_size > 1:
262+
return (dp_rank, dp_size)
263+
return None

nemo_automodel/components/eval/tool_call_evaluator.py

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,6 @@
4646
logger = logging.getLogger(__name__)
4747

4848

49-
_METRIC_KEYS = (
50-
"has_call",
51-
"name_correct",
52-
"args_json_valid",
53-
"args_field_recall",
54-
"args_field_precision",
55-
"args_exact_match",
56-
)
57-
58-
5949
class ToolCallAccuracyEvaluator:
6050
"""Generation-based tool-call accuracy evaluator for agent SFT.
6151
@@ -86,6 +76,19 @@ class ToolCallAccuracyEvaluator:
8676
returned ``_count`` and weighted-summed metrics.
8777
"""
8878

79+
#: Fixed metric names returned by :meth:`evaluate` (one mean each). Exposed
80+
#: so a caller can all-reduce a stable key set across ranks instead of the
81+
#: data-dependent ``_skip_<reason>`` diagnostics, which differ per rank and
82+
#: would desync collectives.
83+
METRIC_KEYS = (
84+
"has_call",
85+
"name_correct",
86+
"args_json_valid",
87+
"args_field_recall",
88+
"args_field_precision",
89+
"args_exact_match",
90+
)
91+
8992
def __init__(
9093
self,
9194
*,
@@ -120,6 +123,20 @@ def __init__(
120123

121124
self._samples_cache: Optional[List[Dict[str, Any]]] = None
122125

126+
@property
127+
def sample_shard(self) -> Optional[tuple]:
128+
"""``(rank, world_size)`` shard, or ``None`` to score every sample.
129+
130+
The training recipe sets this so each data-parallel rank scores a
131+
disjoint subset, but only when the model is replicated per rank (DDP);
132+
sharded strategies must keep every rank on the same samples.
133+
"""
134+
return self._sample_shard
135+
136+
@sample_shard.setter
137+
def sample_shard(self, value: Optional[tuple]) -> None:
138+
self._sample_shard = value
139+
123140
def _cleanup_cuda(self) -> None:
124141
gc.collect()
125142
if torch.cuda.is_available():
@@ -278,7 +295,7 @@ def evaluate(self, model, tokenizer) -> Dict[str, float]:
278295
samples actually scored on this rank.
279296
"""
280297
samples = self._iter_my_samples()
281-
sums = {k: 0.0 for k in _METRIC_KEYS}
298+
sums = {k: 0.0 for k in self.METRIC_KEYS}
282299
n_scored = 0
283300
skip_reasons: Dict[str, int] = {}
284301

@@ -369,7 +386,7 @@ def evaluate(self, model, tokenizer) -> Dict[str, float]:
369386
decoded = tokenizer.decode(new_tokens, skip_special_tokens=False)
370387
pred_calls = parse_tool_calls(decoded)
371388
metrics = compute_sample_metrics(pred_calls, sample["gt_tool_calls"])
372-
for k in _METRIC_KEYS:
389+
for k in self.METRIC_KEYS:
373390
sums[k] += metrics[k]
374391
n_scored += 1
375392
del input_ids, attention_mask, output
@@ -389,10 +406,10 @@ def evaluate(self, model, tokenizer) -> Dict[str, float]:
389406

390407
result: Dict[str, float] = {}
391408
if n_scored > 0:
392-
for k in _METRIC_KEYS:
409+
for k in self.METRIC_KEYS:
393410
result[f"{self.metric_prefix}/{k}"] = sums[k] / n_scored
394411
else:
395-
for k in _METRIC_KEYS:
412+
for k in self.METRIC_KEYS:
396413
result[f"{self.metric_prefix}/{k}"] = 0.0
397414
result[f"{self.metric_prefix}/_count"] = float(n_scored)
398415
result[f"{self.metric_prefix}/_skipped"] = float(n_skipped)

nemo_automodel/recipes/llm/train_eagle1.py

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import math
2222
import os
2323
import pathlib
24+
from contextlib import nullcontext
2425
from types import SimpleNamespace
2526

2627
import torch
@@ -68,6 +69,35 @@ def _optim_steps_per_epoch(num_batches_per_epoch: int, grad_accumulation_steps:
6869
return -(-num_batches_per_epoch // grad_accumulation_steps)
6970

7071

72+
def _should_sync_grads(
73+
*,
74+
pending_micro_batches: int,
75+
grad_accumulation_steps: int,
76+
batch_idx: int,
77+
batches_per_epoch: int | None,
78+
is_ddp: bool,
79+
) -> bool:
80+
"""Return True when this micro-batch's backward should all-reduce gradients.
81+
82+
Under DDP with gradient accumulation only the micro-batch immediately
83+
followed by an ``optimizer.step()`` needs to synchronize: at that point the
84+
locally-accumulated ``.grad`` already holds the whole window's contribution,
85+
so a single all-reduce averages the complete window and the intervening
86+
micro-batches can run under ``no_sync()`` -- saving ``grad_accumulation_steps - 1``
87+
all-reduces per window. That step is either the window closer
88+
(``pending_micro_batches + 1 == grad_accumulation_steps``) or the epoch's
89+
final batch (which the trailing-flush step consumes). When the dataloader
90+
length is unknown we cannot identify the final batch, so we sync every step
91+
(correct, just no speedup). With a single process (no DDP) there is nothing
92+
to synchronize, so this is always True.
93+
"""
94+
if not is_ddp or batches_per_epoch is None:
95+
return True
96+
closes_window = pending_micro_batches + 1 == grad_accumulation_steps
97+
is_last_batch = batch_idx == batches_per_epoch - 1
98+
return closes_window or is_last_batch
99+
100+
71101
def _all_reduce_mean(value: torch.Tensor) -> torch.Tensor:
72102
if dist.is_available() and dist.is_initialized():
73103
dist.all_reduce(value, op=dist.ReduceOp.SUM)
@@ -506,6 +536,11 @@ def run_train_validation_loop(self):
506536
if self.dist_env.is_main:
507537
logger.info("All %d epochs already completed; nothing to do.", self.num_epochs)
508538
return
539+
try:
540+
batches_per_epoch = len(self.train_dataloader)
541+
except TypeError:
542+
batches_per_epoch = None
543+
is_ddp = isinstance(self.trainer_module, DistributedDataParallel)
509544
for epoch_idx in range(start_epoch, self.num_epochs):
510545
if hasattr(self.train_dataloader, "sampler") and hasattr(self.train_dataloader.sampler, "set_epoch"):
511546
self.train_dataloader.sampler.set_epoch(epoch_idx)
@@ -525,16 +560,28 @@ def run_train_validation_loop(self):
525560
attention_mask=batch["attention_mask"],
526561
loss_mask=batch["loss_mask"],
527562
)
528-
metrics = self.trainer_module(
529-
input_ids=target_batch.input_ids,
530-
attention_mask=target_batch.attention_mask,
531-
loss_mask=target_batch.loss_mask,
532-
input_hidden_states=target_batch.input_hidden_states,
533-
target_hidden_states=target_batch.target_hidden_states,
534-
target_logits=target_batch.target_logits,
563+
# Skip DDP's per-micro-batch all-reduce on every micro-batch
564+
# except the one an optimizer step immediately follows; that
565+
# step's all-reduce covers the whole locally-accumulated window.
566+
sync_grads = _should_sync_grads(
567+
pending_micro_batches=pending_micro_batches,
568+
grad_accumulation_steps=self.grad_accumulation_steps,
569+
batch_idx=batch_idx,
570+
batches_per_epoch=batches_per_epoch,
571+
is_ddp=is_ddp,
535572
)
536-
loss = metrics.loss / self.grad_accumulation_steps
537-
loss.backward()
573+
sync_ctx = nullcontext() if sync_grads else self.trainer_module.no_sync()
574+
with sync_ctx:
575+
metrics = self.trainer_module(
576+
input_ids=target_batch.input_ids,
577+
attention_mask=target_batch.attention_mask,
578+
loss_mask=target_batch.loss_mask,
579+
input_hidden_states=target_batch.input_hidden_states,
580+
target_hidden_states=target_batch.target_hidden_states,
581+
target_logits=target_batch.target_logits,
582+
)
583+
loss = metrics.loss / self.grad_accumulation_steps
584+
loss.backward()
538585

539586
running_loss += metrics.loss.detach().item()
540587
running_acc += metrics.accuracy.detach().item()

0 commit comments

Comments
 (0)