From 8504c5337d429b46f0912d0d3ed2af8b26c9cf49 Mon Sep 17 00:00:00 2001
From: Manoj Vivek
Date: Wed, 17 Dec 2025 15:15:04 +0530
Subject: [PATCH 1/3] Flamegraph rendering improvements
---
.../FlameGraphArrow/FlameGraphNodes.tsx | 101 +++++++++++-------
.../FlameGraphArrow/index.tsx | 26 ++++-
.../FlameGraphArrow/useBatchedRendering.ts | 84 +++++++++++++++
.../FlameGraphArrow/useScrollViewport.ts | 45 +++++++-
.../FlameGraphArrow/useVisibleNodes.ts | 41 ++++++-
.../shared/profile/src/hooks/useLabels.ts | 4 +-
6 files changed, 249 insertions(+), 52 deletions(-)
create mode 100644 ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/useBatchedRendering.ts
diff --git a/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/FlameGraphNodes.tsx b/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/FlameGraphNodes.tsx
index 57b9aa758d4..3231fa8bda2 100644
--- a/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/FlameGraphNodes.tsx
+++ b/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/FlameGraphNodes.tsx
@@ -11,7 +11,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import React, {useMemo} from 'react';
+import React, {useMemo, useCallback} from 'react';
import {Table} from 'apache-arrow';
import cx from 'classnames';
@@ -101,36 +101,47 @@ export const FlameNode = React.memo(
effectiveDepth,
tooltipId = 'default',
}: FlameNodeProps): React.JSX.Element {
- // get the columns to read from
- const mappingColumn = table.getChild(FIELD_MAPPING_FILE);
- const functionNameColumn = table.getChild(FIELD_FUNCTION_NAME);
- const cumulativeColumn = table.getChild(FIELD_CUMULATIVE);
- const depthColumn = table.getChild(FIELD_DEPTH);
- const diffColumn = table.getChild(FIELD_DIFF);
- const filenameColumn = table.getChild(FIELD_FUNCTION_FILE_NAME);
- const valueOffsetColumn = table.getChild(FIELD_VALUE_OFFSET);
- const tsColumn = table.getChild(FIELD_TIMESTAMP);
+ // Memoize column references - only changes when table changes
+ const columns = useMemo(() => ({
+ mapping: table.getChild(FIELD_MAPPING_FILE),
+ functionName: table.getChild(FIELD_FUNCTION_NAME),
+ cumulative: table.getChild(FIELD_CUMULATIVE),
+ depth: table.getChild(FIELD_DEPTH),
+ diff: table.getChild(FIELD_DIFF),
+ filename: table.getChild(FIELD_FUNCTION_FILE_NAME),
+ valueOffset: table.getChild(FIELD_VALUE_OFFSET),
+ ts: table.getChild(FIELD_TIMESTAMP),
+ }), [table]);
// get the actual values from the columns
const binaries = useAppSelector(selectBinaries);
- const mappingFile: string | null = arrowToString(mappingColumn?.get(row));
- const functionName: string | null = arrowToString(functionNameColumn?.get(row));
- const cumulative = cumulativeColumn?.get(row) != null ? BigInt(cumulativeColumn?.get(row)) : 0n;
- const diff: bigint | null = diffColumn?.get(row) != null ? BigInt(diffColumn?.get(row)) : null;
- const filename: string | null = arrowToString(filenameColumn?.get(row));
- const depth: number = depthColumn?.get(row) ?? 0;
+ // Memoize row data extraction - only changes when table or row changes
+ const rowData = useMemo(() => {
+ const mappingFile: string | null = arrowToString(columns.mapping?.get(row));
+ const functionName: string | null = arrowToString(columns.functionName?.get(row));
+ const cumulative = columns.cumulative?.get(row) != null ? BigInt(columns.cumulative?.get(row)) : 0n;
+ const diff: bigint | null = columns.diff?.get(row) != null ? BigInt(columns.diff?.get(row)) : null;
+ const filename: string | null = arrowToString(columns.filename?.get(row));
+ const depth: number = columns.depth?.get(row) ?? 0;
+ const valueOffset: bigint =
+ columns.valueOffset?.get(row) !== null && columns.valueOffset?.get(row) !== undefined
+ ? BigInt(columns.valueOffset?.get(row))
+ : 0n;
- const valueOffset: bigint =
- valueOffsetColumn?.get(row) !== null && valueOffsetColumn?.get(row) !== undefined
- ? BigInt(valueOffsetColumn?.get(row))
- : 0n;
+ return { mappingFile, functionName, cumulative, diff, filename, depth, valueOffset };
+ }, [columns, row]);
+
+ const { mappingFile, functionName, cumulative, diff, filename, depth, valueOffset } = rowData;
const colorAttribute =
colorBy === 'filename' ? filename : colorBy === 'binary' ? mappingFile : null;
- const hoveringName =
- hoveringRow !== undefined ? arrowToString(functionNameColumn?.get(hoveringRow)) : '';
+ // Memoize hovering name lookup
+ const hoveringName = useMemo(() => {
+ return hoveringRow !== undefined ? arrowToString(columns.functionName?.get(hoveringRow)) : '';
+ }, [columns.functionName, hoveringRow]);
+
const shouldBeHighlighted =
functionName != null && hoveringName != null && functionName === hoveringName;
@@ -152,13 +163,22 @@ export const FlameNode = React.memo(
return <>>;
}
- const selectionOffset =
- valueOffsetColumn?.get(selectedRow) !== null &&
- valueOffsetColumn?.get(selectedRow) !== undefined
- ? BigInt(valueOffsetColumn?.get(selectedRow))
- : 0n;
- const selectionCumulative =
- cumulativeColumn?.get(selectedRow) !== null ? BigInt(cumulativeColumn?.get(selectedRow)) : 0n;
+ // Memoize selection data - only changes when selectedRow changes
+ const selectionData = useMemo(() => {
+ const selectionOffset =
+ columns.valueOffset?.get(selectedRow) !== null &&
+ columns.valueOffset?.get(selectedRow) !== undefined
+ ? BigInt(columns.valueOffset?.get(selectedRow))
+ : 0n;
+ const selectionCumulative =
+ columns.cumulative?.get(selectedRow) !== null ? BigInt(columns.cumulative?.get(selectedRow)) : 0n;
+ const selectedDepth = columns.depth?.get(selectedRow);
+ const total = columns.cumulative?.get(selectedRow);
+ return { selectionOffset, selectionCumulative, selectedDepth, total };
+ }, [columns, selectedRow]);
+
+ const { selectionOffset, selectionCumulative, selectedDepth, total } = selectionData;
+
if (
valueOffset + cumulative <= selectionOffset ||
valueOffset >= selectionOffset + selectionCumulative
@@ -172,9 +192,10 @@ export const FlameNode = React.memo(
return <>>;
}
+ // Memoize tsBounds - only changes when profileSource changes
+ const tsBounds = useMemo(() => boundsFromProfileSource(profileSource), [profileSource]);
+
// 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.
- const tsBounds = boundsFromProfileSource(profileSource);
- const total = cumulativeColumn?.get(selectedRow);
const totalRatio = cumulative > total ? 1 : Number(cumulative) / Number(total);
const width: number = isFlameChart
? (Number(cumulative) / (Number(tsBounds[1]) - Number(tsBounds[0]))) * totalWidth
@@ -184,35 +205,35 @@ export const FlameNode = React.memo(
return <>>;
}
- const selectedDepth = depthColumn?.get(selectedRow);
const styles =
selectedDepth !== undefined && selectedDepth > depth ? fadedFlameRectStyles : flameRectStyles;
- const onMouseEnter = (): void => {
+ // Memoize event handlers
+ const onMouseEnter = useCallback((): void => {
setHoveringRow(row);
window.dispatchEvent(
new CustomEvent(`flame-tooltip-update-${tooltipId}`, {
detail: {row},
})
);
- };
+ }, [setHoveringRow, row, tooltipId]);
- const onMouseLeave = (): void => {
+ const onMouseLeave = useCallback((): void => {
setHoveringRow(undefined);
window.dispatchEvent(
new CustomEvent(`flame-tooltip-update-${tooltipId}`, {
detail: {row: null},
})
);
- };
+ }, [setHoveringRow, tooltipId]);
- const handleContextMenu = (e: React.MouseEvent): void => {
+ const handleContextMenu = useCallback((e: React.MouseEvent): void => {
onContextMenu(e, row);
- };
+ }, [onContextMenu, row]);
- const ts = tsColumn !== null ? Number(tsColumn.get(row)) : 0;
+ const ts = columns.ts !== null ? Number(columns.ts.get(row)) : 0;
const x =
- isFlameChart && tsColumn !== null
+ isFlameChart && columns.ts !== null
? ((ts - Number(tsBounds[0])) / (Number(tsBounds[1]) - Number(tsBounds[0]))) * totalWidth
: selectedDepth > depth
? 0
diff --git a/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/index.tsx b/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/index.tsx
index 718bf9861ff..e2740e27018 100644
--- a/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/index.tsx
+++ b/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/index.tsx
@@ -25,7 +25,7 @@ import {Table, tableFromIPC} from 'apache-arrow';
import {useContextMenu} from 'react-contexify';
import {FlamegraphArrow} from '@parca/client';
-import {useParcaContext} from '@parca/components';
+import { FlameGraphSkeleton, SandwichFlameGraphSkeleton, useParcaContext } from '@parca/components';
import {USER_PREFERENCES, useCurrentColorProfile, useUserPreference} from '@parca/hooks';
import {ProfileType} from '@parca/parser';
import {getColorForFeature, selectDarkMode, useAppSelector} from '@parca/store';
@@ -38,6 +38,7 @@ import ContextMenuWrapper, {ContextMenuWrapperRef} from './ContextMenuWrapper';
import {FlameNode, RowHeight, colorByColors} from './FlameGraphNodes';
import {MemoizedTooltip} from './MemoizedTooltip';
import {TooltipProvider} from './TooltipContext';
+import { useBatchedRendering } from './useBatchedRendering';
import {useScrollViewport} from './useScrollViewport';
import {useVisibleNodes} from './useVisibleNodes';
import {
@@ -136,6 +137,7 @@ export const FlameGraphArrow = memo(function FlameGraphArrow({
isFlameChart = false,
isRenderedAsFlamegraph = false,
isInSandwichView = false,
+ isHalfScreen,
tooltipId = 'default',
maxFrameCount,
isExpanded = false,
@@ -163,6 +165,7 @@ export const FlameGraphArrow = memo(function FlameGraphArrow({
const svg = useRef(null);
const containerRef = useRef(null);
const renderStartTime = useRef(0);
+ const hasInitialRenderCompleted = useRef(false);
const [svgElement, setSvgElement] = useState(null);
@@ -291,6 +294,15 @@ export const FlameGraphArrow = memo(function FlameGraphArrow({
effectiveDepth: deferredEffectiveDepth,
});
+ // Add nodes in incremental batches to avoid blocking the UI
+ const { items: batchedNodes, isComplete: isBatchingComplete } = useBatchedRendering(visibleNodes, { batchSize: 500 });
+ if (isBatchingComplete) {
+ hasInitialRenderCompleted.current = true;
+ }
+
+ // Show skeleton only during initial load, not during scroll updates
+ const showSkeleton = !hasInitialRenderCompleted.current && batchedNodes.length !== visibleNodes.length;
+
useEffect(() => {
if (perf?.markInteraction != null) {
renderStartTime.current = performance.now();
@@ -327,12 +339,22 @@ export const FlameGraphArrow = memo(function FlameGraphArrow({
isInSandwichView={isInSandwichView}
/>
+ {showSkeleton && (
+
+ {isRenderedAsFlamegraph ? (
+
+ ) : (
+
+ )}
+
+ )}