Skip to content

Commit e1f7e7e

Browse files
Merge pull request AI-Hypercomputer#4238 from AI-Hypercomputer:yujiedeng/vllm_eval_to_merge
PiperOrigin-RevId: 938036820
2 parents 863398e + f2fba92 commit e1f7e7e

6 files changed

Lines changed: 235 additions & 14 deletions

File tree

src/maxtext/checkpoint_conversion/to_huggingface.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,32 @@ def _validate_or_update_architecture(hf_config, max_config, override: bool):
275275
raise ValueError(error_msg)
276276

277277

278+
def _apply_yarn_rope_config(hf_config, max_config) -> None:
279+
"""Apply MaxText YaRN RoPE values without dropping HF model-specific fields."""
280+
rope_scaling = getattr(hf_config, "rope_scaling", None)
281+
if isinstance(rope_scaling, dict):
282+
rope_scaling = dict(rope_scaling)
283+
else:
284+
rope_scaling = {}
285+
286+
rope_type_key = "type" if "type" in rope_scaling and "rope_type" not in rope_scaling else "rope_type"
287+
rope_scaling.update(
288+
{
289+
"beta_fast": float(getattr(max_config, "beta_fast", 32.0)),
290+
"beta_slow": float(getattr(max_config, "beta_slow", 1.0)),
291+
"factor": float(getattr(max_config, "rope_factor", 32.0)),
292+
"original_max_position_embeddings": getattr(max_config, "original_max_position_embeddings", 4096),
293+
"rope_theta": getattr(max_config, "rope_max_timescale"),
294+
rope_type_key: "yarn",
295+
"truncate": getattr(max_config, "rope_truncate", False),
296+
}
297+
)
298+
299+
hf_config.rope_theta = getattr(max_config, "rope_max_timescale")
300+
hf_config.rope_scaling = rope_scaling
301+
hf_config.rope_parameters = rope_scaling
302+
303+
278304
def _transform_weights_to_adapter(param_map, state_dict):
279305
"""Extracts standalone PEFT weights from MaxText state dict."""
280306
processed_params_list = []
@@ -404,6 +430,10 @@ def main(argv: Sequence[str]) -> None:
404430
# Validate architecture consistency (raising ValueError on mismatch) or override HF config if specified.
405431
_validate_or_update_architecture(hf_config_obj, config, override=FLAGS.override_model_architecture)
406432

433+
# Ensure YaRN rope params are preserved if specified in the maxtext config
434+
if getattr(config, "rope_type", None) == "yarn":
435+
_apply_yarn_rope_config(hf_config_obj, config)
436+
407437
# 2. Load Tokenizer
408438
if model_key not in HF_IDS:
409439
raise ValueError(f"HF Tokenizer ID not found for model key: {model_key}")

src/maxtext/eval/runner/harness_runner.py

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,9 @@ def run_harness(cfg: dict, hf_token: str | None = None) -> dict:
123123
num_fewshot = cfg.get("num_fewshot", 0)
124124
num_samples = cfg.get("num_samples")
125125
gcs_results_path = cfg.get("gcs_results_path")
126+
apply_chat_template = cfg.get("apply_chat_template", False)
127+
fewshot_as_multiturn = cfg.get("fewshot_as_multiturn", False)
128+
gen_kwargs = cfg.get("gen_kwargs")
126129
token = resolve_token(cfg, hf_token)
127130

128131
lm_model_type = "local-chat-completions" if backend == "evalchemy" else "local-completions"
@@ -156,14 +159,21 @@ def run_harness(cfg: dict, hf_token: str | None = None) -> dict:
156159
lm_model_type,
157160
server.base_url,
158161
)
159-
raw_results = lm_eval_lib.simple_evaluate(
160-
model=lm_model_type,
161-
model_args=model_args,
162-
tasks=tasks,
163-
num_fewshot=num_fewshot,
164-
limit=num_samples,
165-
log_samples=False,
166-
)
162+
simple_eval_kwargs: dict = {
163+
"model": lm_model_type,
164+
"model_args": model_args,
165+
"tasks": tasks,
166+
"num_fewshot": num_fewshot,
167+
"limit": num_samples,
168+
"log_samples": False,
169+
}
170+
if apply_chat_template:
171+
simple_eval_kwargs["apply_chat_template"] = True
172+
if fewshot_as_multiturn:
173+
simple_eval_kwargs["fewshot_as_multiturn"] = True
174+
if gen_kwargs:
175+
simple_eval_kwargs["gen_kwargs"] = gen_kwargs
176+
raw_results = lm_eval_lib.simple_evaluate(**simple_eval_kwargs)
167177

168178
# All ranks block here until rank-0 finishes evaluation. Non-rank-0 hosts
169179
# keep their in-process LLM alive so rank-0's llm.generate() calls can
@@ -221,6 +231,39 @@ def _build_arg_parser() -> argparse.ArgumentParser:
221231
)
222232
parser.add_argument("--num_fewshot", type=int, default=0, help="Few-shot examples per task.")
223233
parser.add_argument("--num_samples", type=int, help="Limit samples per task (None = full dataset).")
234+
parser.add_argument(
235+
"--apply_chat_template",
236+
action="store_true",
237+
default=False,
238+
help=(
239+
"Wrap each prompt in the tokenizer's chat template before evaluation. "
240+
"Required for instruction-tuned / chat models (e.g. GPT-OSS harmony format). "
241+
"Without this, MMLU/GSM8K prompts arrive as bare text the model was never "
242+
"trained on -> near-random scores."
243+
),
244+
)
245+
parser.add_argument(
246+
"--fewshot_as_multiturn",
247+
action="store_true",
248+
default=False,
249+
help=(
250+
"When --apply_chat_template and --num_fewshot > 0: format each few-shot "
251+
"example as a separate user/assistant chat turn instead of one big context. "
252+
"Recommended for chat models with few-shot MMLU."
253+
),
254+
)
255+
parser.add_argument(
256+
"--gen_kwargs",
257+
type=str,
258+
default=None,
259+
help=(
260+
"Comma-separated key=value overrides for generation tasks (generate_until). "
261+
"Passed directly to lm-eval simple_evaluate(gen_kwargs=...). "
262+
"No effect on loglikelihood tasks (e.g. MMLU). "
263+
"Example for GPT-OSS GSM8K CoT: 'until=[],max_gen_toks=1024' "
264+
"(clears default stop sequences and raises the token budget)."
265+
),
266+
)
224267
return parser
225268

226269

src/maxtext/eval/runner/server_manager.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,16 @@
2727

2828
logger = logging.getLogger(__name__)
2929

30+
3031
_HEALTH_ENDPOINT = "/health"
3132

3233

34+
def _top_logprobs_dict(tokenizer: Any, lp_dict: dict | None) -> "dict[str, float] | None":
35+
if not lp_dict:
36+
return None
37+
return {tokenizer.decode([tid]): lp.logprob for tid, lp in lp_dict.items()}
38+
39+
3340
def _build_app(llm: Any) -> Any:
3441
"""Return a FastAPI app that wraps an in-process vLLM LLM instance."""
3542
import fastapi # pylint: disable=import-outside-toplevel
@@ -48,9 +55,12 @@ async def completions(request: fastapi.Request):
4855
body = await request.json()
4956

5057
raw_prompt = body.get("prompt", "")
51-
prompts = raw_prompt if isinstance(raw_prompt, list) else [raw_prompt]
58+
if isinstance(raw_prompt, list) and raw_prompt and isinstance(raw_prompt[0], int):
59+
prompts = [raw_prompt]
60+
else:
61+
prompts = raw_prompt if isinstance(raw_prompt, list) else [raw_prompt]
5262
model_name = body.get("model", "")
53-
max_tokens = int(body.get("max_tokens") or 256)
63+
max_tokens = int(body["max_tokens"]) if body.get("max_tokens") is not None else 256
5464
temperature = float(body.get("temperature") or 0.0)
5565
logprobs_n = body.get("logprobs") # int | None
5666
echo = bool(body.get("echo", False))
@@ -80,6 +90,7 @@ async def completions(request: fastapi.Request):
8090
if logprobs_n is not None:
8191
tok_strings: list[str] = []
8292
tok_lps: list[float | None] = []
93+
tok_tops: list[dict[str, float] | None] = []
8394
tok_offsets: list[int] = []
8495
running_offset = 0
8596

@@ -93,6 +104,7 @@ async def completions(request: fastapi.Request):
93104
lp_dict = prompt_lps[pos] if pos < len(prompt_lps) else None
94105
lp_val = lp_dict[tok_id].logprob if (lp_dict and tok_id in lp_dict) else None
95106
tok_lps.append(lp_val)
107+
tok_tops.append(_top_logprobs_dict(tokenizer, lp_dict))
96108

97109
gen_lps = gen.logprobs or []
98110
for pos, tok_id in enumerate(gen.token_ids):
@@ -103,15 +115,20 @@ async def completions(request: fastapi.Request):
103115
lp_dict = gen_lps[pos] if pos < len(gen_lps) else None
104116
lp_val = lp_dict[tok_id].logprob if (lp_dict and tok_id in lp_dict) else None
105117
tok_lps.append(lp_val)
118+
tok_tops.append(_top_logprobs_dict(tokenizer, lp_dict))
106119

107120
logprobs_payload = {
108121
"tokens": tok_strings,
109122
"token_logprobs": tok_lps,
110-
"top_logprobs": None,
123+
"top_logprobs": tok_tops,
111124
"text_offset": tok_offsets,
112125
}
113126

114-
text_out = (prompts[idx] + gen.text) if echo else gen.text
127+
if echo:
128+
p = prompts[idx]
129+
text_out = (tokenizer.decode(p) if isinstance(p, list) else p) + gen.text
130+
else:
131+
text_out = gen.text
115132
choices.append(
116133
{
117134
"text": text_out,
@@ -143,7 +160,7 @@ async def chat_completions(request: fastapi.Request): # pylint: disable=unused-
143160
body = await request.json()
144161
messages = body.get("messages", [])
145162
model_name = body.get("model", "")
146-
max_tokens = int(body.get("max_tokens") or 256)
163+
max_tokens = int(body["max_tokens"]) if body.get("max_tokens") is not None else 256
147164
temperature = float(body.get("temperature") or 0.0)
148165
stop = body.get("stop")
149166

tests/unit/eval/test_build_app.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,57 @@ def test_chat_completions_applies_template(self):
223223
passed_messages = call_args[0][0] if call_args[0] else call_args[1].get("conversation")
224224
self.assertIsNotNone(passed_messages)
225225

226+
def test_completions_token_id_prompt_normalised_as_single(self):
227+
"""A list-of-ints prompt must be treated as one token-ID prompt, not a batch of ints."""
228+
mock_output = _make_mock_output(generated_text="out", prompt_token_ids=[1, 2, 3], generated_token_ids=[7])
229+
self.mock_llm.generate.return_value = [mock_output]
230+
231+
resp = self.client.post(
232+
"/v1/completions",
233+
json={"model": "m", "prompt": [1, 2, 3], "max_tokens": 5},
234+
)
235+
self.assertEqual(resp.status_code, 200)
236+
data = resp.json()
237+
self.assertEqual(len(data["choices"]), 1)
238+
self.assertEqual(data["choices"][0]["text"], "out")
239+
# generate() must be called with a single-element list containing the token-ID list
240+
call_prompts = self.mock_llm.generate.call_args[0][0]
241+
self.assertEqual(call_prompts, [[1, 2, 3]])
242+
243+
def test_completions_top_logprobs_populated(self):
244+
"""When logprobs is requested, top_logprobs entries must be non-None dicts."""
245+
mock_output = _make_mock_output(generated_text="hi", prompt_token_ids=[1], generated_token_ids=[4, 5])
246+
mock_output.outputs[0].logprobs = [
247+
{4: SimpleNamespace(logprob=-0.5), 6: SimpleNamespace(logprob=-1.0)},
248+
{5: SimpleNamespace(logprob=-0.8)},
249+
]
250+
self.mock_llm.generate.return_value = [mock_output]
251+
252+
resp = self.client.post(
253+
"/v1/completions",
254+
json={"model": "m", "prompt": "x", "max_tokens": 5, "logprobs": 2},
255+
)
256+
self.assertEqual(resp.status_code, 200)
257+
lp = resp.json()["choices"][0]["logprobs"]
258+
self.assertIsNotNone(lp)
259+
self.assertIsInstance(lp["top_logprobs"][0], dict)
260+
self.assertGreater(len(lp["top_logprobs"][0]), 0)
261+
262+
def test_completions_echo_with_token_id_prompt(self):
263+
"""echo=True with a token-ID prompt must decode the prompt and prepend it to text."""
264+
mock_output = _make_mock_output(generated_text=" world", prompt_token_ids=[1, 2, 3], generated_token_ids=[4])
265+
self.mock_llm.generate.return_value = [mock_output]
266+
267+
resp = self.client.post(
268+
"/v1/completions",
269+
json={"model": "m", "prompt": [1, 2, 3], "max_tokens": 5, "echo": True},
270+
)
271+
self.assertEqual(resp.status_code, 200)
272+
text = resp.json()["choices"][0]["text"]
273+
# tokenizer.decode([1, 2, 3]) → "tok1tok2tok3" (see _make_mock_llm side_effect)
274+
self.assertTrue(text.startswith("tok1tok2tok3"), f"Expected decoded prompt prefix, got: {text!r}")
275+
self.assertIn(" world", text)
276+
226277

227278
if __name__ == "__main__":
228279
unittest.main()

tests/unit/eval/test_lm_eval_runner.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,5 +94,52 @@ def test_empty_tasks_list(self):
9494
self.assertEqual(scores, {})
9595

9696

97+
_REQUIRED_ARGS = [
98+
"--model_name",
99+
"llama3.1-8b",
100+
"--hf_path",
101+
"meta-llama/Llama-3.1-8B",
102+
"--base_output_directory",
103+
"gs://bucket/",
104+
"--run_name",
105+
"test_run",
106+
"--max_model_len",
107+
"4096",
108+
]
109+
110+
111+
class TestArgParser(unittest.TestCase):
112+
"""Tests for _build_arg_parser — new flags added in the vllm_eval commits."""
113+
114+
def setUp(self):
115+
from maxtext.eval.runner.harness_runner import _build_arg_parser # pylint: disable=import-outside-toplevel
116+
117+
self.parser = _build_arg_parser()
118+
119+
def test_apply_chat_template_default_false(self):
120+
args = self.parser.parse_args(_REQUIRED_ARGS)
121+
self.assertFalse(args.apply_chat_template)
122+
123+
def test_apply_chat_template_flag_sets_true(self):
124+
args = self.parser.parse_args(_REQUIRED_ARGS + ["--apply_chat_template"])
125+
self.assertTrue(args.apply_chat_template)
126+
127+
def test_fewshot_as_multiturn_default_false(self):
128+
args = self.parser.parse_args(_REQUIRED_ARGS)
129+
self.assertFalse(args.fewshot_as_multiturn)
130+
131+
def test_fewshot_as_multiturn_flag_sets_true(self):
132+
args = self.parser.parse_args(_REQUIRED_ARGS + ["--fewshot_as_multiturn"])
133+
self.assertTrue(args.fewshot_as_multiturn)
134+
135+
def test_gen_kwargs_default_none(self):
136+
args = self.parser.parse_args(_REQUIRED_ARGS)
137+
self.assertIsNone(args.gen_kwargs)
138+
139+
def test_gen_kwargs_passthrough(self):
140+
args = self.parser.parse_args(_REQUIRED_ARGS + ["--gen_kwargs", "until=[],max_gen_toks=1024"])
141+
self.assertEqual(args.gen_kwargs, "until=[],max_gen_toks=1024")
142+
143+
97144
if __name__ == "__main__":
98145
unittest.main()

tests/unit/hf_checkpoint_conversion_test.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,16 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
""" Tests for kernels """
15+
"""Tests for kernels"""
1616

1717
import unittest
18+
from types import SimpleNamespace
1819
from unittest.mock import MagicMock
1920
import numpy as np
2021
from maxtext.utils.max_utils import permute_to_match_maxtext_rope, unpermute_from_match_maxtext_rope
2122
from maxtext.checkpoint_conversion import to_huggingface as to_hf
2223
from maxtext.checkpoint_conversion.to_huggingface import (
24+
_apply_yarn_rope_config,
2325
_get_lora_delta,
2426
_transform_weights_to_adapter,
2527
_transform_weights_to_full_model,
@@ -61,6 +63,37 @@ def test_huggingface_to_maxtext_back_to_huggingface_flow(self):
6163
if not np.array_equal(wq2, wq4):
6264
print("Test failed: wq2 does not match wq4")
6365

66+
def test_apply_yarn_rope_config_preserves_existing_hf_fields(self):
67+
hf_config = SimpleNamespace(
68+
rope_theta=10_000,
69+
rope_scaling={
70+
"beta_fast": 32.0,
71+
"beta_slow": 1.0,
72+
"factor": 40.0,
73+
"mscale": 0.707,
74+
"mscale_all_dim": 0.707,
75+
"original_max_position_embeddings": 4096,
76+
"rope_theta": 10_000,
77+
"type": "yarn",
78+
},
79+
)
80+
max_config = SimpleNamespace(
81+
beta_fast=32,
82+
beta_slow=1,
83+
rope_factor=40,
84+
original_max_position_embeddings=4096,
85+
rope_max_timescale=10_000,
86+
rope_truncate=True,
87+
)
88+
89+
_apply_yarn_rope_config(hf_config, max_config)
90+
91+
self.assertEqual(hf_config.rope_theta, 10_000)
92+
self.assertEqual(hf_config.rope_scaling["type"], "yarn")
93+
self.assertEqual(hf_config.rope_scaling["mscale"], 0.707)
94+
self.assertEqual(hf_config.rope_scaling["mscale_all_dim"], 0.707)
95+
self.assertTrue(hf_config.rope_scaling["truncate"])
96+
6497

6598
class MaxTextToHFLoRAConversionTest(unittest.TestCase):
6699
"""Tests the conversion modes (Base, Adapter, Merged) in to_huggingface with LoRA support."""

0 commit comments

Comments
 (0)