Skip to content

Use lm-eval 0.4.12's built-in trtllm backend, deprecate lm_eval_tensorrt_llm.py - #2066

Merged
cjluo-nv merged 7 commits into
mainfrom
chenjiel/lm-eval-0412-trtllm-backend
Aug 7, 2026
Merged

Use lm-eval 0.4.12's built-in trtllm backend, deprecate lm_eval_tensorrt_llm.py#2066
cjluo-nv merged 7 commits into
mainfrom
chenjiel/lm-eval-0412-trtllm-backend

Conversation

@cjluo-nv

@cjluo-nv cjluo-nv commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Type of change: documentation / example update (with a behaviour fix)

lm-evaluation-harness 0.4.12 is the first release that ships a TensorRT-LLM backend
(lm_eval.models.trtllm_causallms, registered as trtllm) — it is absent in 0.4.10 and
0.4.11. This example no longer maintains its own, so:

  • Pin lm_eval[api,ifeval]>=0.4.12,<0.5 (the 0.5.0.dev line drops the file) and bump
    lm_eval_hf.py's version guard to match.
  • Delete examples/llm_eval/lm_eval_tensorrt_llm.py (the trt-llm model). Replace
    python lm_eval_tensorrt_llm.py --model trt-llm --model_args tokenizer=<tok>,checkpoint_dir=<ckpt>
    with python lm_eval_trtllm.py --model trtllm --model_args model=<ckpt>,tokenizer=<tok>.
  • Add examples/llm_eval/lm_eval_trtllm.py, whose entire content is one corrected
    _parse_logprobs plus cli_evaluate() (see below). lm_eval_hf.py stays HF-only.
  • examples/hf_ptq/scripts/huggingface_example.sh and the docs use the upstream backend.
    parser.sh gains --input (BUILD_MAX_INPUT_LEN, default 4096) — it already echoed
    that variable but never parsed or defaulted it, so it printed empty on every run.

Why lm_eval_trtllm.py exists: an upstream off-by-one

TensorRT-LLM aligns prompt_logprobs to the next token. executor/base_worker.py:

# Pass prompt_token_ids with an offset of 1 for correct mapping to the context logits
prompt_token_ids = generation_result._generation_request.prompt_token_ids[1:] + first_generation_token

So entry i is the distribution that predicted tokens[i + 1], and _topk_logprobs
appends that token's id when it is not in the top-k. lm-eval's _parse_logprobs instead
reads prompt_logprobs[i][tokens[i]] and applies its own shift on top, which raises
KeyError on the first request of every loglikelihood task (hellaswag, mmlu, arc, ...):

File ".../lm_eval/models/trtllm_causallms.py", line 324, in _parse_logprobs
    current_token_logprob = prompt_logprob[tokens[i]]
KeyError: 6503

Probed against TRT-LLM 1.3.0rc23 with a 14-token prompt for prompt_logprobs 0, 1 and 2:
tokens[i] is missing at every position, tokens[i+1] is present at every position.
Only generate_until tasks work unpatched. This wants an upstream issue against
EleutherAI/lm-evaluation-harness.

The override also fails loudly rather than quietly: it checks prompt_logprobs covers
every prompt token and raises on a missing token, instead of skipping the term and
silently inflating the reported accuracy.

Defaults that must be set explicitly

TRTLLM.__init__ accepts **kwargs but forwards only a fixed set to the TensorRT-LLM
LLM API
, so extra --model_args aimed at the engine are silently dropped. (lm-eval's
own named parameters — max_gen_toks, batch_size, truncation_side, ... — are honored
normally.) Two engine defaults are unsafe for few-shot eval:

  • tensor_parallel_size defaults to 1 (the deleted wrapper used every visible GPU).
  • max_input_len defaults to 2048, and longer prompts are silently left-truncated —
    5-shot MMLU/gsm8k prompts exceed that.

Usage

python lm_eval_trtllm.py --model trtllm \
    --model_args model=<quantized checkpoint dir>,tokenizer=<HF model folder>,tensor_parallel_size=<tp>,max_batch_size=<bs>,max_input_len=4096,max_output_len=512 \
    --tasks hellaswag,gsm8k \
    --batch_size <bs>

Flat arguments (no run subcommand) are what 0.4.12's HarnessCLI.parse_args inserts
run for automatically (_cli/harness.py:48-51); this is the exact command form used for
the results below.

Testing

Unittests/examples/llm_eval/test_lm_eval_trtllm.py, no GPU and no tensorrt_llm
install: stubs the response object and pins the i-1 alignment, the rank != 1
is_greedy rule, the ctxlen=0 edge, and both RuntimeError paths. Mutation-checked —
dropping the -1 shift is caught by 5/5 cases, ignoring ctxlen by 4/5. A sixth test is a
tripwire: it asserts lm-eval's own implementation is still misaligned, so a future
0.4.x that fixes the bug fails the test and says to delete this file rather than being
silently re-broken by the override.

End to endnvidia/Qwen3.5-122B-A10B-NVFP4 (NVFP4 MoE, 256 experts) on 4x B300,
TRT-LLM 1.3.0rc23, lm-eval 0.4.12, --limit 32:

run hellaswag acc hellaswag acc_norm gsm8k flexible gsm8k strict
deleted impl (trt-llm), tp=4 0.7188 0.7812 0.8438 0.7812
lm_eval_trtllm.py, tp=1 0.7188 0.7812 0.8438 0.8125
lm_eval_trtllm.py, tp=2 0.7188 0.7812 0.9062 0.8125
lm_eval_trtllm.py, tp=4 0.7188 0.7812 0.8750 0.8438
  • hellaswag (the loglikelihood path this PR fixes) is identical at every tp and identical
    to the deleted implementation
    — the alignment fix is exact, not approximate.
  • gsm8k varies by 1–2 samples out of 32 (generation path: upstream uses native stop=
    sequences and per-request SamplingParams; the old wrapper used beam-search-of-1 with
    post-hoc string truncation).
  • Without the override, every hellaswag run above dies with the KeyError.
  • Re-verified at tp=4 after the code moved out of lm_eval_hf.py into lm_eval_trtllm.py.

Note: NVFP4 fused-MoE has no CUTLASS tactic on Hopper (No supported MoE GEMM tactic remains after replacing unsupported NO_SMEM epilogues.), so this had to be validated on
Blackwell.

Feature parity notes

Gained from upstream: loglikelihood_rolling (was NotImplementedError), pipeline
parallelism, add_bos_token auto-detection, prompt truncation, per-request sampling params,
prompt_logprobs instead of full-vocab context logits (much lower memory), thinking-tag
handling, batch_size=auto.

Not reachable through the upstream backend (were set by modelopt.deploy.llm.LLM):
enable_attention_dp for MoE, CudaGraphConfig, enable_chunked_prefill,
moe_expert_parallel_size=1, and free_gpu_memory_fraction=0.7 with a capped
kv_cache.max_tokens — upstream uses the TRT-LLM default 0.9 (observed allocating 218 GiB
of paged KV cache on B300), so OOM risk is higher on smaller GPUs. This is documented in
examples/llm_eval/README.md, and huggingface_example.sh honours a preset LM_EVAL_TP
so users can lower the tensor-parallel size without editing the script.

Before your PR is "Ready for review"

  • Is this change backward compatible?: ❌ — lm_eval_tensorrt_llm.py is removed and the CLI changes (--model trt-llmtrtllm, checkpoint_dir=model=). Migration command is in the README and CHANGELOG.
  • If you copied code from any other sources or added a new PIP dependency, did you follow guidance in CONTRIBUTING.md: ✅ — no new dependency; existing lm_eval pin tightened.
  • Did you write any new necessary tests?: ✅ — tests/examples/llm_eval/test_lm_eval_trtllm.py (6 cases, no GPU).
  • Did you update Changelog?: ✅ — under 0.47 Deprecations.
  • Did you get Claude approval on this PR?: ✅ — reviewed, feedback addressed in dcedd37b4 and 622b97c26.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added TensorRT-LLM evaluation through lm-evaluation-harness’s trtllm backend.
    • Added configurable input/output lengths, batching, tensor parallelism, and build input length.
    • Improved prompt log-probability alignment for more accurate evaluation results.
  • Documentation

    • Updated evaluation instructions, truncation guidance, backend limitations, and configuration examples.
  • Deprecations

    • Removed the legacy TensorRT-LLM evaluation script and entry point.
  • Updates

    • lm-evaluation-harness now requires versions 0.4.12 through 0.4.x.

lm-evaluation-harness 0.4.12 is the first release shipping a TensorRT-LLM
backend (lm_eval.models.trtllm_causallms, registered as `trtllm`), so the
example no longer needs its own. Pin lm_eval>=0.4.12,<0.5 (0.5.0.dev drops
the file) and bump lm_eval_hf.py's version guard to match.

lm_eval_tensorrt_llm.py becomes a deprecation shim: it warns, translates the
legacy arguments (checkpoint_dir -> model, max_length -> max_input_len /
max_output_len, --batch_size -> max_batch_size, tensor parallelism over all
visible GPUs) and forwards to `lm_eval_hf.py --model trtllm`, so existing
commands keep working.

The upstream backend accepts **kwargs but only forwards a fixed set to the
TRT-LLM LLM API, so two of its defaults have to be set on every command:
tensor_parallel_size defaults to 1, and max_input_len defaults to 2048, which
silently left-truncates 5-shot prompts. huggingface_example.sh and the docs
now pass both explicitly.

It also misreads TensorRT-LLM's prompt_logprobs. TRT-LLM aligns them to the
next token (base_worker.py passes prompt_token_ids[1:] + first generated
token), so entry i predicts tokens[i + 1]; _parse_logprobs reads
prompt_logprobs[i][tokens[i]] and shifts on top of that, raising KeyError on
the first request of every loglikelihood task. lm_eval_hf.py patches the
alignment when --model trtllm is selected; the patch goes away once that is
fixed upstream.

Tested on nvidia/Qwen3.5-122B-A10B-NVFP4 (4x B300, TRT-LLM 1.3.0rc23) at
tp=1/2/4: hellaswag acc 0.7188 / acc_norm 0.7812 at every tp, identical to
the removed implementation; gsm8k and the deprecation shim also verified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Chenjie Luo <chenjiel@nvidia.com>
@cjluo-nv
cjluo-nv requested review from a team as code owners August 4, 2026 20:04
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

TensorRT-LLM evaluation now uses lm-evaluation-harness’s built-in trtllm backend. A wrapper corrects prompt-logprob alignment. Evaluation scripts accept explicit tensor-parallel and input-length settings. Documentation and dependency constraints require lm-eval 0.4.12 or newer.

Changes

TensorRT-LLM evaluation migration

Layer / File(s) Summary
Built-in backend integration
examples/llm_eval/requirements.txt, examples/llm_eval/lm_eval_hf.py, examples/llm_eval/lm_eval_trtllm.py, tests/examples/llm_eval/test_lm_eval_trtllm.py
The evaluation entry point requires lm-eval 0.4.12, applies the corrected prompt-logprob parser, validates token alignment, and invokes the built-in trtllm backend. Tests cover scoring, greedy detection, trust propagation, and parser errors.
Engine sizing and evaluation wiring
examples/hf_ptq/scripts/parser.sh, examples/hf_ptq/scripts/huggingface_example.sh
The build parser accepts an input-length setting with a 4096-token default. The evaluation script derives tensor parallelism from visible GPUs, supports LM_EVAL_TP, and passes model, tokenizer, batching, and sequence-length settings.
Command adoption and documentation
.agents/skills/deployment/references/trtllm.md, examples/llm_eval/README.md, CHANGELOG.rst, tests/examples/llm_eval/test_llm_eval.py
References and tests describe the new command, truncation behavior, backend limitations, removed legacy entry point, and updated engine sizing.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant EvaluationScript
  participant lm_eval_trtllm.py
  participant lm-evaluation-harness
  participant TRTLLMBackend
  EvaluationScript->>lm_eval_trtllm.py: pass model, tokenizer, and runtime limits
  lm_eval_trtllm.py->>TRTLLMBackend: install corrected prompt-logprob parser
  lm_eval_trtllm.py->>lm-evaluation-harness: invoke cli_evaluate()
  lm-evaluation-harness->>TRTLLMBackend: run trtllm evaluation
  TRTLLMBackend-->>lm_eval_trtllm.py: return aligned log probabilities
Loading
🚥 Pre-merge checks | ✅ 6
✅ Passed checks (6 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adopting lm-eval 0.4.12's built-in trtllm backend and removing the legacy evaluation script.
Docstring Coverage ✅ Passed Docstring coverage is 81.82% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Security Anti-Patterns ✅ Passed No changed modelopt Python files exist; the added example has no unsafe load, hardcoded trust_remote_code, eval/exec, or nosec patterns, and requirements only tightens existing lm_eval.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chenjiel/lm-eval-0412-trtllm-backend

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

Warning

CodeRabbit couldn't request changes on this pull request because it doesn't have sufficient GitHub permissions.

Please grant CodeRabbit Pull requests: Read and write permission and re-run the review.

👉 Steps to fix this

Actionable comments posted: 2

🧹 Nitpick comments (2)
examples/llm_eval/lm_eval_hf.py (1)

314-328: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Clear is_greedy when a token is missing.

The fallback branch skips the token. The score then omits a negative log-probability, and is_greedy can still be reported as True. A skipped token means the rank is unknown, so the greedy claim is not supported. Set is_greedy = False in that branch.

♻️ Proposed change
             if logprob is None:
                 # TRT-LLM always appends the actual token to the top-k dict.
                 logger.warning(f"Token at position {i} missing from prompt_logprobs[{i - 1}]")
+                is_greedy = False
                 continue
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/llm_eval/lm_eval_hf.py` around lines 314 - 328, Update the
missing-token branch in _parse_logprobs to set is_greedy = False before
continuing, while preserving the existing warning and score-skipping behavior.
examples/llm_eval/lm_eval_tensorrt_llm.py (1)

76-79: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the reason for the local torch import.

The coding guidelines allow a local import only for a justified circular dependency, optional dependency, or unusually heavy import, and they require a brief explanatory comment. Add the comment here.

As per coding guidelines: "use local imports only for justified circular dependencies, optional dependencies, or unusually heavy imports, with a brief explanatory comment".

♻️ Proposed change
     if "tensor_parallel_size" not in args:
+        # Local import: torch is heavy and only needed to count visible GPUs.
         import torch
 
         args.setdefault("tensor_parallel_size", str(max(torch.cuda.device_count(), 1)))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/llm_eval/lm_eval_tensorrt_llm.py` around lines 76 - 79, Add a brief
explanatory comment immediately before the local torch import in the
tensor_parallel_size setup, stating the applicable justification for keeping
this import local. Leave the import and surrounding device-count logic
unchanged.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@examples/hf_ptq/scripts/huggingface_example.sh`:
- Around line 307-309: Update the lm_eval_hf.py invocation to use the existing
MODEL_ABS_PATH variable for tokenizer and quote the complete --model_args value,
preserving all existing model argument settings while preventing path resolution
and word-splitting issues.

In `@examples/llm_eval/lm_eval_hf.py`:
- Around line 321-324: The logger.warning call in the token processing logic
includes the token ID from tokens[i] in the message, but the review requests
logging only the token position instead. Update the warning message to remove
the token value and report only the position index i - 1 where the logprob
lookup failed.

---

Nitpick comments:
In `@examples/llm_eval/lm_eval_hf.py`:
- Around line 314-328: Update the missing-token branch in _parse_logprobs to set
is_greedy = False before continuing, while preserving the existing warning and
score-skipping behavior.

In `@examples/llm_eval/lm_eval_tensorrt_llm.py`:
- Around line 76-79: Add a brief explanatory comment immediately before the
local torch import in the tensor_parallel_size setup, stating the applicable
justification for keeping this import local. Leave the import and surrounding
device-count logic unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 4fe4fe98-cc39-409f-ba89-f42ab2adb6f4

📥 Commits

Reviewing files that changed from the base of the PR and between 151d87d and b68114b.

📒 Files selected for processing (8)
  • .agents/skills/deployment/references/trtllm.md
  • CHANGELOG.rst
  • examples/hf_ptq/scripts/huggingface_example.sh
  • examples/llm_eval/README.md
  • examples/llm_eval/lm_eval_hf.py
  • examples/llm_eval/lm_eval_tensorrt_llm.py
  • examples/llm_eval/requirements.txt
  • tests/examples/llm_eval/test_llm_eval.py

Comment thread examples/hf_ptq/scripts/huggingface_example.sh Outdated
Comment thread examples/llm_eval/lm_eval_hf.py Outdated
@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 65.37%. Comparing base (77dbeb1) to head (482a84e).
⚠️ Report is 22 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2066      +/-   ##
==========================================
- Coverage   65.51%   65.37%   -0.14%     
==========================================
  Files         521      522       +1     
  Lines       59812    63843    +4031     
==========================================
+ Hits        39185    41740    +2555     
- Misses      20627    22103    +1476     
Flag Coverage Δ
examples 43.03% <ø> (+2.07%) ⬆️
unit 55.30% <ø> (-0.13%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@kevalmorabia97

Copy link
Copy Markdown
Collaborator

/claude review

@kevalmorabia97 kevalmorabia97 left a comment

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.

LGTM. Please review unresolved coderabbit / claude contents

Comment thread examples/llm_eval/lm_eval_hf.py Outdated
Comment on lines +318 to +324
# Token 0 has no preceding distribution, so it can never be scored.
for i in range(max(ctxlen, 1), len(tokens)):
logprob = prompt_logprobs[i - 1].get(tokens[i])
if logprob is None:
# TRT-LLM always appends the actual token to the top-k dict.
logger.warning(f"Token {tokens[i]} missing from prompt_logprobs[{i - 1}]")
continue

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 Algorithm] The patched loop indexes prompt_logprobs[i - 1] with i taken from lm-eval's full tokens list, but nothing checks that prompt_logprobs actually has one entry per prompt token. Two things in this PR make that assumption fragile:

  1. The PR description itself notes upstream's maybe_truncate left-truncates prompts longer than max_input_len. If the engine sees a truncated prompt while tokens is untruncated, every index shifts by the number of dropped tokens — so prompt_logprobs[i-1] returns the distribution for the wrong position. When the shifted dict happens to contain tokens[i] (very common: repeated tokens in few-shot prompts) you get a plausible-but-wrong logprob with no signal at all; when it doesn't, you hit the continue below and silently drop the term from continuation_logprobs. Both inflate the score rather than failing.
  2. The deleted wrapper set enable_kv_cache_reuse=False precisely because prefix-block reuse returns logits only for the recomputed suffix on shared-prefix requests (hellaswag is exactly that shape). The upstream backend has no equivalent knob and TRT-LLM defaults enable_block_reuse=True, so that invariant is now unenforced and depends entirely on upstream/TRT-LLM behavior.

The continue fallback turns any such misalignment into a quietly optimistic accuracy number, which is the worst outcome for an eval script. A cheap loud check makes the whole patch self-verifying:

    def _parse_logprobs(tokens, outputs, ctxlen):
        prompt_logprobs = outputs.outputs[0].prompt_logprobs
        # prompt_logprobs is next-token aligned and must cover every prompt token; a
        # shorter list means the engine saw a different prompt (e.g. max_input_len
        # truncation or KV-cache prefix reuse), which would silently shift every index.
        if len(prompt_logprobs) < len(tokens) - 1:
            raise RuntimeError(
                f"prompt_logprobs has {len(prompt_logprobs)} entries for {len(tokens)} "
                "tokens; the prompt was likely truncated — raise max_input_len."
            )
        ...
            logprob = prompt_logprobs[i - 1].get(tokens[i])
            if logprob is None:
                raise RuntimeError(
                    f"tokens[{i}] missing from prompt_logprobs[{i - 1}]; prompt_logprobs "
                    "are misaligned with the request tokens."
                )

If you'd rather keep the tolerant path, at minimum make it fail the run (or return -inf) instead of skipping the token — a warning in a multi-thousand-line lm-eval log will not be noticed, and the reported acc will look normal.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Took the hardening, pushing back on the two premises.

Taken (dcedd37b4): the override now checks len(prompt_logprobs) >= len(tokens) - 1 and raises on a missing token instead of continue. You are right that silently dropping a term inflates the score, which is the worst failure mode for an eval script.

Premise 1 — maybe_truncate shifting the indices — does not apply to this path. maybe_truncate is only called in generate_until. _loglikelihood_tokens truncates the request itself:

inp = (context_enc + continuation_enc)[-(self.max_length) :]

and passes that same inp as tokens to _parse_logprobs. The engine and the caller therefore see the same token list by construction; they cannot disagree about truncation.

Premise 2 — enable_block_reuse — is factually wrong. The upstream backend does have the equivalent knob and already sets it, at trtllm_causallms.py:139-142:

# If reuse kv cache, no logprobs can be computed.
kv_cache_config = KvCacheConfig(
    enable_block_reuse=False,
)

So the invariant the deleted wrapper enforced via enable_kv_cache_reuse=False is still enforced, by upstream, for the same stated reason.

The length check goes in anyway as cheap insurance, but neither named mechanism can produce the misalignment.

Comment on lines +300 to +309
# lm-eval's `trtllm` backend defaults to 1 GPU; shard over every visible one instead.
LM_EVAL_TP=$(python -c "import torch; print(max(torch.cuda.device_count(), 1))")

python lm_eval_tensorrt_llm.py \
--model trt-llm \
--model_args tokenizer=$MODEL_PATH,checkpoint_dir=$SAVE_PATH,max_gen_toks=$BUILD_MAX_OUTPUT_LEN \
echo "Using the following config: max output $BUILD_MAX_OUTPUT_LEN max batch $BUILD_MAX_BATCH_SIZE tp $LM_EVAL_TP"

# max_input_len defaults to 2048, which silently truncates 5-shot prompts; size the
# engine for a 4096-token context plus the requested generation length.
python lm_eval_hf.py \
--model trtllm \
--model_args model=$SAVE_PATH,tokenizer=$MODEL_PATH,tensor_parallel_size=$LM_EVAL_TP,max_batch_size=$BUILD_MAX_BATCH_SIZE,max_gen_toks=$BUILD_MAX_OUTPUT_LEN,max_input_len=4096,max_output_len=$BUILD_MAX_OUTPUT_LEN \

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] This now shards over every visible GPU while dropping two settings the removed wrapper set deliberately, and the combination is what makes the loss visible.

modelopt/deploy/llm/generate.py forced moe_expert_parallel_size=1 with an explicit reason — "Force ep=1 to avoid TRT-LLM DeepEP kernel failures on unsupported GPUs (e.g. Blackwell SM 12.0)" — and set enable_attention_dp=True whenever the config had expert counts. The upstream trtllm backend exposes neither (the PR's "Feature parity notes" confirms this), so TRT-LLM's defaults apply. Setting tensor_parallel_size to the full device count is exactly the configuration where expert parallelism kicks in, so an MoE checkpoint evaluated on a multi-GPU SM 12.0 box can now fail in DeepEP kernels on a command that worked before this PR. free_gpu_memory_fraction likewise moves from the wrapper's 0.7 (with a capped kv_cache.max_tokens) to TRT-LLM's 0.9, raising OOM risk on smaller GPUs.

The CI coverage (test_qwen3_eval_fp8, tiny dense Qwen3) cannot catch either.

Two options, either is fine:

  • Make the TP default overridable so a user hitting this has an escape hatch that doesn't require editing the script, e.g. LM_EVAL_TP=${LM_EVAL_TP:-$(python -c "import torch; print(max(torch.cuda.device_count(), 1))")}.
  • Or note the lost settings where users will see them (the examples/llm_eval/README.md TensorRT-LLM section already has three NOTE blocks; a fourth covering "MoE on unsupported GPUs / OOM → lower tensor_parallel_size" would land the PR-description caveat in the docs).

Right now the caveat lives only in the PR body, so it disappears at merge.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Did both, in dcedd37b4.

Escape hatch: LM_EVAL_TP=${LM_EVAL_TP:-$(python -c "import torch; print(max(torch.cuda.device_count(), 1))")}, with a comment naming the DeepEP/SM 12.0 failure.

Docs: examples/llm_eval/README.md gains a fourth NOTE covering the tuning the upstream backend does not expose — expert parallelism left at the TRT-LLM default, and the KV cache at 90% of free memory rather than the wrapper's 70% — telling users to lower tensor_parallel_size if they hit either. Agreed the caveat should not have lived only in the PR body.

# engine for a 4096-token context plus the requested generation length.
python lm_eval_hf.py \
--model trtllm \
--model_args model=$SAVE_PATH,tokenizer=$MODEL_PATH,tensor_parallel_size=$LM_EVAL_TP,max_batch_size=$BUILD_MAX_BATCH_SIZE,max_gen_toks=$BUILD_MAX_OUTPUT_LEN,max_input_len=4096,max_output_len=$BUILD_MAX_OUTPUT_LEN \

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] tokenizer=$MODEL_PATH should be $MODEL_ABS_PATH, and the whole --model_args value should be quoted.

MODEL_ABS_PATH is computed at line 266 (immediately above this block, realpath when MODEL_PATH is a directory) specifically because the script pushd ../llm_eval/ before invoking — a relative --model <dir> resolves against the wrong cwd. The MMLU block a few lines below correctly passes --model_path $MODEL_ABS_PATH; this new line uses the raw $MODEL_PATH, so ./scripts/huggingface_example.sh --model ./my_model ... fails to find the tokenizer. The pre-change command had the same bug, so this is inherited rather than introduced — but the fix is one word and the surrounding code already establishes the convention.

Separately, unquoted $SAVE_PATH/$MODEL_PATH word-split on paths containing spaces, and with set -o pipefail (set earlier in this script) that becomes a confusing arg-parse failure rather than a clear error.

Suggested change
--model_args model=$SAVE_PATH,tokenizer=$MODEL_PATH,tensor_parallel_size=$LM_EVAL_TP,max_batch_size=$BUILD_MAX_BATCH_SIZE,max_gen_toks=$BUILD_MAX_OUTPUT_LEN,max_input_len=4096,max_output_len=$BUILD_MAX_OUTPUT_LEN \
--model_args "model=$SAVE_PATH,tokenizer=$MODEL_ABS_PATH,tensor_parallel_size=$LM_EVAL_TP,max_batch_size=$BUILD_MAX_BATCH_SIZE,max_gen_toks=$BUILD_MAX_OUTPUT_LEN,max_input_len=4096,max_output_len=$BUILD_MAX_OUTPUT_LEN" \

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Both fixed in dcedd37b4 (same change as the CodeRabbit thread on this line): tokenizer=$MODEL_ABS_PATH and the whole --model_args value quoted.

Correct that it was inherited — the deleted lm_eval_tensorrt_llm.py invocation had the same $MODEL_PATH bug — but the line was being rewritten anyway, so it is fixed rather than carried forward.

max_input_len is no longer hardcoded either; it comes from the new --input / BUILD_MAX_INPUT_LEN option, which parser.sh echoed but never actually parsed or defaulted.

Comment on lines +106 to +112
found = _find_option(out, "--model")
if found and found[1] in ("trt-llm", "trt_llm"):
_set_option(out, "--model", found[0], "trtllm")

found = _find_option(out, "--model_args")
if found:
_set_option(out, "--model_args", found[0], _translate_model_args(found[1], batch_size))

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] --model_args is translated unconditionally, even when --model was not the legacy trt-llm/trt_llm. So python lm_eval_tensorrt_llm.py --model hf --model_args pretrained=foo gets tensor_parallel_size / max_input_len / max_output_len / max_batch_size injected into HF model args, which then fails inside HFLM.__init__ rather than surfacing the actual mistake ("this script only ever supported trt-llm"). Gating the rewrite on the branch above keeps the shim's behavior confined to the commands it is emulating:

def _translate(argv: list[str]) -> list[str]:
    """Rewrite a legacy argv so it targets the upstream `trtllm` backend."""
    out = list(argv)
    found = _find_option(out, "--model")
    if not found or found[1] not in ("trt-llm", "trt_llm"):
        # Not a legacy trt-llm command; forward untouched.
        return out
    _set_option(out, "--model", found[0], "trtllm")

    # --batch_size sized the engine in the old wrapper; read it before rewriting.
    found_bs = _find_option(out, "--batch_size")
    batch_size = found_bs[1] if found_bs else None

    found = _find_option(out, "--model_args")
    if found:
        _set_option(out, "--model_args", found[0], _translate_model_args(found[1], batch_size))
    return out

Also worth a one-line comment at line 74: --batch_size auto is a supported lm-eval value, and the isdigit() guard silently leaves max_batch_size at the upstream default in that case (rather than the engine size the old wrapper would have used) — intentional, but not obvious from the code.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Moot — lm_eval_tensorrt_llm.py was deleted outright in dcedd37b4 rather than kept as a translating shim, so there is no _translate left to gate.

Comment on lines +63 to +71
max_gen_toks = int(args.get("max_gen_toks", _DEFAULT_MAX_GEN_TOKS))
# Upstream defaults to max_input_len=2048, short enough to silently left-truncate
# 5-shot prompts, so always derive it from the legacy sizing instead.
args.setdefault("max_output_len", str(max_gen_toks))
max_length = args.pop("max_length", None)
args.setdefault(
"max_input_len",
str(int(max_length) - max_gen_toks) if max_length else str(_DEFAULT_MAX_INPUT_LEN),
)

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] max_gen_toks stays in the translated --model_args (it is not in _RENAMED_ARGS and is never popped), so it is forwarded to upstream TRTLLM.__init__ in addition to being consumed here for sizing. Per the PR description, TRTLLM.__init__ takes **kwargs and forwards only a fixed set to the TRT-LLM LLM API — so this is harmless today (silently dropped, or honored as lm-eval's own generation cap), but it does mean an unrecognized-looking key ends up in the forwarded command that gets echoed in the deprecation warning. A short comment saying max_gen_toks is deliberately kept (it is a valid lm-eval LM arg, not just legacy sizing input) would save the next reader the round trip.

Two smaller edge cases in the same block:

  • int(args.get("max_gen_toks", ...)) and int(max_length) will raise a bare ValueError on a non-integer legacy value, from a script whose only job is to be forgiving of legacy input. A try/except re-raising with the offending key=value would be kinder.
  • max_length - max_gen_toks can go non-positive (max_length=128,max_gen_toks=256), producing max_input_len=-128, which then fails deep inside TRT-LLM engine construction. A max(..., 1) or an explicit error here localizes it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Moot — lm_eval_tensorrt_llm.py was deleted outright in dcedd37b4, so the max_gen_toks passthrough and the int() / negative-max_input_len edge cases go with it. max_input_len is now supplied directly by the caller (--input / BUILD_MAX_INPUT_LEN).

Comment thread examples/llm_eval/lm_eval_hf.py Outdated
Comment on lines +369 to +372
_add_modelopt_args(run_parser)
args = cli.parse_args()
_inject_modelopt_args_into_model_args(args)
if getattr(args, "model", None) == "trtllm":

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 patch is gated on args.model == "trtllm" exactly, but _inject_modelopt_args_into_model_args just above (line 271) already had to enumerate ("hf", "hf-auto", "huggingface", "hf-multimodal") for the same reason — lm-eval registers backends under multiple aliases. If upstream ever registers an alias for this backend (or a user passes it with different casing), the patch silently does not apply and the run dies with the very KeyError this code exists to prevent — with no hint that the guard was the cause.

Since a missing patch is a hard failure rather than a degradation, consider keying off the resolved class instead of the CLI string, or at least normalizing:

    if str(getattr(args, "model", "")).strip().lower() in ("trtllm", "trt-llm"):

Non-blocking — the exact string is correct for 0.4.12.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Moot — the patch is no longer in lm_eval_hf.py, so there is no args.model string guard to normalize. lm_eval_trtllm.py installs the override unconditionally at import, which also removes the failure mode you describe (a guard that silently does not fire).

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude review — 0 CRITICAL, 3 IMPORTANT, 3 SUGGESTION

Scope: trigger comment was a bare /claude review, so full review. 8 files changed; I opened the 5 non-doc ones (examples/llm_eval/lm_eval_hf.py, examples/llm_eval/lm_eval_tensorrt_llm.py, examples/hf_ptq/scripts/huggingface_example.sh, examples/llm_eval/requirements.txt, tests/examples/llm_eval/test_llm_eval.py) plus the removed wrapper's replacement, modelopt/deploy/llm/generate.py, for the parity comparison. CHANGELOG.rst, examples/llm_eval/README.md, and .agents/skills/deployment/references/trtllm.md read as accurate and consistent with the code; I did not review them line by line.

Note: the branch is 3 commits behind main, so a two-dot diff shows unrelated examples/vllm_serve/ and tests/gpu_vllm/ files as reverted. I used the gh pr view file list as authoritative and ignored those.

The core change is well-executed. The _parse_logprobs off-by-one diagnosis is correct and specific — prompt_logprobs[i-1][tokens[i]] is the right indexing for TRT-LLM's next-token-aligned entries, and the identical hellaswag numbers across tp=1/2/4 and against the removed implementation is strong evidence, not just a smoke test. The shim's --name=<value> / --name <value> handling and os.execv forwarding are clean, and the removal of ~200 lines of custom backend is a real maintenance win.

Most impactful findings

1. The _parse_logprobs patch has no alignment guard, and its fallback fails silently upward (examples/llm_eval/lm_eval_hf.py:318-324) — IMPORTANT Algorithm

The patch assumes len(prompt_logprobs) == len(tokens) - 1, but this same PR documents that upstream left-truncates prompts past max_input_len via maybe_truncate. Under truncation every index shifts, and because few-shot prompts repeat tokens heavily, the shifted dict will often contain tokens[i] — yielding a plausible-but-wrong logprob with zero signal. When it doesn't, the continue drops the term from the sum. Both directions inflate accuracy rather than failing.

Compounding it: the deleted wrapper set enable_kv_cache_reuse=False precisely because prefix-block reuse returns logits only for the recomputed suffix on shared-prefix requests (hellaswag's exact shape). The upstream backend has no such knob and TRT-LLM defaults enable_block_reuse=True, so that invariant is now unenforced. A two-line length check plus raising instead of continue makes the patch self-verifying.

2. Silently lost engine settings on the multi-GPU MoE path (examples/hf_ptq/scripts/huggingface_example.sh:300-309) — IMPORTANT Compatibility

modelopt/deploy/llm/generate.py forced moe_expert_parallel_size=1 with an explicit reason ("avoid TRT-LLM DeepEP kernel failures on unsupported GPUs (e.g. Blackwell SM 12.0)") and set enable_attention_dp=True for expert configs. Neither is expressible upstream. Defaulting tensor_parallel_size to the full device count is exactly where EP engages, so an MoE checkpoint on a multi-GPU SM 12.0 box can now fail on a command that worked before. free_gpu_memory_fraction also moves 0.7 → 0.9. The PR body documents all of this; the repo does not — make LM_EVAL_TP overridable via env and/or add the caveat to the README's TensorRT-LLM NOTE blocks so it survives the merge.

3. tokenizer=$MODEL_PATH should be $MODEL_ABS_PATH (huggingface_example.sh:309) — IMPORTANT Compatibility

MODEL_ABS_PATH is computed 40 lines above for exactly this reason (the script pushd ../llm_eval/ first), and the MMLU block below uses it correctly. Pre-existing bug carried into the new command, but it's a one-word fix in a line you're already touching. --model_args should also be quoted.

Suggestions (non-blocking)

  • _translate rewrites --model_args even when --model isn't the legacy trt-llm, injecting TRT-LLM keys into an HF command.
  • int() on legacy max_gen_toks/max_length raises bare ValueError; max_length - max_gen_toks can go negative → max_input_len=-128.
  • The args.model == "trtllm" guard is exact-match, while _inject_modelopt_args_into_model_args right above it had to enumerate 4 aliases for the same backend-naming reason. A missed patch is a hard KeyError, not a degradation.

Minor, no comment posted: bumping the lm_eval_hf.py guard to >= 0.4.12 hard-fails users on 0.4.10/0.4.11 who only use --model hf and never touch trtllm. requirements.txt is the contract for examples, so this is defensible — just noting the blast radius is wider than the trtllm path.

Risk assessment

Low-to-moderate, and confined to examples/. No modelopt/ source, no mode registration, no modelopt_state schema, no export path — so nothing here can affect checkpoint round-tripping or the library API. Backward compatibility for legacy commands is genuinely preserved and was validated end to end. The residual risk is concentrated in the eval-correctness path: finding #1 can produce quietly-wrong accuracy numbers under conditions this PR makes reachable (truncation, KV reuse), and CI's tiny dense Qwen3 cannot detect either that or finding #2. Adding the length/lookup assertions would move this to low risk outright, since the failure mode becomes loud instead of silent.

# engine for a 4096-token context plus the requested generation length.
python lm_eval_hf.py \
--model trtllm \
--model_args model=$SAVE_PATH,tokenizer=$MODEL_PATH,tensor_parallel_size=$LM_EVAL_TP,max_batch_size=$BUILD_MAX_BATCH_SIZE,max_gen_toks=$BUILD_MAX_OUTPUT_LEN,max_input_len=4096,max_output_len=$BUILD_MAX_OUTPUT_LEN \

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

do we have BUILD_MAX_INPUT_LEN here as well?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch — and it was more broken than it looked. parser.sh:163 already echoed BUILD_MAX_INPUT_LEN, but it was never in the getopt list, never parsed and never defaulted, so it printed empty on every run.

dcedd37b4 wires it up: --input -> BUILD_MAX_INPUT_LEN, DEFAULT_BUILD_MAX_INPUT_LEN=4096, and the lm_eval command now passes max_input_len=$BUILD_MAX_INPUT_LEN instead of the hardcoded 4096. The config echo reports it too.

Comment thread examples/llm_eval/lm_eval_hf.py Outdated
args.model_args = model_args


def _patch_trtllm_parse_logprobs():

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

is this a bug with the official TRTLLM backend?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes — a genuine bug in lm-eval 0.4.12's backend, confirmed both in source and at runtime.

TensorRT-LLM aligns prompt_logprobs to the next token. tensorrt_llm/executor/base_worker.py::_compute_pytorch_prompt_logprobs does:

# Pass prompt_token_ids with an offset of 1 for correct mapping to the context logits
prompt_token_ids = generation_result._generation_request.prompt_token_ids[1:] + first_generation_token

so entry i is the distribution that predicted tokens[i + 1], and _topk_logprobs appends that token's id when it is not in the top-k. lm-eval instead reads prompt_logprobs[i][tokens[i]] and applies its own shift on top of that.

Probed directly against TRT-LLM 1.3.0rc23 with a 14-token prompt, for prompt_logprobs 0, 1 and 2 — tokens[i] is missing at every position, tokens[i+1] is present at every position. So it is not an edge case: it raises KeyError on the first request of every loglikelihood task (hellaswag, mmlu, arc, winogrande, ...). Only generate_until tasks work unpatched.

The corrected indexing now lives in lm_eval_trtllm.py and reproduces the removed implementation's hellaswag numbers exactly (0.7188 / 0.7812 at tp=1/2/4).

Comment thread examples/llm_eval/lm_eval_hf.py Outdated
_add_modelopt_args(run_parser)
args = cli.parse_args()
_inject_modelopt_args_into_model_args(args)
if getattr(args, "model", None) == "trtllm":

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

why we handle trtllm here? This should be hf only.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed — moved in dcedd37b4. lm_eval_hf.py is back to HF-only: the _patch_trtllm_parse_logprobs helper, its call site, and the logging import are all gone.

The override now lives in the new examples/llm_eval/lm_eval_trtllm.py, whose entire content is that one corrected _parse_logprobs plus cli_evaluate(). huggingface_example.sh and the docs call it instead.

import copy
import gc
import logging
"""[Deprecated] ModelOpt's TensorRT-LLM backend for lm-evaluation-harness.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think it is ok to deprecate this file entirely

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done — deleted outright in dcedd37b4, no translating shim.

examples/llm_eval/README.md and the CHANGELOG entry carry the replacement command:
python lm_eval_tensorrt_llm.py --model trt-llm --model_args tokenizer=<tok>,checkpoint_dir=<ckpt>
-> python lm_eval_trtllm.py --model trtllm --model_args model=<ckpt>,tokenizer=<tok>,tensor_parallel_size=<tp>,max_input_len=4096.

Review feedback:

- lm_eval_hf.py is the HF entry point and should not carry a TensorRT-LLM
  patch. The prompt_logprobs override moves to a new lm_eval_trtllm.py, whose
  only content is that override plus lm-eval's CLI; lm_eval_hf.py is back to
  HF-only.
- Delete lm_eval_tensorrt_llm.py outright instead of keeping a translating
  shim. README and CHANGELOG give the replacement command.
- huggingface_example.sh hardcoded max_input_len=4096. parser.sh already
  echoed BUILD_MAX_INPUT_LEN but never parsed or defaulted it, so wire up
  --input (default 4096) and use it.
- Use MODEL_ABS_PATH for the tokenizer: the block runs after `pushd
  ../llm_eval/`, so a relative --model would not resolve (the mmlu block
  already does this). Quote --model_args (SC2086).
- LM_EVAL_TP is now overridable, and the README documents the TensorRT-LLM
  tuning the deleted wrapper applied that the upstream backend does not
  expose (expert parallelism, KV-cache fraction).

The override also now fails loudly: it checks prompt_logprobs covers every
prompt token and raises when a token is missing, instead of skipping the term
and silently inflating the reported accuracy.

Re-verified on nvidia/Qwen3.5-122B-A10B-NVFP4 (4x B300, TRT-LLM 1.3.0rc23),
tp=4: hellaswag acc 0.7188 / acc_norm 0.7812, unchanged from the previous
commit and from the deleted implementation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Chenjie Luo <chenjiel@nvidia.com>

@meenchen meenchen left a comment

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.

Bot review (bedrock-claude-opus-5) — DM the bot to share feedback.

Design review (protocol applied). Problem: the repo maintained its own trt-llm lm-eval backend (lm_eval_tensorrt_llm.py, 213 lines on top of modelopt.deploy.llm.LLM); lm-eval 0.4.12 now ships lm_eval.models.trtllm_causallms, so the local copy is redundant. Deleting it in favour of upstream is the right direction — this PR removes a subsystem rather than adding one, and the net is -231/+152. Alternatives considered: (a) keep the local backend (rejected in the body with a feature-parity table — reasonable), (b) fix upstream and pin (the body says an upstream issue/PR is still needed and marks the shim as temporary — reasonable), (c) put the _parse_logprobs patch inside the existing lm_eval_hf.py entry point instead of adding a second entry script — the PR body actually describes option (c) as what was implemented, but the diff implements a separate lm_eval_trtllm.py. That gap is my main concern.

Findings:

  1. The PR body no longer describes the diff, and the testing evidence is therefore unattributable. The body says lm_eval_tensorrt_llm.py "becomes a deprecation shim", that "lm_eval_hf.py patches _parse_logprobs when --model trtllm is selected", that the usage is python lm_eval_hf.py --model trtllm ..., that "the deprecation shim was run end to end", and answers "Is this change backward compatible?: ✅". The diff instead deletes the script (correctly reflected in CHANGELOG.rst, so the CHANGELOG and the body disagree) and adds a new lm_eval_trtllm.py that calls lm_eval.__main__.cli_evaluate. So the shipped code path — a flat python lm_eval_trtllm.py --model trtllm --tasks ... invocation through cli_evaluate on 0.4.12 — is not the one the accuracy tables were produced with. Please either update the body or re-state which command was actually run.

  2. cli_evaluate vs 0.4.12's subcommand CLI is unverified (CI-breaking if wrong). lm_eval_hf.py has to reach into HarnessCLI._subparsers.choices["run"], i.e. 0.4.12's CLI is subcommand-based. It's not obvious that lm_eval.__main__.cli_evaluate still accepts flat --model/--tasks/--batch_size args in 0.4.12; if it doesn't, test_qwen3_eval_fp8 (the only coverage) fails. Related: huggingface_example.sh forwards $lm_eval_flags, which can contain --trust_remote_codelm_eval_hf.py goes out of its way to move that flag into model_args and null it out in the namespace, so please confirm the plain CLI accepts it and that it actually reaches the trtllm backend/tokenizer.

  3. No unit test for the off-by-one fix, which is the only new logic in the PR and is pure Python. A stub outputs object (SimpleNamespace(outputs=[SimpleNamespace(prompt_logprobs=[{tok: Logprob(logprob, rank)}...])])) would pin the alignment, the is_greedy computation, and both RuntimeError paths without a GPU. Right now the fix is only exercised by a GPU/TRT-LLM example test whose reference numbers came from a different implementation (see 1).

  4. Minor: duplicated version guard between lm_eval_hf.py and lm_eval_trtllm.py, and the trtllm path loses lm_eval_hf.py's extras (--accuracy_lower_bound, trust-remote-code plumbing). Folding the patch into lm_eval_hf.py under args.model == "trtllm" (as the body describes) would avoid both.

Licensing: new file carries the standard NVIDIA/Apache-2.0 header (year 2024 vs. LICENSE_HEADER's 2026 — cosmetic, matches existing files); no licensing surface. Doc/CHANGELOG references to the removed script are consistent — no dangling lm_eval_tensorrt_llm.py invocations remain in the repo.


from importlib.metadata import version

from lm_eval.__main__ import cli_evaluate

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.

Bot comment.

cli_evaluate on lm-eval 0.4.12: lm_eval_hf.py (same directory, same version floor) has to hook HarnessCLI._subparsers.choices["run"], which implies 0.4.12's CLI is subcommand-based. Has python lm_eval_trtllm.py --model trtllm --tasks ... --batch_size ... (flat args, no run subcommand) actually been run against 0.4.12? The PR body's usage/testing sections both reference lm_eval_hf.py --model trtllm plus a deprecation shim, neither of which exists in this diff, so the shipped entry point looks unvalidated — and test_qwen3_eval_fp8 is the only thing covering it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes — that exact flat-arg form is what produced the tp=1/2/4 numbers in the PR body. It works because 0.4.12's CLI keeps a legacy path in HarnessCLI.parse_args (lm_eval/_cli/harness.py:48-51):

if len(sys.argv) > 2 and sys.argv[1] not in self._subparsers.choices:
    # Backward compatibility: arguments provided but no valid subcommand - insert 'run'
    sys.argv.insert(1, "run")

lm_eval_hf.py reaches for _subparsers.choices["run"] for a different reason — it adds the ModelOpt arguments (--quant_cfg, --calib_size, ...) to that subparser. lm_eval_trtllm.py adds no arguments, so plain cli_evaluate() is enough.

You were right that it looked unvalidated, though: the PR description was stale — it still documented lm_eval_hf.py --model trtllm and a deprecation shim, neither of which survived dcedd37b4. Rewritten now to match the shipped entry point, and 622b97c26 adds unit coverage so it is not resting on the GPU example test alone.


return continuation_logprobs, is_greedy


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.

Bot comment.

Two robustness points on the monkeypatch:

  1. It silently no-ops semantics if upstream changes: with only a >=0.4.12 floor and a <0.5 pin, a 0.4.13 that fixes _parse_logprobs would be silently overridden by this patch (double-shifting again), and a rename/signature change would surface as an obscure failure. Consider asserting the attribute exists (and ideally a version ceiling / a comment tying removal to the upstream fix).
  2. There is no test for _parse_logprobs itself. This is the only new logic in the PR and needs no GPU — a stub outputs (prompt_logprobs list of {token_id: Logprob(logprob, rank)}) can pin the i-1 alignment, the is_greedy/rank != 1 rule, the max(ctxlen, 1) edge case, and both RuntimeError branches. Please add one; the GPU example test's reference numbers were produced with a different implementation.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Both taken, in 622b97c26.

Testtests/examples/llm_eval/test_lm_eval_trtllm.py, no GPU and no tensorrt_llm install (the module import is guarded upstream, and _parse_logprobs is pure Python over the response object). Stubs prompt_logprobs as {token_id: Logprob(logprob, rank)} and covers the i-1 alignment, the rank != 1 -> is_greedy rule, the max(ctxlen, 1) edge, and both RuntimeError branches. Mutation-checked so the cases are load-bearing: dropping the -1 shift is caught by 5/5, ignoring ctxlen by 4/5.

Drift — agreed a silent double-shift on a fixed 0.4.13 is the dangerous case. I did not add a version ceiling, since that would also block unrelated 0.4.x fixes. Instead the module keeps the original in _UPSTREAM_PARSE_LOGPROBS and a test asserts it is still misaligned:

def test_upstream_is_still_misaligned():
    """Tripwire: when this fails, upstream fixed the bug and this file can be deleted."""
    with pytest.raises(KeyError):
        lm_eval_trtllm._UPSTREAM_PARSE_LOGPROBS(...)

So the day upstream fixes it, CI fails and points at the removal rather than silently re-breaking the alignment. Plus a hasattr check that errors clearly if _parse_logprobs is renamed or removed.

One correction on the aside: the reference numbers were not produced by a different implementation. The tp=4 run was re-done against lm_eval_trtllm.py exactly as shipped after the code moved out of lm_eval_hf.py, and reproduces hellaswag 0.7188 / 0.7812. The unit test is still the right ask — it pins the alignment in a way an end-to-end accuracy number cannot.

# explicitly; the engine's max_seq_len is max_input_len + max_output_len.
python lm_eval_trtllm.py \
--model trtllm \
--model_args "model=$SAVE_PATH,tokenizer=$MODEL_ABS_PATH,tensor_parallel_size=$LM_EVAL_TP,max_batch_size=$BUILD_MAX_BATCH_SIZE,max_gen_toks=$BUILD_MAX_OUTPUT_LEN,max_input_len=$BUILD_MAX_INPUT_LEN,max_output_len=$BUILD_MAX_OUTPUT_LEN" \

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.

Bot comment.

max_gen_toks=$BUILD_MAX_OUTPUT_LEN is carried over from the old backend, but the PR body states the upstream TRTLLM.__init__ only forwards a fixed set of kwargs and silently drops the rest. Is max_gen_toks in that set? If it is dropped, generation length is no longer capped by --output, which is exactly what test_qwen3_eval_fp8 relies on (output=128 "Cap generation length: gsm8k/humaneval otherwise generate up to 1024 tokens/sample") — the test would get slower and the updated comment would be misleading. Worth confirming, and dropping the arg if it's a no-op.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Checked — max_gen_toks is honored, not dropped, so --output still caps generation and test_qwen3_eval_fp8's comment stays accurate.

It is an explicit named parameter of TRTLLM.__init__, not part of the **kwargs that get discarded (lm_eval/models/trtllm_causallms.py):

:60   max_gen_toks: int = 256,           # named __init__ parameter
:86   self._max_gen_toks = max_gen_toks
:575  def max_gen_toks(self) -> int: return self._max_gen_toks
:411  kwargs, until, max_gen_toks = self.modify_gen_kwargs(
:412      gen_kwargs, eos=eos, default_max_gen_toks=self.max_gen_toks)
:427  SamplingParams(max_tokens=max_gen_toks, stop=until, **kwargs)

The PR body caused the confusion and I have fixed it: the "only forwards a fixed set" claim is about the kwargs handed to the TensorRT-LLM LLM API (tensor_parallel_size, max_input_len, kv_cache_config, ...), where engine-level extras really are silently dropped. lm-eval's own named parameters are consumed by the backend normally. Keeping the argument.

Review feedback: the corrected _parse_logprobs is the only new logic in this
PR and had no test of its own -- the GPU numbers exercise it end to end but
cannot pin the alignment.

tests/examples/llm_eval/test_lm_eval_trtllm.py stubs the TensorRT-LLM response
object (no GPU, no tensorrt_llm install) and covers the i-1 alignment, the
rank != 1 -> is_greedy rule, the ctxlen=0 edge, and both RuntimeError paths.
Mutation-checked: dropping the -1 shift is caught by 5/5 cases, ignoring
ctxlen by 4/5.

The last test is a tripwire. lm_eval_trtllm keeps the upstream implementation
in _UPSTREAM_PARSE_LOGPROBS and asserts it still raises KeyError on a
correctly-shaped response. A future 0.4.x that fixes the alignment would
otherwise be silently re-broken by this override, since requirements.txt only
pins >=0.4.12,<0.5; instead that test fails and says to delete the file.

Also raise a clear error if TRTLLM._parse_logprobs disappears, rather than
overriding an attribute that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Chenjie Luo <chenjiel@nvidia.com>

@meenchen meenchen left a comment

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.

Bot review (bedrock-claude-opus-5) — DM the bot to share feedback.

Re-review of PR #2066 (head b68114bb). Design question is settled: this removes the 213-line local trt-llm backend in favour of lm-eval 0.4.12's built-in trtllm backend, and the maintainer explicitly asked for the override to live outside lm_eval_hf.py ("This should be hf only"), which the current diff does. Net size is small and confined to examples/ + tests/. Licensing: new files carry only the standard NVIDIA/Apache header — no licensing surface. No prompt-injection attempts in the PR content.

Previous critical findings — resolved

  • 💬 Body/diff mismatch (shim vs. deleted script, lm_eval_hf.py --model trtllm vs. new entry point): body rewritten to describe the shipped lm_eval_trtllm.py, CHANGELOG and README agree, no dangling references to the deleted script.
  • 💬 Flat-arg cli_evaluate on 0.4.12's subcommand CLI: author cited lm_eval/_cli/harness.py:48-51 (auto-inserts run when argv[1] isn't a subcommand) and states the accuracy tables were produced with exactly that command form. Reasonable.
  • 💬 Missing unit test for the off-by-one: tests/examples/llm_eval/test_lm_eval_trtllm.py adds 6 cases pinning the i-1 alignment, rank != 1is_greedy, the ctxlen=0 edge, both RuntimeError paths, and an upstream-still-broken tripwire; follows the existing test_cast_mxfp4_to_nvfp4.py sys.path convention. Silent-inflation fallback (continue) replaced by a raise plus a length guard, addressing the earlier eval-correctness concern.
  • Shell fixes landed: tokenizer=$MODEL_ABS_PATH, quoted --model_args, LM_EVAL_TP override with the DeepEP/SM 12.0 rationale, and --input/BUILD_MAX_INPUT_LEN wired into parser.sh (previously echoed but never parsed — the fix is correct and defaulted to 4096, matching the test_qwen3_eval_fp8 comment update).

Still worth an owner look

  • CI reach of the new tests. example_tests.yml runs tests/examples/llm_eval only in the nightly trtllm-non-pr matrix; the PR-gated trtllm job is hf_ptq only. So these deliberately GPU-free tests — including the tripwire meant to catch a future 0.4.x that fixes upstream, and the assumption that TRTLLM._parse_logprobs is a staticmethod with (tokens, outputs, ctxlen) — won't run on PRs. Placing them somewhere the PR gate collects would make the protection real.
  • 💬 --trust_remote_code — the author answered the flat-arg half of the earlier question but not this half. huggingface_example.sh still appends --trust_remote_code to $lm_eval_flags, and it now flows into the plain lm-eval CLI, whereas lm_eval_hf.py goes out of its way to rewrite it into model_args and null it in the namespace. Behaviour matches the deleted script (also plain cli_evaluate), so it's not a regression, but nothing confirms it reaches the trtllm tokenizer, and CI doesn't exercise it.
  • Minor / optional: the >=0.4.12 version guard is now duplicated in lm_eval_hf.py and lm_eval_trtllm.py; the trtllm path has no --accuracy_lower_bound equivalent; the guard len(prompt_logprobs) < len(tokens) - 1 still passes if exactly one prompt token were dropped (the author argues truncation can't occur on the loglikelihood path, which looks right); and the test only does importorskip("lm_eval")importorskip("lm_eval.models.trtllm_causallms") would skip rather than error if upstream ever imports tensorrt_llm eagerly.

Review question: lm_eval_trtllm.py calls lm-eval's CLI directly and does no
argument rewriting, while lm_eval_hf.py goes out of its way to move
--trust_remote_code into model_args. Nothing confirmed the flag that
huggingface_example.sh passes actually reaches the trtllm tokenizer.

It does, on lm-eval 0.4.12: _cli/run.py:355 builds the config via
EvaluatorConfig.from_cli, which calls _configure() -> _set_trust_remote_code()
(config/evaluate_config.py:274-276, 420-435). That sets
datasets.config.HF_DATASETS_TRUST_REMOTE_CODE and injects
model_args["trust_remote_code"]=True, which TRTLLM.__init__ takes as a named
parameter and forwards to both AutoTokenizer.from_pretrained and the
TensorRT-LLM LLM kwargs (trtllm_causallms.py:48, 110, 148).

lm_eval_hf.py's manual handling is therefore redundant on 0.4.12 -- it
predates lm-eval doing this itself. Left alone here; it is harmless (it
populates model_args and then nulls the namespace flag, so the built-in path
no-ops) and out of scope for this PR.

Three tests pin the contract we now depend on: the flag is injected when set,
not injected when unset, and trust_remote_code is still a parameter of
TRTLLM.__init__ so the injected key lands somewhere real. No GPU required.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Chenjie Luo <chenjiel@nvidia.com>
@cjluo-nv

cjluo-nv commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

--trust_remote_code — verified, and now pinned by tests (196c32606)

Fair hit: nothing confirmed it. It does reach the tokenizer, and on 0.4.12 lm-eval does the rewriting itself, so this entry point does not need lm_eval_hf.py's manual handling:

_cli/run.py:355              EvaluatorConfig.from_cli(args)
  -> evaluate_config.py:225    instance._configure()
  -> evaluate_config.py:276    ..._set_trust_remote_code()
  -> evaluate_config.py:420-435
        datasets.config.HF_DATASETS_TRUST_REMOTE_CODE = True
        self.model_args["trust_remote_code"] = True
  -> trtllm_causallms.py:48     named __init__ parameter (not part of the dropped **kwargs)
       :110  AutoTokenizer.from_pretrained(..., trust_remote_code=trust_remote_code)
       :148  llm_kwargs["trust_remote_code"]

So it reaches both the tokenizer and the TensorRT-LLM engine.

Worth recording the consequence: lm_eval_hf.py's manual rewriting is now redundant — it predates lm-eval doing this natively. It is harmless (it populates model_args, then nulls the namespace flag so the built-in path no-ops to the same result), so I have left it alone; removing it is a separate cleanup, not this PR's business.

196c32606 adds three GPU-free tests so this is not resting on my reading of the source: flag set -> injected into model_args and the datasets flag flips; flag unset -> not injected; and trust_remote_code is still a parameter of TRTLLM.__init__. That third one matters — without it the first two only prove lm-eval injects a key, and an upstream rename would leave it landing in **kwargs and being silently dropped.

Not run end to end: that needs a trust_remote_code model on Blackwell (fresh allocation, ~10 min load) for a flag whose path is now pinned statically at every hop. Happy to if you want it.

Review nit: lm_eval.models.trtllm_causallms currently guards its tensorrt_llm
import, but if that ever becomes eager, importorskip("lm_eval") would let
collection proceed and then error. Skip on the module actually imported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Chenjie Luo <chenjiel@nvidia.com>

@coderabbitai coderabbitai Bot left a comment

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.

Warning

CodeRabbit couldn't request changes on this pull request because it doesn't have sufficient GitHub permissions.

Please grant CodeRabbit Pull requests: Read and write permission and re-run the review.

👉 Steps to fix this

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/examples/llm_eval/test_lm_eval_trtllm.py`:
- Around line 120-121: Move the imports in the affected test functions,
including datasets, EvaluatorConfig, and inspect, to module scope after the
existing optional lm_eval skip guard. If any optional or heavy dependency must
remain local, add a brief explanatory comment at each such import naming the
reason.
- Around line 112-132: Add a GPU-free test that instantiates the real TRTLLM
class, stubbing only AutoTokenizer.from_pretrained and tensorrt_llm.LLM while
capturing and asserting their forwarded trust_remote_code arguments. Extend
test_trust_remote_code_reaches_the_backend or add a focused neighboring test so
it exercises TRTLLM construction rather than stopping at
EvaluatorConfig._set_trust_remote_code. Move non-optional imports to module
scope and retain optional imports locally only with justification comments.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 0248137d-df43-4006-9aed-58579d141b64

📥 Commits

Reviewing files that changed from the base of the PR and between 622b97c and 196c326.

📒 Files selected for processing (1)
  • tests/examples/llm_eval/test_lm_eval_trtllm.py

Comment on lines +112 to +132
def test_trust_remote_code_reaches_the_backend(monkeypatch):
"""`--trust_remote_code` must land in model_args, since we call lm-eval's CLI directly.

`huggingface_example.sh` passes the flag, and unlike `lm_eval_hf.py` this entry point
does no rewriting of its own -- it relies on lm-eval doing it. On the live path that is
`_cli/run.py:355` -> `EvaluatorConfig.from_cli` -> `_configure()` ->
`_set_trust_remote_code()`.
"""
import datasets
from lm_eval.config.evaluate_config import EvaluatorConfig

monkeypatch.setattr(datasets.config, "HF_DATASETS_TRUST_REMOTE_CODE", False)

cfg = EvaluatorConfig(
model="trtllm", model_args={"model": "/ckpt", "tokenizer": "/tok"}, trust_remote_code=True
)
cfg._set_trust_remote_code()

assert cfg.model_args["trust_remote_code"] is True
assert datasets.config.HF_DATASETS_TRUST_REMOTE_CODE is True

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.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- test file outline ---'
ast-grep outline tests/examples/llm_eval/test_lm_eval_trtllm.py
printf '%s\n' '--- relevant test file ---'
cat -n tests/examples/llm_eval/test_lm_eval_trtllm.py | sed -n '1,190p'
printf '%s\n' '--- TRTLLM definitions and uses ---'
rg -n --glob '*.py' 'class TRTLLM|TRTLLM\(|trust_remote_code|AutoTokenizer|LLM\(' .
printf '%s\n' '--- contributing guidance ---'
rg -n -A8 -B4 'in-function imports|module.scope|mock|real implementation|examples' CONTRIBUTING.md tests/conftest.py 2>/dev/null || true

Repository: NVIDIA/Model-Optimizer

Length of output: 50378


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- changed files ---'
git diff --stat
printf '%s\n' '--- llm_eval example ---'
fd -t f . examples/llm_eval tests/examples/llm_eval | sort
printf '%s\n' '--- lm_eval package location ---'
python3 - <<'PY'
import importlib.util
spec = importlib.util.find_spec("lm_eval")
print(spec.origin if spec else "lm_eval not installed")
print(list(spec.submodule_search_locations or []) if spec else "")
PY
printf '%s\n' '--- installed TRTLLM implementation references ---'
python3 - <<'PY'
import importlib.util
spec = importlib.util.find_spec("lm_eval.models.trtllm_causallms")
print(spec.origin if spec else "module not installed")
PY
printf '%s\n' '--- focused source and dependency metadata ---'
rg -n --glob '*.py' --glob '*.toml' --glob '*.txt' --glob '*.md' \
  'trtllm_causallms|TRTLLM|lm.eval|lm_eval|datasets' \
  examples/llm_eval tests/examples/llm_eval pyproject.toml setup.py setup.cfg requirements* CONTRIBUTING.md 2>/dev/null | head -300

Repository: NVIDIA/Model-Optimizer

Length of output: 1137


🌐 Web query:

NVIDIA Model-Optimizer lm_eval_trtllm.py test_lm_eval_trtllm.py TRTLLM trust_remote_code lm-evaluation-harness source

💡 Result:

In the context of evaluating models with TensorRT-LLM (TRTLLM), there are currently two primary, distinct approaches for integrating with evaluation frameworks: the official NVIDIA Model Optimizer integration and the native TRT-LLM backend within the EleutherAI lm-evaluation-harness. 1. NVIDIA Model Optimizer Integration: NVIDIA's Model Optimizer provides example scripts for evaluating quantized models [1][2]. The script lm_eval_tensorrt_llm.py acts as a wrapper to run TensorRT-LLM models within the evaluation workflow [3][1]. Users typically invoke it via command line: python lm_eval_tensorrt_llm.py --model trt-llm --model_args tokenizer=,checkpoint_dir= --tasks --batch_size <batch_size> [1][2]. 2. EleutherAI lm-evaluation-harness (Native Backend): As of version 0.4.12, the EleutherAI lm-evaluation-harness includes a native TRT-LLM backend (trt-llm) [4]. This was added to align feature parity with other backends like vLLM and sglang [5][6]. - Implementation: The backend is implemented in lm_eval/models/trtllm_causallms.py [6]. - Usage: You can use the native backend directly within the lm-evaluation-harness by specifying the trt-llm model type, which manages the TRT-LLM engine loading and execution internally [4][6]. Regarding your mention of test_lm_eval_trtllm.py, there is no widely documented or standard utility by that name in the primary NVIDIA Model Optimizer or EleutherAI repositories. It is likely a custom testing script for the evaluation wrapper. For general TensorRT-LLM evaluation, NVIDIA also provides the trtllm-eval tool, which is a unified entry point built on the TensorRT-LLM offline API and is distinct from the lm-evaluation-harness, though it may support similar tasks [7]. When using external tools like lm-evaluation-harness with TRT-LLM, users are sometimes advised to set arguments like add_special_tokens=False to prevent accuracy regressions caused by automatic BOS token insertion, depending on the specific model and task [8]. Trust_remote_code is a standard Hugging Face argument often required when loading custom model architectures, which can be passed through the model arguments in these frameworks if the underlying library supports it [6].

Citations:


🏁 Script executed:

#!/bin/bash
set -e
python3 - <<'PY'
from urllib.request import urlopen
urls = {
    "trtllm_backend.py": "https://raw.githubusercontent.com/EleutherAI/lm-evaluation-harness/243c5463f67231b945f6add5f525e599c080ef63/lm_eval/models/trtllm_causallms.py",
    "requirements.txt": "https://raw.githubusercontent.com/NVIDIA/Model-Optimizer/main/examples/llm_eval/requirements.txt",
    "contributing.md": "https://raw.githubusercontent.com/NVIDIA/Model-Optimizer/main/CONTRIBUTING.md",
}
for name, url in urls.items():
    print(f"--- {name} ---")
    try:
        text = urlopen(url, timeout=10).read().decode()
    except Exception as exc:
        print(f"FETCH_ERROR: {exc}")
        continue
    if name == "trtllm_backend.py":
        lines = text.splitlines()
        for i, line in enumerate(lines, 1):
            if any(term in line for term in (
                "class TRTLLM", "trust_remote_code", "AutoTokenizer",
                "Tokenizer", "LLM(", "def __init__"
            )):
                start = max(1, i - 8)
                end = min(len(lines), i + 12)
                print(f"[{start}-{end}]")
                print("\n".join(f"{j}: {lines[j-1]}" for j in range(start, end + 1)))
    else:
        print(text[:12000])
PY

Repository: NVIDIA/Model-Optimizer

Length of output: 679


🏁 Script executed:

#!/bin/bash
set -e
for spec in \
  "backend https://raw.githubusercontent.com/EleutherAI/lm-evaluation-harness/243c5463f67231b945f6add5f525e599c080ef63/lm_eval/models/trtllm_causallms.py" \
  "requirements https://raw.githubusercontent.com/NVIDIA/Model-Optimizer/main/examples/llm_eval/requirements.txt" \
  "contributing https://raw.githubusercontent.com/NVIDIA/Model-Optimizer/main/CONTRIBUTING.md"
do
  name=${spec%% *}
  url=${spec#* }
  printf '%s\n' "--- $name ---"
  curl -ksSfL "$url" | \
    if [ "$name" = backend ]; then
      nl -ba | grep -E -A12 -B8 'class TRTLLM|trust_remote_code|AutoTokenizer|Tokenizer|LLM\(|def __init__'
    else
      head -200
    fi
done

Repository: NVIDIA/Model-Optimizer

Length of output: 313


🏁 Script executed:

#!/bin/bash
set -e
tmp=$(mktemp)
curl -kSfL \
  'https://raw.githubusercontent.com/EleutherAI/lm-evaluation-harness/243c5463f67231b945f6add5f525e599c080ef63/lm_eval/models/trtllm_causallms.py' \
  -o "$tmp"
grep -n -E -A12 -B8 'class TRTLLM|trust_remote_code|AutoTokenizer|Tokenizer|LLM\(|def __init__' "$tmp" || true
rm -f "$tmp"

Repository: NVIDIA/Model-Optimizer

Length of output: 5043


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- llm_eval requirements ---'
cat -n examples/llm_eval/requirements.txt
printf '%s\n' '--- backend initialization source ---'
tmp=$(mktemp)
curl -kSfL \
  'https://raw.githubusercontent.com/EleutherAI/lm-evaluation-harness/243c5463f67231b945f6add5f525e599c080ef63/lm_eval/models/trtllm_causallms.py' \
  -o "$tmp"
sed -n '1,175p' "$tmp"
rm -f "$tmp"
printf '%s\n' '--- test guidance ---'
rg -n -A10 -B5 'imports at module|local import|optional|heavy|mock|real implementation|GPU-free|examples/llm_eval' \
  CONTRIBUTING.md examples/llm_eval/README.md tests/examples/README.md tests/conftest.py 2>/dev/null || true

Repository: NVIDIA/Model-Optimizer

Length of output: 12537


Add a GPU-free TRTLLM forwarding test.

test_trust_remote_code_reaches_the_backend stops at EvaluatorConfig._set_trust_remote_code(), and test_trtllm_backend_accepts_trust_remote_code checks only the constructor signature. Instantiate the real TRTLLM, capture the arguments passed to AutoTokenizer.from_pretrained and tensorrt_llm.LLM, and stub only those external constructors. Move the non-optional test imports to module scope with justification comments for any remaining optional imports.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/examples/llm_eval/test_lm_eval_trtllm.py` around lines 112 - 132, Add a
GPU-free test that instantiates the real TRTLLM class, stubbing only
AutoTokenizer.from_pretrained and tensorrt_llm.LLM while capturing and asserting
their forwarded trust_remote_code arguments. Extend
test_trust_remote_code_reaches_the_backend or add a focused neighboring test so
it exercises TRTLLM construction rather than stopping at
EvaluatorConfig._set_trust_remote_code. Move non-optional imports to module
scope and retain optional imports locally only with justification comments.

Source: Path instructions

Comment thread tests/examples/llm_eval/test_lm_eval_trtllm.py
@cjluo-nv

cjluo-nv commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Remaining items from the re-review

CI reach of the new tests — confirmed: trtllm-pr is [hf_ptq], llm_eval only appears in trtllm-non-pr, so these run nightly. Owner call, and the decision is to leave them nightly. The reasoning: the tripwire fires on a new lm-eval release rather than on a diff, so a nightly cadence matches its trigger, and the alignment logic is a temporary shim scheduled for deletion once upstream is fixed. Adding llm_eval to trtllm-pr would also drag test_qwen3_eval_fp8 (GPU, 900s) onto every PR, which is the split the pr/non-pr comment exists to maintain. Filtering per-job would mean editing _example_tests_runner.yml, shared by every example — out of blast radius for this PR. Noting the residual gap honestly: a broken edit to lm_eval_trtllm.py is not caught by a PR gate beyond ruff.

Taken (d5f868cf1): importorskip("lm_eval.models.trtllm_causallms") instead of importorskip("lm_eval"), so collection skips rather than errors if that module ever imports tensorrt_llm eagerly.

Answered, no change:

  • len(prompt_logprobs) < len(tokens) - 1 still passes if exactly one prompt token were dropped. Correct, but the per-token RuntimeError covers it: a one-position shift means tokens[i] is essentially never present in the shifted dict, and across a full task it is a certainty rather than a probability. Tightening to == len(tokens) would make it deterministic, but I have only observed that exact length on TRT-LLM 1.3.0rc23, so it would bet the whole loglikelihood path on one version's response shape. The tolerant length guard plus the loud per-token check fails in the safe direction without that bet.
  • Duplicated >=0.4.12 guard across the two entry points. Deduping needs a third shared module to save three lines in each; not worth the indirection for two independent scripts.
  • No --accuracy_lower_bound equivalent on the trtllm path. True, and equally true of the deleted lm_eval_tensorrt_llm.py — so not a regression. Reasonable follow-up if you want the gate available for TensorRT-LLM runs.

@meenchen meenchen left a comment

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.

Bot review (bedrock-claude-opus-5) — DM the bot to share feedback.

Re-review of PR #2066 (examples/tests only, +331/−231). Design question stays settled: this removes the 213-line in-repo trt-llm lm-eval backend in favour of lm-eval 0.4.12's built-in trtllm backend, and the only new logic is a narrowly scoped _parse_logprobs override in a thin entry point — which is where the maintainer asked it to live ("this should be hf only"). No new subsystem, no licensing surface beyond the standard NVIDIA/Apache header on the new files, and no prompt-injection attempts in the PR content.

Previously-raised items now resolved

  • importorskip("lm_eval.models.trtllm_causallms") replaces the bare lm_eval skip, so collection skips rather than errors if the backend ever imports tensorrt_llm eagerly (d5f868c).
  • Off-by-one fix is unit-tested (tests/examples/llm_eval/test_lm_eval_trtllm.py): i-1 alignment, rank != 1is_greedy, ctxlen=0 edge, both RuntimeError paths, plus an upstream-still-broken tripwire; the silent-continue fallback is gone in favour of a hard raise.
  • Shell side is correct: --input/BUILD_MAX_INPUT_LEN now actually parsed and defaulted in parser.sh (it was previously echoed only), tokenizer=$MODEL_ABS_PATH, quoted --model_args, LM_EVAL_TP escape hatch with the DeepEP/SM 12.0 rationale, and the lost free_gpu_memory_fraction/expert-parallel tuning documented in examples/llm_eval/README.md.

Still worth an owner look before sign-off

  • 💬 CI reach — author decided to keep the new tests nightly-only (llm_eval appears only in the trtllm-non-pr matrix; trtllm-pr is hf_ptq), reasoning that the tripwire fires on new lm-eval releases rather than diffs and that adding llm_eval to trtllm-pr would drag the 900s GPU test_qwen3_eval_fp8 onto every PR. Reasonable, but the consequence is that the deliberately GPU-free tests protecting the only new logic in this PR never gate a PR (ruff aside), and the tripwire also encodes a version-dependent assumption — that upstream _parse_logprobs is a staticmethod callable as (tokens=, outputs=, ctxlen=); if that changes it fails with TypeError, not the asserted KeyError, and only overnight. Worth a maintainer decision on whether these belong in a PR-run suite.
  • 💬 --trust_remote_code — author traced the full path (EvaluatorConfig._set_trust_remote_codemodel_args["trust_remote_code"] → named TRTLLM.__init__ param → tokenizer and llm_kwargs) and added three GPU-free tests. Flagging only because the coverage is static (config-level injection + inspect.signature), never exercising TRTLLM construction, and no e2e run with a trust_remote_code model was done — acceptable to me, but it's a security-adjacent flag so an owner should accept the level of assurance.
  • Minor / convention: tests/examples/llm_eval/test_lm_eval_trtllm.py still has function-local imports (import datasets + from lm_eval.config.evaluate_config import EvaluatorConfig at ~120 and ~135, import inspect at ~148) with no stated justification; the module already has a top-level importorskip guard, so these can move to module scope after it. Not blocking. Also unaddressed-by-choice and reasonable: the duplicated >=0.4.12 guard across the two entry points, no --accuracy_lower_bound on the trtllm path (parity with the deleted script), and len(prompt_logprobs) < len(tokens) - 1 tolerating a hypothetical one-token drop (the per-token raise covers it in practice).

@cjluo-nv
cjluo-nv enabled auto-merge (squash) August 6, 2026 23:42
is_greedy = True
# Token 0 has no preceding distribution, so it can never be scored.
for i in range(max(ctxlen, 1), len(tokens)):
logprob = prompt_logprobs[i - 1].get(tokens[i])

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.

[P1] Require the TensorRT-LLM version that provides this layout. This i - 1 mapping only became valid in TensorRT-LLM 1.3.0rc11. The hf_ptq README still recommends release:1.2.0; in that version _compute_pytorch_prompt_logprobs does not pass prompt token IDs into compute_logprobs, so prompt_logprobs=1 retains only top-1. A normal non-greedy continuation token is therefore absent and this branch raises, breaking likelihood tasks that worked through the deleted wrapper. See v1.2.0 versus v1.3.0rc11. Please add an explicit >=1.3.0rc11 runtime guard and update the documented container requirement, or keep a compatible path for the supported 1.1/1.2 releases.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 6a54747c3. I diffed the two tags rather than taking it on trust, and you are right:

# v1.2.0 -- no prompt token ids
logprobs_result = compute_logprobs(logprob_params.prompt_logprobs, None,
                                   context_logits, None, None)

# v1.3.0rc11 -- prompt token ids passed in
prompt_token_ids = generation_result._generation_request.prompt_token_ids[1:] + first_generation_token
logprobs_result = compute_logprobs(logprob_params.prompt_logprobs, None,
                                   context_logits, None, None,
                                   prompt_token_ids)

With tokens=None, _topk_logprobs never reaches its "append the requested token if it is not in top-k" branch, so prompt_logprobs=1 keeps only the argmax. A non-greedy continuation token is genuinely absent -- there is no correct value to recover, for this override or for lm-eval's own _parse_logprobs. And since hf_ptq/README.md recommends release:1.2.0, a user following the docs would hit exactly this.

Guard placement. I put it in _parse_logprobs, not at startup. A blanket startup abort would break generative tasks (gsm8k, ifeval) on 1.1/1.2, which work fine on the old layout and worked through the deleted wrapper — that would be a fresh regression rather than a fix. So:

  • _check_trtllm_version() raises from _parse_logprobs on the first loglikelihood request, checked once.
  • __main__ calls the same function and prints a warning, so an unusable container is visible immediately rather than after a multi-minute model load.

Net: loglikelihood on <1.3.0rc11 fails with an explicit "use a newer container" message instead of a confusing missing-token error; generative tasks are untouched.

Docs, as you asked: examples/llm_eval/README.md and examples/hf_ptq/README.md (right under the release:1.2.0 recommendation) both state the requirement, and the CHANGELOG entry does too.

Tests: seven cases — reject on 1.1.0rc2 / 1.2.0 / 1.3.0rc10, accept on 1.3.0rc11 / 1.3.0rc23 / 1.3.0 / 1.4.0, plus one asserting the guard fires from _parse_logprobs and not only from __main__. A missing tensorrt_llm is treated as "nothing to check", which keeps the suite install-free; that branch is unreachable in a real run because the backend refuses to build a model without it.

import datasets
from lm_eval.config.evaluate_config import EvaluatorConfig

monkeypatch.setattr(datasets.config, "HF_DATASETS_TRUST_REMOTE_CODE", False)

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.

[P1] These tests fail with supported datasets 4.x because HF_DATASETS_TRUST_REMOTE_CODE is not defined until lm-eval assigns it. monkeypatch.setattr(..., raising=True) therefore raises AttributeError before testing the production behavior. I reproduced this with exact lm_eval==0.4.12 and datasets==4.8.4: 7 tests passed and these two trust-remote-code tests failed. Please use raising=False or defensively establish/remove the attribute. The relevant trtllm-non-pr suite was skipped by the current PR run, which is why the checks remain green.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Reproduced and fixed in 6a54747c3. I removed HF_DATASETS_TRUST_REMOTE_CODE from datasets.config to simulate 4.x and got exactly your result — 7 passed, 2 failed with AttributeError from monkeypatch.setattr, before either test exercised anything.

My venv has datasets 3.3.2, which still defines the attribute. That is the whole reason it was green locally and red for you; thanks for pinning the version, it made this a two-minute repro instead of a guess.

Fixed with raising=False on both calls, which is the right form here: lm-eval creates the attribute on assignment, and monkeypatch still removes it again on teardown, so the isolation the tests wanted is preserved either way.

Now verified under both: 17 passed on datasets 3.3.2, and 17 passed with the attribute deleted. Also re-ran through the shared tests/examples/conftest.py rather than --noconftest, so the real collection path is covered.

And your closing point is well taken — the trtllm-non-pr suite being skipped is exactly why this reached you instead of CI. That is the nightly-only gap discussed above; both of these findings would have been caught by it.

Two P1 review findings, both reproduced locally before fixing.

1. The i-1 prompt_logprobs mapping this file corrects is only valid from
   TensorRT-LLM 1.3.0rc11. Confirmed against the tags: v1.2.0 calls
   compute_logprobs(prompt_logprobs, None, context_logits, None, None) with no
   prompt token ids, so _topk_logprobs skips the "append the requested token"
   branch and prompt_logprobs=1 keeps only the argmax; a non-greedy
   continuation token is simply absent. hf_ptq/README.md recommends
   release:1.2.0, so a user following the docs would hit this.

   _check_trtllm_version() raises with an actionable message. It runs from
   _parse_logprobs rather than at startup, so generative-only runs -- which
   never touch this path -- keep working on older releases, and __main__ warns
   up front so an unusable container is visible before the model loads. Both
   READMEs and the CHANGELOG now state the requirement.

2. The trust_remote_code tests failed on datasets 4.x, which dropped
   HF_DATASETS_TRUST_REMOTE_CODE; monkeypatch.setattr then raised
   AttributeError before exercising anything. Reproduced by deleting the
   attribute (7 passed, 2 failed, matching the report) and fixed with
   raising=False -- lm-eval creates the attribute on assignment, and
   monkeypatch still removes it on teardown.

Seven new cases cover the version guard, including that it fires from
_parse_logprobs and not only from __main__. A missing tensorrt_llm is treated
as "nothing to check" so the suite still needs no TensorRT-LLM install; that
branch is unreachable in a real run, where the backend refuses to build a
model without it. 17 tests pass under datasets 3.3.2 and under a simulated 4.x.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Chenjie Luo <chenjiel@nvidia.com>
Comment thread examples/hf_ptq/README.md Outdated
For Hugging Face models, please use the TensorRT-LLM docker image (e.g., `nvcr.io/nvidia/tensorrt-llm/release:1.2.0`).
Visit our [installation docs](https://nvidia.github.io/Model-Optimizer/getting_started/2_installation.html) for more information.

> **NOTE:** `--tasks lm_eval` needs **TensorRT-LLM >= 1.3.0rc11** for loglikelihood

@cjluo-nv cjluo-nv Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Maybe we don't need this change

Drop the note added to examples/hf_ptq/README.md; the requirement is stated in
examples/llm_eval/README.md, which is where the evaluation command lives, and
in the CHANGELOG. hf_ptq/README.md is now untouched by this PR.

Users on an older container still get the actionable runtime error from
lm_eval_trtllm.py rather than a confusing missing-token failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Chenjie Luo <chenjiel@nvidia.com>
@cjluo-nv
cjluo-nv merged commit 9b8caf6 into main Aug 7, 2026
50 of 51 checks passed
@cjluo-nv
cjluo-nv deleted the chenjiel/lm-eval-0412-trtllm-backend branch August 7, 2026 22:36
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-07 22:37 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants