|
| 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