Skip to content

Commit 776ba0c

Browse files
authored
Record LLM model execution latency (#21040)
### Summary Fixes #11771. `model_execution_start_ms` and `model_execution_end_ms` were exposed in the LLM runner stats but stayed at zero because the runner never recorded the model execution boundary. This change shares the generation stats with `TextDecoderRunner`, measures calls to `Module::execute()`, resets the values for each generation, and emits them in the observer JSON. ### Test plan - Reproduced on current `main` with a Qwen3.5-0.8B PTE: both model execution timestamps were `0`. - `test_runner --gtest_filter='StatsTest.*'` (2 passed) - `test_runner --gtest_filter='RunnerTest.*'` (12 passed) - `test_runner --gtest_brief=1` (116 passed, 2 skipped because optional PTE test fixtures were not supplied) - Built `llama_main` and ran the same Qwen3.5-0.8B PTE twice. Both runs reported nonzero model execution timestamps ordered within the inference interval; for example, `1784555623119 <= 1784555624620`. cc @larryliu0820 @mergennachin @cccclai @helunwencser @jackzhxng @digantdesai
1 parent 7f86746 commit 776ba0c

8 files changed

Lines changed: 98 additions & 10 deletions

File tree

extension/llm/runner/_llm_runner.pyi

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,10 @@ class Stats:
8383
"""End time of tokenizer encoding in milliseconds."""
8484

8585
model_execution_start_ms: int
86-
"""Start time of model execution in milliseconds."""
86+
"""Start time of the most recent model execution window in milliseconds."""
8787

8888
model_execution_end_ms: int
89-
"""End time of model execution in milliseconds."""
89+
"""End time of the most recent model execution window in milliseconds."""
9090

9191
prompt_eval_end_ms: int
9292
"""End time of prompt evaluation in milliseconds."""
@@ -100,6 +100,9 @@ class Stats:
100100
aggregate_sampling_time_ms: int
101101
"""Total time spent in sampling across all tokens."""
102102

103+
aggregate_model_execution_time_ms: int
104+
"""Total time spent in model execution across all forward calls."""
105+
103106
num_prompt_tokens: int
104107
"""Number of tokens in the input prompt."""
105108

extension/llm/runner/llm_runner_helper.cpp

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,10 +271,16 @@ std::unique_ptr<TextLLMRunner> create_text_llm_runner(
271271
float init_temp = temperature == -1.0f ? 0.0f : temperature;
272272
auto sampler = std::make_unique<Sampler>(vocab_size, init_temp);
273273

274+
auto stats = std::make_unique<Stats>();
275+
274276
// Create text_decoder_runner
275277
ET_LOG(Info, "Using method: %s", method_name.c_str());
276278
auto text_decoder_runner = std::make_unique<TextDecoderRunner>(
277-
module.get(), io_manager.get(), method_name, std::move(sampler));
279+
module.get(),
280+
io_manager.get(),
281+
method_name,
282+
std::move(sampler),
283+
stats.get());
278284

279285
// Create text_prefiller
280286
auto text_prefiller = std::make_unique<TextPrefiller>(
@@ -284,7 +290,6 @@ std::unique_ptr<TextLLMRunner> create_text_llm_runner(
284290
metadata.at(kMaxSeqLen));
285291

286292
// Create text_token_generator with stats
287-
auto stats = std::make_unique<Stats>();
288293
auto text_token_generator = std::make_unique<TextTokenGenerator>(
289294
tokenizer.get(),
290295
text_decoder_runner.get(),

extension/llm/runner/pybindings.cpp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,9 @@ PYBIND11_MODULE(_llm_runner, m) {
325325
.def_readonly("inference_end_ms", &Stats::inference_end_ms)
326326
.def_readonly(
327327
"aggregate_sampling_time_ms", &Stats::aggregate_sampling_time_ms)
328+
.def_readonly(
329+
"aggregate_model_execution_time_ms",
330+
&Stats::aggregate_model_execution_time_ms)
328331
.def_readonly("num_prompt_tokens", &Stats::num_prompt_tokens)
329332
.def_readonly("num_generated_tokens", &Stats::num_generated_tokens)
330333
.def("on_sampling_begin", &Stats::on_sampling_begin)

extension/llm/runner/stats.h

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ struct ET_EXPERIMENTAL Stats {
3232
long inference_start_ms = 0;
3333
// End of the tokenizer encode time.
3434
long token_encode_end_ms = 0;
35-
// Start of the model execution (forward function) time.
35+
// Start timestamp of the most recent model execution (forward) window.
3636
long model_execution_start_ms = 0;
37-
// End of the model execution (forward function) time.
37+
// End timestamp of the most recent model execution (forward) window.
3838
long model_execution_end_ms = 0;
3939
// prompt_eval_end_ms: Prompt array allocation and tokenization. Ends right
4040
// before the inference loop starts
@@ -45,6 +45,8 @@ struct ET_EXPERIMENTAL Stats {
4545
long inference_end_ms = 0;
4646
// Keep a running total of the time spent in sampling.
4747
long aggregate_sampling_time_ms = 0;
48+
// Running total of time spent in model execution (forward).
49+
long aggregate_model_execution_time_ms = 0;
4850
// Token count from prompt
4951
int64_t num_prompt_tokens = 0;
5052
// Token count from generated (total - prompt)
@@ -66,6 +68,16 @@ struct ET_EXPERIMENTAL Stats {
6668
aggregate_sampling_timer_start_timestamp = 0;
6769
}
6870

71+
inline void on_model_execution_begin() {
72+
model_execution_start_ms = time_in_ms();
73+
}
74+
75+
inline void on_model_execution_end() {
76+
model_execution_end_ms = time_in_ms();
77+
aggregate_model_execution_time_ms +=
78+
model_execution_end_ms - model_execution_start_ms;
79+
}
80+
6981
void reset(bool all_stats = false) {
7082
// Not resetting model_load_start_ms and model_load_end_ms because reset is
7183
// typically called after warmup and before running the actual run.
@@ -77,10 +89,13 @@ struct ET_EXPERIMENTAL Stats {
7789
model_load_end_ms = 0;
7890
}
7991
inference_start_ms = 0;
92+
model_execution_start_ms = 0;
93+
model_execution_end_ms = 0;
8094
prompt_eval_end_ms = 0;
8195
first_token_ms = 0;
8296
inference_end_ms = 0;
8397
aggregate_sampling_time_ms = 0;
98+
aggregate_model_execution_time_ms = 0;
8499
num_prompt_tokens = 0;
85100
num_generated_tokens = 0;
86101
gpu_total_bytes = static_cast<uint64_t>(-1);
@@ -121,10 +136,13 @@ inline std::string stats_to_json_string(const Stats& stats) {
121136
<< "\"model_load_end_ms\":" << stats.model_load_end_ms << ","
122137
<< "\"inference_start_ms\":" << stats.inference_start_ms << ","
123138
<< "\"inference_end_ms\":" << stats.inference_end_ms << ","
139+
<< "\"model_execution_start_ms\":" << stats.model_execution_start_ms << ","
140+
<< "\"model_execution_end_ms\":" << stats.model_execution_end_ms << ","
124141
<< "\"prompt_eval_end_ms\":" << stats.prompt_eval_end_ms << ","
125142
<< "\"first_token_ms\":" << stats.first_token_ms << ","
126143
<< "\"aggregate_sampling_time_ms\":" << stats.aggregate_sampling_time_ms
127-
<< ",";
144+
<< ",\"aggregate_model_execution_time_ms\":"
145+
<< stats.aggregate_model_execution_time_ms << ",";
128146
// Only include GPU fields in the JSON if gpu_total_bytes is valid (not
129147
// equal to sentinel -1)
130148
if (stats.gpu_total_bytes != static_cast<uint64_t>(-1)) {

extension/llm/runner/test/test_generation_config.cpp

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,49 @@
1111

1212
using namespace ::testing;
1313
using executorch::extension::llm::GenerationConfig;
14+
using executorch::extension::llm::Stats;
15+
using executorch::extension::llm::stats_to_json_string;
1416

1517
namespace {
1618
class GenerationConfigTest : public Test {};
1719

20+
TEST(StatsTest, SerializesAndResetsModelExecutionTimestamps) {
21+
Stats stats;
22+
stats.model_execution_start_ms = 123;
23+
stats.model_execution_end_ms = 456;
24+
stats.aggregate_model_execution_time_ms = 333;
25+
26+
const std::string json = stats_to_json_string(stats);
27+
EXPECT_NE(json.find("\"model_execution_start_ms\":123"), std::string::npos);
28+
EXPECT_NE(json.find("\"model_execution_end_ms\":456"), std::string::npos);
29+
EXPECT_NE(
30+
json.find("\"aggregate_model_execution_time_ms\":333"),
31+
std::string::npos);
32+
EXPECT_LT(
33+
json.find("\"model_execution_start_ms\""),
34+
json.find("\"model_execution_end_ms\""));
35+
36+
stats.reset();
37+
EXPECT_EQ(stats.model_execution_start_ms, 0);
38+
EXPECT_EQ(stats.model_execution_end_ms, 0);
39+
EXPECT_EQ(stats.aggregate_model_execution_time_ms, 0);
40+
}
41+
42+
TEST(StatsTest, RecordsLatestModelExecutionAndAggregateTime) {
43+
Stats stats;
44+
stats.model_execution_start_ms = 1;
45+
stats.aggregate_model_execution_time_ms = 7;
46+
47+
stats.on_model_execution_begin();
48+
EXPECT_GT(stats.model_execution_start_ms, 1);
49+
stats.on_model_execution_end();
50+
51+
EXPECT_GE(stats.model_execution_end_ms, stats.model_execution_start_ms);
52+
EXPECT_EQ(
53+
stats.aggregate_model_execution_time_ms,
54+
7 + stats.model_execution_end_ms - stats.model_execution_start_ms);
55+
}
56+
1857
TEST_F(GenerationConfigTest, TestResolveMaxNewTokensBothDefault) {
1958
// Test when both seq_len and max_new_tokens are -1 (default)
2059
GenerationConfig config;

extension/llm/runner/text_decoder_runner.cpp

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,13 @@ TextDecoderRunner::TextDecoderRunner(
2626
Module* module,
2727
IOManager* io_manager,
2828
std::string method_name,
29-
std::unique_ptr<Sampler> sampler)
29+
std::unique_ptr<Sampler> sampler,
30+
Stats* stats)
3031
: module_(module),
3132
io_manager_(io_manager),
3233
method_name_(std::move(method_name)),
33-
sampler_(std::move(sampler)) {}
34+
sampler_(std::move(sampler)),
35+
stats_(stats) {}
3436

3537
// This function is functional, meaning it shouldn't modify any state of the
3638
// input. It should be safe to call multiple times with the same inputs. The
@@ -66,7 +68,13 @@ ::executorch::runtime::Result<executorch::aten::Tensor> TextDecoderRunner::step(
6668
io_manager_->prepare_decode(tokens, start_pos_tensor, method_name_);
6769
ET_CHECK_OK_OR_RETURN_ERROR(inputs_res.error());
6870
inputs = inputs_res.get();
71+
if (stats_ != nullptr) {
72+
stats_->on_model_execution_begin();
73+
}
6974
auto outputs_res = module_->execute(method_name_, inputs);
75+
if (stats_ != nullptr) {
76+
stats_->on_model_execution_end();
77+
}
7078
ET_CHECK_OK_OR_RETURN_ERROR(outputs_res.error());
7179

7280
auto update_err =
@@ -86,7 +94,13 @@ ::executorch::runtime::Result<executorch::aten::Tensor> TextDecoderRunner::step(
8694
(void)start_pos; // unused
8795

8896
std::vector<runtime::EValue> inputs{tokens};
97+
if (stats_ != nullptr) {
98+
stats_->on_model_execution_begin();
99+
}
89100
auto outputs_res = module_->execute(method_name_, inputs);
101+
if (stats_ != nullptr) {
102+
stats_->on_model_execution_end();
103+
}
90104
ET_CHECK_OK_OR_RETURN_ERROR(outputs_res.error());
91105
ET_CHECK_MSG(
92106
outputs_res.get().size() == 1,

extension/llm/runner/text_decoder_runner.h

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,16 @@ namespace executorch {
1818
namespace extension {
1919
namespace llm {
2020

21+
struct Stats;
22+
2123
class ET_EXPERIMENTAL TextDecoderRunner {
2224
public:
2325
explicit TextDecoderRunner(
2426
Module* module,
2527
IOManager* io_manager,
2628
std::string method_name = "forward",
27-
std::unique_ptr<Sampler> sampler = nullptr);
29+
std::unique_ptr<Sampler> sampler = nullptr,
30+
Stats* stats = nullptr);
2831

2932
virtual ~TextDecoderRunner() = default;
3033

@@ -102,6 +105,7 @@ class ET_EXPERIMENTAL TextDecoderRunner {
102105
IOManager* io_manager_;
103106
std::string method_name_;
104107
std::unique_ptr<Sampler> sampler_;
108+
Stats* stats_;
105109
};
106110

107111
} // namespace llm

extension/llm/runner/text_llm_runner.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ Error TextLLMRunner::generate(
108108
// return a response token.
109109

110110
stats_->inference_start_ms = time_in_ms();
111+
stats_->model_execution_start_ms = 0;
112+
stats_->model_execution_end_ms = 0;
111113

112114
// Get max_seq_len for single prefill chunk limit
113115
int64_t max_seq_len = metadata_.at(kMaxSeqLen);

0 commit comments

Comments
 (0)