Skip to content

Commit c10c9a9

Browse files
committed
server: emit GPU ttft_ms / generation_ms / decode_tps in stream usage
Measure decode on the batch-engine worker around forward_token (excluding SSE backpressure) and expose timings on the final chat.completion usage chunk so UIs can show accurate tok/s without client-side wall-clock math.
1 parent 772f042 commit c10c9a9

6 files changed

Lines changed: 89 additions & 5 deletions

File tree

runtime/include/sparkinfer/inference_engine.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ class ContinuousBatchEngine {
3232
struct Result {
3333
std::vector<int> tokens;
3434
std::string error;
35+
// GPU-side timings (exclude SSE/on_token backpressure).
36+
double ttft_ms = -1.0;
37+
double generation_ms = -1.0;
38+
double decode_tps = -1.0;
3539
};
3640

3741
ContinuousBatchEngine(Qwen35Model* model, KVCacheManager* kv,

runtime/src/inference_engine.cpp

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ struct ContinuousBatchEngine::Job {
4343
std::string error;
4444
std::function<void(int)> on_token;
4545
bool done = false;
46+
std::chrono::steady_clock::time_point t_submit{};
47+
std::chrono::steady_clock::time_point t_first{};
48+
bool saw_first_tok = false;
49+
double decode_gpu_ms = 0.0;
50+
int decode_forwards = 0;
51+
double ttft_ms = -1.0;
52+
double generation_ms = -1.0;
53+
double decode_tps = -1.0;
4654
};
4755

4856
ContinuousBatchEngine::ContinuousBatchEngine(Qwen35Model* model, KVCacheManager* kv,
@@ -115,6 +123,7 @@ uint64_t ContinuousBatchEngine::submit_locked(Job job, const std::function<void(
115123
job.seq_id = seq_id;
116124
job.on_token = on_token;
117125
job.prefill_pos = job.req.prefill_start;
126+
job.t_submit = std::chrono::steady_clock::now();
118127
auto ptr = std::make_unique<Job>(std::move(job));
119128
const uint64_t rid = ptr->request_id;
120129
jobs_[rid] = std::move(ptr);
@@ -130,7 +139,12 @@ ContinuousBatchEngine::Result ContinuousBatchEngine::wait_locked(uint64_t reques
130139
});
131140
auto it = jobs_.find(request_id);
132141
if (it == jobs_.end()) return Result{{}, "request not found"};
133-
Result out{it->second->output, it->second->error};
142+
Result out;
143+
out.tokens = it->second->output;
144+
out.error = it->second->error;
145+
out.ttft_ms = it->second->ttft_ms;
146+
out.generation_ms = it->second->generation_ms;
147+
out.decode_tps = it->second->decode_tps;
134148
jobs_.erase(it);
135149
return out;
136150
}
@@ -234,11 +248,26 @@ bool ContinuousBatchEngine::step_job(Job& job) {
234248
return true;
235249
}
236250

251+
// Timestamp before on_token so SSE/network backpressure never enters GPU metrics.
252+
const auto t_emit = std::chrono::steady_clock::now();
253+
if (!job.saw_first_tok) {
254+
job.t_first = t_emit;
255+
job.saw_first_tok = true;
256+
job.ttft_ms = std::chrono::duration<double, std::milli>(job.t_first - job.t_submit).count();
257+
}
237258
job.output.push_back(job.next_token);
238259
if (job.on_token) job.on_token(job.next_token);
239260
job.decode_emitted++;
240261

241262
if (job.next_token == cfg.eos_id || job.decode_emitted >= job.req.max_new_tokens) {
263+
const auto t_end = std::chrono::steady_clock::now();
264+
job.generation_ms = std::chrono::duration<double, std::milli>(t_end - job.t_submit).count();
265+
if (job.decode_forwards > 0 && job.decode_gpu_ms > 0.0) {
266+
job.decode_tps = (double)job.decode_forwards * 1000.0 / job.decode_gpu_ms;
267+
} else if (job.saw_first_tok && job.generation_ms > job.ttft_ms && job.decode_emitted > 0) {
268+
const double decode_ms = std::max(job.generation_ms - job.ttft_ms, 1.0);
269+
job.decode_tps = (double)job.decode_emitted * 1000.0 / decode_ms;
270+
}
242271
job.done = true;
243272
if (job.seq_id != 0) model_->close_session(job.seq_id);
244273
else kv_->free(job.seq_id);
@@ -247,7 +276,11 @@ bool ContinuousBatchEngine::step_job(Job& job) {
247276
}
248277

249278
const int prompt_len = (int)job.req.prompt.size();
279+
const auto t0 = std::chrono::steady_clock::now();
250280
job.next_token = model_->forward_token(job.next_token, prompt_len + job.decode_emitted - 1, true);
281+
job.decode_gpu_ms += std::chrono::duration<double, std::milli>(
282+
std::chrono::steady_clock::now() - t0).count();
283+
job.decode_forwards++;
251284
return false;
252285
}
253286

server/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export SPARKINFER_ROOT="$(pwd)"
4545
| `GET /v1/models` | OpenAI model list |
4646
| `GET /v1/info` | Model limits (`max_context`, `max_output_tokens`) |
4747
| `POST /v1/tokenize` | Token count for a chat request body |
48-
| `POST /v1/chat/completions` | Chat (JSON `messages`, optional `stream`, `enable_thinking`). Responses include OpenAI `usage` (`prompt_tokens`, `completion_tokens`, `total_tokens`). Streaming sends a final chunk with `choices:[]` + `usage` before `[DONE]`. |
48+
| `POST /v1/chat/completions` | Chat (JSON `messages`, optional `stream`, `enable_thinking`). Responses include OpenAI `usage` (`prompt_tokens`, `completion_tokens`, `total_tokens`) plus optional GPU timing fields (`ttft_ms`, `generation_ms`, `decode_tps`). Streaming sends a final chunk with `choices:[]` + `usage` before `[DONE]`. |
4949

5050
### RTX PRO 6000 deploy (32k / 4k)
5151

server/include/model_engine.hpp

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88

99
namespace sparkinfer_server {
1010

11+
struct CompletionTiming {
12+
double ttft_ms = -1.0;
13+
double generation_ms = -1.0;
14+
double decode_tps = -1.0;
15+
};
16+
1117
// Thread-safe wrapper around sparkinfer::Qwen35Model + GGUF load.
1218
class ModelEngine {
1319
public:
@@ -39,12 +45,14 @@ class ModelEngine {
3945
const std::function<void(int)>& on_token);
4046

4147
const std::string& last_error() const;
48+
const CompletionTiming& last_timing() const;
4249

4350
private:
4451
struct Impl;
4552
std::unique_ptr<Impl> impl_;
4653
mutable std::mutex mu_;
4754
std::string last_error_;
55+
CompletionTiming last_timing_;
4856
};
4957

5058
} // namespace sparkinfer_server

server/src/model_engine.cpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,10 @@ int ModelEngine::prefix_token_len() const {
167167
return (int)impl_->prefix_tokens.size();
168168
}
169169

170+
const CompletionTiming& ModelEngine::last_timing() const {
171+
return last_timing_;
172+
}
173+
170174
const std::string& ModelEngine::last_error() const {
171175
std::lock_guard<std::mutex> lock(mu_);
172176
return last_error_;
@@ -230,6 +234,9 @@ std::vector<int> ModelEngine::complete_streaming(const std::vector<int>& prompt_
230234
auto result = impl_->batch_engine->complete_streaming(req, on_token);
231235

232236
std::lock_guard<std::mutex> lock(mu_);
237+
last_timing_.ttft_ms = result.ttft_ms;
238+
last_timing_.generation_ms = result.generation_ms;
239+
last_timing_.decode_tps = result.decode_tps;
233240
if (!result.error.empty()) {
234241
last_error_ = result.error;
235242
fprintf(stderr, "[sparkinfer-server] %s\n", last_error_.c_str());

server/src/sparkinfer_server.cpp

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,19 @@ std::string random_id() {
9090
return ss.str();
9191
}
9292

93-
std::string usage_json(int prompt_tokens, int completion_tokens) {
93+
std::string usage_json(int prompt_tokens, int completion_tokens, double ttft_ms = -1.0,
94+
double generation_ms = -1.0, double decode_tps = -1.0) {
9495
std::ostringstream o;
9596
const int total = prompt_tokens + completion_tokens;
9697
o << "\"usage\":{\"prompt_tokens\":" << prompt_tokens << ",\"completion_tokens\":" << completion_tokens
97-
<< ",\"total_tokens\":" << total << "}";
98+
<< ",\"total_tokens\":" << total;
99+
if (ttft_ms >= 0.0)
100+
o << ",\"ttft_ms\":" << std::fixed << std::setprecision(3) << ttft_ms;
101+
if (generation_ms >= 0.0)
102+
o << ",\"generation_ms\":" << std::fixed << std::setprecision(3) << generation_ms;
103+
if (decode_tps >= 0.0)
104+
o << ",\"decode_tps\":" << std::fixed << std::setprecision(2) << decode_tps;
105+
o << "}";
98106
return o.str();
99107
}
100108

@@ -313,13 +321,21 @@ int main(int argc, char** argv) {
313321
std::vector<int> stream_ids;
314322
stream_ids.reserve((size_t)max_tokens);
315323
sparkinfer_server::ThinkingStreamSplitter splitter(enable_thinking);
324+
const auto wall_start = std::chrono::steady_clock::now();
325+
std::chrono::steady_clock::time_point first_tok_time;
326+
bool saw_first_tok = false;
316327
auto on_tok = [&](int tid) {
328+
if (!saw_first_tok) {
329+
first_tok_time = std::chrono::steady_clock::now();
330+
saw_first_tok = true;
331+
}
317332
std::string piece = g_tokenizer.decode_delta(stream_ids, tid);
318333
const auto delta = splitter.feed(piece);
319334
write_stream_delta(sink, cid, created, "reasoning_content", delta.reasoning_content);
320335
write_stream_delta(sink, cid, created, "content", delta.content);
321336
};
322337
engine.complete_streaming(prompt_ids, max_tokens, on_tok);
338+
const auto wall_end = std::chrono::steady_clock::now();
323339
sparkinfer_server::ThinkingStreamSplitter::Delta flush;
324340
splitter.finish(flush);
325341
write_stream_delta(sink, cid, created, "reasoning_content", flush.reasoning_content);
@@ -332,11 +348,27 @@ int main(int argc, char** argv) {
332348
}
333349
const int prompt_tokens = (int)prompt_ids.size();
334350
const int completion_tokens = (int)stream_ids.size();
351+
const auto& timing = engine.last_timing();
352+
double ttft_ms = timing.ttft_ms;
353+
double generation_ms = timing.generation_ms;
354+
double decode_tps = timing.decode_tps;
355+
if (generation_ms < 0.0) {
356+
generation_ms = std::chrono::duration<double, std::milli>(wall_end - wall_start).count();
357+
}
358+
if (ttft_ms < 0.0 && saw_first_tok) {
359+
ttft_ms = std::chrono::duration<double, std::milli>(first_tok_time - wall_start).count();
360+
}
361+
if (decode_tps < 0.0 && completion_tokens > 0 && generation_ms > 0.0) {
362+
const double decode_ms =
363+
(ttft_ms >= 0.0) ? std::max(generation_ms - ttft_ms, 1.0) : generation_ms;
364+
decode_tps = (double)completion_tokens * 1000.0 / decode_ms;
365+
}
335366
std::ostringstream usage_chunk;
336367
usage_chunk << "data: {\"id\":\"" << cid << "\",\"object\":\"chat.completion.chunk\","
337368
<< "\"created\":" << created << ",\"model\":\"" << g_model_name << "\","
338369
<< "\"choices\":[],"
339-
<< usage_json(prompt_tokens, completion_tokens) << "}\n\n";
370+
<< usage_json(prompt_tokens, completion_tokens, ttft_ms, generation_ms, decode_tps)
371+
<< "}\n\n";
340372
sink.write(usage_chunk.str().c_str(), (size_t)usage_chunk.str().size());
341373
std::string tail =
342374
"data: {\"id\":\"" + cid +

0 commit comments

Comments
 (0)