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/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 stringRows = rows.map((row) => row.map((cell) => truncate(formatCell(cell), maxCellWidth)));
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), ...stringRows.map((row) => row[index] ?? '')];
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 = stringRows.map((row) => rowLine(row, widths, style));
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
- formatMs(item.ttft.p50),
87
- formatMs(item.ttft.p95),
88
- formatMs(item.latency.p50),
89
- formatMs(item.latency.p95),
90
- formatNumber(item.tokensPerSecond.avg),
91
- formatNumber(item.outputTokenThroughput),
92
- formatNumber(item.rps),
93
- formatPercent(item.latency.cv),
94
- item.available ? '' : compactReason(item.skippedReason)
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[6], base[8], base[9], base[10], base[11], base[13], base[14]);
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.decodeTokensPerSecond.avg),
145
- formatMs(item.latency.p99),
146
- formatRangeMs(item.latency.ci95Low, item.latency.ci95High),
147
- formatPercent(item.repeatability),
148
- item.description || '-'
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.slice(0, 8);
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 t/s', 'e2e p99', 'e2e 95% ci', 'repeat']
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
- lines.push(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));
189
- lines.push(truncate(` in/out ${formatNumber(item.promptTokens.avg, 0)}/${formatNumber(item.outputTokens.avg, 0)} tok avg | TPOT ${formatMs(item.tpot.p50)} | repeat ${formatPercent(item.repeatability)}`, width));
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) => `${model.name} ${model.available ? 'yes' : 'no'} ${compactReason(model.reason || model.description || '-')}`).join('\n');
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 value = header ? String(cell).toUpperCase() : formatCell(cell);
276
- return ` ${pad(value, widths[index], !header && isNumericCell(value))} `;
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 String(value).length;
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
+ }