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:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user