Skip to content

Commit 8b1128e

Browse files
ui: Flamegraph rendering improvements (#6117)
* Flamegraph rendering improvements * [pre-commit.ci lite] apply automatic fixes * Linter fixes --------- Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
1 parent 1fe1007 commit 8b1128e

File tree

6 files changed

+284
-70
lines changed

6 files changed

+284
-70
lines changed

ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/FlameGraphNodes.tsx

Lines changed: 89 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
// See the License for the specific language governing permissions and
1212
// limitations under the License.
1313

14-
import React, {useMemo} from 'react';
14+
import React, {useCallback, useMemo} from 'react';
1515

1616
import {Table} from 'apache-arrow';
1717
import cx from 'classnames';
@@ -101,36 +101,52 @@ export const FlameNode = React.memo(
101101
effectiveDepth,
102102
tooltipId = 'default',
103103
}: FlameNodeProps): React.JSX.Element {
104-
// get the columns to read from
105-
const mappingColumn = table.getChild(FIELD_MAPPING_FILE);
106-
const functionNameColumn = table.getChild(FIELD_FUNCTION_NAME);
107-
const cumulativeColumn = table.getChild(FIELD_CUMULATIVE);
108-
const depthColumn = table.getChild(FIELD_DEPTH);
109-
const diffColumn = table.getChild(FIELD_DIFF);
110-
const filenameColumn = table.getChild(FIELD_FUNCTION_FILE_NAME);
111-
const valueOffsetColumn = table.getChild(FIELD_VALUE_OFFSET);
112-
const tsColumn = table.getChild(FIELD_TIMESTAMP);
104+
// Memoize column references - only changes when table changes
105+
const columns = useMemo(
106+
() => ({
107+
mapping: table.getChild(FIELD_MAPPING_FILE),
108+
functionName: table.getChild(FIELD_FUNCTION_NAME),
109+
cumulative: table.getChild(FIELD_CUMULATIVE),
110+
depth: table.getChild(FIELD_DEPTH),
111+
diff: table.getChild(FIELD_DIFF),
112+
filename: table.getChild(FIELD_FUNCTION_FILE_NAME),
113+
valueOffset: table.getChild(FIELD_VALUE_OFFSET),
114+
ts: table.getChild(FIELD_TIMESTAMP),
115+
}),
116+
[table]
117+
);
113118

114119
// get the actual values from the columns
115120
const binaries = useAppSelector(selectBinaries);
116121

117-
const mappingFile: string | null = arrowToString(mappingColumn?.get(row));
118-
const functionName: string | null = arrowToString(functionNameColumn?.get(row));
119-
const cumulative = cumulativeColumn?.get(row) != null ? BigInt(cumulativeColumn?.get(row)) : 0n;
120-
const diff: bigint | null = diffColumn?.get(row) != null ? BigInt(diffColumn?.get(row)) : null;
121-
const filename: string | null = arrowToString(filenameColumn?.get(row));
122-
const depth: number = depthColumn?.get(row) ?? 0;
123-
124-
const valueOffset: bigint =
125-
valueOffsetColumn?.get(row) !== null && valueOffsetColumn?.get(row) !== undefined
126-
? BigInt(valueOffsetColumn?.get(row))
127-
: 0n;
122+
// Memoize row data extraction - only changes when table or row changes
123+
const rowData = useMemo(() => {
124+
const mappingFile: string | null = arrowToString(columns.mapping?.get(row));
125+
const functionName: string | null = arrowToString(columns.functionName?.get(row));
126+
const cumulative =
127+
columns.cumulative?.get(row) != null ? BigInt(columns.cumulative?.get(row)) : 0n;
128+
const diff: bigint | null =
129+
columns.diff?.get(row) != null ? BigInt(columns.diff?.get(row)) : null;
130+
const filename: string | null = arrowToString(columns.filename?.get(row));
131+
const depth: number = columns.depth?.get(row) ?? 0;
132+
const valueOffset: bigint =
133+
columns.valueOffset?.get(row) !== null && columns.valueOffset?.get(row) !== undefined
134+
? BigInt(columns.valueOffset?.get(row))
135+
: 0n;
136+
137+
return {mappingFile, functionName, cumulative, diff, filename, depth, valueOffset};
138+
}, [columns, row]);
139+
140+
const {mappingFile, functionName, cumulative, diff, filename, depth, valueOffset} = rowData;
128141

129142
const colorAttribute =
130143
colorBy === 'filename' ? filename : colorBy === 'binary' ? mappingFile : null;
131144

132-
const hoveringName =
133-
hoveringRow !== undefined ? arrowToString(functionNameColumn?.get(hoveringRow)) : '';
145+
// Memoize hovering name lookup
146+
const hoveringName = useMemo(() => {
147+
return hoveringRow !== undefined ? arrowToString(columns.functionName?.get(hoveringRow)) : '';
148+
}, [columns.functionName, hoveringRow]);
149+
134150
const shouldBeHighlighted =
135151
functionName != null && hoveringName != null && functionName === hoveringName;
136152

@@ -147,18 +163,59 @@ export const FlameNode = React.memo(
147163
return row === 0 ? 'root' : nodeLabel(table, row, binaries.length > 1);
148164
}, [table, row, binaries]);
149165

166+
// Memoize selection data - only changes when selectedRow changes
167+
const selectionData = useMemo(() => {
168+
const selectionOffset =
169+
columns.valueOffset?.get(selectedRow) !== null &&
170+
columns.valueOffset?.get(selectedRow) !== undefined
171+
? BigInt(columns.valueOffset?.get(selectedRow))
172+
: 0n;
173+
const selectionCumulative =
174+
columns.cumulative?.get(selectedRow) !== null
175+
? BigInt(columns.cumulative?.get(selectedRow))
176+
: 0n;
177+
const selectedDepth = columns.depth?.get(selectedRow);
178+
const total = columns.cumulative?.get(selectedRow);
179+
return {selectionOffset, selectionCumulative, selectedDepth, total};
180+
}, [columns, selectedRow]);
181+
182+
const {selectionOffset, selectionCumulative, selectedDepth, total} = selectionData;
183+
184+
// Memoize tsBounds - only changes when profileSource changes
185+
const tsBounds = useMemo(() => boundsFromProfileSource(profileSource), [profileSource]);
186+
187+
// Memoize event handlers
188+
const onMouseEnter = useCallback((): void => {
189+
setHoveringRow(row);
190+
window.dispatchEvent(
191+
new CustomEvent(`flame-tooltip-update-${tooltipId}`, {
192+
detail: {row},
193+
})
194+
);
195+
}, [setHoveringRow, row, tooltipId]);
196+
197+
const onMouseLeave = useCallback((): void => {
198+
setHoveringRow(undefined);
199+
window.dispatchEvent(
200+
new CustomEvent(`flame-tooltip-update-${tooltipId}`, {
201+
detail: {row: null},
202+
})
203+
);
204+
}, [setHoveringRow, tooltipId]);
205+
206+
const handleContextMenu = useCallback(
207+
(e: React.MouseEvent): void => {
208+
onContextMenu(e, row);
209+
},
210+
[onContextMenu, row]
211+
);
212+
213+
// Early returns - all hooks must be called before this point
150214
// Hide frames beyond effective depth limit
151215
if (effectiveDepth !== undefined && depth > effectiveDepth) {
152216
return <></>;
153217
}
154218

155-
const selectionOffset =
156-
valueOffsetColumn?.get(selectedRow) !== null &&
157-
valueOffsetColumn?.get(selectedRow) !== undefined
158-
? BigInt(valueOffsetColumn?.get(selectedRow))
159-
: 0n;
160-
const selectionCumulative =
161-
cumulativeColumn?.get(selectedRow) !== null ? BigInt(cumulativeColumn?.get(selectedRow)) : 0n;
162219
if (
163220
valueOffset + cumulative <= selectionOffset ||
164221
valueOffset >= selectionOffset + selectionCumulative
@@ -173,8 +230,6 @@ export const FlameNode = React.memo(
173230
}
174231

175232
// Cumulative can be larger than total when a selection is made. All parents of the selection are likely larger, but we want to only show them as 100% in the graph.
176-
const tsBounds = boundsFromProfileSource(profileSource);
177-
const total = cumulativeColumn?.get(selectedRow);
178233
const totalRatio = cumulative > total ? 1 : Number(cumulative) / Number(total);
179234
const width: number = isFlameChart
180235
? (Number(cumulative) / (Number(tsBounds[1]) - Number(tsBounds[0]))) * totalWidth
@@ -184,35 +239,12 @@ export const FlameNode = React.memo(
184239
return <></>;
185240
}
186241

187-
const selectedDepth = depthColumn?.get(selectedRow);
188242
const styles =
189243
selectedDepth !== undefined && selectedDepth > depth ? fadedFlameRectStyles : flameRectStyles;
190244

191-
const onMouseEnter = (): void => {
192-
setHoveringRow(row);
193-
window.dispatchEvent(
194-
new CustomEvent(`flame-tooltip-update-${tooltipId}`, {
195-
detail: {row},
196-
})
197-
);
198-
};
199-
200-
const onMouseLeave = (): void => {
201-
setHoveringRow(undefined);
202-
window.dispatchEvent(
203-
new CustomEvent(`flame-tooltip-update-${tooltipId}`, {
204-
detail: {row: null},
205-
})
206-
);
207-
};
208-
209-
const handleContextMenu = (e: React.MouseEvent): void => {
210-
onContextMenu(e, row);
211-
};
212-
213-
const ts = tsColumn !== null ? Number(tsColumn.get(row)) : 0;
245+
const ts = columns.ts !== null ? Number(columns.ts.get(row)) : 0;
214246
const x =
215-
isFlameChart && tsColumn !== null
247+
isFlameChart && columns.ts !== null
216248
? ((ts - Number(tsBounds[0])) / (Number(tsBounds[1]) - Number(tsBounds[0]))) * totalWidth
217249
: selectedDepth > depth
218250
? 0

ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/index.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {Table, tableFromIPC} from 'apache-arrow';
2525
import {useContextMenu} from 'react-contexify';
2626

2727
import {FlamegraphArrow} from '@parca/client';
28-
import {useParcaContext} from '@parca/components';
28+
import {FlameGraphSkeleton, SandwichFlameGraphSkeleton, useParcaContext} from '@parca/components';
2929
import {USER_PREFERENCES, useCurrentColorProfile, useUserPreference} from '@parca/hooks';
3030
import {ProfileType} from '@parca/parser';
3131
import {getColorForFeature, selectDarkMode, useAppSelector} from '@parca/store';
@@ -38,6 +38,7 @@ import ContextMenuWrapper, {ContextMenuWrapperRef} from './ContextMenuWrapper';
3838
import {FlameNode, RowHeight, colorByColors} from './FlameGraphNodes';
3939
import {MemoizedTooltip} from './MemoizedTooltip';
4040
import {TooltipProvider} from './TooltipContext';
41+
import {useBatchedRendering} from './useBatchedRendering';
4142
import {useScrollViewport} from './useScrollViewport';
4243
import {useVisibleNodes} from './useVisibleNodes';
4344
import {
@@ -136,6 +137,7 @@ export const FlameGraphArrow = memo(function FlameGraphArrow({
136137
isFlameChart = false,
137138
isRenderedAsFlamegraph = false,
138139
isInSandwichView = false,
140+
isHalfScreen,
139141
tooltipId = 'default',
140142
maxFrameCount,
141143
isExpanded = false,
@@ -163,6 +165,7 @@ export const FlameGraphArrow = memo(function FlameGraphArrow({
163165
const svg = useRef(null);
164166
const containerRef = useRef<HTMLDivElement>(null);
165167
const renderStartTime = useRef<number>(0);
168+
const hasInitialRenderCompleted = useRef(false);
166169

167170
const [svgElement, setSvgElement] = useState<SVGSVGElement | null>(null);
168171

@@ -291,6 +294,18 @@ export const FlameGraphArrow = memo(function FlameGraphArrow({
291294
effectiveDepth: deferredEffectiveDepth,
292295
});
293296

297+
// Add nodes in incremental batches to avoid blocking the UI
298+
const {items: batchedNodes, isComplete: isBatchingComplete} = useBatchedRendering(visibleNodes, {
299+
batchSize: 500,
300+
});
301+
if (isBatchingComplete) {
302+
hasInitialRenderCompleted.current = true;
303+
}
304+
305+
// Show skeleton only during initial load, not during scroll updates
306+
const showSkeleton =
307+
!hasInitialRenderCompleted.current && batchedNodes.length !== visibleNodes.length;
308+
294309
useEffect(() => {
295310
if (perf?.markInteraction != null) {
296311
renderStartTime.current = performance.now();
@@ -327,12 +342,22 @@ export const FlameGraphArrow = memo(function FlameGraphArrow({
327342
isInSandwichView={isInSandwichView}
328343
/>
329344
<MemoizedTooltip contextElement={svgElement} dockedMetainfo={dockedMetainfo} />
345+
{showSkeleton && (
346+
<div className="absolute inset-0 z-10">
347+
{isRenderedAsFlamegraph ? (
348+
<SandwichFlameGraphSkeleton isHalfScreen={isHalfScreen} isDarkMode={isDarkMode} />
349+
) : (
350+
<FlameGraphSkeleton isHalfScreen={isHalfScreen} isDarkMode={isDarkMode} />
351+
)}
352+
</div>
353+
)}
330354
<div
331355
ref={containerRef}
332356
className="overflow-auto scrollbar-thin scrollbar-thumb-gray-400 scrollbar-track-gray-100 dark:scrollbar-thumb-gray-600 dark:scrollbar-track-gray-800 will-change-transform scroll-smooth webkit-overflow-scrolling-touch contain"
333357
style={{
334358
width: width ?? '100%',
335359
contain: 'layout style paint',
360+
visibility: !showSkeleton ? 'visible' : 'hidden',
336361
}}
337362
>
338363
<svg
@@ -342,7 +367,7 @@ export const FlameGraphArrow = memo(function FlameGraphArrow({
342367
preserveAspectRatio="xMinYMid"
343368
ref={svg}
344369
>
345-
{visibleNodes.map(row => (
370+
{batchedNodes.map(row => (
346371
<FlameNode
347372
key={row}
348373
table={table}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright 2022 The Parca Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
import {useEffect, useRef, useState} from 'react';
15+
16+
interface UseBatchedRenderingOptions {
17+
batchSize?: number;
18+
// Delay between batches in ms (0 = next animation frame)
19+
batchDelay?: number;
20+
}
21+
22+
interface UseBatchedRenderingResult<T> {
23+
items: T[];
24+
isComplete: boolean;
25+
}
26+
27+
// useBatchedRendering - Helps in incrementally rendering items in batches to avoid UI blocking.
28+
export const useBatchedRendering = <T>(
29+
items: T[],
30+
options: UseBatchedRenderingOptions = {}
31+
): UseBatchedRenderingResult<T> => {
32+
const {batchSize = 500, batchDelay = 0} = options;
33+
34+
const [renderedCount, setRenderedCount] = useState(0);
35+
const itemsRef = useRef(items);
36+
const rafRef = useRef<number | null>(null);
37+
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
38+
39+
useEffect(() => {
40+
if (itemsRef.current !== items) {
41+
itemsRef.current = items;
42+
setRenderedCount(prev => {
43+
if (items.length === 0) return 0;
44+
// If new items were added (scrolling down), keep current progress
45+
if (items.length > prev) return prev;
46+
// If items reduced, cap to new length
47+
return Math.min(prev, items.length);
48+
});
49+
}
50+
}, [items]);
51+
52+
// Progressively render more items
53+
useEffect(() => {
54+
if (renderedCount === items.length) {
55+
return;
56+
}
57+
58+
const scheduleNextBatch = (): void => {
59+
const incrementState = (): void => {
60+
setRenderedCount(prev => Math.min(prev + batchSize, items.length));
61+
};
62+
if (batchDelay > 0) {
63+
timeoutRef.current = setTimeout(incrementState, batchDelay);
64+
} else {
65+
rafRef.current = requestAnimationFrame(incrementState);
66+
}
67+
};
68+
scheduleNextBatch();
69+
70+
return () => {
71+
if (rafRef.current !== null) {
72+
cancelAnimationFrame(rafRef.current);
73+
}
74+
if (timeoutRef.current !== null) {
75+
clearTimeout(timeoutRef.current);
76+
}
77+
};
78+
}, [renderedCount, items.length, batchSize, batchDelay]);
79+
80+
return {
81+
items: items.slice(0, renderedCount),
82+
isComplete: renderedCount === items.length,
83+
};
84+
};

0 commit comments

Comments
 (0)