Skip to content

Commit 142ff89

Browse files
committed
[UPDATE] Update
[ghstack-poisoned]
1 parent 1a1b822 commit 142ff89

17 files changed

Lines changed: 1132 additions & 43 deletions

examples/models/gemma4_31b/CMakeLists.txt

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,7 @@ if(NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
7979
endif()
8080

8181
if(EXECUTORCH_BUILD_CUDA)
82-
add_executable(
83-
gemma4_31b_worker gemma4_31b_worker.cpp gemma4_31b_engine.cpp
84-
)
82+
add_executable(gemma4_31b_worker gemma4_31b_worker.cpp gemma4_31b_engine.cpp)
8583
target_include_directories(
8684
gemma4_31b_worker PUBLIC ${_common_include_directories} ${_json_include}
8785
)

examples/models/gemma4_31b/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -181,9 +181,9 @@ python -m executorch.examples.models.gemma4_31b.serve \
181181
--max-sessions 1
182182
```
183183

184-
The launcher defaults to the Hermes `<tool_call>{...}</tool_call>` parser. Use
185-
`--tool-parser qwen` or `--tool-parser none` if the model/template you are
186-
testing emits a different tool-call format.
184+
The launcher defaults to Gemma's `<|tool_call>call:...<tool_call|>` parser. Use
185+
`--tool-parser hermes`, `--tool-parser qwen`, or `--tool-parser none` if the
186+
model/template you are testing emits a different tool-call format.
187187

188188
Named sessions and warm resume require worker capacity above one. CUDA exports
189189
with `get_mutable_buffer_metadata` can use per-session mutable rebinding and

examples/models/gemma4_31b/gemma4_31b_engine.cpp

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,6 @@ Result<std::unique_ptr<Gemma4_31BEngine>> Gemma4_31BEngine::create(
457457

458458
auto eos_ids = get_eos_ids(tokenizer.get(), meta_module.get());
459459
eos_ids.insert(static_cast<uint64_t>(config.eos_id));
460-
add_token_piece(tokenizer.get(), eos_ids, "<end_of_turn>");
461460
add_token_piece(tokenizer.get(), eos_ids, "<turn|>");
462461

463462
const auto& metadata = metadata_result.get();

examples/models/gemma4_31b/gemma4_31b_worker.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ DEFINE_bool(
2424
warm_resume,
2525
true,
2626
"Warm append-only resume for named sessions when the engine supports them.");
27-
DEFINE_int32(bos_id, 2, "BOS token id to prepend to every Gemma prompt.");
27+
DEFINE_int32(bos_id, 2, "BOS token id to prepend to server-rendered prompts.");
2828
DEFINE_int32(eos_id, 1, "EOS token id (Gemma convention: 1).");
2929

3030
namespace {

examples/models/gemma4_31b/serve.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,32 @@
99
import argparse
1010
import logging
1111
import os
12+
import re
1213
from pathlib import Path
1314

1415
from executorch.extension.llm.server.python.chat_template import ChatTemplate
1516
from executorch.extension.llm.server.python.serving_chat import ServingChat
1617
from executorch.extension.llm.server.python.session_runtime import SessionRuntime
1718
from executorch.extension.llm.server.python.tool_parsers import (
19+
GemmaToolCallDetector,
1820
HermesDetector,
1921
QwenFunctionCallDetector,
2022
)
2123
from executorch.extension.llm.server.python.worker_client import spawn_worker
2224

2325
logger = logging.getLogger(__name__)
2426

27+
_GEMMA_CHANNEL_SPECIALS = {"<|channel>", "<channel|>", "<|think|>"}
28+
_GEMMA_CHANNEL_BLOCK = re.compile(r"<\|channel>.*?<channel\|>", re.DOTALL)
29+
30+
31+
def _strip_gemma_channels(text: str) -> str:
32+
text = _GEMMA_CHANNEL_BLOCK.sub("", text)
33+
open_idx = text.find("<|channel>")
34+
if open_idx != -1:
35+
text = text[:open_idx]
36+
return text.replace("<channel|>", "").replace("<|think|>", "").strip()
37+
2538

2639
def _default_worker_bin() -> str:
2740
repo_root = Path(__file__).resolve().parents[3]
@@ -62,6 +75,8 @@ def _spawn(args):
6275

6376

6477
def _tool_detector(name: str):
78+
if name == "gemma":
79+
return GemmaToolCallDetector
6580
if name == "hermes":
6681
return HermesDetector
6782
if name == "qwen":
@@ -72,7 +87,13 @@ def _tool_detector(name: str):
7287

7388

7489
def build_app_from_args(args):
75-
template = ChatTemplate(args.hf_tokenizer)
90+
template = ChatTemplate(
91+
args.hf_tokenizer,
92+
assistant_header="<|turn>model\n",
93+
# Gemma's HF template starts with bos_token text. Strip that text before
94+
# C++ tokenization; the worker prepends the numeric BOS id.
95+
strip_rendered_bos=True,
96+
)
7697
worker = _spawn(args)
7798
runtime = SessionRuntime(worker)
7899
serving = ServingChat(
@@ -82,6 +103,8 @@ def build_app_from_args(args):
82103
max_context=args.max_context,
83104
tool_detector_cls=_tool_detector(args.tool_parser),
84105
prompt_token_offset=1,
106+
content_filter=_strip_gemma_channels,
107+
content_filter_specials=_GEMMA_CHANNEL_SPECIALS,
85108
)
86109

87110
from executorch.extension.llm.server.python.server import build_app
@@ -132,11 +155,17 @@ def main() -> None:
132155
)
133156
p.add_argument(
134157
"--tool-parser",
135-
choices=("hermes", "qwen", "none"),
136-
default="hermes",
158+
choices=("gemma", "hermes", "qwen", "none"),
159+
default="gemma",
137160
help="Tool-call format parser to apply to model output.",
138161
)
139-
p.add_argument("--bos-id", type=int, default=2)
162+
p.add_argument(
163+
"--bos-id",
164+
type=int,
165+
default=2,
166+
help="BOS token id to prepend in the worker. The launcher strips the "
167+
"HF template's literal <bos> before C++ tokenization.",
168+
)
140169
p.add_argument("--eos-id", type=int, default=1)
141170
p.add_argument(
142171
"--worker-bin",
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
import json
8+
import os
9+
import urllib.request
10+
11+
import pytest
12+
13+
from executorch.extension.llm.server.python.chat_template import ChatTemplate
14+
from executorch.extension.llm.server.python.protocol import ChatMessage
15+
16+
_SERVER = os.environ.get("GEMMA_SERVER_URL")
17+
_HF_DIR = os.environ.get(
18+
"GEMMA_HF_DIR", "/home/mnachin/local/scripts/models/gemma-4-31B-it-HQQ-INT4"
19+
)
20+
21+
22+
pytestmark = pytest.mark.skipif(
23+
not _SERVER or not os.path.isdir(_HF_DIR),
24+
reason="set GEMMA_SERVER_URL and GEMMA_HF_DIR to run Gemma on-device tests",
25+
)
26+
27+
28+
def _post(path: str, payload: dict) -> dict:
29+
data = json.dumps(payload).encode("utf-8")
30+
req = urllib.request.Request(
31+
_SERVER.rstrip("/") + path,
32+
data=data,
33+
headers={"Content-Type": "application/json"},
34+
method="POST",
35+
)
36+
with urllib.request.urlopen(req, timeout=120) as resp:
37+
return json.loads(resp.read().decode("utf-8"))
38+
39+
40+
def test_prompt_tokens_match_real_template_with_numeric_bos_prefix():
41+
_ = pytest.importorskip("transformers")
42+
from transformers import AutoTokenizer
43+
44+
template = ChatTemplate(
45+
_HF_DIR,
46+
assistant_header="<|turn>model\n",
47+
strip_rendered_bos=True,
48+
)
49+
tok = AutoTokenizer.from_pretrained(_HF_DIR)
50+
messages = [ChatMessage(role="user", content="Say ok.")]
51+
rendered = template.render(messages)
52+
expected_ids = [tok.bos_token_id] + tok.encode(rendered, add_special_tokens=False)
53+
54+
body = _post(
55+
"/v1/chat/completions",
56+
{
57+
"model": "gemma4_31b",
58+
"messages": [{"role": "user", "content": "Say ok."}],
59+
"max_tokens": 1,
60+
"temperature": 0,
61+
"session_id": "gemma-bos-regression",
62+
},
63+
)
64+
assert body["usage"]["prompt_tokens"] == len(expected_ids)

examples/models/gemma4_31b/test_serve.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,19 @@ def test_spawn_defaults_worker_bin_and_omits_empty_data_path(monkeypatch):
8989
assert "--warm_resume=false" in cmd
9090

9191

92+
def test_strip_gemma_channels_returns_visible_answer():
93+
text = "<|channel>thought\nscratch work\n<channel|>The answer."
94+
assert serve._strip_gemma_channels(text) == "The answer."
95+
96+
97+
def test_strip_gemma_channels_cuts_unclosed_channel():
98+
assert serve._strip_gemma_channels("Lead <|channel>thought") == "Lead"
99+
100+
101+
def test_strip_gemma_channels_removes_stray_close():
102+
assert serve._strip_gemma_channels("Visible<channel|>") == "Visible"
103+
104+
92105
def test_rejects_multiple_runners(monkeypatch):
93106
import sys
94107

extension/llm/server/cpp/test_worker_loop.cpp

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ class FakeSession : public LLMSession {
5858

5959
int prefill_calls = 0;
6060
std::vector<size_t> prefill_sizes; // size of each prefill_tokens() call
61+
std::vector<std::vector<uint64_t>> prefill_batches;
6162
int fail_prefill_on = -1; // 0-based call index to fail (-1 = never)
6263
int decode_calls = 0;
6364
int fail_decode_on = -1;
@@ -68,6 +69,7 @@ class FakeSession : public LLMSession {
6869
std::vector<uint64_t> tokens,
6970
const SamplingConfig* /*initial_sampling*/ = nullptr) override {
7071
prefill_sizes.push_back(tokens.size());
72+
prefill_batches.push_back(tokens);
7173
if (prefill_calls++ == fail_prefill_on) {
7274
return ETError::Internal; // failed AFTER (notionally) mutating state
7375
}
@@ -173,13 +175,14 @@ Emitted run(
173175
WorkerSessionState& st,
174176
bool warm,
175177
const nlohmann::json& req,
176-
const std::unordered_map<std::string, int64_t>& md = {}) {
178+
const std::unordered_map<std::string, int64_t>& md = {},
179+
const std::vector<uint64_t>& prefix = {}) {
177180
static FakeTokenizer tok;
178181
std::ostringstream cap;
179182
std::streambuf* old = std::cout.rdbuf(cap.rdbuf());
180183
Emitted em;
181184
try {
182-
worker_handle_request(st, warm, tok, md, req);
185+
worker_handle_request(st, warm, tok, md, req, prefix);
183186
} catch (const std::exception&) {
184187
em.threw = true;
185188
}
@@ -325,6 +328,23 @@ void test_generated_token_ids_excludes_terminal() {
325328
st.resident_token_ids.size() == (size_t)st.session->position());
326329
}
327330

331+
void test_prompt_prefix_ids_prepend_text_prompt_once() {
332+
auto st = makeState();
333+
fake(st).steps = {{0, "", true, true}};
334+
auto em =
335+
run(st,
336+
/*warm=*/true,
337+
{{"max_new_tokens", 1}, {"prompt", "ab"}},
338+
{},
339+
{2});
340+
check("prefix: prompt_tokens includes prefix", em.done["prompt_tokens"] == 3);
341+
check(
342+
"prefix: prefilled ids == [2,'a','b']",
343+
fake(st).prefill_batches ==
344+
std::vector<std::vector<uint64_t>>{
345+
{2, static_cast<uint64_t>('a'), static_cast<uint64_t>('b')}});
346+
}
347+
328348
void test_stop_string_marks_dirty_and_omits_ids() {
329349
auto st = makeState();
330350
fake(st).steps = {
@@ -430,6 +450,7 @@ int main() {
430450
test_equal_prompt_no_empty_prefill();
431451
test_anonymous_never_warm();
432452
test_generated_token_ids_excludes_terminal();
453+
test_prompt_prefix_ids_prepend_text_prompt_once();
433454
test_stop_string_marks_dirty_and_omits_ids();
434455
test_prefill_failure_marks_dirty();
435456
test_decode_failure_marks_dirty();

0 commit comments

Comments
 (0)