Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions profiler-cli/guide.txt
Original file line number Diff line number Diff line change
Expand Up @@ -224,16 +224,16 @@ 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
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: 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.

Comment thread
fatadel marked this conversation as resolved.
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
Expand Down
7 changes: 6 additions & 1 deletion profiler-cli/schemas.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Expand Down
87 changes: 85 additions & 2 deletions profiler-cli/src/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,8 +490,61 @@ 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(` ${counterSparkline(counter)}`);
}
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 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[], min: number, max: number): string {
if (values.length === 0) {
return '';
}
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 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));
}

/**
Expand Down Expand Up @@ -534,6 +587,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(` ${counterSparkline(result)}`);
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');
}

Expand Down
137 changes: 137 additions & 0 deletions profiler-cli/src/test/unit/counter-formatting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ function makeCounter(overrides: Partial<CounterSummary> = {}): CounterSummary {
formattedValue: '27B',
},
],
graph: [],
...overrides,
};
}
Expand Down Expand Up @@ -95,6 +96,64 @@ describe('formatCounterListResult', function () {
'No counters in this profile.'
);
});

it('renders a sparkline next to each counter', function () {
const result: WithContext<CounterListResult> = {
context: createContext(),
type: 'counter-list',
counters: [makeCounter({ graph: [1, 5, 9] })],
};

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<CounterListResult> {
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 () {
Expand All @@ -109,6 +168,7 @@ describe('formatCounterInfoResult', function () {
sampleCount: 7,
rangeStart: 0,
rangeEnd: 10,
overTime: [],
...overrides,
};
}
Expand Down Expand Up @@ -147,4 +207,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(/[▁▂▃▄▅▆▇█]/);
});
});
Loading
Loading