mirror of
https://github.com/mostlygeek/llama-swap.git
synced 2026-06-09 06:46:34 +02:00
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
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user