diff --git a/docs/data-pipeline.md b/docs/data-pipeline.md index 176acc32c..80f27f043 100644 --- a/docs/data-pipeline.md +++ b/docs/data-pipeline.md @@ -192,6 +192,14 @@ Summing on an exact `start_ns` emitted one point per component per tick, each ho Every summed series is therefore evaluated by `sumOntoGrid` on one canonical grid at the blob's native scrape cadence (`canonicalTickNs`, the median gap of the best-sampled series), with each component holding its last sample between its own scrapes and contributing only inside its observed window. The lattice is anchored at `t=0` and shared by every metric, so the pairs that get divided or added downstream (hits/queries, used/total, running+waiting) land on identical `t` values — anchoring each metric at its own first sample instead silently emptied the prefix-cache-hit-rate chart, because `sglang:cached_tokens` starts ~0.18 s off the grid `sglang:prompt_tokens` starts on. +For vLLM builds that expose `prompt_tokens_cached_by_source`, the prompt-token +breakdown combines only `local_compute` from the older logical +`prompt_tokens_by_source` metric with the new physical cached-token buckets. +The logical `local_cache_hit` and `external_kv_transfer` series are replaced, +not added, so HBM, CPU, NVMe, and connector-defined cache tiers do not double +count the same cached tokens. Older vLLM builds retain the logical breakdown, +and SGLang keeps its `cached_tokens{cache_source=...}` mapping. + Mirrored endpoints are collapsed here too, on a **relative** tolerance rather than the gauge path's absolute one — a throughput mean is O(10⁵), so an absolute threshold would never fire and every mirror would be counted twice. This is a correction as well as a de-duplication: the two-API-server vLLM rows were double-counting their queue depth and token totals exactly 2× before v14. Nothing groups on an exact `start_ns` any more; `aggregateByStart` was removed in v14. diff --git a/packages/app/cypress/component/agentic-token-source-chart.cy.tsx b/packages/app/cypress/component/agentic-token-source-chart.cy.tsx new file mode 100644 index 000000000..19f0686b7 --- /dev/null +++ b/packages/app/cypress/component/agentic-token-source-chart.cy.tsx @@ -0,0 +1,45 @@ +import { PathnameContext } from 'next/dist/shared/lib/hooks-client-context.shared-runtime'; + +import { StackedAreaChart } from '@/components/inference/agentic-point/time-series-chart'; + +const sourceSeries = { + local_compute: [{ t: 0, value: 300 }], + 'cache hit (HBM)': [{ t: 0, value: 400 }], + 'cache hit (CPU offload)': [{ t: 0, value: 200 }], + 'cache hit (NVMe offload)': [{ t: 0, value: 100 }], + 'cache hit (weka)': [{ t: 0, value: 50 }], +}; + +function mountChart(pathname: string) { + cy.mount( + + + , + ); +} + +describe('Agentic prompt-token source chart', () => { + it('labels and colors each physical vLLM cache tier', () => { + mountChart('/inference/agentic/421'); + + cy.contains('Prefill').should('be.visible'); + cy.contains('HBM Cache Hit').should('be.visible'); + cy.contains('CPU Offload Cache Hit').should('be.visible'); + cy.contains('NVMe Offload Cache Hit').should('be.visible'); + cy.contains('Cache Hit (weka)').should('be.visible'); + cy.get('path[fill="#3b82f6"]').should('exist'); + cy.get('path[fill="#22c55e"]').should('exist'); + cy.get('path[fill="#a855f7"]').should('exist'); + }); + + it('renders tier labels in Simplified Chinese on the /zh route', () => { + mountChart('/zh/inference/agentic/421'); + + cy.contains('HBM cache 命中').should('be.visible'); + cy.contains('CPU offload cache 命中').should('be.visible'); + cy.contains('NVMe offload cache 命中').should('be.visible'); + cy.contains('weka cache 命中').should('be.visible'); + cy.contains('prefill token 占比').should('be.visible'); + cy.contains('% of prefill tokens').should('not.exist'); + }); +}); diff --git a/packages/app/src/app/api/v1/benchmark-siblings/route.ts b/packages/app/src/app/api/v1/benchmark-siblings/route.ts index a5a5ce5ec..e80d9a493 100644 --- a/packages/app/src/app/api/v1/benchmark-siblings/route.ts +++ b/packages/app/src/app/api/v1/benchmark-siblings/route.ts @@ -13,9 +13,9 @@ export const dynamic = 'force-dynamic'; const getCachedSiblings = cachedQuery( (id: number): Promise => getBenchmarkSiblings(getDb(), id), - // v3 adds DCP/PCP. Roll the blob namespace so cached sibling payloads gain - // the complete parallelism mapping used by chart point labels. - 'benchmark-siblings-v3', + // v4 adds the physical KV tier and P90 frontier coordinates. Roll the blob + // namespace so cached sibling payloads can label and mark AgentX points. + 'benchmark-siblings-v4', ); /** diff --git a/packages/app/src/components/inference/agentic-point/server-metric-cards.tsx b/packages/app/src/components/inference/agentic-point/server-metric-cards.tsx index 545298f41..580a0e9bb 100644 --- a/packages/app/src/components/inference/agentic-point/server-metric-cards.tsx +++ b/packages/app/src/components/inference/agentic-point/server-metric-cards.tsx @@ -6,6 +6,7 @@ import type { RequestChartData } from '@/hooks/api/use-request-chart-data'; import type { MetricSourceDescriptor, QueueDepthPoint } from '@/hooks/api/use-trace-server-metrics'; import { SegmentedToggle, type SegmentedToggleOption } from '@/components/ui/segmented-toggle'; import { track } from '@/lib/analytics'; +import { useLocale } from '@/lib/use-locale'; import { CHART_SIZES, ChartEmpty, ChartSkeleton } from './chart-shared'; import { ExpandableChart } from './expandable-chart'; @@ -386,9 +387,12 @@ export function ThroughputCard({ } export function PromptTokenSourceCard({ sliced }: { sliced: SlicedServerSeries }) { + const locale = useLocale(); return ( { const size = expanded ? CHART_SIZES.expanded : CHART_SIZES.inline; if (!sliced) return ; diff --git a/packages/app/src/components/inference/agentic-point/sibling-nav.test.ts b/packages/app/src/components/inference/agentic-point/sibling-nav.test.ts index c69dc5b35..0bd425f0b 100644 --- a/packages/app/src/components/inference/agentic-point/sibling-nav.test.ts +++ b/packages/app/src/components/inference/agentic-point/sibling-nav.test.ts @@ -2,13 +2,14 @@ import { describe, expect, it } from 'vitest'; import type { BenchmarkSibling } from '@/hooks/api/use-benchmark-siblings'; -import { chipLabel } from './sibling-nav'; +import { chipLabel, dualParetoSiblingIds } from './sibling-nav'; function sibling(overrides: Partial = {}): BenchmarkSibling { return { id: 437312, conc: 2, offload_mode: 'off', + kv_offloading: 'none', decode_tp: 0, decode_ep: 0, decode_pp: 1, @@ -28,6 +29,8 @@ function sibling(overrides: Partial = {}): BenchmarkSibling { disagg: false, is_multinode: true, tput_per_gpu: 348.31, + p90_intvty: 40, + p90_ttft: 2, total_requests: 169, is_current: true, has_trace: true, @@ -74,4 +77,45 @@ describe('chipLabel', () => { ), ).toBe('1xTP8PP2/DCP2/PCP4+1xTP8/DCP8 • c=2'); }); + + it('names each physical offload tier without the legacy off=ON suffix', () => { + expect(chipLabel(sibling({ kv_offloading: 'dram', offload_mode: 'on' }))).toBe( + 'TP8PP2 • c=2 • DRAM', + ); + expect(chipLabel(sibling({ kv_offloading: 'nvme', offload_mode: 'on' }))).toBe( + 'TP8PP2 • c=2 • NVMe', + ); + expect(chipLabel(sibling({ kv_offloading: 'dram+nvme', offload_mode: 'on' }))).toBe( + 'TP8PP2 • c=2 • DRAM+NVMe', + ); + }); + + it('falls back to a readable legacy label when an enabled point has no tier metadata', () => { + expect(chipLabel(sibling({ kv_offloading: null, offload_mode: 'on' }))).toBe( + 'TP8PP2 • c=2 • Offload', + ); + }); +}); + +describe('dualParetoSiblingIds', () => { + it('intersects the P90 interactivity and TTFT frontiers using throughput per GPU', () => { + const rows = [ + sibling({ id: 1, p90_intvty: 100, p90_ttft: 1, tput_per_gpu: 100 }), + sibling({ id: 2, p90_intvty: 80, p90_ttft: 2, tput_per_gpu: 200 }), + sibling({ id: 3, p90_intvty: 50, p90_ttft: 3, tput_per_gpu: 50 }), + sibling({ id: 4, p90_intvty: 120, p90_ttft: 4, tput_per_gpu: 150 }), + ]; + + expect([...dualParetoSiblingIds(rows)]).toEqual([2]); + }); + + it('excludes points missing any frontier coordinate', () => { + const rows = [ + sibling({ id: 1, p90_intvty: 100, p90_ttft: 1, tput_per_gpu: 200 }), + sibling({ id: 2, p90_intvty: null, p90_ttft: 0.5, tput_per_gpu: 100 }), + sibling({ id: 3, p90_intvty: 200, p90_ttft: null, tput_per_gpu: 100 }), + ]; + + expect([...dualParetoSiblingIds(rows)]).toEqual([1]); + }); }); diff --git a/packages/app/src/components/inference/agentic-point/sibling-nav.tsx b/packages/app/src/components/inference/agentic-point/sibling-nav.tsx index e417790aa..5e6d43be8 100644 --- a/packages/app/src/components/inference/agentic-point/sibling-nav.tsx +++ b/packages/app/src/components/inference/agentic-point/sibling-nav.tsx @@ -9,6 +9,7 @@ import { meaningfulParallelismSize, parallelismLabel, } from '@/components/inference/utils/parallelism-label'; +import { offloadTypeLabel } from '@/components/inference/utils/runtime-metadata-labels'; import { Select, SelectContent, @@ -17,6 +18,7 @@ import { SelectValue, } from '@/components/ui/select'; import { track } from '@/lib/analytics'; +import { isFrontierEligible, paretoFrontUpperLeft, paretoFrontUpperRight } from '@/lib/chart-utils'; import { isZhPathname, ZH_PREFIX } from '@/lib/i18n'; const HW_LABELS: Record = { @@ -61,7 +63,15 @@ function frameworkLabel(fw: string) { return fw; } -/** Short label for a sibling chip: parallelism + concurrency. */ +function offloadTierLabel(s: BenchmarkSibling): string | null { + const descriptor = s.kv_offloading?.trim(); + if (descriptor) { + return descriptor.toLowerCase() === 'none' ? null : offloadTypeLabel(descriptor); + } + return s.offload_mode?.toLowerCase() === 'on' ? 'Offload' : null; +} + +/** Short label for a sibling chip: parallelism + concurrency + physical KV tier. */ export function chipLabel(s: BenchmarkSibling): string { const usePrefill = !s.disagg && @@ -117,8 +127,42 @@ export function chipLabel(s: BenchmarkSibling): string { decodeDpAttention: s.decode_dp_attention, decodeNumWorkers: s.decode_num_workers, }); - const offload = s.offload_mode === 'on' ? ' • off=ON' : ''; - return `${parallel} • c=${s.conc}${offload}`; + const offload = offloadTierLabel(s); + return `${parallel} • c=${s.conc}${offload ? ` • ${offload}` : ''}`; +} + +interface SiblingParetoPoint { + id: number; + x: number; + y: number; +} + +const siblingParetoPoints = ( + siblings: BenchmarkSibling[], + xMetric: 'p90_intvty' | 'p90_ttft', +): SiblingParetoPoint[] => + siblings + .map((sibling) => ({ + id: sibling.id, + x: sibling[xMetric] ?? Number.NaN, + y: sibling.tput_per_gpu ?? Number.NaN, + })) + .filter((point) => isFrontierEligible(point) && Number.isFinite(point.y)); + +/** + * Point IDs that lie on both P90 frontiers used by AgentX: interactivity vs. + * throughput (upper-left in the reversed chart) and TTFT vs. throughput + * (upper-right). The shared chart helpers keep tie behavior identical to the + * plotted Pareto lines. + */ +export function dualParetoSiblingIds(siblings: BenchmarkSibling[]): Set { + const interactivity = new Set( + paretoFrontUpperLeft(siblingParetoPoints(siblings, 'p90_intvty')).map((point) => point.id), + ); + const ttft = new Set( + paretoFrontUpperRight(siblingParetoPoints(siblings, 'p90_ttft')).map((point) => point.id), + ); + return new Set([...interactivity].filter((id) => ttft.has(id))); } type SortMode = 'default' | 'conc' | 'parallelism' | 'tput' | 'requests'; @@ -182,9 +226,8 @@ const isSortMode = (v: string | null): v is SortMode => export function SiblingNav({ sku, siblings }: { sku: BenchmarkSku; siblings: BenchmarkSibling[] }) { const router = useRouter(); const pathname = usePathname(); - const agenticBase = isZhPathname(pathname) - ? `${ZH_PREFIX}/inference/agentic` - : '/inference/agentic'; + const isZh = isZhPathname(pathname); + const agenticBase = isZh ? `${ZH_PREFIX}/inference/agentic` : '/inference/agentic'; // Persist the sort in the URL so clicking a point (which remounts this // component on the new route) keeps the chosen order instead of resetting. // Read it once from the URL on mount — this component only renders after the @@ -197,6 +240,7 @@ export function SiblingNav({ sku, siblings }: { sku: BenchmarkSku; siblings: Ben }); const sorted = useMemo(() => sortSiblings(siblings, sortMode), [siblings, sortMode]); + const dualParetoIds = useMemo(() => dualParetoSiblingIds(siblings), [siblings]); // prev/next follow the displayed (sorted) order so navigation matches the row. const currentIdx = sorted.findIndex((s) => s.is_current); @@ -272,6 +316,17 @@ export function SiblingNav({ sku, siblings }: { sku: BenchmarkSku; siblings: Ben
{sorted.map((s) => { const active = s.is_current; + const isDualPareto = dualParetoIds.has(s.id); + const title = [ + isDualPareto + ? isZh + ? '同时位于 P90 交互性与 TTFT Pareto 前沿' + : 'On both P90 interactivity and TTFT Pareto frontiers' + : null, + s.has_trace ? null : isZh ? '无已存储的追踪数据' : 'No stored trace data', + ] + .filter(Boolean) + .join(' · '); return ( @@ -309,6 +374,15 @@ export function SiblingNav({ sku, siblings }: { sku: BenchmarkSku; siblings: Ben next
+ {dualParetoIds.size > 0 && ( +
+
+ )} ); } diff --git a/packages/app/src/components/inference/agentic-point/time-series-chart.tsx b/packages/app/src/components/inference/agentic-point/time-series-chart.tsx index f112538f1..ef8b7e53e 100644 --- a/packages/app/src/components/inference/agentic-point/time-series-chart.tsx +++ b/packages/app/src/components/inference/agentic-point/time-series-chart.tsx @@ -3,14 +3,34 @@ import { useMemo } from 'react'; import type { TimeSeriesPoint } from '@/hooks/api/use-trace-server-metrics'; +import { useLocale } from '@/lib/use-locale'; import { ChartHover, type HoverItem } from './chart-hover'; import { CHART_PAD, ChartEmpty, fmtCount, fmtSeconds } from './chart-shared'; import { interpAt, maxTimeSeriesValue, type ChartSeries } from './time-series-math'; -// Historical entry point: the pure data-shaping helpers lived in this module -// before being extracted; re-export them so both import paths stay valid. -export * from './time-series-math'; +// Historical entry point: keep the extracted data-shaping helpers available +// here through named exports, which also compile in the Cypress component app. +export { interpAt, maxTimeSeriesValue }; +export type { ChartSeries }; +export { + averageSequenceLengthInFlight, + buildThroughputChartSeries, + cumulativeAverage, + cumulativeCompletedRequests, + cumulativeDifferenceMonotonic, + cumulativeTimeAverage, + cumulativeUniqueInputTokens, + inflightUniqueTokens, + quantile, + rollingAverage, + rollingRatioFromComponents, + rollingRatioOfSums, + rollingRequestMetric, + timeRollingAverage, + toggleThroughputSeries, +} from './time-series-math'; +export type { RequestMetric, RequestPercentile, ThroughputSeriesKey } from './time-series-math'; /** A constant horizontal reference line (e.g. a capacity ceiling). */ export interface ReferenceLine { @@ -284,17 +304,64 @@ const KNOWN_SOURCE_COLORS: Record = { miss: '#f97316', 'cache hit (HBM)': '#3b82f6', 'cache hit (CPU offload)': '#22c55e', + 'cache hit (NVMe offload)': '#a855f7', + 'cache hit (P2P)': '#06b6d4', + 'cache hit (filesystem)': '#14b8a6', + 'cache hit (object store)': '#ec4899', + 'cache hit (mixed tiers)': '#ef4444', + 'cache hit (external)': '#64748b', 'cache hit': '#3b82f6', 'compute (miss)': '#f97316', }; -const SOURCE_LABELS: Record = { - local_compute: 'Prefill', - local_cache_hit: 'HBM Cache Hit', - external_kv_transfer: 'Offload Cache Hit', - miss: 'Miss', +const SOURCE_LABELS = { + en: { + local_compute: 'Prefill', + local_cache_hit: 'HBM Cache Hit', + external_kv_transfer: 'Offload Cache Hit', + miss: 'Miss', + 'cache hit (HBM)': 'HBM Cache Hit', + 'cache hit (CPU offload)': 'CPU Offload Cache Hit', + 'cache hit (NVMe offload)': 'NVMe Offload Cache Hit', + 'cache hit (P2P)': 'P2P Cache Hit', + 'cache hit (filesystem)': 'Filesystem Cache Hit', + 'cache hit (object store)': 'Object Store Cache Hit', + 'cache hit (mixed tiers)': 'Mixed-Tier Cache Hit', + 'cache hit (external)': 'External Cache Hit', + 'cache hit': 'Cache Hit', + 'compute (miss)': 'Prefill', + }, + zh: { + local_compute: 'Prefill', + local_cache_hit: 'HBM cache 命中', + external_kv_transfer: 'Offload cache 命中', + miss: '未命中', + 'cache hit (HBM)': 'HBM cache 命中', + 'cache hit (CPU offload)': 'CPU offload cache 命中', + 'cache hit (NVMe offload)': 'NVMe offload cache 命中', + 'cache hit (P2P)': 'P2P cache 命中', + 'cache hit (filesystem)': '文件系统 cache 命中', + 'cache hit (object store)': '对象存储 cache 命中', + 'cache hit (mixed tiers)': '混合层级 cache 命中', + 'cache hit (external)': '外部 cache 命中', + 'cache hit': 'Cache 命中', + 'compute (miss)': 'Prefill', + }, +} as const; + +const STACKED_AREA_STRINGS = { + en: { time: 'time', share: '% of prefill tokens' }, + zh: { time: '时间', share: 'prefill token 占比' }, }; +function sourceLabel(source: string, locale: 'en' | 'zh'): string { + const known = SOURCE_LABELS[locale] as Record; + if (known[source]) return known[source]; + const customTier = /^cache hit \((?.+)\)$/u.exec(source)?.groups?.tier; + if (!customTier) return source; + return locale === 'zh' ? `${customTier} cache 命中` : `Cache Hit (${customTier})`; +} + // Fallback palette for any source name not in KNOWN_SOURCE_COLORS so we never // emit two layers in the same shade. Cycles by stack (insertion) order. const FALLBACK_PALETTE = [ @@ -320,6 +387,8 @@ export function StackedAreaChart({ width?: number; height?: number; }) { + const locale = useLocale(); + const strings = STACKED_AREA_STRINGS[locale]; const W = width; const H = height; @@ -421,7 +490,7 @@ export function StackedAreaChart({ } const items: HoverItem[] = stackOrder.map((name) => ({ color: colorFor(name), - label: SOURCE_LABELS[name] ?? name, + label: sourceLabel(name, locale), value: `${((shares[name]?.[idx] ?? 0) * 100).toFixed(1)}%`, })); return { items, title: fmtSeconds(t) }; @@ -493,7 +562,7 @@ export function StackedAreaChart({ opacity={0.55} textAnchor="middle" > - time + {strings.time} - % of prefill tokens + {strings.share} {(() => { const chipY = H - 8; @@ -515,7 +584,7 @@ export function StackedAreaChart({ - {SOURCE_LABELS[l.name] ?? l.name} + {sourceLabel(l.name, locale)} ); diff --git a/packages/app/src/components/inference/utils/runtime-metadata-labels.test.ts b/packages/app/src/components/inference/utils/runtime-metadata-labels.test.ts index 5f4fd776d..9f4eae73f 100644 --- a/packages/app/src/components/inference/utils/runtime-metadata-labels.test.ts +++ b/packages/app/src/components/inference/utils/runtime-metadata-labels.test.ts @@ -21,6 +21,8 @@ describe('runtime metadata labels', () => { it('formats DRAM and other offload types consistently', () => { expect(offloadTypeLabel('dram')).toBe('DRAM'); + expect(offloadTypeLabel('nvme')).toBe('NVMe'); + expect(offloadTypeLabel('dram+nvme')).toBe('DRAM+NVMe'); expect(offloadTypeLabel('cpu')).toBe('CPU'); }); }); diff --git a/packages/app/src/components/inference/utils/runtime-metadata-labels.ts b/packages/app/src/components/inference/utils/runtime-metadata-labels.ts index 3c151b9a0..c9da5bdd9 100644 --- a/packages/app/src/components/inference/utils/runtime-metadata-labels.ts +++ b/packages/app/src/components/inference/utils/runtime-metadata-labels.ts @@ -16,11 +16,20 @@ const CACHE_IMPLEMENTATION_LABELS: Record = { export const cacheImplementationLabel = (value: string): string => CACHE_IMPLEMENTATION_LABELS[value.toLowerCase()] ?? value; -export const offloadTypeLabel = (value: string): string => { - if (value.toLowerCase() === 'dram') return 'DRAM'; - return value.toUpperCase(); +const OFFLOAD_TIER_LABELS: Record = { + dram: 'DRAM', + nvme: 'NVMe', }; +export const offloadTypeLabel = (value: string): string => + value + .split('+') + .map((tier) => { + const normalized = tier.trim().toLowerCase(); + return OFFLOAD_TIER_LABELS[normalized] ?? normalized.toUpperCase(); + }) + .join('+'); + export const versionedComponentLabel = ( name: string | null | undefined, version: string | null | undefined, diff --git a/packages/app/src/hooks/api/use-benchmark-siblings.ts b/packages/app/src/hooks/api/use-benchmark-siblings.ts index 7471dd45b..0e76e25ea 100644 --- a/packages/app/src/hooks/api/use-benchmark-siblings.ts +++ b/packages/app/src/hooks/api/use-benchmark-siblings.ts @@ -4,6 +4,7 @@ export interface BenchmarkSibling { id: number; conc: number; offload_mode: string | null; + kv_offloading: string | null; decode_tp: number; decode_ep: number; decode_pp: number | null; @@ -23,6 +24,8 @@ export interface BenchmarkSibling { disagg: boolean; is_multinode: boolean; tput_per_gpu: number | null; + p90_intvty: number | null; + p90_ttft: number | null; total_requests: number | null; is_current: boolean; has_trace: boolean; diff --git a/packages/app/src/hooks/api/use-trace-server-metrics.ts b/packages/app/src/hooks/api/use-trace-server-metrics.ts index 68ad6d441..439bf36ce 100644 --- a/packages/app/src/hooks/api/use-trace-server-metrics.ts +++ b/packages/app/src/hooks/api/use-trace-server-metrics.ts @@ -57,6 +57,7 @@ export interface MetricSourceSeries { kvCacheUsage: TimeSeriesPoint[]; prefixCacheHitRate: TimeSeriesPoint[]; queueDepth: QueueDepthPoint[]; + /** Fresh prefill plus physical cache tiers when available; logical fallback otherwise. */ promptTokensBySource: Record; promptTps: TimeSeriesPoint[]; generationTps: TimeSeriesPoint[]; diff --git a/packages/app/src/lib/api-documentation.ts b/packages/app/src/lib/api-documentation.ts index 4a22d16c5..bbea35a63 100644 --- a/packages/app/src/lib/api-documentation.ts +++ b/packages/app/src/lib/api-documentation.ts @@ -1794,6 +1794,7 @@ export const apiOperations: readonly ApiOperation[] = [ id: 421, conc: 32, offload_mode: 'off', + kv_offloading: 'none', decode_tp: 8, decode_ep: 1, decode_pp: null, @@ -1813,6 +1814,8 @@ export const apiOperations: readonly ApiOperation[] = [ disagg: false, is_multinode: false, tput_per_gpu: 128.4, + p90_intvty: 7.2, + p90_ttft: 4.8, total_requests: 320, is_current: true, has_trace: true, @@ -2408,8 +2411,8 @@ export const apiOperations: readonly ApiOperation[] = [ path: '/api/v1/trace-server-metrics', summary: text('Read trace server metrics', '读取跟踪服务器指标'), description: text( - 'Returns point metadata and chart-ready aggregate time series for cache usage, queue depth, prefill and decode throughput, and prompt-token sources. metricSources contains source descriptors; source-specific arrays are loaded by the point-detail UI only when selected.', - '返回点元数据,以及缓存使用率、队列深度、预填充和解码吞吐、提示 token 来源的图表聚合时间序列。metricSources 仅包含来源描述信息;详情页只会在用户选中某个来源时加载其专属数组。', + 'Returns point metadata and chart-ready aggregate time series for cache usage, queue depth, prefill and decode throughput, and prompt-token sources. When vLLM exports physical cache-tier attribution, promptTokensBySource separates HBM, CPU, NVMe, and connector-defined hits while retaining fresh prefill; older rows keep the logical cache-hit fallback. metricSources contains source descriptors; source-specific arrays are loaded by the point-detail UI only when selected.', + '返回点元数据,以及 cache 使用率、队列深度、prefill 和 decode 吞吐、prompt token 来源的图表聚合时间序列。当 vLLM 导出物理 cache 层级归因时,promptTokensBySource 会区分 HBM、CPU、NVMe 和 connector 自定义命中来源,同时保留新计算的 prefill;旧数据继续使用逻辑 cache 命中回退。metricSources 仅包含来源描述信息;详情页只会在用户选中某个来源时加载其专属数组。', ), audience: 'public', stability: 'beta', @@ -2463,7 +2466,11 @@ export const apiOperations: readonly ApiOperation[] = [ kvCacheUsage: [{ t: 0, v: 0.44 }], prefixCacheHitRate: [], queueDepth: [], - promptTokensBySource: {}, + promptTokensBySource: { + local_compute: [{ t: 0, value: 2200 }], + 'cache hit (HBM)': [{ t: 0, value: 6800 }], + 'cache hit (NVMe offload)': [{ t: 0, value: 1000 }], + }, prefillTps: [], decodeTps: [], prefixCacheHitsTps: [], diff --git a/packages/app/src/lib/api-route-catalog.ts b/packages/app/src/lib/api-route-catalog.ts index d60e6fdba..55fdf628b 100644 --- a/packages/app/src/lib/api-route-catalog.ts +++ b/packages/app/src/lib/api-route-catalog.ts @@ -100,7 +100,7 @@ export const apiRouteCatalog = [ method: 'GET', classification: 'published-read', operationId: 'get-benchmark-siblings', - sourceSha256: '2b20de8b2b67ed53027eba478068f8f22ae7e3843ac38423e8ce6b27c1f74fd6', + sourceSha256: '72a4dd160a940a95b9ce7fd27b8f272421178889b8ecae298211174d5c7f506d', }, { source: 'src/app/api/v1/benchmarks/route.ts', @@ -719,7 +719,7 @@ export const apiContractSourceDigests = [ }, { source: '../db/src/queries/benchmark-siblings.ts', - sourceSha256: '07d3d1bf93820091d1014b14bf954555edcec422c05e575ad87142f9845e4156', + sourceSha256: '9ce28ba60ddcfbbba740036060baa7645f2421e3eb4c73db15a6ff3db415452c', reviewArea: { en: 'Benchmark sibling SKU metadata and sibling navigation row shape.', zh: '基准同组 SKU 元数据和同组导航行结构。', @@ -815,7 +815,7 @@ export const apiContractSourceDigests = [ }, { source: '../db/src/queries/trace-server-metrics.ts', - sourceSha256: 'da987d22521dc63da34dcacf3caaad6adb21aa6e5e7a65d1a4fa495a71e9f1f0', + sourceSha256: 'b7ded0f27e9328f9d0e1c2bd349600cdfd93ba6da91b3b9ac479cb0770042d9a', reviewArea: { en: 'Trace server metric metadata, time-series groups, source labels, and units.', zh: '跟踪服务器指标元数据、时间序列分组、来源标签和单位。', diff --git a/packages/app/src/lib/chart-utils.ts b/packages/app/src/lib/chart-utils.ts index 7d3271656..dcbc3e6e1 100644 --- a/packages/app/src/lib/chart-utils.ts +++ b/packages/app/src/lib/chart-utils.ts @@ -637,10 +637,15 @@ export const getNestedYValue = (point: T, key: string): */ export const isFrontierEligible = (p: { x: number }): boolean => Number.isFinite(p.x) && p.x > 0; +interface ParetoPoint { + x: number; + y: number; +} + /** * Calculates the Pareto front (upper right) for a given set of points. */ -export const paretoFrontUpperRight = (points: InferenceData[]): InferenceData[] => { +export const paretoFrontUpperRight = (points: T[]): T[] => { if (points.length === 0) { return []; } @@ -652,7 +657,7 @@ export const paretoFrontUpperRight = (points: InferenceData[]): InferenceData[] return a.x - b.x; }); - const front: InferenceData[] = []; + const front: T[] = []; let maxY = -Infinity; for (const point of points) { @@ -671,7 +676,7 @@ export const paretoFrontUpperRight = (points: InferenceData[]): InferenceData[] /** * Calculates the Pareto front (upper left) for a given set of points. */ -export const paretoFrontUpperLeft = (points: InferenceData[]): InferenceData[] => { +export const paretoFrontUpperLeft = (points: T[]): T[] => { if (points.length === 0) { return []; } @@ -683,7 +688,7 @@ export const paretoFrontUpperLeft = (points: InferenceData[]): InferenceData[] = return a.x - b.x; }); - const front: InferenceData[] = []; + const front: T[] = []; for (const point of points) { if (front.length > 0 && point.x === front.at(-1)!.x) { @@ -704,7 +709,7 @@ export const paretoFrontUpperLeft = (points: InferenceData[]): InferenceData[] = /** * Calculates the Pareto front (lower left) for a given set of points. */ -export const paretoFrontLowerLeft = (points: InferenceData[]): InferenceData[] => { +export const paretoFrontLowerLeft = (points: T[]): T[] => { if (points.length === 0) { return []; } @@ -716,7 +721,7 @@ export const paretoFrontLowerLeft = (points: InferenceData[]): InferenceData[] = return a.x - b.x; }); - const front: InferenceData[] = []; + const front: T[] = []; let minY = Infinity; for (const point of points) { @@ -731,7 +736,7 @@ export const paretoFrontLowerLeft = (points: InferenceData[]): InferenceData[] = /** * Calculates the Pareto front (lower right) for a given set of points. */ -export const paretoFrontLowerRight = (points: InferenceData[]): InferenceData[] => { +export const paretoFrontLowerRight = (points: T[]): T[] => { if (points.length === 0) { return []; } @@ -743,7 +748,7 @@ export const paretoFrontLowerRight = (points: InferenceData[]): InferenceData[] return b.x - a.x; }); - const front: InferenceData[] = []; + const front: T[] = []; let minY = Infinity; for (const point of points) { @@ -767,7 +772,7 @@ export type ParetoDirection = keyof typeof PARETO_BY_DIRECTION; /** Look up the Pareto frontier function for a roofline direction. */ export const paretoFrontForDirection = ( dir: ParetoDirection, -): ((points: InferenceData[]) => InferenceData[]) => PARETO_BY_DIRECTION[dir]; +): ((points: T[]) => T[]) => PARETO_BY_DIRECTION[dir]; // --------------------------------------------------------------------------- // Locale-aware metric label/title helpers diff --git a/packages/db/src/etl/compute-chart-series.test.ts b/packages/db/src/etl/compute-chart-series.test.ts index 54295cc77..2527f40da 100644 --- a/packages/db/src/etl/compute-chart-series.test.ts +++ b/packages/db/src/etl/compute-chart-series.test.ts @@ -63,6 +63,13 @@ function makeBlob(opts?: { return gzipSync(Buffer.from(json)); } +function sourceRateSeries(source: string, rate: number) { + return { + labels: { source }, + timeslices: [{ start_ns: 0, end_ns: 1e9, rate }], + }; +} + /** Build a synthetic per-engine vLLM metric series for the multi-engine test. */ function buildEngineSeries(engineId: number, baseRunning: number) { const labels = { engine: String(engineId) }; @@ -239,6 +246,81 @@ describe('computeChartSeries', () => { expect(series!.promptTokensBySource['miss']).toEqual([{ t: 0, value: 800 }]); }); + it('combines vLLM fresh prefill with physical cache tiers without double counting', async () => { + const blob = gzipSync( + Buffer.from( + JSON.stringify({ + metrics: { + 'vllm:prompt_tokens_by_source': { + series: [ + sourceRateSeries('local_compute', 300), + sourceRateSeries('local_cache_hit', 400), + sourceRateSeries('external_kv_transfer', 300), + ], + }, + 'vllm:prompt_tokens_cached_by_source': { + series: [ + sourceRateSeries('device', 400), + sourceRateSeries('cpu', 200), + sourceRateSeries('disk', 100), + sourceRateSeries('external', 0), + sourceRateSeries('mixed', 0), + ], + }, + }, + }), + ), + ); + + const series = await computeChartSeries(blob); + expect(series?.promptTokensBySource).toEqual({ + local_compute: [{ t: 0, value: 300 }], + 'cache hit (HBM)': [{ t: 0, value: 400 }], + 'cache hit (CPU offload)': [{ t: 0, value: 200 }], + 'cache hit (NVMe offload)': [{ t: 0, value: 100 }], + }); + expect(total(Object.values(series!.promptTokensBySource).flat())).toBe(1000); + expect(series?.promptTokensBySource).not.toHaveProperty('local_cache_hit'); + expect(series?.promptTokensBySource).not.toHaveProperty('external_kv_transfer'); + }); + + it('preserves connector-defined vLLM tiers and treats a missing label as external', async () => { + const blob = gzipSync( + Buffer.from( + JSON.stringify({ + metrics: { + 'vllm:prompt_tokens_by_source': { + series: [ + { + labels: { source: 'local_compute' }, + timeslices: [{ start_ns: 0, end_ns: 1e9, rate: 5 }], + }, + ], + }, + 'vllm:prompt_tokens_cached_by_source': { + series: [ + { + labels: { source: 'weka' }, + timeslices: [{ start_ns: 0, end_ns: 1e9, rate: 7 }], + }, + { + timeslices: [{ start_ns: 0, end_ns: 1e9, rate: 3 }], + }, + ], + }, + }, + }), + ), + ); + + const series = await computeChartSeries(blob); + expect(series?.promptTokensBySource).toEqual({ + local_compute: [{ t: 0, value: 5 }], + 'cache hit (weka)': [{ t: 0, value: 7 }], + 'cache hit (external)': [{ t: 0, value: 3 }], + }); + }); + it('computes timing metadata from the widest metric window', async () => { const series = await computeChartSeries(makeBlob()); // kvCacheUsage has the widest window (0 → 3e9), so startNs=0, endNs=3e9. diff --git a/packages/db/src/etl/compute-chart-series.ts b/packages/db/src/etl/compute-chart-series.ts index 82878ad86..aa660e787 100644 --- a/packages/db/src/etl/compute-chart-series.ts +++ b/packages/db/src/etl/compute-chart-series.ts @@ -120,8 +120,15 @@ import { * per-rank detail stays reachable as that source's own * `kvCacheUsageByEngine`. * + * v16: consume vLLM's physical cached-token source metric. The existing + * `prompt_tokens_by_source` metric remains the source of freshly computed + * prompt tokens, while `prompt_tokens_cached_by_source` replaces its coarse + * local/external cache-hit buckets with device, CPU, disk, and connector-tier + * attribution. Keeping only the logical compute bucket prevents double + * counting and makes the stacked breakdown sum to total prompt-token volume. + * */ -export const CHART_SERIES_VERSION = 15; +export const CHART_SERIES_VERSION = 16; export interface TimeSeriesPoint { /** Seconds from benchmark start. */ @@ -218,6 +225,22 @@ export interface RawMetric { export type MetricsMap = Record; +const VLLM_CACHE_SOURCE_BUCKETS: Record = { + device: 'cache hit (HBM)', + cpu: 'cache hit (CPU offload)', + disk: 'cache hit (NVMe offload)', + p2p: 'cache hit (P2P)', + fs: 'cache hit (filesystem)', + obj: 'cache hit (object store)', + mixed: 'cache hit (mixed tiers)', + external: 'cache hit (external)', +}; + +/** Canonical chart bucket for vLLM's physical cached-token source labels. */ +function vllmCacheSourceBucket(source: string): string { + return VLLM_CACHE_SOURCE_BUCKETS[source] ?? `cache hit (${source})`; +} + /** * The set of metric subtrees the chart consumes. Includes both vllm:* and * sglang:* names so the stream-parse fallback collects whichever framework @@ -234,6 +257,7 @@ export const CHART_METRIC_KEYS = new Set([ 'vllm:prompt_tokens', 'vllm:generation_tokens', 'vllm:prompt_tokens_by_source', + 'vllm:prompt_tokens_cached_by_source', // SGLang 'sglang:token_usage', 'sglang:cached_tokens', @@ -1051,9 +1075,10 @@ function buildSeriesFromMetrics( } // Per-source prompt tokens — sum across engines per source label. - // vllm: vllm:prompt_tokens_by_source has one series per source label - // (local_cache_hit, external_cache_hit, miss, ...). Use the - // `source`/`reason`/`kind` label as the breakdown key. + // vllm: the logical prompt_tokens_by_source metric supplies fresh + // prefill. When prompt_tokens_cached_by_source is available, its + // physical cache tiers replace the logical local/external hit + // buckets so the two metrics are combined without double counting. // sglang: sglang:realtime_tokens uses a `mode` label with values // {prefill_cache, prefill_compute, decode}. Filter to prefill_* // since decode isn't prompt-token volume. @@ -1070,10 +1095,27 @@ function buildSeriesFromMetrics( if (existing) existing.push(series); else promptBySrc.set(label, [series]); }; - for (const series of metrics['vllm:prompt_tokens_by_source']?.series ?? []) { - const labels = series.labels ?? {}; - const source = labels['source'] ?? labels['reason'] ?? labels['kind'] ?? JSON.stringify(labels); - addSeriesRates(source, series); + const logicalVllmSeries = metrics['vllm:prompt_tokens_by_source']?.series ?? []; + const physicalVllmSeries = metrics['vllm:prompt_tokens_cached_by_source']?.series ?? []; + if (physicalVllmSeries.length > 0) { + for (const series of logicalVllmSeries) { + const labels = series.labels ?? {}; + const source = labels['source'] ?? labels['reason'] ?? labels['kind'] ?? ''; + if (source === 'local_compute' || source === 'miss') { + addSeriesRates(source, series); + } + } + for (const series of physicalVllmSeries) { + const source = series.labels?.['source'] || 'external'; + addSeriesRates(vllmCacheSourceBucket(source), series); + } + } else { + for (const series of logicalVllmSeries) { + const labels = series.labels ?? {}; + const source = + labels['source'] ?? labels['reason'] ?? labels['kind'] ?? JSON.stringify(labels); + addSeriesRates(source, series); + } } // SGLang fallback: only consider when the vllm metric wasn't found. // - Cache misses (fresh prefill): `sglang:realtime_tokens[mode=prefill_compute]` diff --git a/packages/db/src/queries/benchmark-siblings.test.ts b/packages/db/src/queries/benchmark-siblings.test.ts new file mode 100644 index 000000000..a1b07eb5c --- /dev/null +++ b/packages/db/src/queries/benchmark-siblings.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; + +import type { DbClient } from '../connection.js'; + +import { getBenchmarkSiblings } from './benchmark-siblings.js'; + +function mockSql(queue: unknown[][]): { + sql: DbClient; + calls: string[]; +} { + const responses = [...queue]; + const calls: string[] = []; + const sql = ((strings: TemplateStringsArray) => { + calls.push(strings.join('?')); + return Promise.resolve(responses.shift() ?? []); + }) as unknown as DbClient; + return { sql, calls }; +} + +describe('getBenchmarkSiblings', () => { + it('returns physical offload tiers and P90 Pareto coordinates', async () => { + const { sql, calls } = mockSql([ + [ + { + hardware: 'h100', + framework: 'vllm', + model: 'minimaxm3', + precision: 'fp8', + spec_method: 'mtp', + benchmark_type: 'agentic_traces', + workflow_run_id: 12, + date: '2026-08-28', + github_run_id: 33169766010, + dataset_slug: 'agentx-minimaxm3', + }, + ], + [ + { + id: 440549, + conc: 7, + offload_mode: 'on', + kv_offloading: 'dram+nvme', + decode_tp: 8, + decode_ep: 1, + decode_pp: 1, + decode_dcp_size: null, + decode_pcp_size: null, + decode_dp_attention: false, + decode_num_workers: 1, + prefill_tp: 8, + prefill_ep: 1, + prefill_pp: 1, + prefill_dcp_size: null, + prefill_pcp_size: null, + prefill_dp_attention: false, + prefill_num_workers: 1, + num_prefill_gpu: 0, + num_decode_gpu: 8, + disagg: false, + is_multinode: false, + tput_per_gpu: '128.4', + p90_intvty: '7.2', + p90_ttft: '4.8', + total_requests: '320', + has_trace: true, + }, + ], + ]); + + const result = await getBenchmarkSiblings(sql, 440549); + + expect(result?.siblings[0]).toMatchObject({ + id: 440549, + kv_offloading: 'dram+nvme', + tput_per_gpu: 128.4, + p90_intvty: 7.2, + p90_ttft: 4.8, + is_current: true, + }); + expect(calls[1]).toContain("br.metrics->>'kv_offloading'"); + expect(calls[1]).toContain("br.metrics->>'p90_intvty'"); + expect(calls[1]).toContain("br.metrics->>'p90_ttft'"); + }); +}); diff --git a/packages/db/src/queries/benchmark-siblings.ts b/packages/db/src/queries/benchmark-siblings.ts index a6a6c41f8..1d9246f99 100644 --- a/packages/db/src/queries/benchmark-siblings.ts +++ b/packages/db/src/queries/benchmark-siblings.ts @@ -12,6 +12,8 @@ export interface BenchmarkSibling { conc: number; /** "on" | "off" | null. */ offload_mode: string | null; + /** Physical KV-cache tier selection reported by the benchmark runner. */ + kv_offloading: string | null; decode_tp: number; decode_ep: number; decode_pp: number | null; @@ -32,6 +34,10 @@ export interface BenchmarkSibling { is_multinode: boolean; /** Throughput per GPU (tok/s/gpu) for this point; null if the metric is absent. */ tput_per_gpu: number | null; + /** P90 interactivity (tok/s/user), used by the AgentX Pareto navigator. */ + p90_intvty: number | null; + /** P90 time to first token (s), used by the AgentX Pareto navigator. */ + p90_ttft: number | null; /** * Total requests for this point — `total_requests_completed` (aiperf runner) * falling back to the legacy `num_requests_total`; null if neither is present. @@ -96,6 +102,7 @@ export async function getBenchmarkSiblings( const rows = (await sql` select br.id, br.conc, br.offload_mode, + nullif(br.metrics->>'kv_offloading', '') as kv_offloading, c.decode_tp, c.decode_ep, (br.metrics->>'decode_pp')::int as decode_pp, coalesce( (br.metrics->>'decode_dcp_size')::int, @@ -118,6 +125,8 @@ export async function getBenchmarkSiblings( c.prefill_dp_attention, c.prefill_num_workers, c.num_prefill_gpu, c.num_decode_gpu, c.disagg, c.is_multinode, (br.metrics->>'tput_per_gpu')::float8 as tput_per_gpu, + (br.metrics->>'p90_intvty')::float8 as p90_intvty, + (br.metrics->>'p90_ttft')::float8 as p90_ttft, coalesce( (br.metrics->>'total_requests_completed')::float8, (br.metrics->>'num_requests_total')::float8 @@ -137,6 +146,7 @@ export async function getBenchmarkSiblings( id: number; conc: number; offload_mode: string | null; + kv_offloading: string | null; decode_tp: number; decode_ep: number; decode_pp: number | null; @@ -156,6 +166,8 @@ export async function getBenchmarkSiblings( disagg: boolean; is_multinode: boolean; tput_per_gpu: number | null; + p90_intvty: number | null; + p90_ttft: number | null; total_requests: number | null; has_trace: boolean; }[]; @@ -164,6 +176,7 @@ export async function getBenchmarkSiblings( id: Number(r.id), conc: r.conc, offload_mode: r.offload_mode, + kv_offloading: r.kv_offloading, decode_tp: r.decode_tp, decode_ep: r.decode_ep, decode_pp: r.decode_pp === null ? null : Number(r.decode_pp), @@ -183,6 +196,8 @@ export async function getBenchmarkSiblings( disagg: r.disagg, is_multinode: r.is_multinode, tput_per_gpu: r.tput_per_gpu === null ? null : Number(r.tput_per_gpu), + p90_intvty: r.p90_intvty === null ? null : Number(r.p90_intvty), + p90_ttft: r.p90_ttft === null ? null : Number(r.p90_ttft), total_requests: r.total_requests === null ? null : Number(r.total_requests), is_current: Number(r.id) === benchmarkResultId, has_trace: r.has_trace, diff --git a/packages/db/src/queries/trace-server-metrics.ts b/packages/db/src/queries/trace-server-metrics.ts index 5e3f5f076..374865118 100644 --- a/packages/db/src/queries/trace-server-metrics.ts +++ b/packages/db/src/queries/trace-server-metrics.ts @@ -86,8 +86,8 @@ export interface TraceServerMetrics { queueDepth: QueueDepthPoint[]; /** * Per-source prompt-token counts over time (counter rate per scrape). - * Keyed by the value of the `source` label (typically `local_cache_hit`, - * `external_cache_hit`, `miss`, etc.). Plot as stacked area. + * Fresh prefill is combined with physical cache-tier hits when the producer + * exposes them; older rows retain their logical cache-hit buckets. */ promptTokensBySource: Record; /** Prefill throughput: vllm:prompt_tokens rate (tokens/sec) per scrape. */