Skip to content

Commit cd6e869

Browse files
committed
[LLM] Extract text-serving contracts and helpers
Prepare a batching-backed serving path without changing current worker behavior. Share the existing prompt-history planner and define experimental serving types for subsequent runtime and transport changes. Test Plan: All 8 existing worker prompt-planner cases passed. The serving contract and planner headers passed standalone C++17 compilation. Behavioral serving tests are introduced alongside their implementations in later commits. Authored with AI assistance from Devmate.
1 parent ffb13bf commit cd6e869

9 files changed

Lines changed: 250 additions & 67 deletions

File tree

‎CMakeLists.txt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,6 +1015,8 @@ if(EXECUTORCH_BUILD_EXTENSION_LLM)
10151015
list(APPEND _executorch_extensions extension_llm_batching
10161016
extension_llm_batching_module
10171017
)
1018+
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/extension/llm/serving)
1019+
list(APPEND _executorch_extensions extension_llm_serving)
10181020
endif()
10191021

10201022
if(EXECUTORCH_BUILD_EXTENSION_RUNNER_UTIL)

‎examples/llm_server/cpp/BUCK‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ fbcode_target(_kind = runtime.cxx_library,
1313
header_namespace = "executorch/examples/llm_server/cpp",
1414
exported_deps = [
1515
"//executorch/extension/llm/runner:runner_lib",
16+
"//executorch/extension/llm/serving:prompt_history",
1617
"//pytorch/tokenizers:headers",
1718
],
1819
external_deps = ["nlohmann_json"],

‎examples/llm_server/cpp/test_worker_prefill_plan.cpp‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
#include <executorch/examples/llm_server/cpp/worker_prefill_plan.h>
1414

1515
#include <cstdio>
16-
#include <cstring>
1716
#include <string>
1817
#include <vector>
1918

@@ -30,15 +29,15 @@ void expect(
3029
size_t suffix_start,
3130
const char* reason) {
3231
bool ok = p.action == action && p.suffix_start == suffix_start &&
33-
std::strcmp(p.reason, reason) == 0;
32+
p.reason == reason;
3433
if (!ok) {
3534
++g_failures;
3635
printf(
3736
" [FAIL] %s: got action=%d suffix_start=%zu reason=%s\n",
3837
name,
3938
(int)p.action,
4039
p.suffix_start,
41-
p.reason);
40+
p.reason.c_str());
4241
} else {
4342
printf(" [PASS] %s\n", name);
4443
}

‎examples/llm_server/cpp/worker_prefill_plan.h‎

Lines changed: 1 addition & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -8,65 +8,4 @@
88

99
#pragma once
1010

11-
// Pure warm-resume prefill decision for the model worker (no model/session/ET
12-
// dependency, so it is unit-testable in isolation). Given a named session's
13-
// resident token ids (exactly the tokens currently in its KV/recurrent state),
14-
// the new request's prompt token ids, and whether the session is dirty, decide
15-
// whether to reset + full-prefill or to keep the state and prefill only the
16-
// suffix. The decision is exact-token (never string / retokenized text), so a
17-
// kSuffix plan is always a correct continuation; anything uncertain falls back
18-
// to kFull. See worker_loop.h for how the plan is executed.
19-
20-
#include <cstddef>
21-
#include <cstdint>
22-
#include <vector>
23-
24-
namespace executorch {
25-
namespace extension {
26-
namespace llm {
27-
28-
struct PrefillPlan {
29-
enum Action {
30-
kFull, // reset + prefill the whole prompt
31-
kSuffix // keep state, prefill prompt_ids[suffix_start:] at pos>0
32-
} action;
33-
size_t suffix_start; // index in prompt_ids where prefill begins (0 for kFull)
34-
// Reported as session_reset_reason: "new" (no resident), "exact_prefix"
35-
// (suffix reuse), "dirty", "mismatch", "equal" (prompt == resident).
36-
const char* reason;
37-
};
38-
39-
inline PrefillPlan plan_prefill(
40-
const std::vector<uint64_t>& resident,
41-
const std::vector<uint64_t>& prompt,
42-
bool dirty) {
43-
if (dirty) {
44-
return {PrefillPlan::kFull, 0, "dirty"};
45-
}
46-
if (resident.empty()) {
47-
return {PrefillPlan::kFull, 0, "new"};
48-
}
49-
if (prompt.size() < resident.size()) {
50-
return {PrefillPlan::kFull, 0, "mismatch"};
51-
}
52-
for (size_t i = 0; i < resident.size(); ++i) {
53-
if (prompt[i] != resident[i]) {
54-
return {PrefillPlan::kFull, 0, "mismatch"};
55-
}
56-
}
57-
if (prompt.size() == resident.size()) {
58-
// Prompt is exactly the resident state (no new tokens). The ideal would be
59-
// to skip prefill and decode straight from the session's pending token, but
60-
// the LLMSession API exposes no "is there a valid pending token?" query, so
61-
// we conservatively reset + full prefill rather than risk
62-
// prefill_tokens([]) or decoding from a stale/absent pending. Rare in
63-
// practice (a new turn adds tokens); a session pending-state query is a
64-
// possible later optimization.
65-
return {PrefillPlan::kFull, 0, "equal"};
66-
}
67-
return {PrefillPlan::kSuffix, resident.size(), "exact_prefix"};
68-
}
69-
70-
} // namespace llm
71-
} // namespace extension
72-
} // namespace executorch
11+
#include <executorch/extension/llm/serving/prompt_history.h>

‎extension/llm/runner/targets.bzl‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,13 @@ def define_common_targets():
3737
exported_headers = [
3838
"model_metadata.h",
3939
"stats.h",
40+
"text_stream.h",
4041
"util.h",
4142
],
4243
visibility = ["PUBLIC"],
4344
exported_deps = [
4445
":constants",
46+
"//pytorch/tokenizers:headers",
4547
"//executorch/extension/module:module" + aten_suffix,
4648
"//executorch/extension/tensor:tensor" + aten_suffix,
4749
],
@@ -101,12 +103,23 @@ def define_common_targets():
101103
)
102104

103105
runtime.cxx_library(
104-
name = "multimodal_runner_lib" + aten_suffix,
106+
name = "multimodal_input" + aten_suffix,
105107
exported_headers = [
106108
"audio.h",
107109
"image.h",
108-
"wav_loader.h",
109110
"multimodal_input.h",
111+
],
112+
visibility = ["PUBLIC"],
113+
exported_deps = [
114+
"//executorch/extension/tensor:tensor" + aten_suffix,
115+
"//executorch/runtime/platform:platform",
116+
],
117+
)
118+
119+
runtime.cxx_library(
120+
name = "multimodal_runner_lib" + aten_suffix,
121+
exported_headers = [
122+
"wav_loader.h",
110123
"multimodal_runner.h",
111124
"multimodal_prefiller.h",
112125
"multimodal_decoder_runner.h",
@@ -118,6 +131,7 @@ def define_common_targets():
118131
":text_decoder_runner" + aten_suffix,
119132
":text_prefiller" + aten_suffix,
120133
":image_prefiller" + aten_suffix,
134+
":multimodal_input" + aten_suffix,
121135
":text_token_generator" + aten_suffix,
122136
],
123137
)

‎extension/llm/serving/BUCK‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
load("@fbcode_macros//build_defs:build_file_migration.bzl", "fbcode_target")
2+
load("@fbsource//xplat/executorch/build:runtime_wrapper.bzl", "runtime")
3+
4+
oncall("executorch")
5+
6+
# The legacy worker only needs the pure planner, not serving or batching types.
7+
fbcode_target(_kind = runtime.cxx_library,
8+
name = "prompt_history",
9+
exported_headers = ["prompt_history.h"],
10+
exported_deps = ["//executorch/runtime/platform:platform"],
11+
header_namespace = "executorch/extension/llm/serving",
12+
visibility = ["PUBLIC"],
13+
)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
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+
add_library(extension_llm_serving INTERFACE)
8+
target_compile_features(extension_llm_serving INTERFACE cxx_std_17)
9+
target_include_directories(
10+
extension_llm_serving INTERFACE ${_common_include_directories}
11+
)
12+
13+
target_link_libraries(extension_llm_serving INTERFACE executorch_core)
14+
15+
install(TARGETS extension_llm_serving EXPORT ExecuTorchTargets)
16+
install(FILES types.h prompt_history.h
17+
DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/executorch/extension/llm/serving
18+
)
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
#pragma once
10+
11+
// Pure exact-token continuation decision. `resident` is the history against
12+
// which the full prompt is compared. It can be committed KV/recurrent tokens,
13+
// or logical history including a pending token if the caller's continuation
14+
// path feeds that token before the suffix. Dirty or uncertain histories replay
15+
// the full prompt; this helper neither inspects nor changes execution state.
16+
17+
#include <cstddef>
18+
#include <cstdint>
19+
#include <string>
20+
#include <vector>
21+
22+
#include <executorch/runtime/platform/compiler.h>
23+
24+
namespace executorch {
25+
namespace extension {
26+
namespace llm {
27+
28+
struct ET_EXPERIMENTAL PrefillPlan {
29+
enum Action {
30+
kFull, // reset + prefill the whole prompt
31+
kSuffix // keep state, prefill prompt_ids[suffix_start:] at pos>0
32+
} action;
33+
size_t suffix_start; // index in prompt_ids where prefill begins (0 for kFull)
34+
// Reported as session_reset_reason: "new" (no resident), "exact_prefix"
35+
// (suffix reuse), "dirty", "mismatch", "equal" (prompt == resident).
36+
std::string reason;
37+
};
38+
39+
ET_EXPERIMENTAL inline PrefillPlan plan_prefill(
40+
const std::vector<uint64_t>& resident,
41+
const std::vector<uint64_t>& prompt,
42+
bool dirty) {
43+
if (dirty) {
44+
return {PrefillPlan::kFull, 0, "dirty"};
45+
}
46+
if (resident.empty()) {
47+
return {PrefillPlan::kFull, 0, "new"};
48+
}
49+
if (prompt.size() < resident.size()) {
50+
return {PrefillPlan::kFull, 0, "mismatch"};
51+
}
52+
for (size_t i = 0; i < resident.size(); ++i) {
53+
if (prompt[i] != resident[i]) {
54+
return {PrefillPlan::kFull, 0, "mismatch"};
55+
}
56+
}
57+
if (prompt.size() == resident.size()) {
58+
// Equal histories conservatively replay: this helper cannot establish that
59+
// decoding without a non-empty suffix is supported or safe for the caller.
60+
return {PrefillPlan::kFull, 0, "equal"};
61+
}
62+
return {PrefillPlan::kSuffix, resident.size(), "exact_prefix"};
63+
}
64+
65+
} // namespace llm
66+
} // namespace extension
67+
} // namespace executorch

‎extension/llm/serving/types.h‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
#pragma once
10+
11+
#include <cstddef>
12+
#include <cstdint>
13+
#include <optional>
14+
#include <string>
15+
#include <variant>
16+
#include <vector>
17+
18+
#include <executorch/extension/llm/batching/types.h>
19+
#include <executorch/extension/llm/runner/multimodal_input.h>
20+
#include <executorch/runtime/platform/compiler.h>
21+
22+
namespace executorch {
23+
namespace extension {
24+
namespace llm {
25+
namespace serving {
26+
27+
// The full prompt, not a session delta. Preparation encodes each text segment
28+
// separately and appends ID segments verbatim, in order, without adding
29+
// BOS/EOS. Segment boundaries therefore matter even between adjacent text
30+
// segments. Only text and token segments are currently supported; other
31+
// modalities, encoding failures, and an empty prepared prompt are rejected with
32+
// InvalidArgument before changing session history.
33+
struct ET_EXPERIMENTAL PromptInput {
34+
std::vector<MultimodalInput> segments;
35+
};
36+
37+
struct ET_EXPERIMENTAL GenerationOptions {
38+
// Must be positive when set. Capped by the context remaining after the full
39+
// prompt; a prompt leaving no generation room is invalid. Unset uses that
40+
// remaining context, or the service default when the context limit is
41+
// unknown.
42+
std::optional<std::int32_t> max_new_tokens;
43+
// Per-request policy: finite temperature >= 0, finite top_p in (0, 1],
44+
// and top_k >= 0. Invalid options leave existing session history unchanged.
45+
batching::SamplingParams sampling;
46+
// Added to the service's default stop tokens. A matched token is retained in
47+
// logical session history, but excluded from text and generated_token_ids.
48+
std::vector<batching::Token> stop_tokens;
49+
// Non-empty strings matched across decoded pieces. The match and everything
50+
// after it are hidden; a match invalidates exact token replay and warm reuse.
51+
std::vector<std::string> stop_strings;
52+
// Forwarded to the executor for this generation, together with sampling.
53+
std::optional<std::uint64_t> seed;
54+
};
55+
56+
enum class ET_EXPERIMENTAL FinishReason {
57+
Stop, // EOS, a stop token, or a stop string
58+
Length, // Generation budget exhausted
59+
Cancelled, // Request cancellation, session close/reset, or shutdown
60+
Failed, // See TerminalEvent::error
61+
};
62+
63+
enum class ET_EXPERIMENTAL ErrorCode {
64+
InvalidArgument,
65+
NotReady,
66+
SessionNotFound,
67+
SessionBusy,
68+
CapacityExceeded,
69+
Internal,
70+
};
71+
72+
struct ET_EXPERIMENTAL ServingError {
73+
ErrorCode code = ErrorCode::Internal;
74+
std::string message;
75+
};
76+
77+
struct ET_EXPERIMENTAL GenerationStats {
78+
// Full prepared prompt size, including any reused prefix.
79+
std::size_t prompt_tokens = 0;
80+
// Tokens processed by text output, excluding EOS/stop tokens but including
81+
// the token that completes a string stop. Later discarded tokens do not
82+
// count. Neither this count nor visible text describes logical session
83+
// history.
84+
std::size_t completion_tokens = 0;
85+
// Committed prompt prefix reused without execution. A pending prediction
86+
// fed by this request counts as prefilled, not reused.
87+
std::size_t reused_prompt_tokens = 0;
88+
// Actually consumed prompt tokens; may be partial on cancellation or failure.
89+
std::size_t prefilled_prompt_tokens = 0;
90+
double prefill_ms = 0.0;
91+
double decode_ms = 0.0;
92+
double total_ms = 0.0;
93+
std::string session_reset_reason;
94+
// Original output IDs counted by completion_tokens, excluding EOS/stop
95+
// tokens; not a retokenization of visible text or the full logical history
96+
// suffix. Present only for Stop/Length completion without a matched string
97+
// stop or rendering error. An empty vector is distinct from absent.
98+
std::optional<std::vector<batching::Token>> generated_token_ids;
99+
};
100+
101+
struct ET_EXPERIMENTAL TextEvent {
102+
std::string text;
103+
};
104+
105+
struct ET_EXPERIMENTAL TerminalEvent {
106+
FinishReason finish_reason = FinishReason::Stop;
107+
GenerationStats stats;
108+
// Present exactly when finish_reason is Failed.
109+
std::optional<ServingError> error;
110+
};
111+
112+
// Each admitted request emits zero or more text events, then one terminal event
113+
// after final text flushing and session finalization. Synchronous rejection
114+
// emits no events. If the sink throws, it is not called again, even for the
115+
// terminal.
116+
using GenerationEvent ET_EXPERIMENTAL = std::variant<TextEvent, TerminalEvent>;
117+
118+
struct ET_EXPERIMENTAL ServingInfo {
119+
bool ready = false;
120+
std::size_t max_context_length = 0;
121+
std::size_t max_sessions = 0;
122+
// Logical serving slots, including slots reserved during reset/reopen.
123+
// This is not a count of physically live executor sessions.
124+
std::size_t active_sessions = 0;
125+
};
126+
127+
} // namespace serving
128+
} // namespace llm
129+
} // namespace extension
130+
} // namespace executorch

0 commit comments

Comments
 (0)