diff --git a/packages/app/cypress/component/gpu-metrics-axis.cy.tsx b/packages/app/cypress/component/gpu-metrics-axis.cy.tsx new file mode 100644 index 000000000..2d7443411 --- /dev/null +++ b/packages/app/cypress/component/gpu-metrics-axis.cy.tsx @@ -0,0 +1,117 @@ +import GpuMetricsChart from '@/components/gpu-power/GpuPowerChart'; +import type { GpuMetricRow } from '@/components/gpu-power/types'; +import { HW_REGISTRY } from '@semianalysisai/inferencex-constants'; +import { useState } from 'react'; + +const svg = () => cy.get('[data-testid="gpu-metrics-chart-svg"]'); + +function samples(clock: (sample: number) => number): GpuMetricRow[] { + return Array.from({ length: 8 }, (_chip, index) => + Array.from({ length: 21 }, (_, sample) => ({ + timestamp: new Date(Date.UTC(2026, 8, 20) + sample * 30_000).toISOString(), + index, + power: 300 + sample * 30, + memClock: clock(sample), + })), + ).flat(); +} + +function mountClock(data: GpuMetricRow[]) { + cy.mount( + , + ); +} + +function assertReadableAxis(value: number) { + svg().should(($svg) => { + const labels = $svg.find('.y-axis .tick text').toArray(); + expect(labels.length, 'multiple useful ticks').to.be.greaterThan(1); + const values = labels.map((label) => Number(label.textContent?.replaceAll(',', ''))); + expect(values.every(Number.isFinite), 'numeric ticks').to.eq(true); + expect(new Set(values).size, 'distinct ticks').to.eq(values.length); + expect(Math.min(...values)).to.be.at.most(value); + expect(Math.max(...values)).to.be.greaterThan(value); + const title = $svg.find('.y-axis-label')[0]!.getBoundingClientRect(); + for (const label of labels) { + expect(label.getBoundingClientRect().left, 'tick clears axis title').to.be.greaterThan( + title.right, + ); + } + }); + svg().find('path.line-path').should('have.length', 8); +} + +describe('PowerX telemetry axis', () => { + for (const width of [1280, 375]) { + it(`keeps constant memory clocks readable at ${width}px`, () => { + cy.viewport(width, 800); + mountClock(samples(() => 390)); + assertReadableAxis(390); + }); + } + + it('does not magnify floating-point noise after rolling averages', () => { + mountClock(samples((sample) => 390 - (sample % 2) * Number.EPSILON * 256)); + assertReadableAxis(390); + }); + + it('gives all-zero telemetry a nonzero, nonnegative range', () => { + mountClock(samples(() => 0)); + assertReadableAxis(0); + svg().find('.y-axis .tick text').first().should('have.text', '0.0'); + }); + + it('preserves varying power measurements and the hardware TDP reference', () => { + cy.mount( + 390)} + visibleGpus={new Set([0, 1, 2, 3, 4, 5, 6, 7])} + metricKey="power" + artifactName="gpu_metrics_b200" + display={{ mode: 'points', windowS: 300, series: 'chips' }} + />, + ); + svg().find('.tdp-line text').should('contain.text', `${HW_REGISTRY.b200.tdp}W`); + svg().should(($svg) => { + const values = $svg + .find('.y-axis .tick text') + .toArray() + .map((label) => Number(label.textContent?.replaceAll(',', ''))); + expect(Math.min(...values)).to.be.at.most(300); + expect(Math.max(...values)).to.be.at.least(HW_REGISTRY.b200.tdp); + }); + }); + + it('removes the power reference when switching to memory clock and restores it once', () => { + function SwitchMetric() { + const [metric, setMetric] = useState<'power' | 'memClock'>('power'); + return ( + <> + + 3996)} + visibleGpus={new Set([0, 1, 2, 3, 4, 5, 6, 7])} + metricKey={metric} + artifactName="gpu_metrics_b200" + display={{ mode: 'rolling', windowS: 300, series: 'chips' }} + /> + + ); + } + cy.mount(); + svg().find('.tdp-line').should('have.length', 1); + cy.contains('button', 'Switch metric').click(); + svg().find('.tdp-line').should('not.exist'); + assertReadableAxis(3996); + cy.contains('button', 'Switch metric').click(); + svg().find('.tdp-line').should('have.length', 1); + }); +}); diff --git a/packages/app/src/components/gpu-power/GpuPowerChart.tsx b/packages/app/src/components/gpu-power/GpuPowerChart.tsx index a1209ec78..ab5c6c9dc 100644 --- a/packages/app/src/components/gpu-power/GpuPowerChart.tsx +++ b/packages/app/src/components/gpu-power/GpuPowerChart.tsx @@ -386,8 +386,14 @@ const GpuMetricsChart = React.memo( if (allPoints.length === 0) return [0, 100] as [number, number]; const ext = d3.extent(allPoints, (d) => d.value) as [number, number]; const range = ext[1] - ext[0]; - const yMin = Math.max(0, ext[0] - range * 0.05); - let yMax = ext[1] + range * 0.05; + // Constant telemetry and floating-point noise still need a readable axis. + const padding = Math.max( + range * 0.05, + Math.max(Math.abs(ext[0]), Math.abs(ext[1])) * 0.01, + 1, + ); + const yMin = Math.max(0, ext[0] - padding); + let yMax = ext[1] + padding; if (tdpInfo && tdpInfo.tdp > yMax) yMax = tdpInfo.tdp * 1.05; return [yMin, yMax] as [number, number]; }, [allPoints, tdpInfo]); @@ -419,32 +425,31 @@ const GpuMetricsChart = React.memo( { type: 'custom', key: 'tdp-line', - render: tdpInfo - ? (group, ctx) => { - const yScale = ctx.yScale as d3.ScaleLinear; - const tdpY = yScale(tdpInfo.tdp); - group.selectAll('.tdp-line').remove(); - const tdpGroup = group.append('g').attr('class', 'tdp-line'); - tdpGroup - .append('line') - .attr('x1', 0) - .attr('x2', ctx.width) - .attr('y1', tdpY) - .attr('y2', tdpY) - .attr('stroke', '#ef4444') - .attr('stroke-width', 1.5) - .attr('stroke-dasharray', '6,4'); - tdpGroup - .append('text') - .attr('x', ctx.width - 4) - .attr('y', tdpY - 6) - .attr('text-anchor', 'end') - .attr('fill', '#ef4444') - .attr('font-size', '11px') - .attr('font-weight', '600') - .text(`${tdpInfo.sku} TDP: ${tdpInfo.tdp}W`); - } - : null, + render: (group, ctx) => { + group.selectAll('.tdp-line').remove(); + if (!tdpInfo) return; + const yScale = ctx.yScale as d3.ScaleLinear; + const tdpY = yScale(tdpInfo.tdp); + const tdpGroup = group.append('g').attr('class', 'tdp-line'); + tdpGroup + .append('line') + .attr('x1', 0) + .attr('x2', ctx.width) + .attr('y1', tdpY) + .attr('y2', tdpY) + .attr('stroke', '#ef4444') + .attr('stroke-width', 1.5) + .attr('stroke-dasharray', '6,4'); + tdpGroup + .append('text') + .attr('x', ctx.width - 4) + .attr('y', tdpY - 6) + .attr('text-anchor', 'end') + .attr('fill', '#ef4444') + .attr('font-size', '11px') + .attr('font-weight', '600') + .text(`${tdpInfo.sku} TDP: ${tdpInfo.tdp}W`); + }, }, // GPU lines {