ui-svelte: add prompt processing histogram (#705)
UI Tests / run-tests (push) Successful in 5m46s
Close inactive issues / close-issues (push) Successful in 2m23s
Build Unified Docker Image / setup (push) Successful in 2s
Build Containers / build-and-push (cuda) (push) Failing after 11s
Build Containers / build-and-push (cuda13) (push) Failing after 11s
Build Containers / build-and-push (intel) (push) Failing after 10s
Build Containers / build-and-push (musa) (push) Failing after 10s
Build Containers / build-and-push (rocm) (push) Failing after 13s
Build Containers / build-and-push (vulkan) (push) Failing after 25s
Build Unified Docker Image / build (push) Failing after 10s
Build Containers / build-and-push (cpu) (push) Failing after 2m44s
Build Containers / delete-untagged-containers (push) Has been skipped

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 totalInputTokens = $metrics.reduce((sum, m) => sum + m.input_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
.filter((m) => m.tokens_per_second > 0)
.map((m) => m.tokens_per_second);
const promptPerSecond = $metrics.filter((m) => m.prompt_per_second > 0).map((m) => m.prompt_per_second);
const histogramData = tokensPerSecond.length > 0
? calculateHistogramData(tokensPerSecond, { minBins: 20, maxBins: 80, binScaling: 3 })
: null;
const tokensPerSecond = $metrics.filter((m) => m.tokens_per_second > 0).map((m) => m.tokens_per_second);
const promptHistogramData =
promptPerSecond.length > 0 ? calculateHistogramData(promptPerSecond) : null;
const genHistogramData =
tokensPerSecond.length > 0 ? calculateHistogramData(tokensPerSecond) : null;
return {
totalRequests,
totalInputTokens,
totalOutputTokens,
totalCacheTokens,
inFlightRequests: $inFlightRequests,
histogramData,
promptHistogramData,
genHistogramData,
};
});
</script>
<div class="card">
<div class="card relative p-3">
<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"
onclick={() => $histogramCollapsed = !$histogramCollapsed}
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)}
title={$histogramCollapsed ? "Show histograms" : "Hide histograms"}
>
<svg
class="w-3 h-3 transition-transform"
style="transform: rotate({$histogramCollapsed ? -90 : 0}deg)"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M4.5 6l3.5 4 3.5-4H4.5z" />
</svg>
Tokens/sec Distribution
{#if $histogramCollapsed}
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M4.5 6l3.5 4 3.5-4H4.5z" />
</svg>
{:else}
<svg class="w-3 h-3" viewBox="0 0 16 16" fill="currentColor">
<path d="M3.5 3.5l9 9M12.5 3.5l-9 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" />
</svg>
{/if}
</button>
{#if !$histogramCollapsed}
{#if stats.histogramData}
<TokenHistogram data={stats.histogramData} />
{:else}
<div class="px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400">
No token speed data yet
<div class="flex flex-col sm:flex-row gap-6 mb-3">
<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">Prompt Processing</div>
{#if stats.promptHistogramData}
<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>
{/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}
<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">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">Generated</div>
<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.inFlightRequests)}</span> waiting
</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">
<span class="font-semibold">{nf.format(stats.totalInputTokens)}</span> tokens
</div>
+7 -1
View File
@@ -50,7 +50,7 @@
<a
href="/"
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
</a>
@@ -59,6 +59,8 @@
use:link
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:underline={isActive("/models", $currentRoute)}
class:underline-offset-4={isActive("/models", $currentRoute)}
>
Models
</a>
@@ -67,6 +69,8 @@
use:link
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:underline={isActive("/activity", $currentRoute)}
class:underline-offset-4={isActive("/activity", $currentRoute)}
>
Activity
</a>
@@ -75,6 +79,8 @@
use:link
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:underline={isActive("/logs", $currentRoute)}
class:underline-offset-4={isActive("/logs", $currentRoute)}
>
Logs
</a>
+40 -8
View File
@@ -1,10 +1,18 @@
<script lang="ts">
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 padding = { top: 5, right: 45, bottom: 15, left: 45 };
const height = 250;
const padding = { top: 30, right: 20, bottom: 40, left: 75 };
const viewBoxWidth = 1200;
const chartWidth = viewBoxWidth - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
@@ -31,6 +39,24 @@
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 -->
<line
x1={padding.left}
@@ -57,9 +83,9 @@
height={barHeight}
fill="currentColor"
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>
{/each}
@@ -101,13 +127,19 @@
/>
<!-- 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)}
</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)}
</text>
</svg>
</div>
+14 -10
View File
@@ -52,14 +52,14 @@ describe("calculateHistogramData", () => {
const values = Array.from({ length: 100 }, (_, i) => i);
const result = calculateHistogramData(values);
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);
expect(binSum).toBe(100);
});
it("places values in correct bins", () => {
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!.bins.length).toBe(3);
expect(result!.bins.reduce((s, b) => s + b, 0)).toBe(8);
@@ -114,27 +114,31 @@ describe("calculateHistogramData", () => {
describe("bin count adaptation", () => {
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);
expect(result!.bins.length).toBe(10);
expect(result!.bins.length).toBe(5);
});
it("scales bins with dataset size", () => {
// n=100: sturges=8
const values = Array.from({ length: 100 }, (_, i) => i);
const result = calculateHistogramData(values);
expect(result!.bins.length).toBe(20);
expect(result!.bins.length).toBe(8);
});
it("caps bins at maximum", () => {
const values = Array.from({ length: 200 }, (_, i) => i);
const result = calculateHistogramData(values);
expect(result!.bins.length).toBe(30);
// n=1000: sturges=11, clamped down to maxBins=10
const values = Array.from({ length: 1000 }, (_, i) => i);
const result = calculateHistogramData(values, { minBins: 5, maxBins: 10 });
expect(result!.bins.length).toBe(10);
});
it("respects custom options", () => {
// n=100: sturges=8, within [minBins=5, maxBins=10]
const values = Array.from({ length: 100 }, (_, i) => i);
const result = calculateHistogramData(values, { minBins: 5, maxBins: 10, binScaling: 2 });
expect(result!.bins.length).toBe(10);
const result = calculateHistogramData(values, { minBins: 5, maxBins: 10 });
expect(result!.bins.length).toBe(8);
});
});
+5 -6
View File
@@ -3,13 +3,11 @@ import type { HistogramData } from "./types";
export interface HistogramOptions {
minBins?: number;
maxBins?: number;
binScaling?: number;
}
const DEFAULT_OPTIONS: HistogramOptions = {
minBins: 10,
maxBins: 30,
binScaling: 5,
minBins: 5,
maxBins: 20,
};
function percentile(sorted: number[], p: number): number {
@@ -50,8 +48,9 @@ export function calculateHistogramData(
};
}
const { minBins = 10, maxBins = 30, binScaling = 5 } = options;
const binCount = Math.min(maxBins, Math.max(minBins, Math.floor(values.length / binScaling)));
const { minBins = 5, maxBins = 20 } = options;
const sturges = Math.ceil(Math.log2(values.length)) + 1;
const binCount = Math.min(maxBins, Math.max(minBins, sturges));
const binSize = (max - min) / binCount;
const bins = new Array(binCount).fill(0);
-1
View File
@@ -63,7 +63,6 @@
</script>
<div class="p-2">
<h1 class="text-2xl font-bold">Activity</h1>
<div class="mt-4 mb-4">
<ActivityStats />
</div>