Skip to content

Commit 14ea80a

Browse files
authored
feat(llama): accept checkpoints that name several stop tokens (1288)
## Implementation Everything downstream was already built for this and is already tested: `LlamaTextGenConfig` carries `id_eos_ids`, `pipeline.cpp:46` reconciles the scalar and the list, and `sampler.cpp:26-30` stops on any id in the set — covered by `test_generate_stops_at_any_default_eos` and `test_explicit_eos_override_replaces_default_set`. Only the two ends were missing. - `families/llama/model.py` normalises the stop tokens from `config.json` or `generation_config.json` into a list, writes the first id as the scalar `eos_token_id`, and writes the full list as `eos_token_ids` **only when there is more than one**. Single-stop bundles therefore keep exactly the field set they had before, and any bundle stays readable by a runtime that predates the list. Booleans are rejected explicitly, because `bool` is an `int` subclass and would otherwise pass as a token id. - `families/llama/runtime/plugin.cpp` reads the optional key, counts it in the strict field-set check, and validates every id against the vocabulary. The validation sits after the dimension checks so `vocab_size` is known good before it is used as a bound. This mirrors `families/qwen3_8/model.py:40-53`, which already normalises to `eos_token_ids`.
1 parent 730cb29 commit 14ea80a

5 files changed

Lines changed: 158 additions & 3 deletions

File tree

apps/benchmark/performance/release.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1534,6 +1534,8 @@ additional_profiles:
15341534
inherit: gpt2.generate
15351535
- model: internvl3-8b
15361536
inherit: internvl.generate
1537+
- model: minicpm5-2b
1538+
inherit: llama.generate
15371539
- model: minitron-4b-depth
15381540
inherit: llama.generate
15391541
- model: minitron-4b-width

families/llama/model.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,24 @@ def _build_engine(
8989
)
9090

9191

92+
def _eos_token_ids(value: object) -> list[int]:
93+
"""Normalise the stop tokens into a list.
94+
95+
A checkpoint may name one stop token or several. Llama 3 and MiniCPM5 both
96+
ship a list, so a scalar cannot be assumed; booleans are rejected because
97+
`bool` is an `int` subclass and would otherwise pass as a token id.
98+
"""
99+
values = value if isinstance(value, list) else [value]
100+
ids: list[int] = []
101+
for item in values:
102+
if isinstance(item, bool) or not isinstance(item, int):
103+
raise ValueError("llama eos_token_id must be an integer or a list of integers")
104+
ids.append(int(item))
105+
if not ids:
106+
raise ValueError("llama eos_token_id must name at least one token")
107+
return ids
108+
109+
92110
def _runtime_config(model_dir: Path, config: ModelConfig, **updates) -> dict:
93111
runtime = {
94112
"vocab_size": config.vocab_size,
@@ -102,13 +120,21 @@ def _runtime_config(model_dir: Path, config: ModelConfig, **updates) -> dict:
102120
"pad_token_id": config.pad_token_id,
103121
}
104122
runtime.update(config.raw.get("_native_kv_cache_metadata", {}))
123+
eos = config.eos_token_id
105124
generation_path = model_dir / "generation_config.json"
106125
if generation_path.is_file():
107126
generation = json.loads(generation_path.read_text(encoding="utf-8"))
108127
if not isinstance(generation, dict):
109128
raise ValueError("generation_config.json must contain one JSON object")
110129
if "eos_token_id" in generation:
111-
runtime["eos_token_id"] = generation["eos_token_id"]
130+
eos = generation["eos_token_id"]
131+
eos_token_ids = _eos_token_ids(eos)
132+
# The scalar stays the first id so a bundle stays readable by a runtime that
133+
# predates multiple stop tokens; the list is written only when it adds
134+
# something, which keeps single-stop bundles byte-identical to before.
135+
runtime["eos_token_id"] = eos_token_ids[0]
136+
if len(eos_token_ids) > 1:
137+
runtime["eos_token_ids"] = eos_token_ids
112138
runtime.update(updates)
113139
return runtime
114140

families/llama/runtime/plugin.cpp

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ struct RuntimeConfig {
3838
std::string precision;
3939
std::string decoder_engine_layout;
4040
bool dynamic_kv_cache;
41+
// Empty unless the bundle names more than one stop token.
42+
std::vector<std::int32_t> eos_token_ids;
4143
};
4244

4345
template <typename T>
@@ -62,8 +64,9 @@ RuntimeConfig parse_runtime_config(const BundleReader& bundle) {
6264
if (!json.is_object())
6365
throw std::runtime_error("llama runtime.json must be an object");
6466
const bool dynamic_kv_cache = json.contains("dynamic_kv_cache");
65-
const std::size_t expected_fields =
66-
12 + (json.contains("native_kv_cache") ? 2 : 0) + (dynamic_kv_cache ? 1 : 0);
67+
const bool multi_eos = json.contains("eos_token_ids");
68+
const std::size_t expected_fields = 12 + (json.contains("native_kv_cache") ? 2 : 0) +
69+
(dynamic_kv_cache ? 1 : 0) + (multi_eos ? 1 : 0);
6770
if (json.size() != expected_fields)
6871
throw std::runtime_error("llama runtime.json has an unexpected field set");
6972
if (json.contains("native_kv_cache") &&
@@ -97,6 +100,15 @@ RuntimeConfig parse_runtime_config(const BundleReader& bundle) {
97100
config.num_key_value_heads * config.head_dim > config.hidden_size) {
98101
throw std::runtime_error("llama runtime.json contains invalid dimensions");
99102
}
103+
if (multi_eos) {
104+
config.eos_token_ids = require_value<std::vector<std::int32_t>>(json, "eos_token_ids");
105+
if (config.eos_token_ids.empty())
106+
throw std::runtime_error("llama runtime.json has an empty 'eos_token_ids'");
107+
for (const std::int32_t token_id : config.eos_token_ids) {
108+
if (token_id < 0 || token_id >= config.vocab_size)
109+
throw std::runtime_error("llama runtime.json has an out-of-range 'eos_token_ids'");
110+
}
111+
}
100112
if (config.precision != "fp16" && config.precision != "bf16" && config.precision != "fp32") {
101113
throw std::runtime_error("llama runtime.json contains invalid precision");
102114
}
@@ -226,6 +238,7 @@ ITask* create(const FamilyContext& context) {
226238
text_config.vocab_size = config.vocab_size;
227239
text_config.id_bos = config.bos_token_id;
228240
text_config.id_eos = config.eos_token_id;
241+
text_config.id_eos_ids = config.eos_token_ids;
229242
text_config.chat_template_format =
230243
llama_detect_chat_template_format(chat_template(context.reader));
231244
text_config.prefill_max_length = prefill_token_limit(*modules.prefill);
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"name": "minicpm5-2b",
3+
"hf_id": "openbmb/MiniCPM5-2B",
4+
"hf_revision": "12a3808a956f869c767195e9266b59c4d21d92e2",
5+
"bundle": "minicpm5-2b.bundle",
6+
"family": "llama",
7+
"task": "text_generation",
8+
"trust_remote_code": false,
9+
"precision": "fp16",
10+
"testcases": [
11+
{
12+
"name": "minicpm5-2b",
13+
"premerge": true,
14+
"prompt": "What is the capital of France? Answer in one word.",
15+
"max_new_tokens": 10,
16+
"use_chat_template": true,
17+
"reference_precision": "fp32",
18+
"enable_thinking": false
19+
}
20+
],
21+
"max_sequence_length": 256,
22+
"tensor_parallel_size": 1
23+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Stop-token handling for checkpoints that name more than one EOS id."""
5+
6+
from __future__ import annotations
7+
8+
import json
9+
from pathlib import Path
10+
11+
import pytest
12+
13+
from ..config import ModelConfig
14+
from ..model import _eos_token_ids, _runtime_config
15+
16+
17+
def _config(eos: object) -> ModelConfig:
18+
return ModelConfig.from_json(
19+
json.dumps(
20+
{
21+
"model_type": "llama",
22+
"hidden_size": 8,
23+
"num_hidden_layers": 2,
24+
"num_attention_heads": 2,
25+
"num_key_value_heads": 1,
26+
"head_dim": 4,
27+
"vocab_size": 32,
28+
"bos_token_id": 0,
29+
"eos_token_id": eos,
30+
"pad_token_id": 0,
31+
}
32+
)
33+
)
34+
35+
36+
def test_a_single_stop_token_is_normalised_to_one_entry() -> None:
37+
assert _eos_token_ids(2) == [2]
38+
assert _eos_token_ids([2]) == [2]
39+
40+
41+
def test_several_stop_tokens_keep_their_order() -> None:
42+
"""MiniCPM5 names two; the second is the one it actually emits."""
43+
assert _eos_token_ids([1, 130073]) == [1, 130073]
44+
45+
46+
def test_a_boolean_is_not_a_token_id() -> None:
47+
"""`bool` is an `int` subclass, so it would otherwise pass silently."""
48+
with pytest.raises(ValueError, match="must be an integer or a list of integers"):
49+
_eos_token_ids(True)
50+
with pytest.raises(ValueError, match="must be an integer or a list of integers"):
51+
_eos_token_ids([2, False])
52+
53+
54+
def test_a_non_integer_stop_token_is_refused() -> None:
55+
with pytest.raises(ValueError, match="must be an integer or a list of integers"):
56+
_eos_token_ids("</s>")
57+
58+
59+
def test_an_empty_stop_token_list_is_refused() -> None:
60+
with pytest.raises(ValueError, match="must name at least one token"):
61+
_eos_token_ids([])
62+
63+
64+
def test_one_stop_token_writes_only_the_scalar(tmp_path: Path) -> None:
65+
"""A single-stop bundle keeps the field set it had before multi-EOS."""
66+
runtime = _runtime_config(tmp_path, _config(2))
67+
assert runtime["eos_token_id"] == 2
68+
assert "eos_token_ids" not in runtime
69+
70+
71+
def test_several_stop_tokens_write_both_fields(tmp_path: Path) -> None:
72+
"""The scalar stays readable by a runtime that predates the list."""
73+
runtime = _runtime_config(tmp_path, _config([1, 130073]))
74+
assert runtime["eos_token_id"] == 1
75+
assert runtime["eos_token_ids"] == [1, 130073]
76+
77+
78+
def test_generation_config_overrides_the_model_config(tmp_path: Path) -> None:
79+
"""A checkpoint may widen its stop set in generation_config.json."""
80+
(tmp_path / "generation_config.json").write_text(
81+
json.dumps({"eos_token_id": [7, 8, 9]}), encoding="utf-8"
82+
)
83+
runtime = _runtime_config(tmp_path, _config(2))
84+
assert runtime["eos_token_id"] == 7
85+
assert runtime["eos_token_ids"] == [7, 8, 9]
86+
87+
88+
def test_generation_config_must_hold_one_object(tmp_path: Path) -> None:
89+
(tmp_path / "generation_config.json").write_text(json.dumps([1, 2]), encoding="utf-8")
90+
with pytest.raises(ValueError, match="must contain one JSON object"):
91+
_runtime_config(tmp_path, _config(2))

0 commit comments

Comments
 (0)