fm-bench 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -17
- package/docs/methodology.md +15 -1
- package/package.json +1 -1
- package/src/bench.js +150 -7
- package/src/cli.js +66 -4
- package/src/fm.js +2 -1
- package/src/process.js +6 -1
- package/src/progress.js +190 -0
- package/src/prompts.js +22 -0
- package/src/report.js +4 -0
- package/src/stats.js +11 -2
- package/src/table.js +225 -56
package/src/table.js
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
|
+
import { stripAnsi } from './ansi.js';
|
|
2
|
+
|
|
1
3
|
export function renderTable(headers, rows, options = {}) {
|
|
2
4
|
const ascii = Boolean(options.ascii);
|
|
3
5
|
const maxCellWidth = options.maxCellWidth || 60;
|
|
4
|
-
const
|
|
6
|
+
const normalizedRows = rows.map((row) => row.map((cell) => {
|
|
7
|
+
const normalized = normalizeCell(cell);
|
|
8
|
+
return {
|
|
9
|
+
...normalized,
|
|
10
|
+
text: truncate(normalized.text, maxCellWidth)
|
|
11
|
+
};
|
|
12
|
+
}));
|
|
5
13
|
const widths = headers.map((header, index) => {
|
|
6
|
-
const values = [truncate(header, maxCellWidth), ...
|
|
14
|
+
const values = [truncate(header, maxCellWidth), ...normalizedRows.map((row) => row[index]?.text ?? '')];
|
|
7
15
|
return Math.max(...values.map(visibleLength));
|
|
8
16
|
});
|
|
9
17
|
const style = ascii ? ASCII_TABLE : UNICODE_TABLE;
|
|
@@ -11,8 +19,8 @@ export function renderTable(headers, rows, options = {}) {
|
|
|
11
19
|
const top = rule(style.topLeft, style.topJoin, style.topRight, style.horizontal, widths);
|
|
12
20
|
const middle = rule(style.midLeft, style.midJoin, style.midRight, style.horizontal, widths);
|
|
13
21
|
const bottom = rule(style.bottomLeft, style.bottomJoin, style.bottomRight, style.horizontal, widths);
|
|
14
|
-
const headerLine = rowLine(headers.map((header) => truncate(header, maxCellWidth)), widths, style, true);
|
|
15
|
-
const bodyLines =
|
|
22
|
+
const headerLine = rowLine(headers.map((header) => truncate(header, maxCellWidth)), widths, style, true, options);
|
|
23
|
+
const bodyLines = normalizedRows.map((row) => rowLine(row, widths, style, false, options));
|
|
16
24
|
|
|
17
25
|
return [top, headerLine, middle, ...bodyLines, bottom].join('\n');
|
|
18
26
|
}
|
|
@@ -41,11 +49,11 @@ export function renderBenchmarkReport(payload, options = {}) {
|
|
|
41
49
|
lines.push('');
|
|
42
50
|
|
|
43
51
|
if (mode === 'compact') {
|
|
44
|
-
lines.push(renderCompactSummary(payload.summary, { ...options, width }));
|
|
52
|
+
lines.push(renderCompactSummary(payload.summary, { ...options, width, slo: payload.options.slo }));
|
|
45
53
|
} else {
|
|
46
|
-
lines.push(renderSummaryTable(payload.summary, { ...options, mode, width }));
|
|
54
|
+
lines.push(renderSummaryTable(payload.summary, { ...options, mode, width, slo: payload.options.slo }));
|
|
47
55
|
lines.push('');
|
|
48
|
-
lines.push(renderDetailTable(payload.summary, { ...options, mode, width }));
|
|
56
|
+
lines.push(renderDetailTable(payload.summary, { ...options, mode, width, slo: payload.options.slo }));
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
lines.push('');
|
|
@@ -74,24 +82,27 @@ export function formatPercent(value, digits = 0) {
|
|
|
74
82
|
export function renderSummaryTable(summary, options = {}) {
|
|
75
83
|
const mode = options.mode || 'wide';
|
|
76
84
|
const hasGoodput = summary.some((item) => item.goodputRate != null);
|
|
85
|
+
const ranks = rankSummary(summary);
|
|
77
86
|
const rows = summary.map((item) => {
|
|
78
87
|
const status = item.available ? (item.failures > 0 ? 'partial' : 'ok') : 'skipped';
|
|
88
|
+
const tones = metricTones(item, ranks, options.slo);
|
|
79
89
|
const base = [
|
|
80
|
-
formatConcurrency(item.concurrency),
|
|
81
|
-
item.model,
|
|
82
|
-
status,
|
|
83
|
-
item.attempted ? `${item.successes}/${item.attempted}` : '-',
|
|
84
|
-
formatPercent(item.successRate),
|
|
85
|
-
formatPercent(item.goodputRate),
|
|
86
|
-
|
|
87
|
-
formatMs(item.ttft.
|
|
88
|
-
formatMs(item.
|
|
89
|
-
formatMs(item.latency.
|
|
90
|
-
|
|
91
|
-
formatNumber(item.
|
|
92
|
-
formatNumber(item.
|
|
93
|
-
|
|
94
|
-
item.
|
|
90
|
+
cell(formatConcurrency(item.concurrency)),
|
|
91
|
+
cell(item.model, item.available ? null : 'muted'),
|
|
92
|
+
cell(status, statusTone(status)),
|
|
93
|
+
cell(item.attempted ? `${item.successes}/${item.attempted}` : '-', item.failures > 0 ? 'yellow' : item.available ? 'green' : 'muted'),
|
|
94
|
+
cell(formatPercent(item.successRate), percentTone(item.successRate, 0.95, 1)),
|
|
95
|
+
cell(formatPercent(item.goodputRate), percentTone(item.goodputRate, 0.8, 1)),
|
|
96
|
+
cell(formatNumber(item.goodputRps), tones.goodputRps),
|
|
97
|
+
cell(formatMs(item.ttft.p50), tones.ttft),
|
|
98
|
+
cell(formatMs(item.ttft.p95), tones.ttftP95),
|
|
99
|
+
cell(formatMs(item.latency.p50), tones.e2e),
|
|
100
|
+
cell(formatMs(item.latency.p95), tones.e2eP95),
|
|
101
|
+
cell(formatNumber(item.tokensPerSecond.avg), tones.userTps),
|
|
102
|
+
cell(formatNumber(item.outputTokenThroughput), tones.systemTps),
|
|
103
|
+
cell(formatNumber(item.rps), tones.rps),
|
|
104
|
+
cell(formatPercent(item.latency.cv), cvTone(item.latency.cv)),
|
|
105
|
+
cell(item.available ? '' : compactReason(item.skippedReason), item.available ? null : 'yellow')
|
|
95
106
|
];
|
|
96
107
|
|
|
97
108
|
if (mode === 'medium') {
|
|
@@ -101,68 +112,76 @@ export function renderSummaryTable(summary, options = {}) {
|
|
|
101
112
|
base[2],
|
|
102
113
|
base[3]
|
|
103
114
|
];
|
|
104
|
-
if (hasGoodput) medium.push(base[5]);
|
|
105
|
-
medium.push(base[
|
|
115
|
+
if (hasGoodput) medium.push(base[5], base[6]);
|
|
116
|
+
medium.push(base[7], base[9], base[10], base[11], base[12], base[14], base[15]);
|
|
106
117
|
return medium;
|
|
107
118
|
}
|
|
108
119
|
|
|
109
120
|
const wide = [base[0], base[1], base[2], base[3], base[4]];
|
|
110
|
-
if (hasGoodput) wide.push(base[5]);
|
|
121
|
+
if (hasGoodput) wide.push(base[5], base[6]);
|
|
111
122
|
wide.push(
|
|
112
|
-
base[6],
|
|
113
123
|
base[7],
|
|
114
124
|
base[8],
|
|
115
125
|
base[9],
|
|
116
|
-
formatMs(item.tpot.p50),
|
|
117
|
-
formatMs(item.tpot.p95),
|
|
118
126
|
base[10],
|
|
127
|
+
cell(formatMs(item.tpot.p50), tones.tpot),
|
|
128
|
+
cell(formatMs(item.tpot.p95), tones.tpotP95),
|
|
119
129
|
base[11],
|
|
120
130
|
base[12],
|
|
121
131
|
base[13],
|
|
122
|
-
base[14]
|
|
132
|
+
base[14],
|
|
133
|
+
base[15]
|
|
123
134
|
);
|
|
124
135
|
return wide;
|
|
125
136
|
});
|
|
126
137
|
|
|
127
|
-
const mediumHeaders = ['c', 'model', 'status', 'ok', 'good', 'ttft', 'e2e', 'e2e p95', 'user/s', 'sys/s', 'cv', 'note'];
|
|
128
|
-
const wideHeaders = ['c', 'model', 'status', 'ok/runs', 'succ', 'good', 'ttft', 'ttft p95', 'e2e', 'e2e p95', 'tpot', 'tpot p95', 'user t/s', 'sys t/s', 'rps', 'cv', 'note'];
|
|
138
|
+
const mediumHeaders = ['c', 'model', 'status', 'ok', 'good', 'good rps', 'ttft', 'e2e', 'e2e p95', 'user/s', 'sys/s', 'cv', 'note'];
|
|
139
|
+
const wideHeaders = ['c', 'model', 'status', 'ok/runs', 'succ', 'good', 'good rps', 'ttft', 'ttft p95', 'e2e', 'e2e p95', 'tpot', 'tpot p95', 'user t/s', 'sys t/s', 'rps', 'cv', 'note'];
|
|
129
140
|
const headers = mode === 'medium'
|
|
130
|
-
? (hasGoodput ? mediumHeaders : mediumHeaders.filter((header) => header !== 'good'))
|
|
131
|
-
: (hasGoodput ? wideHeaders : wideHeaders.filter((header) => header !== 'good'));
|
|
141
|
+
? (hasGoodput ? mediumHeaders : mediumHeaders.filter((header) => header !== 'good' && header !== 'good rps'))
|
|
142
|
+
: (hasGoodput ? wideHeaders : wideHeaders.filter((header) => header !== 'good' && header !== 'good rps'));
|
|
132
143
|
|
|
133
144
|
return renderTable(headers, rows, { ...options, maxCellWidth: mode === 'medium' ? 24 : 52 });
|
|
134
145
|
}
|
|
135
146
|
|
|
136
147
|
export function renderDetailTable(summary, options = {}) {
|
|
137
148
|
const mode = options.mode || 'wide';
|
|
149
|
+
const ranks = rankSummary(summary);
|
|
138
150
|
const rows = summary.map((item) => {
|
|
151
|
+
const tones = metricTones(item, ranks, options.slo);
|
|
139
152
|
const base = [
|
|
140
|
-
formatConcurrency(item.concurrency),
|
|
141
|
-
item.model,
|
|
142
|
-
formatNumber(item.promptTokens.avg, 0),
|
|
143
|
-
formatNumber(item.outputTokens.avg, 0),
|
|
144
|
-
formatNumber(item.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
item.
|
|
153
|
+
cell(formatConcurrency(item.concurrency)),
|
|
154
|
+
cell(item.model, item.available ? null : 'muted'),
|
|
155
|
+
cell(formatNumber(item.promptTokens.avg, 0)),
|
|
156
|
+
cell(formatNumber(item.outputTokens.avg, 0)),
|
|
157
|
+
cell(formatNumber(item.prefillTokensPerSecond?.avg), tones.prefillTps),
|
|
158
|
+
cell(formatNumber(item.decodeTokensPerSecond?.avg), tones.decodeTps),
|
|
159
|
+
cell(formatMs(item.secondChunk?.p50), tones.secondChunk),
|
|
160
|
+
cell(formatMs(item.chunkGap?.p95), tones.chunkGapP95),
|
|
161
|
+
cell(formatMs(item.latency.p99), tones.e2eP99),
|
|
162
|
+
cell(formatRangeMs(item.latency.ci95Low, item.latency.ci95High), cvTone(item.latency.cv)),
|
|
163
|
+
cell(formatPercent(item.repeatability), percentTone(item.repeatability, 0.5, 0.9)),
|
|
164
|
+
cell(item.description || '-', 'muted')
|
|
149
165
|
];
|
|
150
166
|
|
|
151
167
|
if (mode === 'medium') {
|
|
152
|
-
return base
|
|
168
|
+
return [base[0], base[1], base[2], base[3], base[4], base[5], base[7], base[8], base[10]];
|
|
153
169
|
}
|
|
154
170
|
|
|
155
171
|
return base;
|
|
156
172
|
});
|
|
157
173
|
|
|
158
174
|
const headers = mode === 'medium'
|
|
159
|
-
? ['c', 'model', 'in avg', 'out avg', 'decode
|
|
175
|
+
? ['c', 'model', 'in avg', 'out avg', 'prefill/s', 'decode/s', 'chunk p95', 'e2e p99', 'repeat']
|
|
160
176
|
: [
|
|
161
177
|
'c',
|
|
162
178
|
'model',
|
|
163
179
|
'in tok avg',
|
|
164
180
|
'out tok avg',
|
|
181
|
+
'prefill tok/s',
|
|
165
182
|
'decode tok/s',
|
|
183
|
+
'2nd chunk',
|
|
184
|
+
'chunk p95',
|
|
166
185
|
'e2e p99',
|
|
167
186
|
'e2e 95% ci',
|
|
168
187
|
'repeat',
|
|
@@ -176,19 +195,22 @@ export function renderCompactSummary(summary, options = {}) {
|
|
|
176
195
|
const width = options.width || 80;
|
|
177
196
|
const separator = options.ascii ? '-' : '─';
|
|
178
197
|
const lines = [];
|
|
198
|
+
const ranks = rankSummary(summary);
|
|
179
199
|
|
|
180
200
|
for (const item of summary) {
|
|
181
201
|
const status = item.available ? (item.failures > 0 ? 'partial' : 'ok') : 'skipped';
|
|
182
202
|
const title = `${item.model} c${formatConcurrency(item.concurrency)} ${status} ${item.attempted ? `${item.successes}/${item.attempted}` : '-'}`;
|
|
183
|
-
lines.push(truncate(title, width));
|
|
203
|
+
lines.push(toneText(truncate(title, width), statusTone(status), options));
|
|
184
204
|
|
|
185
205
|
if (item.available) {
|
|
186
|
-
lines.push(truncate(` TTFT ${formatMs(item.ttft.p50)} p95 ${formatMs(item.ttft.p95)} | E2E ${formatMs(item.latency.p50)} p95 ${formatMs(item.latency.p95)} p99 ${formatMs(item.latency.p99)}`, width));
|
|
187
206
|
const goodput = item.goodputRate == null ? '' : ` | good ${formatPercent(item.goodputRate)}`;
|
|
188
|
-
|
|
189
|
-
lines.push(truncate(`
|
|
207
|
+
const tones = metricTones(item, ranks, options.slo);
|
|
208
|
+
lines.push(toneText(truncate(` TTFT ${formatMs(item.ttft.p50)} p95 ${formatMs(item.ttft.p95)} | E2E ${formatMs(item.latency.p50)} p95 ${formatMs(item.latency.p95)} p99 ${formatMs(item.latency.p99)}`, width), worstTone(tones.ttft, tones.e2e, tones.e2eP95), options));
|
|
209
|
+
lines.push(toneText(truncate(` user ${formatNumber(item.tokensPerSecond.avg)} tok/s | system ${formatNumber(item.outputTokenThroughput)} tok/s | RPS ${formatNumber(item.rps)} | CV ${formatPercent(item.latency.cv)}${goodput}`, width), worstTone(tones.userTps, tones.systemTps, cvTone(item.latency.cv), percentTone(item.goodputRate, 0.8, 1)), options));
|
|
210
|
+
lines.push(toneText(truncate(` prefill ${formatNumber(item.prefillTokensPerSecond?.avg)} tok/s | TPOT ${formatMs(item.tpot.p50)} | chunk p95 ${formatMs(item.chunkGap?.p95)}`, width), worstTone(tones.prefillTps, tones.tpot, tones.chunkGapP95), options));
|
|
211
|
+
lines.push(toneText(truncate(` in/out ${formatNumber(item.promptTokens.avg, 0)}/${formatNumber(item.outputTokens.avg, 0)} tok avg | repeat ${formatPercent(item.repeatability)}`, width), percentTone(item.repeatability, 0.5, 0.9), options));
|
|
190
212
|
} else {
|
|
191
|
-
lines.push(truncate(` ${compactReason(item.skippedReason)}`, width));
|
|
213
|
+
lines.push(toneText(truncate(` ${compactReason(item.skippedReason)}`, width), 'yellow', options));
|
|
192
214
|
}
|
|
193
215
|
lines.push(separator.repeat(Math.min(width, 72)));
|
|
194
216
|
}
|
|
@@ -201,12 +223,15 @@ export function renderModelsTable(models, options = {}) {
|
|
|
201
223
|
const width = terminalWidth(options);
|
|
202
224
|
const compact = options.compact || width < 88;
|
|
203
225
|
if (compact) {
|
|
204
|
-
return models.map((model) =>
|
|
226
|
+
return models.map((model) => {
|
|
227
|
+
const status = model.available ? 'yes' : 'no';
|
|
228
|
+
return `${model.name} ${toneText(status, model.available ? 'green' : 'yellow', options)} ${compactReason(model.reason || model.description || '-')}`;
|
|
229
|
+
}).join('\n');
|
|
205
230
|
}
|
|
206
231
|
|
|
207
232
|
return renderTable(['model', 'available', 'description', 'quota'], models.map((model) => [
|
|
208
|
-
model.name,
|
|
209
|
-
model.available ? 'yes' : 'no',
|
|
233
|
+
cell(model.name),
|
|
234
|
+
cell(model.available ? 'yes' : 'no', model.available ? 'green' : 'yellow'),
|
|
210
235
|
model.description || '-',
|
|
211
236
|
compactReason(model.quota || model.reason || '-')
|
|
212
237
|
]), options);
|
|
@@ -270,13 +295,32 @@ function rule(left, join, right, horizontal, widths) {
|
|
|
270
295
|
return `${left}${widths.map((width) => horizontal.repeat(width + 2)).join(join)}${right}`;
|
|
271
296
|
}
|
|
272
297
|
|
|
273
|
-
function rowLine(row, widths, style, header = false) {
|
|
298
|
+
function rowLine(row, widths, style, header = false, options = {}) {
|
|
274
299
|
return `${style.vertical}${row.map((cell, index) => {
|
|
275
|
-
const
|
|
276
|
-
|
|
300
|
+
const normalized = header ? { text: String(cell).toUpperCase(), tone: 'header' } : normalizeCell(cell);
|
|
301
|
+
const value = normalized.text;
|
|
302
|
+
const padded = pad(value, widths[index], !header && isNumericCell(value));
|
|
303
|
+
return ` ${toneText(padded, normalized.tone, options)} `;
|
|
277
304
|
}).join(style.vertical)}${style.vertical}`;
|
|
278
305
|
}
|
|
279
306
|
|
|
307
|
+
function cell(text, tone = null) {
|
|
308
|
+
return { text, tone };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function normalizeCell(value) {
|
|
312
|
+
if (value && typeof value === 'object' && Object.hasOwn(value, 'text')) {
|
|
313
|
+
return {
|
|
314
|
+
text: formatCell(value.text),
|
|
315
|
+
tone: value.tone || null
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
text: formatCell(value),
|
|
320
|
+
tone: null
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
280
324
|
function formatCell(value) {
|
|
281
325
|
if (value == null) return '';
|
|
282
326
|
return String(value);
|
|
@@ -293,7 +337,7 @@ function isNumericCell(value) {
|
|
|
293
337
|
}
|
|
294
338
|
|
|
295
339
|
function visibleLength(value) {
|
|
296
|
-
return
|
|
340
|
+
return stripAnsi(value).length;
|
|
297
341
|
}
|
|
298
342
|
|
|
299
343
|
function compactReason(value) {
|
|
@@ -308,3 +352,128 @@ function truncate(value, width) {
|
|
|
308
352
|
if (width <= 1) return '…';
|
|
309
353
|
return `${text.slice(0, width - 1)}…`;
|
|
310
354
|
}
|
|
355
|
+
|
|
356
|
+
const TONES = {
|
|
357
|
+
header: ['\x1b[1m', '\x1b[0m'],
|
|
358
|
+
green: ['\x1b[32m', '\x1b[0m'],
|
|
359
|
+
yellow: ['\x1b[33m', '\x1b[0m'],
|
|
360
|
+
red: ['\x1b[31m', '\x1b[0m'],
|
|
361
|
+
muted: ['\x1b[2m', '\x1b[0m']
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
function toneText(text, tone, options = {}) {
|
|
365
|
+
if (!options.color || !tone || !TONES[tone]) return text;
|
|
366
|
+
const [open, close] = TONES[tone];
|
|
367
|
+
return `${open}${text}${close}`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function statusTone(status) {
|
|
371
|
+
if (status === 'ok') return 'green';
|
|
372
|
+
if (status === 'partial') return 'yellow';
|
|
373
|
+
if (status === 'skipped') return 'yellow';
|
|
374
|
+
return 'red';
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function percentTone(value, yellowAt, greenAt) {
|
|
378
|
+
if (value == null || !Number.isFinite(value)) return null;
|
|
379
|
+
if (value >= greenAt) return 'green';
|
|
380
|
+
if (value >= yellowAt) return 'yellow';
|
|
381
|
+
return 'red';
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function cvTone(value) {
|
|
385
|
+
if (value == null || !Number.isFinite(value)) return null;
|
|
386
|
+
if (value <= 0.1) return 'green';
|
|
387
|
+
if (value <= 0.25) return 'yellow';
|
|
388
|
+
return 'red';
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function thresholdTone(value, threshold) {
|
|
392
|
+
if (value == null || !Number.isFinite(value) || !Number.isFinite(threshold)) return null;
|
|
393
|
+
if (value <= threshold * 0.8) return 'green';
|
|
394
|
+
if (value <= threshold) return 'yellow';
|
|
395
|
+
return 'red';
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function rankSummary(summary) {
|
|
399
|
+
return {
|
|
400
|
+
ttft: collectMetric(summary, (item) => item.ttft?.p50),
|
|
401
|
+
ttftP95: collectMetric(summary, (item) => item.ttft?.p95),
|
|
402
|
+
e2e: collectMetric(summary, (item) => item.latency?.p50),
|
|
403
|
+
e2eP95: collectMetric(summary, (item) => item.latency?.p95),
|
|
404
|
+
e2eP99: collectMetric(summary, (item) => item.latency?.p99),
|
|
405
|
+
tpot: collectMetric(summary, (item) => item.tpot?.p50),
|
|
406
|
+
tpotP95: collectMetric(summary, (item) => item.tpot?.p95),
|
|
407
|
+
secondChunk: collectMetric(summary, (item) => item.secondChunk?.p50),
|
|
408
|
+
chunkGapP95: collectMetric(summary, (item) => item.chunkGap?.p95),
|
|
409
|
+
userTps: collectMetric(summary, (item) => item.tokensPerSecond?.avg),
|
|
410
|
+
systemTps: collectMetric(summary, (item) => item.outputTokenThroughput),
|
|
411
|
+
totalTps: collectMetric(summary, (item) => item.totalTokenThroughput),
|
|
412
|
+
rps: collectMetric(summary, (item) => item.rps),
|
|
413
|
+
goodputRps: collectMetric(summary, (item) => item.goodputRps),
|
|
414
|
+
decodeTps: collectMetric(summary, (item) => item.decodeTokensPerSecond?.avg),
|
|
415
|
+
prefillTps: collectMetric(summary, (item) => item.prefillTokensPerSecond?.avg)
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function collectMetric(summary, accessor) {
|
|
420
|
+
return summary.map(accessor).filter((value) => Number.isFinite(value));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function rankTone(value, values) {
|
|
424
|
+
if (value == null || !Number.isFinite(value) || values.length === 0) return null;
|
|
425
|
+
if (values.length === 1) return 'green';
|
|
426
|
+
const min = Math.min(...values);
|
|
427
|
+
const max = Math.max(...values);
|
|
428
|
+
if (max === min) return 'green';
|
|
429
|
+
const position = (value - min) / (max - min);
|
|
430
|
+
if (position >= 0.67) return 'green';
|
|
431
|
+
if (position >= 0.34) return 'yellow';
|
|
432
|
+
return 'red';
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function rankToneLower(value, values) {
|
|
436
|
+
if (value == null || !Number.isFinite(value) || values.length === 0) return null;
|
|
437
|
+
if (values.length === 1) return 'green';
|
|
438
|
+
const min = Math.min(...values);
|
|
439
|
+
const max = Math.max(...values);
|
|
440
|
+
if (max === min) return 'green';
|
|
441
|
+
const position = (max - value) / (max - min);
|
|
442
|
+
if (position >= 0.67) return 'green';
|
|
443
|
+
if (position >= 0.34) return 'yellow';
|
|
444
|
+
return 'red';
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function metricTones(item, ranks, slo = {}) {
|
|
448
|
+
return {
|
|
449
|
+
ttft: thresholdTone(item.ttft?.p50, slo.ttftMs) || rankToneLower(item.ttft?.p50, ranks.ttft),
|
|
450
|
+
ttftP95: thresholdTone(item.ttft?.p95, slo.ttftMs) || rankToneLower(item.ttft?.p95, ranks.ttftP95),
|
|
451
|
+
e2e: thresholdTone(item.latency?.p50, slo.e2eMs) || rankToneLower(item.latency?.p50, ranks.e2e),
|
|
452
|
+
e2eP95: thresholdTone(item.latency?.p95, slo.e2eMs) || rankToneLower(item.latency?.p95, ranks.e2eP95),
|
|
453
|
+
e2eP99: thresholdTone(item.latency?.p99, slo.e2eMs) || rankToneLower(item.latency?.p99, ranks.e2eP99),
|
|
454
|
+
tpot: thresholdTone(item.tpot?.p50, slo.tpotMs) || rankToneLower(item.tpot?.p50, ranks.tpot),
|
|
455
|
+
tpotP95: thresholdTone(item.tpot?.p95, slo.tpotMs) || rankToneLower(item.tpot?.p95, ranks.tpotP95),
|
|
456
|
+
secondChunk: rankToneLower(item.secondChunk?.p50, ranks.secondChunk),
|
|
457
|
+
chunkGapP95: rankToneLower(item.chunkGap?.p95, ranks.chunkGapP95),
|
|
458
|
+
userTps: rankTone(item.tokensPerSecond?.avg, ranks.userTps),
|
|
459
|
+
systemTps: rankTone(item.outputTokenThroughput, ranks.systemTps),
|
|
460
|
+
totalTps: rankTone(item.totalTokenThroughput, ranks.totalTps),
|
|
461
|
+
rps: rankTone(item.rps, ranks.rps),
|
|
462
|
+
goodputRps: goodputRpsTone(item, ranks.goodputRps),
|
|
463
|
+
decodeTps: rankTone(item.decodeTokensPerSecond?.avg, ranks.decodeTps),
|
|
464
|
+
prefillTps: rankTone(item.prefillTokensPerSecond?.avg, ranks.prefillTps)
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function worstTone(...tones) {
|
|
469
|
+
if (tones.includes('red')) return 'red';
|
|
470
|
+
if (tones.includes('yellow')) return 'yellow';
|
|
471
|
+
if (tones.includes('green')) return 'green';
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function goodputRpsTone(item, values) {
|
|
476
|
+
const passTone = percentTone(item.goodputRate, 0.8, 1);
|
|
477
|
+
if (passTone && passTone !== 'green') return passTone;
|
|
478
|
+
return rankTone(item.goodputRps, values) || passTone;
|
|
479
|
+
}
|