From 661622a6323a63287fafa412f47cce30bb9482a9 Mon Sep 17 00:00:00 2001 From: fatadel Date: Mon, 29 Jun 2026 18:27:05 +0200 Subject: [PATCH 1/2] Show counter values over time in profiler-cli Add an "over time" section to counter info that splits the current view into time buckets, so a counter's trajectory is visible rather than only an aggregate. Each slice shows the graph-type-appropriate value (level and delta for accumulated counters, the amount for rate counters), its share of the range, and a CO2e estimate where the schema requests one. Values reuse the tooltip formatters and ts-N time names, matching the timeline. A fixed-width sparkline of the trajectory is drawn alongside, in both `counter info` and `counter list`. Closes #6112 --- profiler-cli/guide.txt | 10 +- profiler-cli/schemas.txt | 7 +- profiler-cli/src/formatters.ts | 68 ++++- .../src/test/unit/counter-formatting.test.ts | 92 ++++++ src/profile-query/formatters/counter-info.ts | 265 +++++++++++++++++- src/profile-query/index.ts | 1 + src/profile-query/types.ts | 28 ++ .../profile-query/profile-querier.test.ts | 96 +++++++ 8 files changed, 555 insertions(+), 12 deletions(-) diff --git a/profiler-cli/guide.txt b/profiler-cli/guide.txt index 65cef7e2eb..e14b2c59a3 100644 --- a/profiler-cli/guide.txt +++ b/profiler-cli/guide.txt @@ -224,7 +224,7 @@ COUNTERS network bandwidth, process CPU, power, and similar. Each counter has a handle (c-0, c-1, ...) and carries its own display metadata (label, unit, graph type). - profiler-cli counter list List all counters with one-line summaries + profiler-cli counter list List all counters, each with a sparkline profiler-cli counter info c-0 Detailed info and stats for one counter Counters also appear in "profile info", listed under their owning process @@ -235,6 +235,14 @@ COUNTERS memory range for Memory, data transferred for Bandwidth, or energy used (with a CO2e estimate) for Power. + "counter info" also prints an "over time" section that splits the current view + into time buckets, so you can see when a counter's value changed - for any + counter, in its own unit. What each slice shows follows the counter's graph + type: accumulated counters (e.g. Memory) show the level and its change, rate + counters (e.g. Bandwidth, Power) show the amount in the slice. Each slice also + shows its share of the range, and a sparkline draws the trajectory above them. + "counter list" shows a sparkline next to each counter as well. + All counter stats respect the current zoom: with no zoom they cover the whole profile; after "zoom push" they cover the committed range. Combine with zoom to see, for example, how much memory a specific time window allocated: diff --git a/profiler-cli/schemas.txt b/profiler-cli/schemas.txt index 602560a352..b3a5de298f 100644 --- a/profiler-cli/schemas.txt +++ b/profiler-cli/schemas.txt @@ -32,7 +32,8 @@ CounterSummary: unit, graphType, color, pid, mainThreadIndex, mainThreadHandle, mainThreadName, rangeSampleCount, - stats: [{ source, label, value, formattedValue, carbon? }] + stats: [{ source, label, value, formattedValue, carbon? }], + graph: [number] } profiler-cli counter list --json @@ -49,6 +50,10 @@ profiler-cli counter info --json description, sampleCount, rangeStart, rangeEnd, + overTime: [{ startTime, startTimeName, startTimeStr, + endTime, endTimeName, endTimeStr, + value, formattedValue, delta?, formattedDelta?, + percentage?, formattedPercentage?, carbon? }], context: SessionContext } diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts index 6dee2303bc..4bae81bb0c 100644 --- a/profiler-cli/src/formatters.ts +++ b/profiler-cli/src/formatters.ts @@ -490,8 +490,42 @@ export function formatCounterListResult( if (result.counters.length === 0) { return `${contextHeader}\n\nNo counters in this profile.`; } - const lines = result.counters.map(formatCounterSummaryLine); - return `${contextHeader}\n\nCounters (${result.counters.length}):\n${lines.join('\n')}`; + const blocks = result.counters.map((counter) => { + const block = [formatCounterSummaryLine(counter)]; + if (counter.graph.length > 0) { + block.push(` ${renderSparkline(counter.graph)}`); + } + return block.join('\n'); + }); + // Trailing blank line so the last counter's sparkline is separated from the + // prompt, matching the blank lines between counters. + return `${contextHeader}\n\nCounters (${result.counters.length}):\n${blocks.join('\n\n')}\n`; +} + +const SPARKLINE_CHARS = '▁▂▃▄▅▆▇█'; + +/** + * Render a compact sparkline of the given values using block characters. + * Heights are normalized across the series (min..max); a flat series renders + * at a mid-height rather than the floor so it doesn't read as zero. + */ +function renderSparkline(values: number[]): string { + if (values.length === 0) { + return ''; + } + const min = Math.min(...values); + const max = Math.max(...values); + const range = max - min; + const lastIndex = SPARKLINE_CHARS.length - 1; + if (range === 0) { + return SPARKLINE_CHARS[Math.floor(lastIndex / 2)].repeat(values.length); + } + return values + .map((value) => { + const index = Math.round(((value - min) / range) * lastIndex); + return SPARKLINE_CHARS[index]; + }) + .join(''); } /** @@ -534,6 +568,36 @@ export function formatCounterInfoResult( lines.push(` ${stat.label}: ${value}`); } } + if (result.overTime.length > 0) { + lines.push(` ${result.label} over time:`); + if (result.graph.length > 0) { + lines.push(` ${renderSparkline(result.graph)}`); + lines.push(''); + } + // Build the columns first, then pad each to its widest cell so the values + // line up in a column. + const rows = result.overTime.map((bucket) => { + const extras = [ + bucket.formattedDelta, + bucket.formattedPercentage, + bucket.carbon, + ].filter((part) => part !== undefined); + return { + handles: `[${bucket.startTimeName} → ${bucket.endTimeName}]`, + times: `(${bucket.startTimeStr} - ${bucket.endTimeStr})`, + value: bucket.formattedValue, + extras: extras.length > 0 ? `(${extras.join(', ')})` : '', + }; + }); + const handlesWidth = Math.max(...rows.map((row) => row.handles.length)); + const timesWidth = Math.max(...rows.map((row) => row.times.length)); + const valueWidth = Math.max(...rows.map((row) => row.value.length)); + for (const row of rows) { + lines.push( + ` ${row.handles.padEnd(handlesWidth)} ${row.times.padEnd(timesWidth)} ${row.value.padEnd(valueWidth)} ${row.extras}`.trimEnd() + ); + } + } return lines.join('\n'); } diff --git a/profiler-cli/src/test/unit/counter-formatting.test.ts b/profiler-cli/src/test/unit/counter-formatting.test.ts index a9b7ca0958..ff18cc2178 100644 --- a/profiler-cli/src/test/unit/counter-formatting.test.ts +++ b/profiler-cli/src/test/unit/counter-formatting.test.ts @@ -46,6 +46,7 @@ function makeCounter(overrides: Partial = {}): CounterSummary { formattedValue: '27B', }, ], + graph: [], ...overrides, }; } @@ -95,6 +96,19 @@ describe('formatCounterListResult', function () { 'No counters in this profile.' ); }); + + it('renders a sparkline next to each counter', function () { + const result: WithContext = { + context: createContext(), + type: 'counter-list', + counters: [makeCounter({ graph: [1, 5, 9] })], + }; + + const output = formatCounterListResult(result); + expect(output).toContain('c-0: Memory (Memory)'); + expect(output).toContain('▁'); // lowest graph value + expect(output).toContain('█'); // highest graph value + }); }); describe('formatCounterInfoResult', function () { @@ -109,6 +123,7 @@ describe('formatCounterInfoResult', function () { sampleCount: 7, rangeStart: 0, rangeEnd: 10, + overTime: [], ...overrides, }; } @@ -147,4 +162,81 @@ describe('formatCounterInfoResult', function () { 'Energy used in the visible range: 5 Wh (2 g CO₂e)' ); }); + + it('renders the over-time section with level and delta', function () { + const output = formatCounterInfoResult( + makeInfo({ + overTime: [ + { + startTime: 0, + startTimeName: 'ts-0', + startTimeStr: '0s', + endTime: 5, + endTimeName: 'ts-K', + endTimeStr: '5ms', + value: 2_100_000, + formattedValue: '2.1 MB', + delta: 2_100_000, + formattedDelta: '+2.1 MB', + percentage: 0.25, + formattedPercentage: '25%', + }, + { + startTime: 5, + startTimeName: 'ts-K', + startTimeStr: '5ms', + endTime: 10, + endTimeName: 'ts-Z', + endTimeStr: '10ms', + value: 8_400_000, + formattedValue: '8.4 MB', + delta: 6_300_000, + formattedDelta: '+6.3 MB', + percentage: 1, + formattedPercentage: '100%', + }, + ], + }) + ); + expect(output).toContain('Memory over time:'); + // Columns are padded for alignment, so allow variable whitespace between them. + expect(output).toMatch( + /\[ts-0 → ts-K\]\s+\(0s - 5ms\)\s+2\.1 MB\s+\(\+2\.1 MB, 25%\)/ + ); + expect(output).toMatch( + /\[ts-K → ts-Z\]\s+\(5ms - 10ms\)\s+8\.4 MB\s+\(\+6\.3 MB, 100%\)/ + ); + }); + + function makeBucket( + value: number, + index: number + ): CounterInfoResult['overTime'][number] { + return { + startTime: index, + startTimeName: `ts-${index}`, + startTimeStr: `${index}ms`, + endTime: index + 1, + endTimeName: `ts-${index + 1}`, + endTimeStr: `${index + 1}ms`, + value, + formattedValue: `${value}B`, + }; + } + + it('renders a sparkline from the graph values', function () { + const output = formatCounterInfoResult( + makeInfo({ overTime: [makeBucket(1, 0)], graph: [1, 5, 9] }) + ); + expect(output).toContain('Memory over time:'); + expect(output).toContain('▁'); // lowest graph value + expect(output).toContain('█'); // highest graph value + }); + + it('omits the sparkline when the graph is empty', function () { + const output = formatCounterInfoResult( + makeInfo({ overTime: [makeBucket(1, 0)], graph: [] }) + ); + expect(output).not.toMatch(/[▁▂▃▄▅▆▇█]/); + }); }); diff --git a/src/profile-query/formatters/counter-info.ts b/src/profile-query/formatters/counter-info.ts index 57ad58f0bd..e8aa9fbd8b 100644 --- a/src/profile-query/formatters/counter-info.ts +++ b/src/profile-query/formatters/counter-info.ts @@ -4,9 +4,10 @@ import { getProfile, - getProfileRootRange, getCounters, getCounterSelectors, + getCommittedRange, + getProfileInterval, getMeta, } from 'firefox-profiler/selectors/profile'; import { @@ -25,19 +26,29 @@ import { import { getCounterHandle, parseCounterHandle } from '../counter-map'; import type { CounterIndex, + CounterDisplayConfig, CounterTooltipDataSource, CounterTooltipFormat, + CounterTooltipRow, ProfileMeta, } from 'firefox-profiler/types'; import type { Store } from '../../types/store'; import type { ThreadMap } from '../thread-map'; +import type { TimestampManager } from '../timestamps'; import type { CounterStat, CounterSummary, + CounterTimeBucket, CounterListResult, CounterInfoResult, } from '../types'; +// Number of buckets in the detailed "over time" table. +const OVER_TIME_BUCKET_COUNT = 10; + +// Width (in characters/points) of the higher-resolution sparkline series. +const GRAPH_BUCKET_COUNT = 50; + // The tooltip schema describes many per-sample and preview-selection rows that // only make sense at a hover point. These are the two sources that aggregate // over the whole committed range, so they are the only ones a CLI summary can @@ -200,6 +211,7 @@ export function collectCounterSummary( mainThreadName, rangeSampleCount: Math.max(0, rangeEndIndex - rangeStartIndex), stats: collectCounterStats(store, counterIndex), + graph: collectCounterGraph(store, counterIndex), }; } @@ -219,12 +231,251 @@ export function collectCounterList( }; } +/** + * Decide how to aggregate a counter's values per time bucket, and which tooltip + * format to render them with. Accumulated counters report the running level; + * rate counters report the amount summed over the bucket (using their + * range-aggregate row's format), except process CPU, which averages its ratio. + */ +function getOverTimeMode(display: CounterDisplayConfig): { + kind: 'level' | 'sum' | 'avg-ratio'; + format: CounterTooltipFormat; +} { + type ValueRow = Extract; + const rowFor = (source: CounterTooltipDataSource): ValueRow | undefined => { + for (const row of display.tooltipRows) { + if (row.type === 'value' && row.source === source) { + return row; + } + } + return undefined; + }; + const fallback: CounterTooltipFormat = { + unit: display.unit === 'bytes' ? 'bytes' : 'number', + }; + + if (display.graphType === 'line-accumulated') { + return { kind: 'level', format: rowFor('accumulated')?.format ?? fallback }; + } + + const rangeRow = rowFor('count-range') ?? rowFor('committed-range-total'); + if (rangeRow) { + return { kind: 'sum', format: rangeRow.format }; + } + const cpuRow = rowFor('cpu-ratio'); + if (cpuRow) { + return { kind: 'avg-ratio', format: cpuRow.format }; + } + return { kind: 'sum', format: rowFor('count')?.format ?? fallback }; +} + +type BinnedBucket = { + startTime: number; + endTime: number; + value: number; + delta?: number; // accumulated counters only +}; + +type BinnedCounter = { + kind: 'level' | 'sum' | 'avg-ratio'; + format: CounterTooltipFormat; + countRange: number; // for the accumulated (level) share + totalSum: number; // for the rate (sum) share + buckets: BinnedBucket[]; +}; + +/** + * Split the current committed range into `requestedBucketCount` equal-width time + * buckets and compute a raw value per bucket. Sample times are absolute. Shared + * by the detailed "over time" table and the sparkline. + * + * `capToSampleCount` keeps the table from showing more rows than there are + * samples; the sparkline leaves it off for a consistent width, filling + * sample-less buckets by carrying the level forward (accumulated) or with zero. + */ +function binCounterValues( + store: Store, + counterIndex: CounterIndex, + requestedBucketCount: number, + capToSampleCount: boolean +): BinnedCounter | null { + const state = store.getState(); + const selectors = getCounterSelectors(counterIndex); + const counter = selectors.getCounter(state); + const { samples, display } = counter; + const range = getCommittedRange(state); + const [startIndex, endIndex] = + selectors.getCommittedRangeCounterSampleRange(state); + const { minCount, countRange, accumulatedCounts } = + selectors.getAccumulateCounterSamples(state); + + const span = range.end - range.start; + if (endIndex <= startIndex || span <= 0) { + return null; + } + + const { kind, format } = getOverTimeMode(display); + const interval = getProfileInterval(state); + const maxCountPerMs = + kind === 'avg-ratio' ? selectors.getMaxCounterSampleCountPerMs(state) : 0; + + const bucketCount = Math.max( + 1, + capToSampleCount + ? Math.min(requestedBucketCount, endIndex - startIndex) + : requestedBucketCount + ); + const width = span / bucketCount; + + type Acc = { + sum: number; + ratioSum: number; + ratioN: number; + level: number | null; + }; + const accs: Acc[] = Array.from({ length: bucketCount }, () => ({ + sum: 0, + ratioSum: 0, + ratioN: 0, + level: null, + })); + + for (let i = startIndex; i < endIndex; i++) { + const bucketIndex = Math.min( + bucketCount - 1, + Math.max(0, Math.floor((samples.time[i] - range.start) / width)) + ); + const acc = accs[bucketIndex]; + if (kind === 'level') { + acc.level = accumulatedCounts[i] - minCount; + } else if (kind === 'sum') { + acc.sum += samples.count[i]; + } else { + const dt = i === 0 ? interval : samples.time[i] - samples.time[i - 1]; + if (dt > 0 && maxCountPerMs > 0) { + acc.ratioSum += samples.count[i] / dt / maxCountPerMs; + acc.ratioN += 1; + } + } + } + + const totalSum = accs.reduce((sum, acc) => sum + acc.sum, 0); + + let carriedLevel = 0; + const buckets: BinnedBucket[] = accs.map((acc, bucketIndex) => { + const startTime = range.start + bucketIndex * width; + const endTime = + bucketIndex === bucketCount - 1 + ? range.end + : range.start + (bucketIndex + 1) * width; + + let value: number; + let delta: number | undefined; + if (kind === 'level') { + const level = acc.level ?? carriedLevel; + delta = level - carriedLevel; + carriedLevel = level; + value = level; + } else if (kind === 'avg-ratio') { + value = acc.ratioN > 0 ? acc.ratioSum / acc.ratioN : 0; + } else { + value = acc.sum; + } + return { startTime, endTime, value, delta }; + }); + + return { kind, format, countRange, totalSum, buckets }; +} + +/** + * The detailed "over time" table: a small number of buckets with formatted + * values, deltas, and per-slice shares. Rate buckets are shown as a share of the + * range total; accumulated buckets as a share of the range peak (countRange). + */ +function collectCounterOverTime( + store: Store, + counterIndex: CounterIndex, + timestampManager: TimestampManager +): CounterTimeBucket[] { + const binned = binCounterValues( + store, + counterIndex, + OVER_TIME_BUCKET_COUNT, + true + ); + if (binned === null) { + return []; + } + const meta = getMeta(store.getState()); + const { kind, format, countRange, totalSum, buckets } = binned; + + return buckets.map((bucket): CounterTimeBucket => { + const { formattedValue, carbon } = formatCounterRowValue( + bucket.value, + format, + meta + ); + + let formattedDelta: string | undefined; + if (bucket.delta !== undefined) { + const sign = bucket.delta < 0 ? '-' : '+'; + formattedDelta = + sign + + formatCounterRowValue(Math.abs(bucket.delta), format, meta) + .formattedValue; + } + + let percentage: number | undefined; + if (kind === 'level' && countRange > 0) { + percentage = bucket.value / countRange; + } else if (kind === 'sum' && totalSum > 0) { + percentage = bucket.value / totalSum; + } + const formattedPercentage = + percentage !== undefined ? formatPercent(percentage) : undefined; + + return { + startTime: bucket.startTime, + startTimeName: timestampManager.nameForTimestamp(bucket.startTime), + startTimeStr: timestampManager.timestampString(bucket.startTime), + endTime: bucket.endTime, + endTimeName: timestampManager.nameForTimestamp(bucket.endTime), + endTimeStr: timestampManager.timestampString(bucket.endTime), + value: bucket.value, + formattedValue, + delta: bucket.delta, + formattedDelta, + percentage, + formattedPercentage, + carbon, + }; + }); +} + +/** + * A higher-resolution series of raw per-bucket values for the sparkline, + * independent of the detailed "over time" table's coarser buckets. + */ +function collectCounterGraph( + store: Store, + counterIndex: CounterIndex +): number[] { + const binned = binCounterValues( + store, + counterIndex, + GRAPH_BUCKET_COUNT, + false + ); + return binned === null ? [] : binned.buckets.map((bucket) => bucket.value); +} + /** * Build detailed information about a single counter, resolved by handle. */ export function collectCounterInfo( store: Store, threadMap: ThreadMap, + timestampManager: TimestampManager, counterHandle: string ): CounterInfoResult { const state = store.getState(); @@ -237,14 +488,11 @@ export function collectCounterInfo( const [rangeStartIndex, rangeEndIndex] = selectors.getCommittedRangeCounterSampleRange(state); - const zeroAt = getProfileRootRange(state).start; + // Sample times are already in absolute profile-time space (the same space as + // the committed range), so they need no zeroAt offset here. const hasRange = rangeEndIndex > rangeStartIndex; - const rangeStart = hasRange - ? counter.samples.time[rangeStartIndex] + zeroAt - : null; - const rangeEnd = hasRange - ? counter.samples.time[rangeEndIndex - 1] + zeroAt - : null; + const rangeStart = hasRange ? counter.samples.time[rangeStartIndex] : null; + const rangeEnd = hasRange ? counter.samples.time[rangeEndIndex - 1] : null; return { ...summary, @@ -253,5 +501,6 @@ export function collectCounterInfo( sampleCount: counter.samples.length, rangeStart, rangeEnd, + overTime: collectCounterOverTime(store, counterIndex, timestampManager), }; } diff --git a/src/profile-query/index.ts b/src/profile-query/index.ts index 73d9959263..369725a12e 100644 --- a/src/profile-query/index.ts +++ b/src/profile-query/index.ts @@ -230,6 +230,7 @@ export class ProfileQuerier { const result = collectCounterInfo( this._store, this._threadMap, + this._timestampManager, counterHandle ); return { ...result, context: this._getContext() }; diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index 65e1135b3a..d3bc43c43b 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -704,6 +704,9 @@ export type CounterSummary = { mainThreadName: string; rangeSampleCount: number; // samples within the current range stats: CounterStat[]; // range-aggregate stats from the tooltip schema + // Raw values for a sparkline of the counter's trajectory over the current + // view. Empty when the counter has no in-range samples. + graph: number[]; }; export type CounterListResult = { @@ -711,12 +714,37 @@ export type CounterListResult = { counters: CounterSummary[]; }; +/** + * One time bucket of a counter's "over time" breakdown. The current view is + * split into equal-width buckets; each carries the bucket's value formatted via + * the tooltip schema. `delta` is present only for accumulated counters. + */ +export type CounterTimeBucket = { + startTime: number; // absolute time of the bucket start + startTimeName: string; // e.g. "ts-0" + startTimeStr: string; // human-readable, relative to profile start, e.g. "1.4s" + endTime: number; + endTimeName: string; + endTimeStr: string; + value: number; + formattedValue: string; + delta?: number; + formattedDelta?: string; // signed, e.g. "+6.3 MB" + // Ratio (0..1) of the bucket's value: share of the range total for rate + // counters, share of the range peak for accumulated counters. Omitted for + // process CPU, whose value is already a percentage. + percentage?: number; + formattedPercentage?: string; // e.g. "60%" + carbon?: string; // when the row's format requests a CO2e estimate +}; + export type CounterInfoResult = CounterSummary & { type: 'counter-info'; description: string; sampleCount: number; // total samples in the counter (whole profile) rangeStart: number | null; // absolute time of first in-range sample rangeEnd: number | null; // absolute time of last in-range sample + overTime: CounterTimeBucket[]; // per-bucket values across the current view }; // ===== Profile Commands ===== diff --git a/src/test/unit/profile-query/profile-querier.test.ts b/src/test/unit/profile-query/profile-querier.test.ts index eec3b04b6e..1734d0d94a 100644 --- a/src/test/unit/profile-query/profile-querier.test.ts +++ b/src/test/unit/profile-query/profile-querier.test.ts @@ -20,6 +20,7 @@ import { ProfileQuerier } from 'firefox-profiler/profile-query'; import { getProfileFromTextSamples, getCounterForThread, + getCounterForThreadWithSamples, } from '../../fixtures/profiles/processed-profile'; import { getProfileRootRange } from 'firefox-profiler/selectors/profile'; import { storeWithProfile } from '../../fixtures/stores'; @@ -450,6 +451,101 @@ describe('ProfileQuerier', function () { 'Unknown counter c-0' ); }); + + it('reports counter values over time', async function () { + const { profile } = getProfileFromTextSamples(` + 0 1 2 3 4 5 6 7 8 9 + A A A A A A A A A A + `); + const counter = getCounterForThreadWithSamples( + profile.threads[0], + 0, + { + count: [0, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000], + length: 10, + }, + 'malloc', + 'Memory' + ); + profile.counters = [counter]; + + const info = await querierFor(profile).counterInfo('c-0'); + + expect(info.overTime.length).toBeGreaterThan(0); + // Accumulated counters report a per-bucket level, delta, and share of peak. + expect(info.overTime.every((b) => b.delta !== undefined)).toBe(true); + expect(info.overTime.every((b) => b.percentage !== undefined)).toBe(true); + expect(info.overTime[0].startTimeName).toMatch(/^ts-/); + // Memory only grows here, so the last level is at least the first. + const first = info.overTime[0].value; + const last = info.overTime[info.overTime.length - 1].value; + expect(last).toBeGreaterThanOrEqual(first); + // The sparkline is of fixed width. + expect(info.graph.length).toBe(50); + }); + + it('gives the graph a fixed width, wider than the over-time buckets', async function () { + const columns = Array.from({ length: 60 }, (_, i) => i); + const { profile } = getProfileFromTextSamples( + `${columns.join(' ')}\n${columns.map(() => 'A').join(' ')}` + ); + const counter = getCounterForThreadWithSamples( + profile.threads[0], + 0, + { count: columns.map(() => 1000), length: 60 }, + 'malloc', + 'Memory' + ); + profile.counters = [counter]; + + const info = await querierFor(profile).counterInfo('c-0'); + + expect(info.overTime.length).toBe(10); + expect(info.graph.length).toBe(50); + }); + + it('reports a per-bucket amount (no delta) for rate counters', async function () { + const { profile } = getProfileFromTextSamples(` + 0 1 2 3 4 5 6 7 8 9 + A A A A A A A A A A + `); + const counter = getCounterForThreadWithSamples( + profile.threads[0], + 0, + { count: [0, 10, 20, 30, 40, 50, 60, 70, 80, 90], length: 10 }, + 'eth0', + 'Bandwidth' + ); + profile.counters = [counter]; + + const info = await querierFor(profile).counterInfo('c-0'); + + expect(info.graphType).toBe('line-rate'); + expect(info.overTime.length).toBeGreaterThan(0); + // Rate counters report an amount per bucket (no level/delta) and a share + // of the range total. + expect(info.overTime.every((b) => b.delta === undefined)).toBe(true); + expect(info.overTime.every((b) => b.percentage !== undefined)).toBe(true); + expect(info.graph.length).toBeGreaterThan(0); + }); + + it('reports range times relative to the profile start, not double-offset', async function () { + // Profile that does not start at zero. + const { profile } = getProfileFromTextSamples(` + 1000 1010 1020 + A A A + `); + const counter = getCounterForThread(profile.threads[0], 0, { + name: 'malloc', + category: 'Memory', + }); + profile.counters = [counter]; + + const info = await querierFor(profile).counterInfo('c-0'); + + expect(info.rangeStart).not.toBeNull(); + expect(info.rangeStart! - info.context.rootRange.start).toBe(0); + }); }); describe('threadSamples', function () { From 1a5333cba06f55916288a32ea6d0655954e21b0c Mon Sep 17 00:00:00 2001 From: fatadel Date: Tue, 7 Jul 2026 15:58:54 +0200 Subject: [PATCH 2/2] Refine profiler-cli counter over-time scaling and range edges - Scale the sparkline per graph type: relative (min-max) for accumulated counters, absolute for rate counters (process CPU pinned to 0-100%). - Compute process-CPU ratios with the range-scoped max, matching the timeline tooltip. - Exclude the padded boundary samples from the range's counts, sums, and time span. - Rename the internal bucket types and helper (drop "binned"). - Trim the guide's counters section. --- profiler-cli/guide.txt | 16 ++--- profiler-cli/src/formatters.ts | 39 +++++++++--- .../src/test/unit/counter-formatting.test.ts | 45 ++++++++++++++ src/profile-query/formatters/counter-info.ts | 62 +++++++++++++------ .../profile-query/profile-querier.test.ts | 30 +++++++++ 5 files changed, 152 insertions(+), 40 deletions(-) diff --git a/profiler-cli/guide.txt b/profiler-cli/guide.txt index e14b2c59a3..65884b75a1 100644 --- a/profiler-cli/guide.txt +++ b/profiler-cli/guide.txt @@ -230,18 +230,10 @@ COUNTERS Counters also appear in "profile info", listed under their owning process next to that process's threads (much like the timeline track list). - The stats shown come from the counter's own tooltip schema, so they match the - timeline tooltips. Each counter reports its whole-range aggregates, e.g. the - memory range for Memory, data transferred for Bandwidth, or energy used (with a - CO2e estimate) for Power. - - "counter info" also prints an "over time" section that splits the current view - into time buckets, so you can see when a counter's value changed - for any - counter, in its own unit. What each slice shows follows the counter's graph - type: accumulated counters (e.g. Memory) show the level and its change, rate - counters (e.g. Bandwidth, Power) show the amount in the slice. Each slice also - shows its share of the range, and a sparkline draws the trajectory above them. - "counter list" shows a sparkline next to each counter as well. + "counter info" also prints an "over time" section: the current view split into + time buckets. Accumulated counters (e.g. Memory) show each bucket's level and its + change; rate counters (e.g. Bandwidth, Power) show the amount per bucket and its + share of the range. All counter stats respect the current zoom: with no zoom they cover the whole profile; after "zoom push" they cover the committed range. Combine with zoom to diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts index 4bae81bb0c..9b07369de2 100644 --- a/profiler-cli/src/formatters.ts +++ b/profiler-cli/src/formatters.ts @@ -493,7 +493,7 @@ export function formatCounterListResult( const blocks = result.counters.map((counter) => { const block = [formatCounterSummaryLine(counter)]; if (counter.graph.length > 0) { - block.push(` ${renderSparkline(counter.graph)}`); + block.push(` ${counterSparkline(counter)}`); } return block.join('\n'); }); @@ -505,29 +505,48 @@ export function formatCounterListResult( const SPARKLINE_CHARS = '▁▂▃▄▅▆▇█'; /** - * Render a compact sparkline of the given values using block characters. - * Heights are normalized across the series (min..max); a flat series renders - * at a mid-height rather than the floor so it doesn't read as zero. + * Render a compact sparkline of the values using block characters, scaled + * between `min` (floor) and `max` (top). Values are clamped to that range; a + * zero-width range renders at mid-height so it doesn't read as the floor. */ -function renderSparkline(values: number[]): string { +function renderSparkline(values: number[], min: number, max: number): string { if (values.length === 0) { return ''; } - const min = Math.min(...values); - const max = Math.max(...values); const range = max - min; const lastIndex = SPARKLINE_CHARS.length - 1; - if (range === 0) { + if (range <= 0) { return SPARKLINE_CHARS[Math.floor(lastIndex / 2)].repeat(values.length); } return values .map((value) => { - const index = Math.round(((value - min) / range) * lastIndex); + const clamped = Math.min(max, Math.max(min, value)); + const index = Math.round(((clamped - min) / range) * lastIndex); return SPARKLINE_CHARS[index]; }) .join(''); } +/** + * Render a counter's sparkline, choosing the baseline per graph type so the + * heights are meaningful: accumulated counters (e.g. Memory) are relative, so + * they scale between their own min and max; rate counters are absolute and + * anchored at zero, with percent counters (e.g. Process CPU) pinned to 0-100%. + */ +function counterSparkline(counter: CounterSummary): string { + const { graph } = counter; + if (graph.length === 0) { + return ''; + } + if (counter.graphType === 'line-accumulated') { + return renderSparkline(graph, Math.min(...graph), Math.max(...graph)); + } + if (counter.unit === 'percent') { + return renderSparkline(graph, 0, 1); + } + return renderSparkline(graph, 0, Math.max(...graph)); +} + /** * Format a CounterInfoResult as plain text. */ @@ -571,7 +590,7 @@ export function formatCounterInfoResult( if (result.overTime.length > 0) { lines.push(` ${result.label} over time:`); if (result.graph.length > 0) { - lines.push(` ${renderSparkline(result.graph)}`); + lines.push(` ${counterSparkline(result)}`); lines.push(''); } // Build the columns first, then pad each to its widest cell so the values diff --git a/profiler-cli/src/test/unit/counter-formatting.test.ts b/profiler-cli/src/test/unit/counter-formatting.test.ts index ff18cc2178..7377d0720c 100644 --- a/profiler-cli/src/test/unit/counter-formatting.test.ts +++ b/profiler-cli/src/test/unit/counter-formatting.test.ts @@ -106,9 +106,54 @@ describe('formatCounterListResult', function () { const output = formatCounterListResult(result); expect(output).toContain('c-0: Memory (Memory)'); + // Memory is relative: it scales between its own min and max. expect(output).toContain('▁'); // lowest graph value expect(output).toContain('█'); // highest graph value }); + + function listOf(counter: CounterSummary): WithContext { + return { + context: createContext(), + type: 'counter-list', + counters: [counter], + }; + } + + it('scales a percent (CPU) sparkline absolutely, 0 to 100%', function () { + const output = formatCounterListResult( + listOf( + makeCounter({ + label: 'Process CPU', + category: 'CPU', + graphType: 'line-rate', + unit: 'percent', + graph: [0.5, 0.55, 0.6], + }) + ) + ); + // ~50-60% on a 0-100% scale is mid-height: not the floor (the reviewer's + // "50% treated as 0" bug) and not the top (60% is not 100%). + expect(output).not.toContain('▁'); + expect(output).not.toContain('█'); + expect(output).toContain('▅'); + }); + + it('anchors a rate (bytes) sparkline at zero, not the series min', function () { + const output = formatCounterListResult( + listOf( + makeCounter({ + label: 'Bandwidth', + category: 'Bandwidth', + graphType: 'line-rate', + unit: 'bytes', + graph: [10, 20, 30], + }) + ) + ); + // The smallest slice (10) sits above the zero baseline, so it isn't the floor. + expect(output).not.toContain('▁'); + expect(output).toContain('█'); // the peak slice + }); }); describe('formatCounterInfoResult', function () { diff --git a/src/profile-query/formatters/counter-info.ts b/src/profile-query/formatters/counter-info.ts index e8aa9fbd8b..6a296af860 100644 --- a/src/profile-query/formatters/counter-info.ts +++ b/src/profile-query/formatters/counter-info.ts @@ -23,6 +23,7 @@ import { ENERGY_LADDER, pickTier, } from 'firefox-profiler/components/timeline/TrackCounterTooltipFormat'; +import { getSampleIndexRangeForSelection } from 'firefox-profiler/profile-logic/profile-data'; import { getCounterHandle, parseCounterHandle } from '../counter-map'; import type { CounterIndex, @@ -191,8 +192,10 @@ export function collectCounterSummary( const counter = selectors.getCounter(state); const { display } = counter; - const [rangeStartIndex, rangeEndIndex] = - selectors.getCommittedRangeCounterSampleRange(state); + const [rangeStartIndex, rangeEndIndex] = getInRangeSampleIndexes( + store, + counterIndex + ); const mainThreadName = profile.threads[counter.mainThreadIndex]?.name ?? ''; @@ -269,19 +272,35 @@ function getOverTimeMode(display: CounterDisplayConfig): { return { kind: 'sum', format: rowFor('count')?.format ?? fallback }; } -type BinnedBucket = { +/** + * The sample indexes strictly inside the committed range. The counter + * selectors' committed range is padded by one sample on each side (for graph + * continuity); those boundary samples are outside the current view, so counts, + * sums, and time spans should exclude them. + */ +function getInRangeSampleIndexes( + store: Store, + counterIndex: CounterIndex +): [number, number] { + const state = store.getState(); + const { samples } = getCounterSelectors(counterIndex).getCounter(state); + const range = getCommittedRange(state); + return getSampleIndexRangeForSelection(samples, range.start, range.end); +} + +type CounterBucket = { startTime: number; endTime: number; value: number; delta?: number; // accumulated counters only }; -type BinnedCounter = { +type CounterBuckets = { kind: 'level' | 'sum' | 'avg-ratio'; format: CounterTooltipFormat; countRange: number; // for the accumulated (level) share totalSum: number; // for the rate (sum) share - buckets: BinnedBucket[]; + buckets: CounterBucket[]; }; /** @@ -293,19 +312,18 @@ type BinnedCounter = { * samples; the sparkline leaves it off for a consistent width, filling * sample-less buckets by carrying the level forward (accumulated) or with zero. */ -function binCounterValues( +function getCounterBuckets( store: Store, counterIndex: CounterIndex, requestedBucketCount: number, capToSampleCount: boolean -): BinnedCounter | null { +): CounterBuckets | null { const state = store.getState(); const selectors = getCounterSelectors(counterIndex); const counter = selectors.getCounter(state); const { samples, display } = counter; const range = getCommittedRange(state); - const [startIndex, endIndex] = - selectors.getCommittedRangeCounterSampleRange(state); + const [startIndex, endIndex] = getInRangeSampleIndexes(store, counterIndex); const { minCount, countRange, accumulatedCounts } = selectors.getAccumulateCounterSamples(state); @@ -316,8 +334,12 @@ function binCounterValues( const { kind, format } = getOverTimeMode(display); const interval = getProfileInterval(state); + // Use the same range-aware max as the timeline's cpu-ratio tooltip + // (getMaxRangeCounterSampleCountPerMs), so the CLI and UI agree. const maxCountPerMs = - kind === 'avg-ratio' ? selectors.getMaxCounterSampleCountPerMs(state) : 0; + kind === 'avg-ratio' + ? selectors.getMaxRangeCounterSampleCountPerMs(state) + : 0; const bucketCount = Math.max( 1, @@ -362,7 +384,7 @@ function binCounterValues( const totalSum = accs.reduce((sum, acc) => sum + acc.sum, 0); let carriedLevel = 0; - const buckets: BinnedBucket[] = accs.map((acc, bucketIndex) => { + const buckets: CounterBucket[] = accs.map((acc, bucketIndex) => { const startTime = range.start + bucketIndex * width; const endTime = bucketIndex === bucketCount - 1 @@ -397,17 +419,17 @@ function collectCounterOverTime( counterIndex: CounterIndex, timestampManager: TimestampManager ): CounterTimeBucket[] { - const binned = binCounterValues( + const counterBuckets = getCounterBuckets( store, counterIndex, OVER_TIME_BUCKET_COUNT, true ); - if (binned === null) { + if (counterBuckets === null) { return []; } const meta = getMeta(store.getState()); - const { kind, format, countRange, totalSum, buckets } = binned; + const { kind, format, countRange, totalSum, buckets } = counterBuckets; return buckets.map((bucket): CounterTimeBucket => { const { formattedValue, carbon } = formatCounterRowValue( @@ -460,13 +482,15 @@ function collectCounterGraph( store: Store, counterIndex: CounterIndex ): number[] { - const binned = binCounterValues( + const counterBuckets = getCounterBuckets( store, counterIndex, GRAPH_BUCKET_COUNT, false ); - return binned === null ? [] : binned.buckets.map((bucket) => bucket.value); + return counterBuckets === null + ? [] + : counterBuckets.buckets.map((bucket) => bucket.value); } /** @@ -485,8 +509,10 @@ export function collectCounterInfo( const summary = collectCounterSummary(store, threadMap, counterIndex); const selectors = getCounterSelectors(counterIndex); const counter = selectors.getCounter(state); - const [rangeStartIndex, rangeEndIndex] = - selectors.getCommittedRangeCounterSampleRange(state); + const [rangeStartIndex, rangeEndIndex] = getInRangeSampleIndexes( + store, + counterIndex + ); // Sample times are already in absolute profile-time space (the same space as // the committed range), so they need no zeroAt offset here. diff --git a/src/test/unit/profile-query/profile-querier.test.ts b/src/test/unit/profile-query/profile-querier.test.ts index 1734d0d94a..10d52d2540 100644 --- a/src/test/unit/profile-query/profile-querier.test.ts +++ b/src/test/unit/profile-query/profile-querier.test.ts @@ -546,6 +546,36 @@ describe('ProfileQuerier', function () { expect(info.rangeStart).not.toBeNull(); expect(info.rangeStart! - info.context.rootRange.start).toBe(0); }); + + it('excludes the padded boundary samples when zoomed', async function () { + const { profile } = getProfileFromTextSamples(` + 0 10 20 30 40 + A A A A A + `); + const counter = getCounterForThreadWithSamples( + profile.threads[0], + 0, + { + time: [0, 10, 20, 30, 40], + count: [0, 100, 100, 100, 100], + length: 5, + }, + 'malloc', + 'Memory' + ); + profile.counters = [counter]; + + const querier = querierFor(profile); + const startName = querier._timestampManager.nameForTimestamp(15); + const endName = querier._timestampManager.nameForTimestamp(35); + await querier.pushViewRange(`${startName},${endName}`); + + const info = await querier.counterInfo('c-0'); + + // Only the samples at 20 and 30 are inside [15, 35]; the boundary samples + // at 10 and 40 (which the counter selectors pad the range with) are not. + expect(info.rangeSampleCount).toBe(2); + }); }); describe('threadSamples', function () {