Skip to content

Commit 738fc28

Browse files
authored
[LLM serving 2/x] Add batching-backed serving session lifecycle (#23277)
Introduce an experimental ServingRuntime that manages named sessions on top of the existing batching runtime. It owns one Runner and scheduler, while physical session state and cleanup remain with batching::Session. * Add asynchronous open, close, and reset operations, with bounded session capacity and pending operations processed in admission order. * Reset destroys and reopens the physical session under the same key. Reopening failure leaves the key explicitly unavailable until reset succeeds or close releases it. * Use RAII for physical session closure, and provide synchronized readiness information and idempotent shutdown that settles accepted operations. * Add thread-safe Runner::initialization_state() reporting Pending, Ready, or Failed. Initialization failures and exceptions refuse work and settle queued opens without an executor wrapper. This PR covers lifecycle management only. Generation, tokenization, streaming, and prefix reuse are added separately; no new scheduler or decode loop is introduced.
1 parent 900b9fd commit 738fc28

9 files changed

Lines changed: 1678 additions & 15 deletions

File tree

‎extension/llm/batching/executor.h‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ class ET_EXPERIMENTAL Executor {
7272
// Optional one-time setup, called on the engine thread before any other
7373
// method.
7474
//
75-
// false = the runner admits no work, so open_session_async() reports nullopt.
75+
// false or an exception = the runner admits no work, so
76+
// open_session_async() reports nullopt.
7677
virtual bool initialize() {
7778
return true;
7879
}

‎extension/llm/batching/runner.cpp‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,10 @@ class RunnerImpl : public std::enable_shared_from_this<RunnerImpl> {
290290
GenConfig config,
291291
GenerationCallback on_update);
292292

293+
InitializationState initialization_state() const noexcept {
294+
return initialization_state_.load(std::memory_order_acquire);
295+
}
296+
293297
// Engine-thread data, so only stable once that thread is joined.
294298
EngineMetrics metrics() const {
295299
return metrics_;
@@ -510,6 +514,8 @@ class RunnerImpl : public std::enable_shared_from_this<RunnerImpl> {
510514
std::condition_variable stopped_cv_;
511515
std::vector<Command> inbox_;
512516
std::atomic<Lifecycle> lifecycle_{Lifecycle::Running};
517+
std::atomic<InitializationState> initialization_state_{
518+
InitializationState::Pending};
513519
bool join_in_progress_ = false;
514520
std::thread::id engine_thread_id_;
515521

@@ -636,6 +642,10 @@ void Runner::shutdown() {
636642
impl_->shutdown();
637643
}
638644

645+
InitializationState Runner::initialization_state() const noexcept {
646+
return impl_->initialization_state();
647+
}
648+
639649
EngineMetrics Runner::metrics() const {
640650
return impl_->metrics();
641651
}
@@ -711,8 +721,21 @@ void RunnerImpl::run_() {
711721
// handed a session for an executor that did not come up, and so one-time
712722
// setup is not charged to whichever generation happened to go first.
713723
const MetricsTime init_start = MetricsClock::now();
714-
const bool ready = executor_.initialize();
724+
bool ready = false;
725+
#if ET_HAS_EXCEPTIONS
726+
try {
727+
#endif
728+
ready = executor_.initialize();
729+
#if ET_HAS_EXCEPTIONS
730+
} catch (...) {
731+
// Use the same shutdown/drain path as an explicit initialization failure.
732+
}
733+
#endif
715734
metrics_.init_us = us_between(init_start, MetricsClock::now());
735+
// Publish the reason before a stopped Runner can refuse an open.
736+
initialization_state_.store(
737+
ready ? InitializationState::Ready : InitializationState::Failed,
738+
std::memory_order_release);
716739
if (!ready) {
717740
// Stop without running work. The drain below still answers whatever was
718741
// queued while this was starting, so no caller is left waiting.

‎extension/llm/batching/runner.h‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,8 @@ class ET_EXPERIMENTAL Session {
236236
std::unique_ptr<SessionState> state_;
237237
};
238238

239+
enum class ET_EXPERIMENTAL InitializationState { Pending, Ready, Failed };
240+
239241
class ET_EXPERIMENTAL Runner {
240242
public:
241243
// Takes the scheduler, one per runner, which also supplies the prefill chunk
@@ -252,6 +254,11 @@ class ET_EXPERIMENTAL Runner {
252254
Runner(const Runner&) = delete;
253255
Runner& operator=(const Runner&) = delete;
254256

257+
// Any thread. Pending until executor initialization returns; Failed if it
258+
// returns false or throws. The result is retained after shutdown, so Ready
259+
// describes successful initialization, not whether work is still accepted.
260+
InitializationState initialization_state() const noexcept;
261+
255262
// Any thread; queued to the engine thread and acked.
256263
//
257264
// nullopt = the executor is at capacity, or the runner is shutting down.

‎extension/llm/batching/test/runner_test.cpp‎

Lines changed: 177 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ using executorch::extension::llm::batching::FinishReason;
3636
using executorch::extension::llm::batching::GenConfig;
3737
using executorch::extension::llm::batching::GenerationHandle;
3838
using executorch::extension::llm::batching::GenerationUpdate;
39+
using executorch::extension::llm::batching::InitializationState;
3940
using executorch::extension::llm::batching::Position;
4041
using executorch::extension::llm::batching::Runner;
4142
using executorch::extension::llm::batching::SamplingParams;
@@ -2668,19 +2669,189 @@ TEST(InitializeTest, FailureStopsTheRunnerAndRefusesSessions) {
26682669
<< "a session must not be opened on an executor that failed to start";
26692670
}
26702671

2671-
// Opens queued while the engine was starting still have to be answered, or a
2672-
// caller blocked on the future would hang.
2673-
TEST(InitializeTest, FailureDoesNotStrandAQueuedOpen) {
2674-
FakeExecutor executor;
2675-
executor.fail_initialize = true;
2672+
namespace {
2673+
2674+
enum class InitializeOutcome {
2675+
Success,
2676+
Failure,
2677+
#if ET_HAS_EXCEPTIONS
2678+
Exception,
2679+
NonStandardException,
2680+
#endif
2681+
};
2682+
2683+
class GatedInitializeExecutor : public FakeExecutor {
2684+
public:
2685+
explicit GatedInitializeExecutor(InitializeOutcome outcome)
2686+
: outcome_(outcome) {
2687+
fail_initialize = outcome_ == InitializeOutcome::Failure;
2688+
}
2689+
2690+
bool initialize() override {
2691+
const bool ready = FakeExecutor::initialize();
2692+
{
2693+
std::unique_lock<std::mutex> lock(mutex_);
2694+
entered_ = true;
2695+
cv_.notify_all();
2696+
cv_.wait(lock, [this] { return released_; });
2697+
}
2698+
#if ET_HAS_EXCEPTIONS
2699+
if (outcome_ == InitializeOutcome::Exception) {
2700+
throw std::runtime_error("initialize failed");
2701+
}
2702+
if (outcome_ == InitializeOutcome::NonStandardException) {
2703+
throw 7;
2704+
}
2705+
#endif
2706+
return ready;
2707+
}
2708+
2709+
bool wait_for_initialize() {
2710+
std::unique_lock<std::mutex> lock(mutex_);
2711+
return cv_.wait_for(lock, kTimeout, [this] { return entered_; });
2712+
}
2713+
2714+
void release_initialize() {
2715+
{
2716+
std::lock_guard<std::mutex> lock(mutex_);
2717+
released_ = true;
2718+
}
2719+
cv_.notify_all();
2720+
}
2721+
2722+
private:
2723+
const InitializeOutcome outcome_;
2724+
std::mutex mutex_;
2725+
std::condition_variable cv_;
2726+
bool entered_ = false;
2727+
bool released_ = false;
2728+
};
2729+
2730+
class InitializeStateTest : public ::testing::TestWithParam<InitializeOutcome> {
2731+
};
2732+
2733+
} // namespace
2734+
2735+
TEST_P(InitializeStateTest, PendingSettlesQueuedOpensAndRetainsResult) {
2736+
GatedInitializeExecutor executor(GetParam());
26762737
Fixture fixture(executor);
2738+
// Keep assertions nonfatal until the gate is released so teardown can join.
2739+
EXPECT_TRUE(executor.wait_for_initialize());
2740+
EXPECT_EQ(
2741+
fixture.runner.initialization_state(), InitializationState::Pending);
26772742

26782743
std::vector<std::future<std::optional<Session>>> opens;
26792744
for (int i = 0; i < 4; ++i) {
26802745
opens.push_back(fixture.runner.open_session_async());
2746+
EXPECT_EQ(
2747+
opens.back().wait_for(std::chrono::seconds(0)),
2748+
std::future_status::timeout);
26812749
}
2750+
EXPECT_TRUE(executor.opened().empty());
2751+
EXPECT_EQ(
2752+
fixture.runner.initialization_state(), InitializationState::Pending);
2753+
executor.release_initialize();
2754+
2755+
const bool succeeds = GetParam() == InitializeOutcome::Success;
2756+
const auto expected =
2757+
succeeds ? InitializationState::Ready : InitializationState::Failed;
26822758
for (auto& opened : opens) {
26832759
ASSERT_EQ(opened.wait_for(kTimeout), std::future_status::ready);
2684-
EXPECT_FALSE(opened.get().has_value());
2760+
auto session = opened.get();
2761+
EXPECT_EQ(session.has_value(), succeeds);
2762+
EXPECT_EQ(fixture.runner.initialization_state(), expected);
2763+
if (session) {
2764+
EXPECT_TRUE(session->valid());
2765+
}
2766+
}
2767+
if (!succeeds) {
2768+
auto refused = fixture.runner.open_session_async();
2769+
ASSERT_EQ(
2770+
refused.wait_for(std::chrono::seconds(0)), std::future_status::ready);
2771+
EXPECT_FALSE(refused.get());
2772+
}
2773+
2774+
fixture.runner.shutdown();
2775+
EXPECT_EQ(fixture.runner.initialization_state(), expected);
2776+
fixture.runner.shutdown();
2777+
EXPECT_EQ(fixture.runner.initialization_state(), expected);
2778+
auto refused = fixture.runner.open_session_async();
2779+
ASSERT_EQ(
2780+
refused.wait_for(std::chrono::seconds(0)), std::future_status::ready);
2781+
EXPECT_FALSE(refused.get());
2782+
EXPECT_EQ(executor.initialize_calls(), 1);
2783+
EXPECT_FALSE(executor.called_before_initialize());
2784+
EXPECT_EQ(executor.opened().size(), succeeds ? 4u : 0u);
2785+
EXPECT_EQ(executor.closed().size(), executor.opened().size());
2786+
EXPECT_EQ(executor.open_count(), 0);
2787+
EXPECT_EQ(fixture.runner.metrics().steps, 0u);
2788+
}
2789+
2790+
TEST_P(
2791+
InitializeStateTest,
2792+
ShutdownWhilePendingStillRetainsInitializationResult) {
2793+
GatedInitializeExecutor executor(GetParam());
2794+
Fixture fixture(executor);
2795+
EXPECT_TRUE(executor.wait_for_initialize());
2796+
std::vector<std::future<std::optional<Session>>> opens;
2797+
opens.push_back(fixture.runner.open_session_async());
2798+
EXPECT_EQ(
2799+
opens.back().wait_for(std::chrono::seconds(0)),
2800+
std::future_status::timeout);
2801+
2802+
std::thread stopping([&] { fixture.runner.shutdown(); });
2803+
bool stopped_admission = false;
2804+
const auto deadline = std::chrono::steady_clock::now() + kTimeout;
2805+
while (std::chrono::steady_clock::now() < deadline) {
2806+
auto probe = fixture.runner.open_session_async();
2807+
if (probe.wait_for(std::chrono::milliseconds(1)) ==
2808+
std::future_status::ready) {
2809+
stopped_admission = !probe.get();
2810+
break;
2811+
}
2812+
opens.push_back(std::move(probe));
26852813
}
2814+
EXPECT_TRUE(stopped_admission);
2815+
EXPECT_EQ(
2816+
fixture.runner.initialization_state(), InitializationState::Pending);
2817+
executor.release_initialize();
2818+
stopping.join();
2819+
2820+
for (auto& opened : opens) {
2821+
ASSERT_EQ(opened.wait_for(kTimeout), std::future_status::ready);
2822+
EXPECT_FALSE(opened.get());
2823+
}
2824+
EXPECT_EQ(
2825+
fixture.runner.initialization_state(),
2826+
GetParam() == InitializeOutcome::Success ? InitializationState::Ready
2827+
: InitializationState::Failed);
2828+
EXPECT_EQ(executor.initialize_calls(), 1);
2829+
EXPECT_TRUE(executor.opened().empty());
2830+
EXPECT_EQ(fixture.runner.metrics().steps, 0u);
26862831
}
2832+
2833+
INSTANTIATE_TEST_SUITE_P(
2834+
InitializeTest,
2835+
InitializeStateTest,
2836+
::testing::Values(
2837+
InitializeOutcome::Success,
2838+
#if ET_HAS_EXCEPTIONS
2839+
InitializeOutcome::Exception,
2840+
InitializeOutcome::NonStandardException,
2841+
#endif
2842+
InitializeOutcome::Failure),
2843+
[](const ::testing::TestParamInfo<InitializeOutcome>& info) {
2844+
switch (info.param) {
2845+
case InitializeOutcome::Success:
2846+
return "Success";
2847+
case InitializeOutcome::Failure:
2848+
return "Failure";
2849+
#if ET_HAS_EXCEPTIONS
2850+
case InitializeOutcome::Exception:
2851+
return "Exception";
2852+
case InitializeOutcome::NonStandardException:
2853+
return "NonStandardException";
2854+
#endif
2855+
}
2856+
return "Unknown";
2857+
});

‎extension/llm/serving/CMakeLists.txt‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,22 @@
44
# This source code is licensed under the BSD-style license found in the
55
# LICENSE file in the root directory of this source tree.
66

7-
add_library(extension_llm_serving INTERFACE)
8-
target_compile_features(extension_llm_serving INTERFACE cxx_std_17)
7+
add_library(extension_llm_serving STATIC serving_runtime.cpp)
8+
target_compile_features(extension_llm_serving PUBLIC cxx_std_17)
99
target_include_directories(
10-
extension_llm_serving INTERFACE ${_common_include_directories}
10+
extension_llm_serving PUBLIC ${_common_include_directories}
11+
)
12+
target_compile_options(extension_llm_serving PUBLIC ${_common_compile_options})
13+
target_link_libraries(
14+
extension_llm_serving PUBLIC extension_llm_batching executorch_core
1115
)
1216

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+
install(
18+
TARGETS extension_llm_serving
19+
EXPORT ExecuTorchTargets
20+
DESTINATION ${CMAKE_INSTALL_LIBDIR}
21+
)
22+
install(FILES types.h prompt_history.h serving_runtime.h
1723
DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/executorch/extension/llm/serving
1824
)
1925

@@ -26,3 +32,7 @@ if(NOT EXECUTORCH_BUILD_EXTENSION_LLM_RUNNER)
2632
DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/executorch/extension/llm/runner
2733
)
2834
endif()
35+
36+
if(BUILD_TESTING)
37+
add_subdirectory(test)
38+
endif()

0 commit comments

Comments
 (0)