Skip to content

Commit e39bda8

Browse files
authored
Add metrics to runner (pytorch#22283)
Adds timing instrumentation to the batched runner, which previously reported nothing. metrics.h defines two tiers: GenerationMetrics, published on GenerationHandle beside finish_reason(), covering the submit→first-step→first-token→end timeline, token counts, and an inter-token latency summary; and EngineMetrics, owned by the runner and read after shutdown(), covering step counts and latency, decode/prefill sequences and tokens, admitted-versus-ready decode sequences, and TTFT rollup. cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani
1 parent d5e4ae9 commit e39bda8

8 files changed

Lines changed: 1264 additions & 34 deletions

File tree

‎extension/llm/batching/CMakeLists.txt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ if(NOT EXECUTORCH_ROOT)
1414
set(EXECUTORCH_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/../../..)
1515
endif()
1616

17-
add_library(extension_llm_batching runner.cpp)
17+
add_library(extension_llm_batching runner.cpp metrics.cpp)
1818
# std::optional, std::function, and std::future in the public headers.
1919
target_compile_features(extension_llm_batching PUBLIC cxx_std_17)
2020
target_include_directories(

‎extension/llm/batching/metrics.cpp‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
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+
// The report formatters, kept out of metrics.h so the stream headers they need
10+
// stay out of every translation unit that merely reads a counter.
11+
12+
#include <executorch/extension/llm/batching/metrics.h>
13+
14+
#include <iomanip>
15+
#include <sstream>
16+
#include <string>
17+
18+
namespace executorch {
19+
namespace extension {
20+
namespace llm {
21+
namespace batching {
22+
23+
namespace detail {
24+
25+
inline std::string fixed(double v, int places) {
26+
std::ostringstream os;
27+
os << std::fixed << std::setprecision(places) << v;
28+
return os.str();
29+
}
30+
31+
inline std::string ms(std::int64_t microseconds) {
32+
return fixed(static_cast<double>(microseconds) / 1000.0, 2);
33+
}
34+
35+
} // namespace detail
36+
37+
// Returned rather than printed: these headers stay free of ExecuTorch's
38+
// logging, so the caller decides where it goes.
39+
std::string format_report(const GenerationMetrics& m) {
40+
std::ostringstream os;
41+
os << "session " << m.sid << ": " << m.n_prompt_tokens << " prompt + "
42+
<< m.n_generated_tokens << " generated tokens\n"
43+
<< " ttft " << detail::ms(m.ttft_us()) << " ms = queue "
44+
<< detail::ms(m.queue_wait_us()) << " + prefill "
45+
<< detail::ms(m.prefill_span_us()) << " ("
46+
<< detail::fixed(m.prefill_tokens_per_sec(), 1) << " tok/s, "
47+
<< m.n_prefill_steps
48+
<< (m.n_prefill_steps == 1 ? " step)\n" : " steps)\n");
49+
if (m.itl_count > 0) {
50+
// The decode span and its rate are omitted: both follow from the mean
51+
// below, which is decode time over decode tokens by construction.
52+
os << " decode " << detail::ms(m.itl_sum_us / m.itl_count)
53+
<< " ms/token, max " << detail::ms(m.itl_max_us) << " -> "
54+
<< detail::fixed(m.decode_tokens_per_sec(), 1) << " tok/s over "
55+
<< m.n_decode_steps << " steps\n";
56+
}
57+
os << " total " << detail::ms(m.e2e_us()) << " ms\n";
58+
return os.str();
59+
}
60+
61+
std::string format_report(const EngineMetrics& m) {
62+
std::ostringstream os;
63+
const double wall = m.wall_us();
64+
const auto share = [wall](std::int64_t part) {
65+
return wall > 0.0 ? detail::fixed(100.0 * part / wall, 0)
66+
: std::string("0");
67+
};
68+
// An empty bucket has no mean. Printing 0.00 ms would read as a measurement
69+
// rather than an absence, and the count of zero is the informative part.
70+
const auto bucket = [](double mean_us, std::uint64_t n) {
71+
return n > 0 ? detail::ms(static_cast<std::int64_t>(mean_us)) + " ms"
72+
: std::string("--");
73+
};
74+
75+
os << "engine\n"
76+
<< " run " << detail::fixed(wall / 1e6, 2) << " s wall, "
77+
<< m.steps << " steps";
78+
if (m.steps_failed > 0) {
79+
os << " (" << m.steps_failed << " failed)";
80+
}
81+
os << ", " << detail::fixed(100.0 * m.idle_fraction(), 1) << "% idle\n";
82+
if (m.init_us > 0) {
83+
os << " init " << detail::ms(m.init_us)
84+
<< " ms executor setup, before the wall above\n";
85+
}
86+
os << " generations " << m.generations_completed << " of "
87+
<< m.generations_started << ": " << m.finished_stop_token << " stop, "
88+
<< m.finished_token_limit << " limit, " << m.finished_cancelled
89+
<< " cancelled, " << m.finished_failed << " failed\n"
90+
<< " sessions " << m.peak_concurrent_generations
91+
<< " peak concurrent, " << m.sessions_refused << " refused at capacity\n"
92+
<< "\n"
93+
// A partition, so the three shares add up. The gap between the first two
94+
// is what packing prefill alongside decode cost the decodes.
95+
<< " step time decode-only "
96+
<< bucket(m.mean_decode_only_step_us(), m.decode_only_steps()) << " x"
97+
<< m.decode_only_steps() << " (" << share(m.decode_only_latency_sum_us)
98+
<< "%)\n"
99+
<< " mixed "
100+
<< bucket(m.mean_mixed_step_us(), m.mixed_steps()) << " x"
101+
<< m.mixed_steps() << " (" << share(m.mixed_latency_sum_us) << "%)\n"
102+
<< " prefill-only "
103+
<< bucket(m.mean_prefill_only_step_us(), m.prefill_only_steps()) << " x"
104+
<< m.prefill_only_steps() << " (" << share(m.prefill_only_latency_sum_us)
105+
<< "%)\n"
106+
<< "\n"
107+
<< " decode " << detail::fixed(m.decode_only_tokens_per_sec(), 1)
108+
<< " tok/s on decode-only steps at "
109+
<< detail::fixed(m.mean_decode_step_sessions(), 2) << " sessions/step\n"
110+
<< " "
111+
<< detail::fixed(m.mean_decode_us_per_token() / 1000.0, 2)
112+
<< " ms/token per session across all " << m.steps_with_decode
113+
<< " decode steps\n"
114+
<< " context " << detail::fixed(m.mean_context_per_step(), 0)
115+
<< " tokens resident/step, max " << m.context_max << " per session\n"
116+
<< "\n"
117+
<< " scheduler admitted "
118+
<< detail::fixed(m.mean_admitted_decode_sessions(), 2) << " of "
119+
<< detail::fixed(m.mean_ready_decode_sessions(), 2)
120+
<< " ready decode sessions ("
121+
<< detail::fixed(100.0 * m.admitted_ratio(), 1) << "%)\n"
122+
<< " ttft mean " << detail::ms(m.mean_ttft_us()) << " ms, min "
123+
<< detail::ms(m.min_ttft_us()) << " ms, max " << detail::ms(m.ttft_max_us)
124+
<< " ms over " << m.ttft_count
125+
<< " generations\n"
126+
// Wall-clock rates: these move with the prompt-to-generation mix, so they
127+
// describe the workload as much as the engine.
128+
<< " delivered " << m.total_generated_tokens << " generated -> "
129+
<< detail::fixed(m.generated_tokens_per_sec(), 1) << " tok/s wall\n"
130+
<< " processed " << m.model_input_tokens() << " tokens -> "
131+
<< detail::fixed(m.model_input_tokens_per_sec(), 1) << " tok/s ("
132+
<< m.decode_tokens_total << " decode + " << m.prefill_tokens_total
133+
<< " prompt)\n";
134+
return os.str();
135+
}
136+
137+
} // namespace batching
138+
} // namespace llm
139+
} // namespace extension
140+
} // namespace executorch

0 commit comments

Comments
 (0)