ui-svelte: add prompt processing histogram (#705)

Activities page shows histograms for prompt processing and token generation times. 

Fix: #691
Fix: #703
This commit is contained in:
Benson Wong
2026-04-25 16:13:07 -07:00
committed by GitHub
parent 3cd7837b1f
commit ce28485be2
6 changed files with 117 additions and 52 deletions
+51 -26
View File
@@ -11,57 +11,82 @@
const totalRequests = $metrics.length; const totalRequests = $metrics.length;
const totalInputTokens = $metrics.reduce((sum, m) => sum + m.input_tokens, 0); const totalInputTokens = $metrics.reduce((sum, m) => sum + m.input_tokens, 0);
const totalOutputTokens = $metrics.reduce((sum, m) => sum + m.output_tokens, 0); const totalOutputTokens = $metrics.reduce((sum, m) => sum + m.output_tokens, 0);
const totalCacheTokens = $metrics.reduce((sum, m) => sum + m.cache_tokens, 0);
const tokensPerSecond = $metrics const promptPerSecond = $metrics.filter((m) => m.prompt_per_second > 0).map((m) => m.prompt_per_second);
.filter((m) => m.tokens_per_second > 0)
.map((m) => m.tokens_per_second);
const histogramData = tokensPerSecond.length > 0 const tokensPerSecond = $metrics.filter((m) => m.tokens_per_second > 0).map((m) => m.tokens_per_second);
? calculateHistogramData(tokensPerSecond, { minBins: 20, maxBins: 80, binScaling: 3 })
: null; const promptHistogramData =
promptPerSecond.length > 0 ? calculateHistogramData(promptPerSecond) : null;
const genHistogramData =
tokensPerSecond.length > 0 ? calculateHistogramData(tokensPerSecond) : null;
return { return {
totalRequests, totalRequests,
totalInputTokens, totalInputTokens,
totalOutputTokens, totalOutputTokens,
totalCacheTokens,
inFlightRequests: $inFlightRequests, inFlightRequests: $inFlightRequests,
histogramData, promptHistogramData,
genHistogramData,
}; };
}); });
</script> </script>
<div class="card"> <div class="card relative p-3">
<button <button
class="flex items-center gap-1 px-4 pt-3 text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors" class="absolute top-2 right-2 w-6 h-6 flex items-center justify-center rounded-full border border-gray-300 dark:border-gray-600 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:border-gray-400 dark:hover:border-gray-400 transition-colors"
onclick={() => $histogramCollapsed = !$histogramCollapsed} onclick={() => ($histogramCollapsed = !$histogramCollapsed)}
title={$histogramCollapsed ? "Show histograms" : "Hide histograms"}
> >
<svg {#if $histogramCollapsed}
class="w-3 h-3 transition-transform" <svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
style="transform: rotate({$histogramCollapsed ? -90 : 0}deg)" <path d="M4.5 6l3.5 4 3.5-4H4.5z" />
viewBox="0 0 16 16" </svg>
fill="currentColor" {:else}
> <svg class="w-3 h-3" viewBox="0 0 16 16" fill="currentColor">
<path d="M4.5 6l3.5 4 3.5-4H4.5z" /> <path d="M3.5 3.5l9 9M12.5 3.5l-9 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" />
</svg> </svg>
Tokens/sec Distribution {/if}
</button> </button>
{#if !$histogramCollapsed} {#if !$histogramCollapsed}
{#if stats.histogramData} <div class="flex flex-col sm:flex-row gap-6 mb-3">
<TokenHistogram data={stats.histogramData} /> <div class="w-full sm:w-1/2 min-w-0">
{:else} <div class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Prompt Processing</div>
<div class="px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400"> {#if stats.promptHistogramData}
No token speed data yet <TokenHistogram
data={stats.promptHistogramData}
unit="prompt tokens/sec"
colorClass="text-amber-500 dark:text-amber-400"
/>
{:else}
<div class="py-6 text-center text-sm text-gray-500 dark:text-gray-400">No prompt speed data yet</div>
{/if}
</div> </div>
{/if} <div class="w-full sm:w-1/2 min-w-0">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Token Generation</div>
{#if stats.genHistogramData}
<TokenHistogram data={stats.genHistogramData} unit="tokens/sec" />
{:else}
<div class="py-6 text-center text-sm text-gray-500 dark:text-gray-400">No generation speed data yet</div>
{/if}
</div>
</div>
{/if} {/if}
<div class="grid grid-cols-3 gap-x-6 gap-y-1 px-4 pb-3 text-sm"> <div class="grid grid-cols-4 gap-x-6 gap-y-1 text-sm">
<div class="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">Requests</div> <div class="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">Requests</div>
<div class="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">Cached</div>
<div class="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">Processed</div> <div class="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">Processed</div>
<div class="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">Generated</div> <div class="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">Generated</div>
<div class="text-sm text-gray-700 dark:text-gray-300"> <div class="text-sm text-gray-700 dark:text-gray-300">
<span class="font-semibold">{nf.format(stats.totalRequests)}</span> completed, <span class="font-semibold">{nf.format(stats.totalRequests)}</span> completed,
<span class="font-semibold">{nf.format(stats.inFlightRequests)}</span> waiting <span class="font-semibold">{nf.format(stats.inFlightRequests)}</span> waiting
</div> </div>
<div class="text-sm text-gray-700 dark:text-gray-300">
<span class="font-semibold">{nf.format(stats.totalCacheTokens)}</span> tokens
</div>
<div class="text-sm text-gray-700 dark:text-gray-300"> <div class="text-sm text-gray-700 dark:text-gray-300">
<span class="font-semibold">{nf.format(stats.totalInputTokens)}</span> tokens <span class="font-semibold">{nf.format(stats.totalInputTokens)}</span> tokens
</div> </div>
+7 -1
View File
@@ -50,7 +50,7 @@
<a <a
href="/" href="/"
use:link use:link
class="p-1 whitespace-nowrap {isActive('/', $currentRoute) ? 'font-semibold' : ''} {$playgroundActivity ? 'activity-link' : 'text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100'}" class="p-1 whitespace-nowrap {isActive('/', $currentRoute) ? 'font-semibold underline underline-offset-4' : ''} {$playgroundActivity ? 'activity-link' : 'text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100'}"
> >
Playground Playground
</a> </a>
@@ -59,6 +59,8 @@
use:link use:link
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap" class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
class:font-semibold={isActive("/models", $currentRoute)} class:font-semibold={isActive("/models", $currentRoute)}
class:underline={isActive("/models", $currentRoute)}
class:underline-offset-4={isActive("/models", $currentRoute)}
> >
Models Models
</a> </a>
@@ -67,6 +69,8 @@
use:link use:link
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap" class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
class:font-semibold={isActive("/activity", $currentRoute)} class:font-semibold={isActive("/activity", $currentRoute)}
class:underline={isActive("/activity", $currentRoute)}
class:underline-offset-4={isActive("/activity", $currentRoute)}
> >
Activity Activity
</a> </a>
@@ -75,6 +79,8 @@
use:link use:link
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap" class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
class:font-semibold={isActive("/logs", $currentRoute)} class:font-semibold={isActive("/logs", $currentRoute)}
class:underline={isActive("/logs", $currentRoute)}
class:underline-offset-4={isActive("/logs", $currentRoute)}
> >
Logs Logs
</a> </a>
+40 -8
View File
@@ -1,10 +1,18 @@
<script lang="ts"> <script lang="ts">
import type { HistogramData } from "../lib/types"; import type { HistogramData } from "../lib/types";
let { data }: { data: HistogramData } = $props(); let {
data,
unit = "tokens/sec",
colorClass = "text-blue-500 dark:text-blue-400",
}: {
data: HistogramData;
unit?: string;
colorClass?: string;
} = $props();
const height = 55; const height = 250;
const padding = { top: 5, right: 45, bottom: 15, left: 45 }; const padding = { top: 30, right: 20, bottom: 40, left: 75 };
const viewBoxWidth = 1200; const viewBoxWidth = 1200;
const chartWidth = viewBoxWidth - padding.left - padding.right; const chartWidth = viewBoxWidth - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom; const chartHeight = height - padding.top - padding.bottom;
@@ -31,6 +39,24 @@
opacity="0.3" opacity="0.3"
/> />
<!-- Y-axis ticks and labels -->
{#each [0, 0.5, 1] as fraction}
{@const tickCount = Math.round(maxCount * fraction)}
{@const tickY = height - padding.bottom - fraction * chartHeight}
<line
x1={padding.left - 8}
y1={tickY}
x2={padding.left}
y2={tickY}
stroke="currentColor"
stroke-width="1"
opacity="0.4"
/>
<text x={padding.left - 10} y={tickY + 10} font-size="26" fill="currentColor" opacity="0.8" text-anchor="end">
{tickCount}
</text>
{/each}
<!-- X-axis --> <!-- X-axis -->
<line <line
x1={padding.left} x1={padding.left}
@@ -57,9 +83,9 @@
height={barHeight} height={barHeight}
fill="currentColor" fill="currentColor"
opacity="0.6" opacity="0.6"
class="text-blue-500 dark:text-blue-400 hover:opacity-90 transition-opacity cursor-pointer" class="{colorClass} hover:opacity-90 transition-opacity cursor-pointer"
/> />
<title>{`${binStart.toFixed(1)} - ${binEnd.toFixed(1)} tokens/sec\nCount: ${count}`}</title> <title>{`${binStart.toFixed(1)} - ${binEnd.toFixed(1)} ${unit}\nCount: ${count}`}</title>
</g> </g>
{/each} {/each}
@@ -101,13 +127,19 @@
/> />
<!-- X-axis labels --> <!-- X-axis labels -->
<text x={padding.left} y={height - 5} font-size="10" fill="currentColor" opacity="0.6" text-anchor="start"> <text x={padding.left} y={height - 8} font-size="26" fill="currentColor" opacity="0.8" text-anchor="start">
{data.min.toFixed(1)} {data.min.toFixed(1)}
</text> </text>
<text x={viewBoxWidth - padding.right} y={height - 5} font-size="10" fill="currentColor" opacity="0.6" text-anchor="end"> <text
x={viewBoxWidth - padding.right}
y={height - 8}
font-size="26"
fill="currentColor"
opacity="0.8"
text-anchor="end"
>
{data.max.toFixed(1)} {data.max.toFixed(1)}
</text> </text>
</svg> </svg>
</div> </div>
+14 -10
View File
@@ -52,14 +52,14 @@ describe("calculateHistogramData", () => {
const values = Array.from({ length: 100 }, (_, i) => i); const values = Array.from({ length: 100 }, (_, i) => i);
const result = calculateHistogramData(values); const result = calculateHistogramData(values);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result!.bins.length).toBe(20); expect(result!.bins.length).toBe(8);
const binSum = result!.bins.reduce((s, b) => s + b, 0); const binSum = result!.bins.reduce((s, b) => s + b, 0);
expect(binSum).toBe(100); expect(binSum).toBe(100);
}); });
it("places values in correct bins", () => { it("places values in correct bins", () => {
const values = [1, 1, 1, 5, 5, 9, 9, 9]; const values = [1, 1, 1, 5, 5, 9, 9, 9];
const result = calculateHistogramData(values, { minBins: 3, maxBins: 3, binScaling: 1 }); const result = calculateHistogramData(values, { minBins: 3, maxBins: 3 });
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result!.bins.length).toBe(3); expect(result!.bins.length).toBe(3);
expect(result!.bins.reduce((s, b) => s + b, 0)).toBe(8); expect(result!.bins.reduce((s, b) => s + b, 0)).toBe(8);
@@ -114,27 +114,31 @@ describe("calculateHistogramData", () => {
describe("bin count adaptation", () => { describe("bin count adaptation", () => {
it("uses minimum bins for small datasets", () => { it("uses minimum bins for small datasets", () => {
const values = Array.from({ length: 20 }, (_, i) => i); // n=8: sturges=4, clamped up to minBins=5
const values = Array.from({ length: 8 }, (_, i) => i);
const result = calculateHistogramData(values); const result = calculateHistogramData(values);
expect(result!.bins.length).toBe(10); expect(result!.bins.length).toBe(5);
}); });
it("scales bins with dataset size", () => { it("scales bins with dataset size", () => {
// n=100: sturges=8
const values = Array.from({ length: 100 }, (_, i) => i); const values = Array.from({ length: 100 }, (_, i) => i);
const result = calculateHistogramData(values); const result = calculateHistogramData(values);
expect(result!.bins.length).toBe(20); expect(result!.bins.length).toBe(8);
}); });
it("caps bins at maximum", () => { it("caps bins at maximum", () => {
const values = Array.from({ length: 200 }, (_, i) => i); // n=1000: sturges=11, clamped down to maxBins=10
const result = calculateHistogramData(values); const values = Array.from({ length: 1000 }, (_, i) => i);
expect(result!.bins.length).toBe(30); const result = calculateHistogramData(values, { minBins: 5, maxBins: 10 });
expect(result!.bins.length).toBe(10);
}); });
it("respects custom options", () => { it("respects custom options", () => {
// n=100: sturges=8, within [minBins=5, maxBins=10]
const values = Array.from({ length: 100 }, (_, i) => i); const values = Array.from({ length: 100 }, (_, i) => i);
const result = calculateHistogramData(values, { minBins: 5, maxBins: 10, binScaling: 2 }); const result = calculateHistogramData(values, { minBins: 5, maxBins: 10 });
expect(result!.bins.length).toBe(10); expect(result!.bins.length).toBe(8);
}); });
}); });
+5 -6
View File
@@ -3,13 +3,11 @@ import type { HistogramData } from "./types";
export interface HistogramOptions { export interface HistogramOptions {
minBins?: number; minBins?: number;
maxBins?: number; maxBins?: number;
binScaling?: number;
} }
const DEFAULT_OPTIONS: HistogramOptions = { const DEFAULT_OPTIONS: HistogramOptions = {
minBins: 10, minBins: 5,
maxBins: 30, maxBins: 20,
binScaling: 5,
}; };
function percentile(sorted: number[], p: number): number { function percentile(sorted: number[], p: number): number {
@@ -50,8 +48,9 @@ export function calculateHistogramData(
}; };
} }
const { minBins = 10, maxBins = 30, binScaling = 5 } = options; const { minBins = 5, maxBins = 20 } = options;
const binCount = Math.min(maxBins, Math.max(minBins, Math.floor(values.length / binScaling))); const sturges = Math.ceil(Math.log2(values.length)) + 1;
const binCount = Math.min(maxBins, Math.max(minBins, sturges));
const binSize = (max - min) / binCount; const binSize = (max - min) / binCount;
const bins = new Array(binCount).fill(0); const bins = new Array(binCount).fill(0);
-1
View File
@@ -63,7 +63,6 @@
</script> </script>
<div class="p-2"> <div class="p-2">
<h1 class="text-2xl font-bold">Activity</h1>
<div class="mt-4 mb-4"> <div class="mt-4 mb-4">
<ActivityStats /> <ActivityStats />
</div> </div>