Skip to content
Open
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
18 changes: 18 additions & 0 deletions .github/workflows/config/.secrets.baseline

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
defaults: ../../../nemo_gym/grpo_workplace_assistant_nemotron_nano_v2_9b.yaml
grpo:
num_prompts_per_step: 4
num_generations_per_prompt: 2
adv_estimator:
name: reinforce_plus_plus
minus_baseline: false
async_grpo:
in_flight_weight_updates: true
Comment on lines +8 to +9

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.

in_flight_weight_updates: true has no async loop to apply to — this recipe runs synchronous GRPO.

enabled isn't set here, and the base config has async_grpo.enabled: false, so the dispatch at run_grpo_nemo_gym.py:289 (elif config.grpo.async_grpo.enabled:) falls through to the synchronous branch at :334 and async_grpo_train never runs.

It isn't fully inert, though: line 54 interpolates this value into trtllm_cfg.in_flight_weight_updates, which trtllm_generation.py:474-479 turns into drain=not in_flight — so refit skips the drain under a sync loop.

Dropping the block is the smaller fix, and it matches the recipe's own name (no async); the base already supplies in_flight_weight_updates: false. If async was the intent instead, add enabled: true alongside it, the way grpo-nanov3-30BA3B-2n8g-megatron_generation-noncolocated-async-gym.yaml:3-5 does.

Suggested change
async_grpo:
in_flight_weight_updates: true

val_period: 1000
val_at_start: false
checkpointing:
checkpoint_dir: results/grpo-qwen3-0.6b-1n4g-megatron-trtllm-tp2-noncolocated-gym
policy:
model_name: Qwen/Qwen3-0.6B
tokenizer:
name: Qwen/Qwen3-0.6B
chat_template_kwargs:
enable_thinking: false
train_global_batch_size: 8
max_total_sequence_length: 2048
make_sequence_length_divisible_by: 2
megatron_cfg:
sequence_parallel: true
activation_checkpointing: false
apply_rope_fusion: false
defer_fp32_logits: true
moe_per_layer_logging: false
optimizer:
name: torch.optim.AdamW
kwargs:
lr: 5.0e-06
weight_decay: 0.01
betas:
- 0.9
- 0.999
eps: 1.0e-08
Comment on lines +29 to +37

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.

nit: nothing reads this block — the megatron path builds its optimizer from megatron_cfg.optimizer.

policy.optimizer is consumed only by the DTensor/AutoModel workers (dtensor_policy_worker.py:491, automodel/setup.py:759); this recipe inherits megatron_cfg.enabled: true and dtensor_cfg.enabled: false, and these values duplicate megatron_cfg.optimizer in the base exactly. The base sets optimizer: null, and the nearest sibling recipe re-asserts it.

Suggest fix: drop lines 29-37.

generation:
backend: trtllm
max_new_tokens: 512
stop_token_ids:
- 151643
- 151645
Comment on lines +41 to +43

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.

Heads-up, pre-existing and no action needed in this PR: these lines cannot take effect on a gym run.

setup_nemo_gym_config clears both stop settings unconditionally, for every backend, after its backend branch:

    # Stop strings or token ids are not supported
    generation_config["stop_strings"] = None
    generation_config["stop_token_ids"] = None

It is the only writer of that value, all three gym entrypoints go through it (run_grpo_nemo_gym.py:193, run_distillation_nemo_gym.py:107, run_grpo_single_controller.py:145), and nothing repopulates it afterwards — configure_generation_config runs earlier, at run_grpo_nemo_gym.py:180. So under gym the worker always sees None, whatever the recipe asks for.

The values are still covered by the server-side fallbacks: trtllm_http_server.py:154 adds tokenizer.eos_token_id (151645) and :161 adds the generation_config.json eos list, which for Qwen3-0.6B is [151645, 151643] — both of the ids here. So nothing breaks; the three lines just don't do anything. Worth deciding whether to keep them as documentation of intent or drop them, and worth knowing when reading the recipe.

trtllm_cfg:
tensor_parallel_size: 2
moe_tensor_parallel_size: null
moe_expert_parallel_size: null
max_model_len: ${policy.max_total_sequence_length}
precision: ${policy.precision}
async_engine: true
gpu_memory_utilization: 0.6
max_batch_size: 64
max_num_tokens: 4096
in_flight_weight_updates: ${grpo.async_grpo.in_flight_weight_updates}
recompute_kv_cache_after_weight_updates: ${grpo.async_grpo.recompute_kv_cache_after_weight_updates}
expose_http_server: true
default_chat_template_kwargs:
enable_thinking: false
tool_parser: qwen3_coder

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.

qwen3_coder can't parse what Qwen3-0.6B emits, so tool calls in this nightly come back empty.

Qwen3's chat template emits a JSON body inside <tool_call> tags. Qwen3CoderToolParser expects <function=...> XML instead, but its has_tool_call only tests for the bare <tool_call> substring — so it enters parsing, _parse_block matches no <function=, and it returns zero calls. _make_parse_tool_calls then hits if not calls: return text, [] — the raw text survives as content, but the gym env sees no tool call, and there is no fallback parser.

Verified against the TRT-LLM commit this repo actually builds, not the PyPI release: 3rdparty/TensorRT-LLM-workspace/pyproject.toml [tool.trtllm] pins ref = bf2ef86f9a2652132b11773d4041e292c553c142.

  • tool_parser_factory.py:58-59 — the YAML string routes through {"qwen3": Qwen3ToolParser, "qwen3_coder": Qwen3CoderToolParser}.
  • tool_parser_factory.py:20-22 — upstream's own MODEL_TYPE_TO_TOOL_PARSER resolves model_type: "qwen3" to "qwen3". Qwen/Qwen3-0.6B@c1899de2's config.json is model_type: "qwen3", so qwen3_coder is not what upstream would auto-pick for this model — it is not the target of any model_type.
  • qwen3_coder_parser.py:57-58has_tool_call is self.tool_call_start_token in text with the token being the bare <tool_call>; :40-42tool_call_function_regex = r"<function=(.*?)</function>|<function=(.*)$" is the only thing _parse_block looks for.
  • qwen3_tool_parser.py:34 — the JSON-format parser this model's output actually fits (bot_token = "<tool_call>\n").

In-repo, tool_parser always tracks the model's emitted format: the base config this recipe inherits uses nemotron_json for Nemotron Nano (grpo_workplace_assistant_nemotron_nano_v2_9b.yaml:257), and the other Qwen gym recipes use vLLM's JSON-format hermes. qwen3_coder's only other use in the repo is a Nemotron-Omni VLM recipe. This PR's own TRT-LLM parity test uses _build_tool_parser("qwen3") for the same model.

Suggested change
tool_parser: qwen3_coder
tool_parser: qwen3

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.

traced by claude. I'm not sure if change to qwen3 is correct. could you help double check?

reasoning_parser: qwen3
trtllm_kwargs:
batch_wait_timeout_iters: 32
batch_wait_max_tokens_ratio: 0.5
colocated:
enabled: false
resources:
gpus_per_node: 2
num_nodes: 1
env:
nemo_gym:
policy_model:
responses_api_models:
vllm_model:
uses_reasoning_parser: true
logger:
tensorboard_enabled: true
wandb:
project: nemo-rl
name: grpo-qwen3-0.6b-1n4g-megatron-trtllm-tp2-noncolocated-gym
cluster:
gpus_per_node: 4
17 changes: 8 additions & 9 deletions nemo_rl/models/generation/openai_server_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
so it has no retokenization drift to correct.
"""

from collections.abc import Collection
from typing import Any


Expand All @@ -30,6 +31,7 @@ def replace_prefix_tokens(
model_prefix_token_ids: list[int],
template_prefix_token_ids: list[int],
template_token_ids: list[int],
model_stop_token_ids: Collection[int] | None = None,
Comment thread
terrykong marked this conversation as resolved.
) -> list[int]:
"""This is a subroutine used inside the OpenAI-compatible Chat Completion server.

Expand Down Expand Up @@ -95,11 +97,9 @@ def replace_prefix_tokens(
eos_token_id = tokenizer.eos_token_id
assert eos_token_id is not None, "Tokenizer must have an EOS token ID"

# The model isn't guaranteed to end on EOS (e.g. it hit max_tokens); chat
# templates always add one, so cut the model input to just before its EOS.
model_cut_end = len(model_prefix_token_ids)
if model_prefix_token_ids[-1] == eos_token_id:
model_cut_end -= 1
effective_stop_token_ids = set(model_stop_token_ids or ())
effective_stop_token_ids.add(eos_token_id)
model_ended_with_stop = model_prefix_token_ids[-1] in effective_stop_token_ids

# Locate the turn boundary by EOS count rather than token position. Qwen3
# templates may strip prior reasoning blocks when re-rendering history;
Expand All @@ -112,7 +112,8 @@ def replace_prefix_tokens(
if tid == eos_token_id:
count_seen += 1
if count_seen == count_needed:
template_cut_start = pos
# Keep sampled stops; otherwise let the template add the missing EOS.

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.

nit: the docstring still describes the pre-PR cut.

Lines 71-73 say the splice cuts "at the N-th EOS in template_token_ids", but this line cuts one past it whenever the model's own last token was a stop. The worked example below still evaluates correctly, so this is one stale sentence rather than a stale example — and model_stop_token_ids is not named in the prose at all.

Suggest fix: reword to "...cut at the N-th EOS in template_token_ids, or just past it when the model's own last token was a stop token", naming model_stop_token_ids as the set that decides.

template_cut_start = pos + int(model_ended_with_stop)
break

assert template_cut_start >= 0, (
Expand All @@ -124,6 +125,4 @@ def replace_prefix_tokens(
f"Template repr (detokenized): {repr(tokenizer.decode(template_token_ids))}"
)

return (
model_prefix_token_ids[:model_cut_end] + template_token_ids[template_cut_start:]
)
return model_prefix_token_ids + template_token_ids[template_cut_start:]
72 changes: 46 additions & 26 deletions nemo_rl/models/generation/trtllm/trtllm_http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,31 @@
logger = logging.getLogger(__name__)


def _build_reasoning_parser(name: str, chat_template_kwargs: dict[str, Any]) -> Any:
from tensorrt_llm.llmapi.reasoning_parser import ReasoningParserFactory
def _tokens_for_response_text(
token_ids: list[int], stop_token_ids: set[int]
) -> list[int]:
text_token_end = len(token_ids)
while text_token_end and token_ids[text_token_end - 1] in stop_token_ids:
text_token_end -= 1
return token_ids if text_token_end == len(token_ids) else token_ids[:text_token_end]

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.

The identity return aliases the caller's list, and gen_token_ids is what ships back as training data.

Both branches are behaviourally identical — the only consumer is tokenizer.decode at :302 — so the conditional buys one skipped copy of at most max_new_tokens ints and costs an alias: if anything downstream ever trims text_token_ids, it silently mutates the generation_token_ids sent at :331, which is the class of bug this hunk exists to prevent. test_response_text_excludes_stops_without_mutating_training_tokens asserts exactly that invariant, while test_response_text_reuses_unterminated_generation_tokens pins the alias with is and so blocks the simpler form.

Separately, text_token_end reaching 0 is reachable — a generation that is entirely stop tokens, e.g. [151645] — and neither new test covers it. It doesn't crash (decode([]) is ""), but it is the boundary the while text_token_end and ... guard exists for, so it is worth pinning.

Suggested companion edit, replacing test_trtllm_http_server.py:110-115:

def test_response_text_returns_all_tokens_when_unterminated():
    generation_token_ids = [10, 20]

    assert _tokens_for_response_text(generation_token_ids, {2, 3}) == [10, 20]


def test_response_text_is_empty_when_generation_is_all_stops():
    assert _tokens_for_response_text([2, 3], {2, 3}) == []
Suggested change
return token_ids if text_token_end == len(token_ids) else token_ids[:text_token_end]
return token_ids[:text_token_end]


if name == "deepseek-r1" and "enable_thinking" in chat_template_kwargs:
from tensorrt_llm.llmapi.reasoning_parser import DeepSeekR1Parser

return DeepSeekR1Parser(
reasoning_at_start=bool(chat_template_kwargs["enable_thinking"]),
chat_template_kwargs=chat_template_kwargs,
)
def _build_reasoning_parser(
name: str,
chat_template_kwargs: dict[str, Any],
*,
reasoning_at_start: bool = False,
) -> Any:
from tensorrt_llm.llmapi.reasoning_parser import (
DeepSeekR1Parser,
ReasoningParserFactory,
)

return ReasoningParserFactory.create_reasoning_parser(name, chat_template_kwargs)
parser = ReasoningParserFactory.create_reasoning_parser(name, chat_template_kwargs)
if isinstance(parser, DeepSeekR1Parser):
parser.reasoning_at_start = reasoning_at_start
parser.in_reasoning = reasoning_at_start
return parser


def _build_sampling_params(
Expand Down Expand Up @@ -107,6 +120,10 @@ def create_app(
**(default_chat_template_kwargs or {}),
}

def _prompt_opens_reasoning(token_ids: list[int]) -> bool:

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.

The True branch of _prompt_opens_reasoning has no coverage, and it's the branch a shipped recipe takes.

It's a closure inside create_app, so no test can import it — every other module-level helper in this file is imported and tested by test_trtllm_http_server.py. The CPU contract test passes reasoning_at_start straight into _build_reasoning_parser, and all three golden scenarios end their prompt in assistant\n or </think>, so the e2e path only ever reaches False. grpo_qwen3_30b_async_swe_trtllm.yaml pairs deepseek-r1 with Qwen3-30B-A3B-Thinking, whose template ends <|im_start|>assistant\n<think>\n.

Suggest fix:

  1. Hoist it to module scope taking tokenizer as a parameter — that is the only name it captures.
  2. Add a table test: assistant\n -> False, <think>\n\n</think>\n\n -> False, ...<think>\n -> True.

tail = tokenizer.decode(token_ids[-16:], skip_special_tokens=False)
return tail.rstrip().endswith("<think>")

# Use the configured parser or infer one from the model config.
_tool_parser_name = _resolve_tool_parser_name(tool_parser, model_name)
_tool_parser_instance = _build_tool_parser(_tool_parser_name)
Expand All @@ -123,8 +140,7 @@ def create_app(
if reasoning_parser is not None:
_build_reasoning_parser(reasoning_parser, _server_template_kwargs)

# Match TRT-LLM's effective EOS set so this adapter can trim every returned
# stop token and its logprob together, preserving multi-turn continuity.
# Retain stop IDs in metadata but exclude them from parsed text.
_eos_token_ids: set[int] = set(stop_token_ids or [])

def _add_eos_token_ids(token_ids: Any) -> None:
Expand Down Expand Up @@ -170,14 +186,8 @@ async def chat_completions(request: Request):
per_request_kwargs: dict[str, Any] = body.get("chat_template_kwargs") or {}
effective_template_kwargs = {**_server_template_kwargs, **per_request_kwargs}

_active_reasoning_parser = (
_build_reasoning_parser(reasoning_parser, effective_template_kwargs)
if reasoning_parser is not None
else None
)

try:
conversation, mm_coroutine, _ = parse_chat_messages_coroutines(
conversation, mm_coroutine, *_ = parse_chat_messages_coroutines(
messages, model_config
)
mm_data, mm_embeddings = await mm_coroutine
Expand Down Expand Up @@ -216,6 +226,21 @@ async def chat_completions(request: Request):
model_prefix_token_ids=required_prefix_ids,
template_prefix_token_ids=template_prefix_ids,
template_token_ids=prompt_token_ids,
model_stop_token_ids=_eos_token_ids,
)

# Infer parser state from the exact engine prompt.
reasoning_at_start = reasoning_parser is not None and _prompt_opens_reasoning(
adj_prompt
)
_active_reasoning_parser = (
_build_reasoning_parser(
reasoning_parser,
effective_template_kwargs,
reasoning_at_start=reasoning_at_start,
)
if reasoning_parser is not None
else None
)

max_tokens_requested = (
Expand Down Expand Up @@ -272,14 +297,9 @@ async def chat_completions(request: Request):
else:
raise TypeError(f"Unsupported TRT-LLM logprob type: {type(lp)}")

# Strip trailing stop tokens TRT-LLM appends — apply_chat_template doesn't reproduce
# <|endoftext|>, so they'd break seen_token_ids contiguity. Trim logprobs in lockstep.
while gen_token_ids and gen_token_ids[-1] in _eos_token_ids:
gen_token_ids.pop()
if gen_logprobs:
gen_logprobs.pop()

gen_text = tokenizer.decode(gen_token_ids, skip_special_tokens=False)
# Parse without trailing stops; retain full response metadata.

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.

nit: the comment on include_stop_str_in_output still describes the trimming this hunk removed.

trtllm_http_server.py:95 reads "so the adapter can trim tokens and logprobs together", but the lockstep pop() loop is gone and the flag is now load-bearing for the opposite reason — stop tokens must be retained so generation_token_ids matches vLLM and the next turn's replace_prefix_tokens can see model_ended_with_stop. _tokens_for_response_text is text-only and never touches logprobs.

Suggest fix: reword line 95 to "Include generated stop tokens so generation_token_ids stays contiguous with the next turn's prompt."

text_token_ids = _tokens_for_response_text(gen_token_ids, _eos_token_ids)
gen_text = tokenizer.decode(text_token_ids, skip_special_tokens=False)

finish_reason = "stop"
if gen.finish_reason is not None:
Expand Down
14 changes: 13 additions & 1 deletion nemo_rl/models/generation/vllm/vllm_worker_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,14 @@ def _setup_vllm_openai_api_server(self, app: FastAPI) -> FastAPI:

engine_client = self.llm
model_config = self.llm_async_engine_args.create_model_config()
model_stop_token_ids = set(self.cfg.get("stop_token_ids") or ())
generation_eos_token_ids = model_config.try_get_generation_config().get(
"eos_token_id"
)
if isinstance(generation_eos_token_ids, int):
model_stop_token_ids.add(generation_eos_token_ids)
elif generation_eos_token_ids is not None:
model_stop_token_ids.update(generation_eos_token_ids)
Comment on lines +400 to +407

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.

The generation-config eos should be a fallback, not a union — as written it silently widens an explicitly configured stop_token_ids.

configure_generation_config only fills this in when it is None (__init__.py:75-76), so a value that reaches the worker is either the user's explicit list or the auto-filled [tokenizer.eos_token_id] — in both cases it is the configured answer to "what terminates a turn", and unioning generation_config.json's eos list into it overrides that. A recipe pinning [151645] precisely to avoid terminating on <|endoftext|> would still get {151645, 151643}, model_ended_with_stop would fire on the excluded token, and the next turn's prompt would lose the template <|im_end|>.

Today the gym path nulls stop_token_ids outright (nemo_gym.py:952-954), so the fallback is what runs and behaviour is unchanged either way — this is about which precedence the code encodes for when that changes.

Suggested change
model_stop_token_ids = set(self.cfg.get("stop_token_ids") or ())
generation_eos_token_ids = model_config.try_get_generation_config().get(
"eos_token_id"
)
if isinstance(generation_eos_token_ids, int):
model_stop_token_ids.add(generation_eos_token_ids)
elif generation_eos_token_ids is not None:
model_stop_token_ids.update(generation_eos_token_ids)
model_stop_token_ids = set(self.cfg.get("stop_token_ids") or ())
if not model_stop_token_ids:
generation_eos_token_ids = model_config.try_get_generation_config().get(
"eos_token_id"
)
if isinstance(generation_eos_token_ids, int):
generation_eos_token_ids = [generation_eos_token_ids]
model_stop_token_ids = set(generation_eos_token_ids or ())

Note the isinstance normalisation still has to stay: eos_token_id is an int for many models and a list for Qwen3, and replace_prefix_tokens calls set() on whatever it receives (openai_server_utils.py:100), which would raise on a bare int.

base_model_paths = [
BaseModelPath(
name=model_config.served_model_name, model_path=model_config.model
Expand Down Expand Up @@ -563,10 +571,10 @@ async def preprocess_chat(
model_prefix_token_ids=request.required_prefix_token_ids,
template_prefix_token_ids=actual_corresponding_token_ids,
template_token_ids=engine_prompt["prompt_token_ids"],
model_stop_token_ids=model_stop_token_ids,
)

engine_prompt["prompt_token_ids"] = final_prompt_token_ids

# Clamp after prefix replacement since the prompt length may have changed.
if actual_request_max_tokens is not None:
self._clamp_max_tokens(
Expand Down Expand Up @@ -914,6 +922,10 @@ def _setup_vllm_server(self) -> "tuple[threading.Thread, str, uvicorn.Server]":
# e.g. last-run middleware.
app = FastAPI()

@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}

app = self._setup_vllm_openai_api_server(app)
if self._sparse_refit_receiver is not None:
self._sparse_refit_receiver.setup_api_server(app)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/bin/bash
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd)
source "$SCRIPT_DIR/common.env"

# ===== BEGIN CONFIG =====
NUM_NODES=1
GPUS_PER_NODE=4
STEPS_PER_RUN=6
MAX_STEPS=6
NUM_RUNS=$(( (MAX_STEPS + STEPS_PER_RUN - 1) / STEPS_PER_RUN ))
NUM_MINUTES=90
# ===== END CONFIG =====

exit_if_max_steps_reached

cd "$PROJECT_ROOT"

DATA_DIR="$EXP_DIR/data"
mkdir -p "$DATA_DIR"
cd 3rdparty/Gym-workspace/Gym
if [[ ! -f env.yaml ]]; then
if [[ -z "${HF_TOKEN:-}" ]]; then
echo "[ERROR] HF_TOKEN is not set"
exit 1
fi
echo "hf_token: $HF_TOKEN" >> env.yaml
fi
uv run ng_prepare_data "+config_paths=[resources_servers/workplace_assistant/configs/workplace_assistant.yaml]" \
+output_dirpath=data/workplace_assistant \
+mode=train_preparation \
+should_download=true \
+data_source=huggingface
cd -

TRAIN_PATH="$DATA_DIR/workplace_assistant_train.jsonl"
VALIDATION_PATH="$DATA_DIR/workplace_assistant_validation.jsonl"
jq -c '.responses_create_params.tools |= (.[0:1])' \
3rdparty/Gym-workspace/Gym/data/workplace_assistant/train.jsonl > "$TRAIN_PATH"
jq -c '.responses_create_params.tools |= (.[0:1])' \
3rdparty/Gym-workspace/Gym/data/workplace_assistant/validation.jsonl > "$VALIDATION_PATH"

uv run examples/nemo_gym/run_grpo_nemo_gym.py \
--config "$CONFIG_PATH" \
grpo.max_num_steps="$MAX_STEPS" \
logger.log_dir="$LOG_DIR" \
logger.wandb_enabled=true \
logger.wandb.project=nemo-rl \
logger.wandb.name="$EXP_NAME" \
logger.monitor_gpus=true \
logger.tensorboard_enabled=true \
checkpointing.enabled=true \
checkpointing.checkpoint_dir="$CKPT_DIR" \
data.train.data_path="$TRAIN_PATH" \
data.validation.data_path="$VALIDATION_PATH" \
"$@" \
2>&1 | tee "$RUN_LOG"

uv run tests/json_dump_tb_logs.py "$LOG_DIR" --output_path "$JSON_METRICS"

LAST_STEP=$(jq 'to_entries | .[] | select(.key == "train/loss") | .value | keys | map(tonumber) | max' "$JSON_METRICS")
if [[ "$LAST_STEP" -lt "$MAX_STEPS" ]]; then
echo "[ERROR] Expected step $MAX_STEPS, reached $LAST_STEP"
exit 1
fi

uv run tests/check_metrics.py "$JSON_METRICS" \
'mean(data["train/reward"]) > 0.05' \
'median(data["train/token_mult_prob_error"]) < 1.1' \
'mean(data["train/gen_kl_error"]) < 0.02' \
'mean(data["train/grad_norm"], 2, 0) > 0.1' \
'mean(data["train/grad_norm"], 2, 0) < 30.0'
Comment on lines +66 to +71

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.

could you paste the wandb curves or metric results into the PR description to show that the new nightly test can run well?


rm -rf "$CKPT_DIR"
1 change: 1 addition & 0 deletions tests/test_suites/nightly_gb200.txt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ tests/test_suites/llm/grpo-nanov3-30BA3B-4n4g-megatron_generation-noncolocated-m
# TRT-LLM generation backend
tests/test_suites/llm/grpo-qwen3-1.7b-2n4g-fsdp2-trtllm.sh
tests/test_suites/llm/grpo-qwen2.5-0.5b-1n4g-megatron-trtllm-noncolocated-async.sh
tests/test_suites/llm/grpo-qwen3-0.6b-1n4g-megatron-trtllm-tp2-noncolocated-gym.sh

# Functional moonlight run
tests/test_suites/llm/grpo-moonlight-16ba3b-4n4g-megatron.sh
Expand Down
Loading
Loading