Skip to content

Commit 282dfd2

Browse files
committed
Address checkpoint evaluation review feedback
Forward native lmms-eval options through the convenience CLI, preserve timeout compatibility, reject byte-string arguments, and disambiguate persisted result paths. Signed-off-by: Johannes Rausch <jrausch@nvidia.com>
1 parent ec0f281 commit 282dfd2

6 files changed

Lines changed: 114 additions & 9 deletions

File tree

examples/puzzletron/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,8 @@ The default one-GPU smoke evaluates eight samples each from IFEval and GSM8K.
336336
Qwen 3.5 checkpoints are configured automatically. See
337337
[checkpoint evaluation](docs/checkpoint_evaluation.md) to choose tasks, run a
338338
full evaluation, find results, or override model detection. For options not
339-
covered by the convenience command, use the native `python -m lmms_eval` CLI.
339+
covered by the convenience command, append `--lmms-eval-args` followed by the
340+
native lmms-eval options.
340341

341342
## Run with an agent
342343

examples/puzzletron/docs/checkpoint_evaluation.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ code. After the smoke succeeds, use `--full` with a separate output directory
4646
to evaluate the complete task datasets. Use `--timeout-seconds` if the full run
4747
needs a different limit.
4848

49-
For options not exposed by this convenience command, use
50-
`python -m lmms_eval --help` and the native lmms-eval CLI.
49+
Pass additional native options after `--lmms-eval-args`, which must be the last
50+
wrapper option. See `python -m lmms_eval --help` for the available options.
5151

5252
## Results and troubleshooting
5353

examples/puzzletron/evaluate_lmms_checkpoint.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,13 @@ def _build_parser() -> argparse.ArgumentParser:
221221
action="store_true",
222222
help="Allow reviewed checkpoint-provided Python code (disabled by default).",
223223
)
224+
parser.add_argument(
225+
"--lmms-eval-args",
226+
nargs=argparse.REMAINDER,
227+
default=[],
228+
metavar="ARG",
229+
help="Forward remaining arguments to lmms-eval; this option must be last.",
230+
)
224231
return parser
225232

226233

@@ -265,8 +272,11 @@ def _settings(
265272
},
266273
"model_args": model_args,
267274
}
275+
extra_args = list(args.lmms_eval_args)
268276
if compatibility_tasks_root is not None:
269-
settings["extra_args"] = ["--include_path", str(compatibility_tasks_root)]
277+
extra_args.extend(["--include_path", str(compatibility_tasks_root)])
278+
if extra_args:
279+
settings["extra_args"] = extra_args
270280
return settings
271281

272282

modelopt/torch/puzzletron/evaluation/lmms.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
DEFAULT_LMMS_EVAL_TIMEOUT_SECONDS = 3600.0
8686
_PROCESS_CLEANUP_TIMEOUT_SECONDS = 10.0
8787
_PROCESS_GROUP_POLL_INTERVAL_SECONDS = 0.1
88+
_TIMEOUT_ERRORS = (TimeoutError, asyncio.TimeoutError)
8889

8990

9091
class LmmsEvalTimeoutError(TimeoutError):
@@ -272,7 +273,7 @@ def _extra_args(settings: Mapping[str, Any]) -> list[str]:
272273
return []
273274
if isinstance(raw, str):
274275
values = shlex.split(raw)
275-
elif isinstance(raw, Sequence):
276+
elif isinstance(raw, Sequence) and not isinstance(raw, (bytes, bytearray)):
276277
values = [str(item) for item in raw]
277278
else:
278279
raise TypeError("evaluation settings.extra_args must be a string or sequence")
@@ -573,15 +574,15 @@ async def _run_process_async(
573574
)
574575
try:
575576
await asyncio.wait_for(process.wait(), timeout)
576-
except TimeoutError as error:
577+
except _TIMEOUT_ERRORS as error:
577578
_signal_process_group(process, signal.SIGTERM)
578579
try:
579580
await asyncio.wait_for(process.wait(), _PROCESS_CLEANUP_TIMEOUT_SECONDS)
580-
except TimeoutError:
581+
except _TIMEOUT_ERRORS:
581582
_signal_process_group(process, signal.SIGKILL)
582583
try:
583584
await asyncio.wait_for(process.wait(), _PROCESS_CLEANUP_TIMEOUT_SECONDS)
584-
except TimeoutError:
585+
except _TIMEOUT_ERRORS:
585586
pass
586587
if _process_group_exists(process):
587588
_signal_process_group(process, signal.SIGKILL)
@@ -701,7 +702,7 @@ def run_lmms_eval_checkpoint(
701702
summary = {
702703
"checkpoint": str(checkpoint_path),
703704
"metrics": metrics,
704-
"result_path": str(result_path),
705+
"raw_result_path": str(result_path),
705706
"sample_counts": sample_counts,
706707
}
707708
summary_path = _atomic_json(output / "summary.json", summary)

tests/unit/torch/puzzletron/test_evaluate_lmms_checkpoint_cli.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def test_cli_help_explains_qwen_profile_and_native_escape_hatch():
5353
assert "--model-profile {auto,none}" in help_text
5454
assert "Qwen 3.5" in help_text
5555
assert "reasoning_parser=qwen3" in help_text
56+
assert "--lmms-eval-args" in help_text
5657
assert "python -m lmms_eval --help" in normalized_help
5758

5859

@@ -225,6 +226,37 @@ def test_cli_full_run_and_runtime_overrides_are_wired(tmp_path):
225226
assert settings["model_args"]["trust_remote_code"] is True
226227

227228

229+
def test_cli_forwards_native_lmms_eval_options_with_compatibility_path(tmp_path):
230+
checkpoint = tmp_path / "teacher"
231+
checkpoint.mkdir()
232+
compatibility_tasks_root = tmp_path / "task-configs"
233+
234+
args = evaluate_lmms_checkpoint._build_parser().parse_args(
235+
[
236+
"--checkpoint",
237+
str(checkpoint),
238+
"--output-dir",
239+
str(tmp_path / "results"),
240+
"--lmms-eval-args",
241+
"--verbosity",
242+
"DEBUG",
243+
"--apply_chat_template",
244+
]
245+
)
246+
247+
settings = evaluate_lmms_checkpoint._settings(
248+
args,
249+
compatibility_tasks_root=compatibility_tasks_root,
250+
)
251+
assert settings["extra_args"] == [
252+
"--verbosity",
253+
"DEBUG",
254+
"--apply_chat_template",
255+
"--include_path",
256+
str(compatibility_tasks_root),
257+
]
258+
259+
228260
def test_cli_maps_gsm8k_to_namespaced_compatibility_task(tmp_path):
229261
checkpoint = tmp_path / "teacher"
230262
checkpoint.mkdir()

tests/unit/torch/puzzletron/test_lmms_evaluation.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515

1616
"""Tests for the reusable lmms-eval checkpoint backend."""
1717

18+
import asyncio
1819
import json
1920
import os
21+
import signal
2022
import sys
2123
from pathlib import Path
2224

@@ -216,6 +218,16 @@ def test_command_rejects_reserved_extra_args_setting(tmp_path, extra_args, expec
216218
assert expected in str(exc_info.value)
217219

218220

221+
@pytest.mark.parametrize("extra_args", [b"--verbosity DEBUG", bytearray(b"--verbosity DEBUG")])
222+
def test_command_rejects_byte_string_extra_args(tmp_path, extra_args):
223+
with pytest.raises(TypeError, match="extra_args must be a string or sequence"):
224+
lmms._build_command(
225+
{**_settings("ifeval"), "extra_args": extra_args},
226+
checkpoint="/ckpts/candidate",
227+
output_path=tmp_path / "results",
228+
)
229+
230+
219231
@pytest.mark.skipif(os.name != "posix", reason="process groups are POSIX-specific")
220232
def test_timeout_kills_ignored_process_group_members(monkeypatch, tmp_path):
221233
script = (
@@ -239,6 +251,53 @@ def test_timeout_kills_ignored_process_group_members(monkeypatch, tmp_path):
239251
assert exc_info.value.stderr == ""
240252

241253

254+
def test_legacy_asyncio_timeout_is_classified(monkeypatch, tmp_path):
255+
class LegacyAsyncioTimeoutError(Exception):
256+
pass
257+
258+
class Process:
259+
pid = 123
260+
returncode = None
261+
262+
async def wait(self):
263+
return self.returncode
264+
265+
process = Process()
266+
wait_calls = 0
267+
268+
async def create_subprocess_exec(*_args, **_kwargs):
269+
return process
270+
271+
async def wait_for(awaitable, _timeout):
272+
nonlocal wait_calls
273+
wait_calls += 1
274+
awaitable.close()
275+
if wait_calls == 1:
276+
raise LegacyAsyncioTimeoutError
277+
process.returncode = -signal.SIGTERM
278+
return process.returncode
279+
280+
monkeypatch.setattr(
281+
lmms,
282+
"_TIMEOUT_ERRORS",
283+
(TimeoutError, LegacyAsyncioTimeoutError),
284+
)
285+
monkeypatch.setattr(lmms.asyncio, "create_subprocess_exec", create_subprocess_exec)
286+
monkeypatch.setattr(lmms.asyncio, "wait_for", wait_for)
287+
monkeypatch.setattr(lmms, "_signal_process_group", lambda *_args: None)
288+
monkeypatch.setattr(lmms, "_process_group_exists", lambda _process: False)
289+
290+
with pytest.raises(lmms.LmmsEvalTimeoutError):
291+
asyncio.run(
292+
lmms._run_process_async(
293+
[sys.executable, "-c", "pass"],
294+
cwd=str(tmp_path),
295+
env=os.environ.copy(),
296+
timeout=1.0,
297+
)
298+
)
299+
300+
242301
def test_run_checkpoint_flattens_metrics_and_preserves_artifacts(monkeypatch, tmp_path):
243302
checkpoint = tmp_path / "checkpoint"
244303
checkpoint.mkdir()
@@ -275,6 +334,8 @@ def fake_run(argv, *, cwd, env, timeout):
275334
assert Path(result["stderr_path"]).read_text() == ""
276335
summary = json.loads(Path(result["result_path"]).read_text())
277336
assert summary["checkpoint"] == str(checkpoint.resolve())
337+
assert summary["raw_result_path"] == result["raw_result_path"]
338+
assert "result_path" not in summary
278339
assert summary["sample_counts"] == {"gsm8k": 4.0, "ifeval": 4.0}
279340

280341

0 commit comments

Comments
 (0)