Skip to content

Commit 950096c

Browse files
committed
[UPDATE] Update
[ghstack-poisoned]
2 parents 5e5a3a0 + 921d819 commit 950096c

16 files changed

Lines changed: 485 additions & 67 deletions

extension/llm/runner/llm_session.h

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88

99
// Model-agnostic Engine/Session interfaces. Model-specific execution lives in
1010
// adapters that implement these (TextLLMSession over TextLLMRunner today;
11-
// Gemma4Session etc. later); the server and pybind layer depend only on these
12-
// interfaces, never on a concrete runner.
11+
// Gemma4Session etc. later); the serving code (HTTP control plane + C++ worker
12+
// binaries) depends only on these interfaces, never on a concrete runner.
1313

1414
#pragma once
1515

extension/llm/runner/test/test_util.cpp

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ namespace {
1818
using ::executorch::aten::ScalarType;
1919
using ::executorch::extension::make_tensor_ptr;
2020
using ::executorch::extension::llm::convert_to_bfloat16;
21+
using ::executorch::extension::llm::stop_safe_prefix_len;
2122
using ::executorch::extension::llm::utf8_complete_prefix_len;
2223

2324
class ConvertToBFloat16Test : public ::testing::Test {
@@ -86,4 +87,39 @@ TEST(Utf8CompletePrefixLenTest, HandlesAsciiAndMultiByteBoundaries) {
8687
EXPECT_EQ(utf8_complete_prefix_len("\x80"), 1u);
8788
}
8889

90+
TEST(StopSafePrefixLenTest, NoStopsEmitsEverything) {
91+
bool hit = true;
92+
EXPECT_EQ(stop_safe_prefix_len("hello world", {}, hit), 11u);
93+
EXPECT_FALSE(hit);
94+
}
95+
96+
TEST(StopSafePrefixLenTest, StopFoundReturnsEarliestOffsetAndExcludesIt) {
97+
bool hit = false;
98+
// "STOP" begins at offset 6; emit "Hello " (6 bytes), drop the stop and rest.
99+
EXPECT_EQ(stop_safe_prefix_len("Hello STOP there", {"STOP"}, hit), 6u);
100+
EXPECT_TRUE(hit);
101+
// Earliest of several wins.
102+
hit = false;
103+
EXPECT_EQ(stop_safe_prefix_len("aXbY", {"Y", "X"}, hit), 1u);
104+
EXPECT_TRUE(hit);
105+
}
106+
107+
TEST(StopSafePrefixLenTest, HoldsBackPossiblePartialStopTail) {
108+
bool hit = false;
109+
// No full stop yet, but the trailing "ST" could become "STOP": hold back
110+
// len("STOP")-1 == 3 bytes, so of "hi ST" (5 bytes) only "hi" (2) is safe.
111+
EXPECT_EQ(stop_safe_prefix_len("hi ST", {"STOP"}, hit), 2u);
112+
EXPECT_FALSE(hit);
113+
}
114+
115+
TEST(StopSafePrefixLenTest, HoldBackSnapsToUtf8Boundary) {
116+
bool hit = false;
117+
// "ab" + "€"(3 bytes). Stop "XX" => hold back 1 byte, which would land inside
118+
// the euro sign; snap down so the multi-byte char isn't split.
119+
const std::string text = "ab\xe2\x82\xac";
120+
const size_t safe = stop_safe_prefix_len(text, {"XX"}, hit);
121+
EXPECT_FALSE(hit);
122+
EXPECT_EQ(safe, 2u); // only "ab"; the € is held whole
123+
}
124+
89125
} // namespace

extension/llm/runner/util.h

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
#include <executorch/runtime/platform/compiler.h>
1414
#include <stdio.h>
1515
#include <time.h>
16+
#include <algorithm>
1617
#include <cctype>
1718
#include <string>
1819
#include <vector>
@@ -98,6 +99,55 @@ ET_EXPERIMENTAL size_t inline utf8_complete_prefix_len(const std::string& s) {
9899
return i;
99100
}
100101

102+
// How many leading bytes of `text` a streaming consumer may safely emit given a
103+
// set of `stops` strings, and whether a stop was hit (`stop_hit`).
104+
// * If any stop occurs, returns the byte offset of the EARLIEST occurrence and
105+
// sets stop_hit=true — text before it is safe; the stop and everything after
106+
// are dropped (the stop is excluded from output).
107+
// * Otherwise returns the length minus the longest possible partial-stop tail
108+
// (max(len(stop))-1 bytes), snapped DOWN to a UTF-8 boundary so a multi-byte
109+
// character is never split; stop_hit=false. Holding back that tail lets a
110+
// stop that straddles the next piece still be caught.
111+
// `text` is expected to be complete-UTF-8 (e.g. the assembled output of
112+
// utf8_complete_prefix_len). Empty `stops` => emit everything, no hold-back.
113+
ET_EXPERIMENTAL size_t inline stop_safe_prefix_len(
114+
const std::string& text,
115+
const std::vector<std::string>& stops,
116+
bool& stop_hit) {
117+
stop_hit = false;
118+
if (stops.empty()) {
119+
return text.size();
120+
}
121+
size_t earliest = std::string::npos;
122+
size_t max_len = 0;
123+
for (const auto& s : stops) {
124+
if (s.empty()) {
125+
continue;
126+
}
127+
max_len = std::max(max_len, s.size());
128+
const size_t p = text.find(s);
129+
if (p != std::string::npos &&
130+
(earliest == std::string::npos || p < earliest)) {
131+
earliest = p;
132+
}
133+
}
134+
if (earliest != std::string::npos) {
135+
stop_hit = true;
136+
return earliest;
137+
}
138+
const size_t hold = max_len > 0 ? max_len - 1 : 0;
139+
if (text.size() <= hold) {
140+
return 0;
141+
}
142+
size_t end = text.size() - hold;
143+
// Don't cut in the middle of a UTF-8 character: back up over continuation
144+
// bytes (10xxxxxx).
145+
while (end > 0 && (static_cast<unsigned char>(text[end]) & 0xC0) == 0x80) {
146+
--end;
147+
}
148+
return end;
149+
}
150+
101151
// ----------------------------------------------------------------------------
102152
// utilities: time
103153

extension/llm/server/cpp/CMakeLists.txt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,19 @@ set(link_libraries executorch gflags)
4545
list(APPEND link_libraries optimized_native_cpu_ops_lib cpublas eigen_blas)
4646
executorch_target_link_options_shared_lib(optimized_native_cpu_ops_lib)
4747

48+
# Custom + quantized kernels that export_llm models need, whole-archived so the
49+
# static op registrations survive the linker: llama::custom_sdpa (from
50+
# use_sdpa_with_kv_cache) and quantized_decomposed ops (from quantized exports).
51+
# Without these the model loads but execution fails with "Missing operator".
52+
if(TARGET custom_ops)
53+
executorch_target_link_options_shared_lib(custom_ops)
54+
list(APPEND link_libraries custom_ops)
55+
endif()
56+
if(TARGET quantized_ops_lib)
57+
list(APPEND link_libraries quantized_kernels quantized_ops_lib)
58+
executorch_target_link_options_shared_lib(quantized_ops_lib)
59+
endif()
60+
4861
# Extensions (Engine/Session lives in extension_llm_runner)
4962
list(
5063
APPEND

extension/llm/server/cpp/text_llm_worker.cpp

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@
1616
// Protocol (one JSON object per line; identical to worker_client.py):
1717
// worker -> stdout, once: {"ready": true}
1818
// client -> stdin, per request: {"prompt": str, "max_new_tokens": int,
19-
// "temperature": float}
19+
// "temperature": float, "stop": [str, ...]}
2020
// worker -> stdout, per request: {"token": str} * (streamed)
2121
// {"done": true, "prompt_tokens": int,
22-
// "completion_tokens": int}
22+
// "completion_tokens": int,
23+
// "finish_reason": "stop" | "length"}
2324
// or {"error": str}
2425
//
2526
// stdout carries ONLY protocol JSON; all logs go to stderr (ET_LOG). One
@@ -74,6 +75,11 @@ void handle_request(
7475
const std::string prompt = req.at("prompt").get<std::string>();
7576
int64_t max_new = req.value("max_new_tokens", static_cast<int64_t>(-1));
7677
const float temperature = req.value("temperature", 0.0f);
78+
// Stop strings (the request's `stop` sequences): terminate at the token
79+
// boundary where one appears so we don't generate to EOS/max_new past it. The
80+
// control plane also enforces these as a backstop.
81+
const std::vector<std::string> stops =
82+
req.value("stop", std::vector<std::string>{});
7783

7884
if (session.reset() != Error::Ok) {
7985
throw std::runtime_error("session reset failed");
@@ -90,12 +96,21 @@ void handle_request(
9096
}
9197
const int64_t num_prompt = static_cast<int64_t>(ids.size());
9298

93-
if (max_new <= 0) {
94-
// Fill the remaining context window when the client doesn't bound it.
95-
const auto it = metadata.find(llm::kMaxContextLen);
96-
max_new = (it != metadata.end())
97-
? std::max<int64_t>(1, it->second - num_prompt)
98-
: 2048;
99+
// Bound generation to the context window: default to filling the remaining
100+
// room, and clamp an explicit max_new_tokens too, so decode never steps past
101+
// the window (which would error mid-generation after partial output).
102+
const auto ctx_it = metadata.find(llm::kMaxContextLen);
103+
if (ctx_it != metadata.end()) {
104+
const int64_t room = ctx_it->second - num_prompt;
105+
if (room <= 0) {
106+
throw std::runtime_error(
107+
"prompt fills the context window; no room to generate");
108+
}
109+
if (max_new <= 0 || max_new > room) {
110+
max_new = room;
111+
}
112+
} else if (max_new <= 0) {
113+
max_new = 2048;
99114
}
100115

101116
llm::SamplingConfig sampling;
@@ -104,32 +119,55 @@ void handle_request(
104119
throw std::runtime_error("prefill failed");
105120
}
106121

107-
std::string buf; // holds bytes not yet forming a complete UTF-8 prefix
122+
std::string buf; // bytes not yet forming a complete UTF-8 prefix
123+
std::string pending; // complete-UTF-8 text held back for stop-string matching
108124
int64_t num_generated = 0;
125+
std::string finish = "length"; // EOS or stop string -> "stop"
126+
bool stop_string = false; // a request stop string was matched
109127
for (int64_t step = 0; step < max_new; ++step) {
110128
auto step_result = session.decode_one(sampling);
111129
if (step_result.error() != Error::Ok) {
112130
throw std::runtime_error("decode failed");
113131
}
114132
const auto& d = step_result.get();
115133
if (d.is_terminal) {
116-
break; // terminal step: not generated output
134+
finish = "stop";
135+
break; // terminal step (EOS / cooperative stop): not emitted or counted
117136
}
118137
++num_generated;
119138
buf += d.text_piece;
120139
const size_t cut = llm::utf8_complete_prefix_len(buf);
121140
if (cut > 0) {
122-
emit({{"token", buf.substr(0, cut)}});
141+
pending += buf.substr(0, cut);
123142
buf.erase(0, cut);
124143
}
144+
bool stop_hit = false;
145+
const size_t safe = llm::stop_safe_prefix_len(pending, stops, stop_hit);
146+
if (safe > 0) {
147+
emit({{"token", pending.substr(0, safe)}});
148+
pending.erase(0, safe);
149+
}
150+
if (stop_hit) {
151+
finish = "stop"; // reached a stop string: drop it and everything after
152+
stop_string = true;
153+
break;
154+
}
125155
}
126-
if (!buf.empty()) {
127-
emit({{"token", buf}}); // flush any trailing bytes (replaced if incomplete)
156+
if (!stop_string) {
157+
// EOS or length: flush held-back text + any trailing incomplete bytes
158+
// (replaced if invalid). A stop-string hit drops the remainder instead.
159+
pending += buf;
160+
if (!pending.empty()) {
161+
emit({{"token", pending}});
162+
}
128163
}
164+
// finish_reason: "stop" if the model emitted EOS or hit a stop string, else
165+
// "length" — it ran to max_new (possibly clamped to the context window).
129166
emit(
130167
{{"done", true},
131168
{"prompt_tokens", num_prompt},
132-
{"completion_tokens", num_generated}});
169+
{"completion_tokens", num_generated},
170+
{"finish_reason", finish}});
133171
}
134172

135173
} // namespace

extension/llm/server/python/errors.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,26 @@ def body(self) -> dict:
3030

3131

3232
class ContextLengthExceeded(APIError):
33-
def __init__(self, num_tokens: int, max_context: int):
34-
super().__init__(
35-
status=400,
36-
message=(
33+
def __init__(self, num_tokens: int, max_context: int, completion_tokens: int = 0):
34+
# completion_tokens > 0: the prompt fits but prompt + requested
35+
# max_tokens would run past the window — reject up front rather than
36+
# fail (or truncate) mid-generation.
37+
if completion_tokens > 0:
38+
message = (
39+
f"This model's maximum context length is {max_context} tokens. "
40+
f"However, you requested {num_tokens + completion_tokens} tokens "
41+
f"({num_tokens} in the messages, {completion_tokens} in the "
42+
f"completion). Please reduce the length of the messages or "
43+
f"completion."
44+
)
45+
else:
46+
message = (
3747
f"This model's maximum context length is {max_context} tokens, "
3848
f"but the request has {num_tokens} prompt tokens."
39-
),
49+
)
50+
super().__init__(
51+
status=400,
52+
message=message,
4053
err_type="invalid_request_error",
4154
code="context_length_exceeded",
4255
)

extension/llm/server/python/runner_pool.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
class GenStats:
3939
prompt_tokens: int = 0
4040
completion_tokens: int = 0
41+
# Worker-reported stop reason ("stop" | "length"), or None if not reported.
42+
finish_reason: Optional[str] = None
4143

4244

4345
class RunnerPool:
@@ -98,6 +100,7 @@ def token_cb(token: str) -> None:
98100
def stats_cb(s) -> None:
99101
out_stats.prompt_tokens = s.num_prompt_tokens
100102
out_stats.completion_tokens = s.num_generated_tokens
103+
out_stats.finish_reason = getattr(s, "finish_reason", None)
101104

102105
def run() -> None:
103106
try:

0 commit comments

Comments
 (0)