diff --git a/src/components/MetricsChart.tsx b/src/components/MetricsChart.tsx index 7735b45..2b9308c 100644 --- a/src/components/MetricsChart.tsx +++ b/src/components/MetricsChart.tsx @@ -43,31 +43,46 @@ export default function MetricsChart({ hostData, deviceData, globalRange, highli const metricsZoomRange = useStore((s) => s.metricsZoomRange) const setMetricsZoom = useStore((s) => s.setMetricsZoom) - // Session date/time header derived from globalRange + // chartRange is derived from the actual metric timestamps, NOT globalRange. + // globalRange can be dominated by short kernel events (e.g. 18 ms) which would + // clip metric samples that fall outside that narrow window. + const chartRange = useMemo(() => { + const allTs = [ + ...(hostData || []).map(m => m.tsNs), + ...(deviceData || []).map(m => m.tsNs), + ] + if (allTs.length === 0) return globalRange + const minTs = Math.min(...allTs) + const maxTs = Math.max(...allTs) + if (minTs < maxTs) return { start_ns: minTs, end_ns: maxTs } + return { start_ns: minTs - 500_000, end_ns: minTs + 500_000 } + }, [hostData, deviceData, globalRange]) + + // Session date/time header derived from chartRange const sessionHeader = useMemo(() => { - if (!globalRange) return null - const startMs = globalRange.start_ns / 1_000_000 - const endMs = globalRange.end_ns / 1_000_000 + if (!chartRange) return null + const startMs = chartRange.start_ns / 1_000_000 + const endMs = chartRange.end_ns / 1_000_000 const startDate = fmtDate(startMs) const endDate = fmtDate(endMs) const startTime = fmtTime(startMs) const endTime = fmtTime(endMs) const sameDay = startDate === endDate return { date: startDate, startTime, endTime: sameDay ? endTime : `${endDate} ${endTime}` } - }, [globalRange]) + }, [chartRange]) const hostChartData = useMemo(() => { - if (!hostData || !globalRange) return [] - const start = globalRange.start_ns + if (!hostData || !chartRange) return [] + const start = chartRange.start_ns return hostData.map(m => ({ ...m, ts_rel_us: nsToUs(m.tsNs - start) })).sort((a, b) => a.ts_rel_us - b.ts_rel_us) - }, [hostData, globalRange]) + }, [hostData, chartRange]) const deviceChartData = useMemo(() => { - if (!deviceData || !globalRange) return [] - const start = globalRange.start_ns + if (!deviceData || !chartRange) return [] + const start = chartRange.start_ns const byTs = new Map() for (const m of deviceData) { const key = m.tsNs @@ -82,7 +97,7 @@ export default function MetricsChart({ hostData, deviceData, globalRange, highli byTs.set(key, row) } return Array.from(byTs.values()).sort((a, b) => a.ts_rel_us - b.ts_rel_us) - }, [deviceData, globalRange]) + }, [deviceData, chartRange]) const deviceIds = useMemo(() => { if (!deviceData) return [] @@ -90,16 +105,16 @@ export default function MetricsChart({ hostData, deviceData, globalRange, highli }, [deviceData]) const domain = useMemo(() => { - if (globalRange) { - const durationUs = nsToUs(globalRange.end_ns - globalRange.start_ns) + if (chartRange) { + const durationUs = nsToUs(chartRange.end_ns - chartRange.start_ns) return [0, Math.max(durationUs, 1)] } return ['dataMin', 'dataMax'] - }, [globalRange]) + }, [chartRange]) const ticks = useMemo(() => { - if (!globalRange) return undefined - const durationUs = nsToUs(globalRange.end_ns - globalRange.start_ns) + if (!chartRange) return undefined + const durationUs = nsToUs(chartRange.end_ns - chartRange.start_ns) let step = 100 if (durationUs > 1_000) step = 200 @@ -124,13 +139,13 @@ export default function MetricsChart({ hostData, deviceData, globalRange, highli // Format a relative-us tick as an absolute wall-clock time (HH:MM:SS or HH:MM:SS.mmm) const formatTickTime = (us: number) => { - if (!globalRange) return '' - const absMs = (globalRange.start_ns / 1_000_000) + (us / 1_000) + if (!chartRange) return '' + const absMs = (chartRange.start_ns / 1_000_000) + (us / 1_000) const d = new Date(absMs) const hh = String(d.getHours()).padStart(2, '0') const mm = String(d.getMinutes()).padStart(2, '0') const ss = String(d.getSeconds()).padStart(2, '0') - const durationUs = nsToUs(globalRange.end_ns - globalRange.start_ns) + const durationUs = nsToUs(chartRange.end_ns - chartRange.start_ns) // Show milliseconds only for short durations if (durationUs < 10_000_000) { const ms = String(d.getMilliseconds()).padStart(3, '0') @@ -141,24 +156,24 @@ export default function MetricsChart({ hostData, deviceData, globalRange, highli // Tooltip label: full absolute timestamp const formatTooltipTime = (us: number) => { - if (!globalRange) return '' - const absMs = (globalRange.start_ns / 1_000_000) + (us / 1_000) + if (!chartRange) return '' + const absMs = (chartRange.start_ns / 1_000_000) + (us / 1_000) const d = new Date(absMs) return d.toLocaleTimeString([], { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0') } const highlightArea = useMemo(() => { - if (!highlightRange || !globalRange) return null + if (!highlightRange || !chartRange) return null return ( ) - }, [highlightRange, globalRange]) + }, [highlightRange, chartRange]) const renderToggle = (key: MetricKey, label: string) => ( 0.3) return { background: 'rgba(220,38,38,0.12)' } + if (stallShare > 0.15) return { background: 'rgba(202,138,4,0.10)' } + return {} +} + +function divColor(pct: number): string { + if (pct > 20) return '#dc2626' + if (pct > 10) return '#ca8a04' + return '#16a34a' +} + +export default function SourceCorrelationView() { + const profileSamples = useStore((s) => s.profileSamples) + const currentSessionId = useStore((s) => s.currentSessionId) + const [selectedFuncKey, setSelectedFuncKey] = useState(null) + + const functionEntries: FunctionEntry[] = useMemo(() => { + const samples = profileSamples.filter((s) => s.sessionId === currentSessionId) + if (samples.length === 0) return [] + + type LineAgg = { + funcKey: string + displayName: string + sourceFile: string + sourceLine: number | null + stallHits: number + instExec: number + threadExec: number + } + + const lineMap = new Map() + + for (const s of samples) { + const parsed = parseFunctionKey(s.functionName) + const displayName = parsed.name || s.functionName || '(unknown)' + const sourceFile = s.sourceFile ?? parsed.sourceFile + const funcKey = `${displayName}@${sourceFile}` + const sourceLine = (s.sourceLine != null && s.sourceLine > 0) ? s.sourceLine : null + const lineKey = `${funcKey}::${sourceLine}` + + if (!lineMap.has(lineKey)) { + lineMap.set(lineKey, { funcKey, displayName, sourceFile, sourceLine, stallHits: 0, instExec: 0, threadExec: 0 }) + } + const agg = lineMap.get(lineKey)! + + if (s.sampleKind === 'pc_sampling') { + agg.stallHits += s.occurrenceCount + } else if (s.sampleKind === 'sass_metric') { + if (s.metricName === 'smsp__sass_inst_executed') { + agg.instExec += s.metricValue ?? 0 + } else if (s.metricName === 'smsp__sass_thread_inst_executed') { + agg.threadExec += s.metricValue ?? 0 + } + } + } + + // Group lines by function + const funcMap = new Map() + for (const agg of lineMap.values()) { + if (!funcMap.has(agg.funcKey)) funcMap.set(agg.funcKey, []) + funcMap.get(agg.funcKey)!.push(agg) + } + + const result: FunctionEntry[] = [] + for (const [funcKey, lines] of funcMap) { + const { displayName, sourceFile } = lines[0] + const totalStalls = lines.reduce((s, l) => s + l.stallHits, 0) + + const rows: SourceLineRow[] = lines.map((l) => { + const avg = l.instExec > 0 ? l.threadExec / l.instExec : 0 + const divergencePct = l.instExec > 0 ? ((32 - Math.min(32, avg)) / 32) * 100 : null + const stallShare = totalStalls > 0 ? l.stallHits / totalStalls : 0 + return { + key: `${funcKey}::${l.sourceLine}`, + sourceLine: l.sourceLine, + stallHits: l.stallHits, + stallShare, + instExec: l.instExec, + threadExec: l.threadExec, + divergencePct, + } + }) + + rows.sort((a, b) => (a.sourceLine ?? 999999) - (b.sourceLine ?? 999999)) + result.push({ funcKey, displayName, sourceFile, totalStalls, rows }) + } + + result.sort((a, b) => b.totalStalls - a.totalStalls) + return result + }, [profileSamples, currentSessionId]) + + const selected = + functionEntries.find((e) => e.funcKey === selectedFuncKey) ?? functionEntries[0] ?? null + + const dash = + + const columns: ColumnsType = [ + { + title: 'Line', + dataIndex: 'sourceLine', + width: 70, + render: (v: number | null) => ( + {v != null ? v : dash} + ), + }, + { + title: 'Stall Hits', + dataIndex: 'stallHits', + width: 100, + align: 'right', + defaultSortOrder: 'descend', + sorter: (a, b) => a.stallHits - b.stallHits, + render: (v: number) => (v > 0 ? v.toLocaleString() : dash), + }, + { + title: 'Stall %', + dataIndex: 'stallShare', + width: 90, + align: 'right', + render: (v: number) => (v > 0 ? `${(v * 100).toFixed(1)}%` : dash), + }, + { + title: 'Warp Instr', + dataIndex: 'instExec', + width: 110, + align: 'right', + render: (v: number) => (v > 0 ? v.toLocaleString() : dash), + }, + { + title: 'Thread Instr', + dataIndex: 'threadExec', + width: 120, + align: 'right', + render: (v: number) => (v > 0 ? v.toLocaleString() : dash), + }, + { + title: 'Divergence', + dataIndex: 'divergencePct', + width: 110, + align: 'right', + sorter: (a, b) => (a.divergencePct ?? -1) - (b.divergencePct ?? -1), + render: (v: number | null) => + v != null ? ( + {v.toFixed(1)}% + ) : ( + dash + ), + }, + ] + + if (functionEntries.length === 0) { + return ( +
+ + No profile sample data for this session. +
+ Run with PcSampling or SassMetrics engine enabled. + + } + /> +
+ ) + } + + return ( +
+ {/* Left panel — function list sorted by stall count */} +
+ {functionEntries.map((entry) => { + const isSelected = + selectedFuncKey === entry.funcKey || + (selectedFuncKey === null && entry === functionEntries[0]) + const basename = entry.sourceFile.split('/').pop() ?? entry.sourceFile + return ( +
setSelectedFuncKey(entry.funcKey)} + style={{ + padding: '8px 12px', + cursor: 'pointer', + background: isSelected ? '#1e293b' : 'transparent', + borderLeft: isSelected ? '3px solid #3b82f6' : '3px solid transparent', + }} + > +
+ {entry.displayName} +
+
+ {basename || '(no source)'} +
+ {entry.totalStalls > 0 && ( +
+ {entry.totalStalls.toLocaleString()} stalls +
+ )} +
+ ) + })} +
+ + {/* Right panel — per-source-line table */} +
+ {selected && ( + <> +
+
+ {selected.displayName} +
+
+ {selected.sourceFile || '(source path unknown)'} +
+
+ ({ style: rowStyle(record.stallShare) })} + /> + + )} + + + ) +} diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 3d757a7..33a7ba0 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from 'react' -import { Card, Space, Tag, Button, Tabs } from 'antd' +import { Card, Space, Tag, Button, Tabs, Typography } from 'antd' import { useParams, useNavigate } from 'react-router-dom' import { ArrowLeftOutlined } from '@ant-design/icons' import { useStore } from '@/store/useStore' @@ -8,6 +8,7 @@ import KernelTimeline from '@/components/KernelTimeline' import ScopeView from '@/components/ScopeView' import Inspector from '@/components/Inspector' import InsightsPanel from '@/components/InsightsPanel' +import SourceCorrelationView from '@/components/SourceCorrelationView' export default function Dashboard() { const { sessionId } = useParams() @@ -26,6 +27,8 @@ export default function Dashboard() { const setActiveTab = useStore((s) => s.setActiveTab) const profileSamples = useStore((s) => s.profileSamples) const fetchProfileSamples = useStore((s) => s.fetchProfileSamples) + const systemEvents = useStore((s) => s.systemEvents) + const metricsRange = useStore((s) => s.metricsRange) const insights = useStore((s) => s.insights) const fetchInsights = useStore((s) => s.fetchInsights) @@ -37,7 +40,7 @@ export default function Dashboard() { }, [sessionId, selectSession, fetchSystemMetrics]) React.useEffect(() => { - if (activeTab === 'scopes' && sessionId && profileSamples.length === 0) { + if ((activeTab === 'scopes' || activeTab === 'profile') && sessionId && profileSamples.length === 0) { fetchProfileSamples(sessionId) } }, [activeTab, sessionId, profileSamples.length, fetchProfileSamples]) @@ -48,6 +51,16 @@ export default function Dashboard() { } }, [activeTab, sessionId, insights, fetchInsights]) + // Format nanosecond epoch timestamp as HH:MM:SS.mmm — same style as chart x-axis + const fmtWallTime = (ns: number) => { + const d = new Date(ns / 1_000_000) + const hh = String(d.getHours()).padStart(2, '0') + const mm = String(d.getMinutes()).padStart(2, '0') + const ss = String(d.getSeconds()).padStart(2, '0') + const ms = String(d.getMilliseconds()).padStart(3, '0') + return `${hh}:${mm}:${ss}.${ms}` + } + const session = useMemo( () => sessions.find((s) => s.sessionId === sessionId), [sessions, sessionId], @@ -101,7 +114,7 @@ export default function Dashboard() {
setActiveTab(key as 'kernels' | 'scopes' | 'system' | 'insights')} + onChange={(key) => setActiveTab(key as 'kernels' | 'scopes' | 'profile' | 'system' | 'insights')} className="dashboard-tabs" items={[ { @@ -155,6 +168,23 @@ export default function Dashboard() {
), }, + { + key: 'profile', + label: 'Profile', + children: ( +
+ +
+ ), + }, { key: 'system', label: 'System', @@ -169,6 +199,49 @@ export default function Dashboard() { padding: 16, }} > + {systemEvents.length > 0 && ( +
+
+ System Events + {metricsRange && ( + + Chart range:  + {fmtWallTime(metricsRange.start_ns)} + + {fmtWallTime(metricsRange.end_ns)} + + )} +
+
+ + + + + + + + + + + {systemEvents.map((ev, i) => ( + + + + + + + + ))} + +
EventNameAppPIDTime
+ + {ev.eventType} + + {ev.name}{ev.app}{ev.pid} + {fmtWallTime(ev.tsNs)} +
+
+ )} = [ title: 'End Time', dataIndex: 'endTime', key: 'endTime', - render: (v?: string) => v ? dayjs(v).format('HH:mm:ss') : running, + render: (v?: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : running, }, { title: 'GPUs', diff --git a/src/store/useStore.ts b/src/store/useStore.ts index 528c211..3e851dc 100644 --- a/src/store/useStore.ts +++ b/src/store/useStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import { Session, TraceEvent, HostMetricSample, DeviceMetricSample, InitResponse, SystemMetricsResponse, HostSummary, ProfileSample, InsightDto } from '@/types'; +import { Session, TraceEvent, HostMetricSample, DeviceMetricSample, InitResponse, SystemMetricsResponse, SystemEventRecord, HostSummary, ProfileSample, InsightDto } from '@/types'; import { apiFetch } from '@/api'; export type HostMetricKey = 'cpuPct' | 'ramUsedMib' | 'ramTotalMib'; @@ -12,6 +12,7 @@ interface AppState { events: TraceEvent[]; hostMetrics: HostMetricSample[]; deviceMetrics: DeviceMetricSample[]; + systemEvents: SystemEventRecord[]; profileSamples: ProfileSample[]; insights: InsightDto[] | null; metricsRange?: { start_ns: number; end_ns: number }; @@ -20,7 +21,7 @@ interface AppState { activeEventId?: string; highlightRange?: { start_ns: number; end_ns: number }; metricVisibility: Record; - activeTab: 'kernels' | 'scopes' | 'system' | 'insights'; + activeTab: 'kernels' | 'scopes' | 'profile' | 'system' | 'insights'; comparedScopeIds: string[]; metricsZoomRange?: [number, number]; activeScopeKey?: string; @@ -39,7 +40,7 @@ interface AppState { setActiveEvent: (id?: string) => void; setMetricVisibility: (key: MetricKey, visible: boolean) => void; updateGlobalRange: () => void; - setActiveTab: (tab: 'kernels' | 'scopes' | 'system' | 'insights') => void; + setActiveTab: (tab: 'kernels' | 'scopes' | 'profile' | 'system' | 'insights') => void; toggleComparedScope: (id: string) => void; setMetricsZoom: (range?: [number, number]) => void; setActiveScopeKey: (key?: string) => void; @@ -52,6 +53,7 @@ export const useStore = create((set, get) => ({ events: [], hostMetrics: [], deviceMetrics: [], + systemEvents: [], profileSamples: [], insights: null, metricsRange: undefined, @@ -68,7 +70,7 @@ export const useStore = create((set, get) => ({ powerW: true, fanSpeedPct: false, }, - activeTab: 'kernels' as 'kernels' | 'scopes' | 'system' | 'insights', + activeTab: 'kernels' as 'kernels' | 'scopes' | 'profile' | 'system' | 'insights', comparedScopeIds: [], metricsZoomRange: undefined, activeScopeKey: undefined, @@ -221,60 +223,68 @@ export const useStore = create((set, get) => ({ try { const res = await apiFetch(`/api/v1/events/system?sessionId=${sessionId}`); if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`); - const rawData = await res.json(); - - // The API returns an array of objects, take the first one - const data: SystemMetricsResponse = Array.isArray(rawData) ? rawData[0] : rawData; - - if (!data) { - set({ hostMetrics: [], deviceMetrics: [], metricsRange: undefined }); + // API returns List: each element is one system event with + // all session host/device metrics embedded. Take metrics from the first + // element (they're identical across all elements) and store all events. + const rawData: any[] = await res.json(); + + if (!Array.isArray(rawData) || rawData.length === 0) { + set({ systemEvents: [], hostMetrics: [], deviceMetrics: [], metricsRange: undefined }); return; } - const hostMetrics = data.hostMetrics || []; - const deviceMetrics = (data.deviceMetrics || []).map((m: any) => ({ + const systemEvents: SystemEventRecord[] = rawData.map((e: any) => ({ + sessionId: e.sessionId, + pid: e.pid, + app: e.app, + name: e.name, + eventType: e.eventType, + tsNs: e.tsNs, + rangeStart: e.rangeStart, + rangeEnd: e.rangeEnd, + })); + + const first = rawData[0]; + const hostMetrics: HostMetricSample[] = first.hostMetrics || []; + const deviceMetrics = (first.deviceMetrics || []).map((m: any) => ({ ...m, - // Map backend names to frontend expected names memUsedMib: m.memUsedMib ?? m.usedMib ?? 0, memTotalMib: m.memTotalMib ?? m.totalMib ?? 0, powerW: m.powerW ?? (m.powerMw ? m.powerMw / 1000 : 0), fanSpeedPct: m.fanSpeedPct ?? 0, })); - - let startNs = data.rangeStart; - let endNs = data.rangeEnd; - // Fallback: Calculate range from data if missing - if (!startNs || !endNs || startNs >= endNs) { - const allTs = [ - ...hostMetrics.map(m => m.tsNs), - ...deviceMetrics.map(m => m.tsNs) - ]; - if (allTs.length > 1) { - startNs = Math.min(...allTs); - endNs = Math.max(...allTs); - } else if (allTs.length === 1) { - startNs = allTs[0] - 500_000; // 0.5ms before - endNs = allTs[0] + 500_000; // 0.5ms after - } + // Compute time range from actual metric timestamps (event rangeStart/End + // is just the event's own ts_ns, not the span of all metrics). + const allTs = [ + ...hostMetrics.map((m: HostMetricSample) => m.tsNs), + ...deviceMetrics.map((m: any) => m.tsNs), + ]; + let startNs: number | undefined; + let endNs: number | undefined; + if (allTs.length > 1) { + startNs = Math.min(...allTs); + endNs = Math.max(...allTs); + } else if (allTs.length === 1) { + startNs = allTs[0] - 500_000; + endNs = allTs[0] + 500_000; } - - // Ensure minimum duration of 1us - if (startNs && endNs && endNs - startNs < 1_000) { + if (startNs != null && endNs != null && endNs - startNs < 1_000) { const center = (startNs + endNs) / 2; startNs = center - 500; - endNs = center + 500; + endNs = center + 500; } set({ + systemEvents, hostMetrics, deviceMetrics, - metricsRange: startNs && endNs ? { start_ns: startNs, end_ns: endNs } : undefined, + metricsRange: startNs != null && endNs != null ? { start_ns: startNs, end_ns: endNs } : undefined, }); get().updateGlobalRange(); } catch (err) { console.error('Failed to fetch system metrics', err); - set({ hostMetrics: [], deviceMetrics: [], metricsRange: undefined }); + set({ systemEvents: [], hostMetrics: [], deviceMetrics: [], metricsRange: undefined }); } }, fetchProfileSamples: async (sessionId: string) => { @@ -300,7 +310,7 @@ export const useStore = create((set, get) => ({ } }, selectSession: (id: string) => { - set({ currentSessionId: id }); + set({ currentSessionId: id, activeTab: 'kernels', activeEventId: undefined, highlightRange: undefined, profileSamples: [] }); get().updateGlobalRange(); }, setActiveEvent: (id?: string) => { diff --git a/src/types.ts b/src/types.ts index 8e7f672..25e6d93 100644 --- a/src/types.ts +++ b/src/types.ts @@ -101,6 +101,17 @@ export interface SystemMetricsResponse { deviceMetrics: DeviceMetricSample[]; } +export interface SystemEventRecord { + sessionId: string; + pid: number; + app: string; + name: string; + eventType: string; // "system_start" | "system_stop" + tsNs: number; + rangeStart: number; + rangeEnd: number; +} + export interface RawSession { sessionId: string; app: string; @@ -144,6 +155,9 @@ export interface ProfileSample { occurrenceCount: number; metricName?: string; metricValue?: number; + sourceFile?: string; + sourceLine?: number; + corrId?: number; createdAt?: string; }