Skip to content

Commit 05e3223

Browse files
committed
ui: Add bulk columnar decode to QueryResult and use it on track hot paths
Add a decodeColumns() API to QueryResult that decodes entire columns at once into TypedArrays (Float64Array, BigInt64Array, etc.) instead of row-by-row iteration. The return type is fully mapped from the spec so callers get correctly-typed columns without casts. Also adds an allocation-free varint reader (readVarint/varintToNumber/ varintToBigInt) used by decodeColumns to avoid per-cell LongBits allocation from protobufjs. Switch counter_track, slice_track, and cpu_freq_track rendering hot paths to use the new columnar decode. Includes benchmarks and unit tests.
1 parent 4117ea3 commit 05e3223

8 files changed

Lines changed: 749 additions & 108 deletions

File tree

‎ui/src/components/sql_utils/thread_state.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ export async function getThreadStateFromConstraints(
172172
// TODO(altimin): Consider fetching thread / process info using a single
173173
// query instead of one per row.
174174
result.push({
175-
id: it.id as ThreadStateSqlId,
175+
id: it.id as unknown as ThreadStateSqlId,
176176
schedSqlId: fromNumNull(it.sched_id) as SchedSqlId | undefined,
177177
ts: Time.fromRaw(it.ts),
178178
dur: it.dur,

‎ui/src/components/tracks/counter_track.ts‎

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -915,41 +915,47 @@ export class CounterTrack implements TrackRenderer {
915915
: undefined;
916916
const task = await deferChunkedTask({priority});
917917

918-
const it = queryRes.iter({
918+
// Bulk-decode the whole result into columnar typed arrays in one pass. This
919+
// is much faster than iter() for large counter tracks: it avoids the
920+
// per-row valid()/next()/get round-trip, and (when the engine emits
921+
// fixed-width int cells) lifts the integer columns straight off the wire
922+
// buffer without a per-cell varint decode.
923+
const cols = queryRes.decodeColumns({
919924
tsRel: NUM,
920925
minDisplayValue: NUM,
921926
maxDisplayValue: NUM,
922927
lastDisplayValue: NUM,
923928
});
929+
const tsRelCol = cols.tsRel;
930+
const minCol = cols.minDisplayValue;
931+
const maxCol = cols.maxDisplayValue;
932+
const lastCol = cols.lastDisplayValue;
924933

925934
const numRows = queryRes.numRows();
926-
const timestampsRel = new Float32Array(numRows);
927-
const timestampsRelNext = new Float32Array(numRows);
928-
const minDisplayValues = new Float32Array(numRows);
929-
const maxDisplayValues = new Float32Array(numRows);
930-
const lastDisplayValues = new Float32Array(numRows);
935+
// Bulk Float64 -> Float32 conversion (native optimized code, no per-element
936+
// JS loop).
937+
const timestampsRel = new Float32Array(tsRelCol);
938+
const minDisplayValues = new Float32Array(minCol);
939+
const maxDisplayValues = new Float32Array(maxCol);
940+
const lastDisplayValues = new Float32Array(lastCol);
941+
942+
// Compute min/max.
931943
let min = 0;
932944
let max = 0;
933-
934-
for (let row = 0; it.valid(); it.next(), row++) {
935-
if (signal.isCancelled) throw TASK_CANCELLED;
936-
if (row % 50 === 0 && task.shouldYield()) {
937-
await task.yield();
938-
}
939-
940-
timestampsRel[row] = it.tsRel;
941-
minDisplayValues[row] = it.minDisplayValue;
942-
maxDisplayValues[row] = it.maxDisplayValue;
943-
lastDisplayValues[row] = it.lastDisplayValue;
944-
min = Math.min(min, it.minDisplayValue);
945-
max = Math.max(max, it.maxDisplayValue);
946-
947-
if (row > 0) {
948-
// Fill in the next
949-
timestampsRelNext[row - 1] = it.tsRel;
945+
for (let row = 0; row < numRows; row++) {
946+
if (row % 64 === 0) {
947+
if (signal.isCancelled) throw TASK_CANCELLED;
948+
if (task.shouldYield()) await task.yield();
950949
}
950+
min = Math.min(min, minCol[row]);
951+
max = Math.max(max, maxCol[row]);
951952
}
952953

954+
// timestampsRelNext[i] = timestampsRel[i+1], last element = end - start.
955+
const timestampsRelNext = new Float32Array(numRows);
956+
if (numRows > 1) {
957+
timestampsRelNext.set(timestampsRel.subarray(1));
958+
}
953959
if (numRows > 0) {
954960
timestampsRelNext[numRows - 1] = Number(end - start);
955961
}

‎ui/src/components/tracks/slice_track.ts‎

Lines changed: 64 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -911,37 +911,44 @@ export class SliceTrack<T extends RowSchema> implements TrackRenderer {
911911
const depths = new Uint16Array(count);
912912
const instants = new Array<Instant<T>>(count);
913913

914-
const it = queryResult.iter({
914+
// Bulk-decode all columns in one pass (much faster than per-row iter() for
915+
// large tracks). The fixed columns come back as typed arrays; the schema
916+
// columns are read per-row to build the `row` object the callbacks need.
917+
const cols = queryResult.decodeColumns({
915918
__id: NUM,
916919
__ts: NUM,
917920
__count: NUM,
918921
__depth: NUM,
919922
...dataset.schema,
920923
});
924+
const idCol = cols.__id;
925+
const tsCol = cols.__ts;
926+
const countCol = cols.__count;
927+
const depthCol = cols.__depth;
928+
const schemaKeys = Object.keys(dataset.schema);
929+
const schemaCols = schemaKeys.map(
930+
(k) => cols[k] as unknown as ArrayLike<SqlValue>,
931+
);
921932

922-
for (let i = 0; it.valid(); it.next(), ++i) {
933+
for (let i = 0; i < count; i++) {
923934
if (i % 64 === 0) {
924935
if (signal.isCancelled) throw TASK_CANCELLED;
925936
if (task.shouldYield()) await task.yield();
926937
}
927938

928-
const id = it.__id;
929-
const ts = it.__ts;
930-
const count = it.__count;
931-
const depth = it.__depth;
932-
const title = this.getTitle(it);
933-
const subtitle = this.getSubtitle(it);
934-
const colorScheme = this.getColor(it, title);
935-
const row = this.extractKeys(it, dataset.schema);
936-
937-
xs[i] = ts;
938-
depths[i] = depth;
939+
const row = this.buildSchemaRow(schemaKeys, schemaCols, i);
940+
const title = this.getTitle(row);
941+
const subtitle = this.getSubtitle(row);
942+
const colorScheme = this.getColor(row, title);
943+
944+
xs[i] = tsCol[i];
945+
depths[i] = depthCol[i];
939946
instants[i] = {
940-
id,
947+
id: idCol[i],
941948
title,
942949
subtitle,
943950
colorScheme,
944-
count,
951+
count: countCol[i],
945952
row,
946953
};
947954
}
@@ -1014,7 +1021,10 @@ export class SliceTrack<T extends RowSchema> implements TrackRenderer {
10141021
const patterns = new Uint8Array(count);
10151022
const slices = new Array<Slice<T>>(count);
10161023

1017-
const it = sliceQueryRes.iter({
1024+
// Bulk-decode all columns in one pass. Fixed columns become typed arrays;
1025+
// schema columns are read per-row to build the `row` object the callbacks
1026+
// need. __end is nullable (NUM_NULL) so comes back as a plain array.
1027+
const cols = sliceQueryRes.decodeColumns({
10181028
__id: NUM,
10191029
__start: NUM,
10201030
__end: NUM_NULL,
@@ -1023,38 +1033,42 @@ export class SliceTrack<T extends RowSchema> implements TrackRenderer {
10231033
__incomplete: NUM,
10241034
...dataset.schema,
10251035
});
1036+
const idCol = cols.__id;
1037+
const startCol = cols.__start;
1038+
const endCol = cols.__end;
1039+
const countCol = cols.__count;
1040+
const depthCol = cols.__depth;
1041+
const incompleteCol = cols.__incomplete;
1042+
const schemaKeys = Object.keys(dataset.schema);
1043+
const schemaCols = schemaKeys.map((k) => cols[k]);
10261044

1027-
for (let i = 0; it.valid(); it.next(), ++i) {
1045+
for (let i = 0; i < count; i++) {
10281046
if (i % 64 === 0) {
10291047
if (signal.isCancelled) throw TASK_CANCELLED;
10301048
if (task.shouldYield()) await task.yield();
10311049
}
10321050

1033-
const count = it.__count;
1034-
const id = it.__id;
1035-
const start = it.__start;
1036-
const end = it.__end;
1037-
const depth = it.__depth;
1038-
const title = this.getTitle(it);
1039-
const subtitle = this.getSubtitle(it);
1040-
const colorScheme = this.getColor(it, title);
1041-
const isIncomplete = it.__incomplete === 1;
1042-
const row = this.extractKeys(it, dataset.schema);
1043-
1044-
starts[i] = start;
1051+
const row = this.buildSchemaRow(schemaKeys, schemaCols, i);
1052+
const title = this.getTitle(row);
1053+
const subtitle = this.getSubtitle(row);
1054+
const colorScheme = this.getColor(row, title);
1055+
const end = endCol[i];
1056+
const isIncomplete = incompleteCol[i] === 1;
1057+
1058+
starts[i] = startCol[i];
10451059
// Incomplete slices are assigned a +Infinity end
10461060
ends[i] = end === null ? Number.POSITIVE_INFINITY : end;
1047-
depths[i] = depth;
1061+
depths[i] = depthCol[i];
10481062
patterns[i] = isIncomplete
10491063
? RECT_PATTERN_FADE_RIGHT
1050-
: (this.attrs.slicePattern?.(it) ?? 0);
1064+
: (this.attrs.slicePattern?.(row) ?? 0);
10511065
slices[i] = {
1052-
id,
1066+
id: idCol[i],
10531067
title,
10541068
subtitle,
10551069
colorScheme,
1056-
count,
1057-
fillRatio: this.attrs.fillRatio?.(it) ?? 1,
1070+
count: countCol[i],
1071+
fillRatio: this.attrs.fillRatio?.(row) ?? 1,
10581072
row,
10591073
};
10601074
}
@@ -1069,16 +1083,23 @@ export class SliceTrack<T extends RowSchema> implements TrackRenderer {
10691083
};
10701084
}
10711085

1072-
// Efficiently copy a sebset of keys from a raw value based on some template.
1073-
// Note: Only the template's keys are used, the values are ignored (hence the
1074-
// unknown value types).
1075-
private extractKeys(from: T, template: Record<keyof T, unknown>): T {
1076-
const result = {} as T;
1077-
// eslint-disable-next-line guard-for-in
1078-
for (const k in template) {
1079-
result[k] = from[k];
1086+
// Builds a per-row object holding just the dataset.schema columns, read out
1087+
// of the columnar arrays returned by QueryResult.decodeColumns(). |keys| and
1088+
// |cols| are parallel arrays (key[k] is stored in cols[k]) precomputed once
1089+
// per query. This is the columnar replacement for the old per-row
1090+
// extractKeys(it, schema) copy: the resulting object is both stored as the
1091+
// slice/instant `row` and handed to the user-provided title/colorizer/pattern
1092+
// callbacks (which only ever read schema fields).
1093+
private buildSchemaRow(
1094+
keys: ReadonlyArray<string>,
1095+
cols: ReadonlyArray<ArrayLike<SqlValue>>,
1096+
i: number,
1097+
): T {
1098+
const row: Record<string, SqlValue> = {};
1099+
for (let k = 0; k < keys.length; k++) {
1100+
row[keys[k]] = cols[k][i];
10801101
}
1081-
return result;
1102+
return row as T;
10821103
}
10831104

10841105
private async deferChunkedTask() {

‎ui/src/plugins/dev.perfetto.CpuFreq/cpu_freq_track.ts‎

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -293,43 +293,60 @@ export class CpuFreqTrack implements TrackRenderer {
293293
const maxYs = new Float32Array(freqRows); // Min freq (lower value = higher Y after transform)
294294
const fills = new Float32Array(freqRows); // 1.0 when not idle, 0.0 when idle
295295

296-
const freqIt = freqResult.iter({
296+
// Bulk-decode both results into columnar typed arrays in one pass each.
297+
// This is faster than iter() for large tracks (no per-row valid()/next()
298+
// round-trip) and, when the engine emits fixed-width int cells, lifts the
299+
// integer columns straight off the wire buffer without a per-cell varint
300+
// decode.
301+
const freqCols = freqResult.decodeColumns({
297302
ts: LONG,
298303
minFreq: NUM,
299304
maxFreq: NUM,
300305
lastFreq: NUM,
301306
});
302-
const idleIt = idleResult.iter({
307+
const idleCols = idleResult.decodeColumns({
303308
lastIdle: NUM,
304309
});
305-
for (let i = 0; freqIt.valid(); ++i, freqIt.next(), idleIt.next()) {
306-
if (i % 50 === 0) {
310+
const tsCol = freqCols.ts as BigInt64Array;
311+
const minFreqCol = freqCols.minFreq as Float64Array;
312+
const maxFreqCol = freqCols.maxFreq as Float64Array;
313+
const lastFreqCol = freqCols.lastFreq as Float64Array;
314+
const lastIdleCol = idleCols.lastIdle as Float64Array;
315+
316+
for (let i = 0; i < freqRows; ++i) {
317+
if (i % 64 === 0) {
307318
if (signal.isCancelled) return TASK_CANCELLED;
308319
if (task.shouldYield()) {
309320
await task.yield();
310321
}
311322
}
312323

313-
timestamps[i] = freqIt.ts;
314-
minFreqKHz[i] = freqIt.minFreq;
315-
maxFreqKHz[i] = freqIt.maxFreq;
316-
lastFreqKHz[i] = freqIt.lastFreq;
317-
lastIdleValues[i] = idleIt.lastIdle;
324+
const ts = tsCol[i];
325+
const minFreq = minFreqCol[i];
326+
const maxFreq = maxFreqCol[i];
327+
const lastFreq = lastFreqCol[i];
328+
const lastIdle = lastIdleCol[i];
329+
330+
timestamps[i] = ts;
331+
minFreqKHz[i] = minFreq;
332+
maxFreqKHz[i] = maxFreq;
333+
lastFreqKHz[i] = lastFreq;
334+
lastIdleValues[i] = lastIdle;
318335

319336
// Populate step area buffers with raw values
320-
const x = Number(freqIt.ts - start);
337+
const x = Number(ts - start);
321338
xs[i] = Math.max(0, x); // Clamp to the start of the frame
322-
ys[i] = freqIt.lastFreq;
339+
ys[i] = lastFreq;
323340

324-
fills[i] = idleIt.lastIdle < 0 ? 1.0 : 0.0;
341+
fills[i] = lastIdle < 0 ? 1.0 : 0.0;
325342
if (i > 0) {
326343
xnext[i - 1] = x;
327344
const yprev = ys[i - 1];
328-
minYs[i] = Math.min(freqIt.minFreq, yprev);
329-
maxYs[i] = Math.max(freqIt.maxFreq, yprev);
345+
minYs[i] = Math.min(minFreq, yprev);
346+
maxYs[i] = Math.max(maxFreq, yprev);
330347
} else {
331-
minYs[i] = freqIt.minFreq;
332-
maxYs[i] = freqIt.maxFreq;
348+
minYs[i] = minFreq;
349+
maxYs[i] = maxFreq;
333350
}
334351
}
335352

‎ui/src/trace_processor/dataset.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {getOrCreate} from '../base/utils';
1717
import {
1818
checkExtends,
1919
NUM,
20+
type Row,
2021
type SqlValue,
2122
unionTypes,
2223
UNKNOWN,
@@ -66,9 +67,10 @@ export interface Dataset<T extends DatasetSchema = DatasetSchema> {
6667

6768
/**
6869
* Defines a list of columns and types that define the shape of the data
69-
* represented by a dataset.
70+
* represented by a dataset. Uses the same type as the query result spec
71+
* so that dataset schemas can be passed directly to iter()/decodeColumns().
7072
*/
71-
export type DatasetSchema = Readonly<Record<string, SqlValue>>;
73+
export type DatasetSchema = Row;
7274

7375
/**
7476
* A filter used to express that a column must equal a value.
@@ -709,7 +711,10 @@ ${indent(chunk.join('\nUNION ALL\n'), 2)}
709711
* @param row - A row object containing __groupid and __partition values
710712
* @returns The source dataset(s) that produced this row
711713
*/
712-
resolveLineage(row: typeof lineageSchema): readonly Dataset[] {
714+
resolveLineage(row: {
715+
__groupid: number;
716+
__partition: SqlValue;
717+
}): readonly Dataset[] {
713718
const groupId = row.__groupid as number;
714719
const partitionValue = row.__partition;
715720

0 commit comments

Comments
 (0)