Skip to content

Commit 01415c2

Browse files
fix(llm_eval): repair test_qwen3_eval_fp8 end-to-end (#1650)
### What does this PR do? Type of change: Bug fix `tests/examples/llm_eval/test_llm_eval.py::test_qwen3_eval_fp8` was silently passing while its evals crashed, then began failing as a timeout. This repairs the whole pipeline: - **lm_eval `IndexError` (root cause):** TRT-LLM KV-cache prefix reuse returns truncated `context_logits` for shared-prefix requests (e.g. hellaswag's one-context / many-endings), which breaks `parse_logprobs`. Add an `enable_kv_cache_reuse` flag to `modelopt.deploy.llm.LLM` (default `True`, unchanged) and disable it for the eval deployment so full-length context logits are returned. - **Silent CI green:** `python eval.py | tee result.txt` returns `tee`'s exit code, so a crashing eval was masked. Add `set -o pipefail` to `huggingface_example.sh` so failures fail the test. - **Long-prompt overflows:** with the tiny test model's toy tokenizer, gsm8k/MMLU prompts exceed `max_seq_len`. Bump test `max_position_embeddings` to 8192, skip MMLU prompts that don't fit even at zero-shot, and add an MMLU sample limit (`--mmlu_limit`). - **human-eval build failures:** install with `--no-build-isolation` (`pkg_resources` is absent in pip's isolated build env), patch its malformed `console_scripts` entry point, and pin the clone. - **Cleanups:** gate the post-quant `run_tensorrt_llm.py` smoke test behind the `quant` task (eval tasks deploy on their own; ~45s saved for eval-only runs); replace the SIGPIPE-prone serve-readiness `tail -f | while` with a poll loop (required under `pipefail`). ### Usage N/A — example/test fix. ### Testing All four eval tasks verified end-to-end in the CI container (TRT-LLM 1.3.0rc17, RTX 6000 Ada): lm_eval (hellaswag + gsm8k), MMLU, and simple_eval (humaneval) all complete with exit 0 and no `IndexError`/overflow. Cold full run ≈ 340s on this GPU. CI test on 2-gpu: https://github.com/NVIDIA/Model-Optimizer/actions/runs/27154417497/job/80153551154 ### Before your PR is "*Ready for review*" - Is this change backward compatible?: ✅ (new `enable_kv_cache_reuse` defaults to current behavior; new script flags are optional) - If you copied code from any other sources or added a new PIP dependency, did you follow guidance in `CONTRIBUTING.md`: N/A (no new dependencies) - Did you write any new necessary tests?: N/A (fixes and strengthens an existing test) - Did you update Changelog?: N/A (bug fix to examples/tests) - Did you get Claude approval on this PR?: ❌ (pending) ### Additional Information The full test runs ~340s on an RTX 6000 Ada; CI runners are historically slower, while `@pytest.mark.timeout` is set to 600 — worth watching the first CI run and bumping if it's close. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added an option to limit MMLU evaluation length. * **Bug Fixes** * Disabled KV-cache prefix reuse for evaluations needing per-token context logits to prevent truncated/incorrect logprobs. * Skip examples whose prompts remain too long; warn and report accuracy as NaN if all examples are skipped. * **Chores / Scripts** * Improved example scripts for reproducible installs, patched entry point handling, pipeline failure detection, conditional test invocation, polling-based log wait, and a new CLI flag for MMLU limits. * **Tests** * Increased timeout and prompt headroom; capped MMLU smoke tests for speed. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Keval Morabia <28916987+kevalmorabia97@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1f4a489 commit 01415c2

8 files changed

Lines changed: 89 additions & 16 deletions

File tree

examples/llm_eval/lm_eval_tensorrt_llm.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ def __init__(
6464
tokenizer=self.tokenizer,
6565
max_batch_size=int(batch_size),
6666
max_seq_len=max_length,
67+
# Loglikelihood tasks request context logits. KV cache prefix reuse would return
68+
# logits only for the recomputed suffix on shared-prefix requests (e.g. hellaswag),
69+
# truncating context_logits and breaking parse_logprobs. Disable it.
70+
enable_kv_cache_reuse=False,
6771
)
6872
self.max_length = max_length - 1
6973
logger.info("Loaded TRT-LLM")

examples/llm_eval/mmlu.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,10 @@ def gen_prompt(train_df, subject, k=-1):
183183
def evaluate(args, subject, model: EvalModel | LLM, dev_df, test_df):
184184
cors = []
185185
all_probs = []
186-
for i in range(test_df.shape[0]):
186+
num_examples = test_df.shape[0]
187+
if args.limit is not None:
188+
num_examples = min(num_examples, args.limit)
189+
for i in range(num_examples):
187190
# get prompt and make sure it fits
188191
k = args.ntrain
189192
prompt_end = format_example(test_df, i, include_answer=False)
@@ -201,6 +204,12 @@ def check_valid_length(model, prompt):
201204
train_prompt = gen_prompt(dev_df, subject, k)
202205
prompt = train_prompt + prompt_end
203206

207+
# Skip examples that do not fit even at zero-shot, otherwise the backend rejects
208+
# prompts longer than max_seq_len and aborts the whole evaluation.
209+
if not check_valid_length(model, prompt):
210+
print(f"Skipping {subject} example {i}: prompt exceeds max_seq_len even at 0-shot.")
211+
continue
212+
204213
label = test_df.iloc[i, test_df.shape[1] - 1]
205214
if isinstance(model, EvalModel):
206215
pred = model.run(prompt)
@@ -212,7 +221,11 @@ def check_valid_length(model, prompt):
212221
cors.append(cor)
213222
all_probs.append(probs)
214223

215-
acc = np.mean(cors)
224+
if not cors:
225+
# Every example was skipped (all prompts exceeded max_seq_len). Surface it instead of
226+
# silently producing a nan accuracy downstream.
227+
print(f"WARNING: all {subject} examples were skipped; reporting accuracy as nan.")
228+
acc = np.mean(cors) if cors else float("nan")
216229
cors = np.array(cors)
217230

218231
all_probs = np.array(all_probs)
@@ -233,8 +246,12 @@ def main(
233246
auto_quantize_score_size: int = 128,
234247
auto_quantize_checkpoint: str | None = None,
235248
sparse_cfg: str | None = None,
249+
limit: int | None = None,
236250
**kwargs,
237251
):
252+
if limit is not None and limit <= 0:
253+
raise ValueError(f"limit must be a positive integer when provided, got {limit}.")
254+
238255
random.seed(RAND_SEED)
239256
np.random.seed(RAND_SEED)
240257

examples/llm_eval/run_simple_eval.sh

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,23 @@ if [ ! -d "human-eval" ]; then
2828
git clone https://github.com/openai/human-eval.git
2929
fi
3030

31+
# Pin to a known commit for reproducibility (and so the entry-point patch below matches), forcing
32+
# it every run so a reused checkout cannot drift to an arbitrary revision. -f discards the patch
33+
# applied to setup.py on a previous run before re-applying it below.
34+
git -C human-eval checkout -q -f 6d43fb980f9fee3c892a914eda09951f772ad10d
35+
36+
# human-eval's console_scripts entry point lacks the ":callable" suffix, which newer pip/setuptools
37+
# reject ("A callable suffix is required"). The target module defines main(), so point at it.
38+
sed -i 's|human_eval\.evaluate_functional_correctness"|human_eval.evaluate_functional_correctness:main"|' human-eval/setup.py
39+
3140
if [ ! -d "simple-evals" ]; then
3241
git clone https://github.com/openai/simple-evals.git
3342
fi
3443

35-
pip install -e human-eval
44+
# --no-build-isolation: human-eval's legacy setup.py imports pkg_resources at build time,
45+
# which pip's isolated build env does not provide with newer setuptools. Build against the
46+
# base environment (which has setuptools/pkg_resources) instead.
47+
pip install -e human-eval --no-build-isolation
3648
pip install openai
3749

3850
pushd simple-evals

examples/llm_ptq/run_tensorrt_llm.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,14 @@ def run(args):
6666

6767
print("TensorRT-LLM example outputs:")
6868

69-
llm = LLM(args.checkpoint_dir, tokenizer=tokenizer, max_batch_size=len(input_texts))
69+
# generate_context_logits() below requires KV cache reuse disabled: with prefix block reuse,
70+
# shared-prefix inputs return truncated (silently incorrect) context logits.
71+
llm = LLM(
72+
args.checkpoint_dir,
73+
tokenizer=tokenizer,
74+
max_batch_size=len(input_texts),
75+
enable_kv_cache_reuse=False,
76+
)
7077
torch.cuda.cudart().cudaProfilerStart()
7178
outputs = llm.generate_text(input_texts, args.max_output_len)
7279
torch.cuda.cudart().cudaProfilerStop()

examples/llm_ptq/scripts/huggingface_example.sh

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ for i in $(env | grep ^SLURM_ | cut -d"=" -f 1); do unset -v $i; done
2929
for i in $(env | grep ^PMI_ | cut -d"=" -f 1); do unset -v $i; done
3030
for i in $(env | grep ^PMIX_ | cut -d"=" -f 1); do unset -v $i; done
3131

32+
# Fail on errors inside pipelines (e.g. `python eval.py | tee result.txt`), otherwise a crashing
33+
# eval is masked by tee's exit code and the script passes silently.
34+
set -o pipefail
35+
3236
if [ -z "$MODEL_PATH" ]; then
3337
echo "Unsupported model argument: Expected a huggingface model path or model name" >&2
3438
exit 1
@@ -216,7 +220,11 @@ if [[ $TASKS =~ "quant" ]] || [[ ! -d "$SAVE_PATH" ]] || [[ ! $(ls -A $SAVE_PATH
216220
RUN_ARGS+=" --trust_remote_code "
217221
fi
218222

219-
python run_tensorrt_llm.py --checkpoint_dir=$SAVE_PATH $RUN_ARGS
223+
# Only run the deploy+generate smoke test when "quant" is explicitly requested. Eval tasks
224+
# (lm_eval/mmlu/simple_eval) deploy the checkpoint themselves, so it is redundant there.
225+
if [[ $TASKS =~ "quant" ]]; then
226+
python run_tensorrt_llm.py --checkpoint_dir=$SAVE_PATH $RUN_ARGS
227+
fi
220228
fi
221229

222230
if [[ -d "${MODEL_PATH}" ]]; then
@@ -285,11 +293,16 @@ if [[ $TASKS =~ "mmlu" ]]; then
285293
tar -xf /tmp/mmlu.tar -C data && mv data/data $MMLU_DATA_PATH
286294
fi
287295

296+
mmlu_flags=""
297+
if [ -n "$MMLU_LIMIT" ]; then
298+
mmlu_flags+=" --limit $MMLU_LIMIT "
299+
fi
300+
288301
python mmlu.py \
289302
--model_name causal \
290303
--model_path $MODEL_ABS_PATH \
291304
--checkpoint_dir $SAVE_PATH \
292-
--data_dir $MMLU_DATA_PATH | tee $MMLU_RESULT
305+
--data_dir $MMLU_DATA_PATH $mmlu_flags | tee $MMLU_RESULT
293306
popd
294307

295308
fi
@@ -304,16 +317,16 @@ if [[ $TASKS =~ "livecodebench" || $TASKS =~ "simple_eval" ]]; then
304317
trtllm-serve $SAVE_PATH --host 0.0.0.0 --port $PORT >$SAVE_PATH/serve.txt 2>&1 &
305318
SERVE_PID=$!
306319

307-
tail -f $SAVE_PATH/serve.txt | while read line; do
308-
if echo "$line" | grep -q "Application startup complete"; then
309-
echo "Application startup complete."
310-
break
311-
fi
320+
# Poll the log instead of `tail -f | while ... break`: under `set -o pipefail` (set above),
321+
# breaking out of that pipeline leaves tail to die by SIGPIPE, which would abort the script.
322+
while ! grep -q "Application startup complete" $SAVE_PATH/serve.txt 2>/dev/null; do
312323
if ! kill -0 $SERVE_PID 2>/dev/null; then
313324
echo "trtllm-serve has exited."
314325
exit 1
315326
fi
327+
sleep 2
316328
done
329+
echo "Application startup complete."
317330

318331
pushd ../llm_eval/
319332

examples/llm_ptq/scripts/parser.sh

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ parse_options() {
2828
LM_EVAL_TASKS="mmlu,gsm8k"
2929
LM_EVAL_LIMIT=
3030
SIMPLE_EVAL_TASKS="mmlu"
31+
MMLU_LIMIT=
3132

3233
TASKS="quant"
3334

@@ -38,7 +39,7 @@ parse_options() {
3839
CAST_MXFP4_TO_NVFP4=false
3940

4041
# Parse command-line options
41-
ARGS=$(getopt -o "" -l "model:,quant:,recipe:,kv_cache_quant:,tp:,pp:,sparsity:,awq_block_size:,calib:,calib_batch_size:,auto_quantize_bits:,output:,batch:,tasks:,lm_eval_tasks:,lm_eval_limit:,simple_eval_tasks:,simple_eval_limit:,trust_remote_code,use_seq_device_map,gpu_max_mem_percentage:,kv_cache_free_gpu_memory_fraction:,low_memory_mode,no-verbose,calib_dataset:,calib_seq:,auto_quantize_method:,auto_quantize_score_size:,auto_quantize_checkpoint:,moe_calib_experts_ratio:,cast_mxfp4_to_nvfp4" -n "$0" -- "$@")
42+
ARGS=$(getopt -o "" -l "model:,quant:,recipe:,kv_cache_quant:,tp:,pp:,sparsity:,awq_block_size:,calib:,calib_batch_size:,auto_quantize_bits:,output:,batch:,tasks:,lm_eval_tasks:,lm_eval_limit:,simple_eval_tasks:,simple_eval_limit:,mmlu_limit:,trust_remote_code,use_seq_device_map,gpu_max_mem_percentage:,kv_cache_free_gpu_memory_fraction:,low_memory_mode,no-verbose,calib_dataset:,calib_seq:,auto_quantize_method:,auto_quantize_score_size:,auto_quantize_checkpoint:,moe_calib_experts_ratio:,cast_mxfp4_to_nvfp4" -n "$0" -- "$@")
4243

4344
eval set -- "$ARGS"
4445
while true; do
@@ -61,6 +62,7 @@ parse_options() {
6162
--lm_eval_limit ) LM_EVAL_LIMIT="$2"; shift 2;;
6263
--simple_eval_tasks ) SIMPLE_EVAL_TASKS="$2"; shift 2;;
6364
--simple_eval_limit ) SIMPLE_EVAL_LIMIT="$2"; shift 2;;
65+
--mmlu_limit ) MMLU_LIMIT="$2"; shift 2;;
6466
--trust_remote_code ) TRUST_REMOTE_CODE=true; shift;;
6567
--use_seq_device_map ) USE_SEQ_DEVICE_MAP=true; shift;;
6668
--gpu_max_mem_percentage ) GPU_MAX_MEM_PERCENTAGE="$2"; shift 2;;
@@ -161,6 +163,7 @@ parse_options() {
161163
echo "lm_eval_limit: $LM_EVAL_LIMIT"
162164
echo "simple_eval_tasks: $SIMPLE_EVAL_TASKS"
163165
echo "simple_eval_limit: $SIMPLE_EVAL_LIMIT"
166+
echo "mmlu_limit: $MMLU_LIMIT"
164167
echo "num_sample: $NUM_SAMPLES"
165168
echo "use_seq_device_map: $USE_SEQ_DEVICE_MAP"
166169
echo "gpu_max_mem_percentage: $GPU_MAX_MEM_PERCENTAGE"

modelopt/deploy/llm/generate.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def __init__(
6262
trust_remote_code: bool = False,
6363
max_seq_len: int = 0,
6464
max_batch_size: int = 0,
65+
enable_kv_cache_reuse: bool = True,
6566
):
6667
"""Initializes the LLM runner class.
6768
@@ -73,6 +74,10 @@ def __init__(
7374
trust_remote_code: whether to trust the remote code (for the torch backend).
7475
max_seq_len: Max sequence length for the LLM backend. If 0, it is not specified.
7576
max_batch_size: Max batch size for the LLM backend. If 0, it is not specified.
77+
enable_kv_cache_reuse: whether to enable KV cache block reuse. Must be disabled when
78+
requesting context logits (e.g. lm-eval loglikelihood tasks): with prefix block
79+
reuse, shared-prefix requests only return logits for the recomputed suffix, which
80+
breaks per-token logprob computation.
7681
"""
7782
with open(Path(checkpoint_dir) / "config.json") as config_file:
7883
config = json.load(config_file)
@@ -124,6 +129,8 @@ def _find_max_position_embeddings(cfg: dict) -> int | None:
124129
trt_kv_cache_config.max_tokens = self._max_seq_len * (
125130
max_batch_size if max_batch_size > 0 else 8
126131
)
132+
trt_kv_cache_config.enable_block_reuse = enable_kv_cache_reuse
133+
self._enable_kv_cache_reuse = enable_kv_cache_reuse
127134

128135
cuda_graph_config = None
129136
if max_batch_size > 0:
@@ -281,6 +288,11 @@ def generate_context_logits(
281288
assert self._support_context_logits_and_stop_words, (
282289
"Context logits are not supported with the current tensorrt_llm version."
283290
)
291+
assert not self._enable_kv_cache_reuse, (
292+
"Context logits require enable_kv_cache_reuse=False: with KV cache prefix reuse, "
293+
"shared-prefix requests only return logits for the recomputed suffix, producing "
294+
"truncated (and silently incorrect) context logits."
295+
)
284296
assert temperature >= 0.0, "Temperature must be greater than 0.0."
285297

286298
kwargs = _sanitize_temperature_and_top_p(temperature, top_p)

tests/examples/llm_eval/test_llm_eval.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,13 @@ def test_lm_eval_hf(tmp_path):
4141

4242

4343
@minimum_sm(89)
44-
@pytest.mark.timeout(600)
44+
@pytest.mark.timeout(900)
4545
def test_qwen3_eval_fp8(tmp_path):
46-
# Bump max_position_embeddings: TRT-LLM serve rejects prompts longer than
47-
# max_seq_len, and the default (32) is shorter than even simple MMLU prompts.
48-
model_dir = create_tiny_qwen3_dir(tmp_path, with_tokenizer=True, max_position_embeddings=2048)
46+
# Bump max_position_embeddings: TRT-LLM serve rejects prompts longer than max_seq_len.
47+
# The default (32) is shorter than even simple MMLU prompts, and 2048 is shorter than
48+
# 5-shot gsm8k prompts (~3.9k tokens). The eval LLM caps max_seq_len at max_gen_toks + 4096,
49+
# so 8192 leaves headroom for the longest prompts we evaluate.
50+
model_dir = create_tiny_qwen3_dir(tmp_path, with_tokenizer=True, max_position_embeddings=8192)
4951
try:
5052
run_llm_ptq_command(
5153
model=str(model_dir),
@@ -56,6 +58,9 @@ def test_qwen3_eval_fp8(tmp_path):
5658
simple_eval_tasks="humaneval",
5759
lm_eval_limit=16,
5860
simple_eval_limit=16,
61+
# MMLU has no inherent sample cap and otherwise evaluates the full ~14k-question
62+
# test set; limit it like lm_eval/simple_eval to keep this a fast smoke test.
63+
mmlu_limit=16,
5964
output=128, # Cap generation length: gsm8k/humaneval otherwise generate up to 1024 tokens/sample
6065
batch=8,
6166
)

0 commit comments

Comments
 (0)