First pass at flame graph chart

This commit is contained in:
Brian Vaughn
2019-03-17 09:49:10 -07:00
parent 01ad9f1da6
commit c82a7fb560
11 changed files with 363 additions and 13 deletions

View File

@@ -631,10 +631,14 @@ export function attach(
}
if (isProfiling) {
// Tree base duration updates are included in the operations typed array.
// So we have to convert them from milliseconds to microseconds so we can send them as ints.
const treeBaseDuration = Math.floor(fiber.treeBaseDuration * 1000);
const operation = new Uint32Array(3);
operation[0] = TREE_OPERATION_UPDATE_TREE_BASE_DURATION;
operation[1] = id;
operation[2] = fiber.treeBaseDuration;
operation[2] = treeBaseDuration;
addOperation(operation);
}
}
@@ -702,6 +706,10 @@ export function attach(
if (isProfiling) {
if (treeBaseDuration !== fiber.alternate.treeBaseDuration) {
// Tree base duration updates are included in the operations typed array.
// So we have to convert them from milliseconds to microseconds so we can send them as ints.
const treeBaseDuration = Math.floor(fiber.treeBaseDuration * 1000);
const operation = new Uint32Array(3);
operation[0] = TREE_OPERATION_UPDATE_TREE_BASE_DURATION;
operation[1] = getFiberID(getPrimaryFiber(fiber));
@@ -1443,6 +1451,8 @@ export function attach(
((initialTreeBaseDurationsMap: any): Map<number, number>).forEach(
(treeBaseDuration, id) => {
if (idToRootMap.get(id) === rootID) {
// We don't need to convert milliseconds to microseconds in this case,
// because the profiling summary is JSON serialized.
initialTreeBaseDurations.push(id, treeBaseDuration);
}
}

View File

@@ -6,6 +6,10 @@ import {
getCommitTree,
invalidateCommitTrees,
} from 'src/devtools/views/Profiler/CommitTreeBuilder';
import {
getChartData as getFlamegraphChartData,
invalidateChartData as invalidateFlamegraphChartData,
} from 'src/devtools/views/Profiler/FlamegraphChartBuilder';
import {
getChartData as getRankedChartData,
invalidateChartData as invalidateRankedChartData,
@@ -22,6 +26,7 @@ import type {
CommitTree as CommitTreeFrontend,
ProfilingSummary as ProfilingSummaryFrontend,
} from 'src/devtools/views/Profiler/types';
import type { ChartData as FlamegraphChartData } from 'src/devtools/views/Profiler/FlamegraphChartBuilder';
import type { ChartData as RankedChartData } from 'src/devtools/views/Profiler/RankedChartBuilder';
type CommitDetailsParams = {|
@@ -130,6 +135,24 @@ export default class ProfilingCache {
store: this._store,
});
getFlamegraphChartData = ({
commitDetails,
commitIndex,
commitTree,
rootID,
}: {|
commitDetails: CommitDetailsFrontend,
commitIndex: number,
commitTree: CommitTreeFrontend,
rootID: number,
|}): FlamegraphChartData =>
getFlamegraphChartData({
commitDetails,
commitIndex,
commitTree,
rootID,
});
getRankedChartData = ({
commitDetails,
commitIndex,
@@ -154,6 +177,7 @@ export default class ProfilingCache {
// Invalidate non-Suspense caches too.
invalidateCommitTrees();
invalidateFlamegraphChartData();
invalidateRankedChartData();
this._pendingCommitDetailsMap.clear();

View File

@@ -13,7 +13,6 @@ type Props = {|
onClick: Function,
onDoubleClick?: Function,
placeLabelAboveNode?: boolean,
title: string,
width: number,
x: number,
y: number,
@@ -28,14 +27,13 @@ export default function ChartNode({
label,
onClick,
onDoubleClick,
title,
width,
x,
y,
}: Props) {
return (
<g className={styles.Group} transform={`translate(${x},${y})`}>
<title>{title}</title>
<title>{label}</title>
<rect
width={width}
height={height}

View File

@@ -0,0 +1,5 @@
.Container {
width: 100%;
height: 100%;
padding: 0.5rem;
}

View File

@@ -1,16 +1,64 @@
// @flow
import React, { useContext } from 'react';
import React, { useCallback, useContext, useMemo } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList } from 'react-window';
import { ProfilerContext } from './ProfilerContext';
import NoCommitData from './NoCommitData';
import CommitFlamegraphListItem from './CommitFlamegraphListItem';
import { barHeight } from './constants';
import { scale } from './utils';
import { StoreContext } from '../context';
import styles from './CommitFlamegraph.css';
export default function CommitFlamegraph(_: {||}) {
const { rendererID, rootID, selectedCommitIndex } = useContext(
ProfilerContext
import type { ChartData, ChartNode } from './FlamegraphChartBuilder';
export type ItemData = {|
chartData: ChartData,
scaleX: (value: number, fallbackValue: number) => number,
selectedChartNode: ChartNode,
selectedChartNodeIndex: number,
selectFiber: (id: number | null) => void,
width: number,
|};
export default function CommitFlamegraphAutoSizer(_: {||}) {
const { selectFiber } = useContext(ProfilerContext);
const deselectCurrentFiber = useCallback(
event => {
event.stopPropagation();
selectFiber(null);
},
[selectFiber]
);
return (
<div className={styles.Container} onClick={deselectCurrentFiber}>
<AutoSizer>
{({ height, width }) => (
<CommitFlamegraph height={height} width={width} />
)}
</AutoSizer>
</div>
);
}
function CommitFlamegraph({
height,
width,
}: {|
height: number,
width: number,
|}) {
const {
rendererID,
rootID,
selectedCommitIndex,
selectFiber,
selectedFiberID,
} = useContext(ProfilerContext);
const { profilingCache } = useContext(StoreContext);
const profilingSummary = profilingCache.ProfilingSummary.read({
@@ -31,5 +79,58 @@ export default function CommitFlamegraph(_: {||}) {
rootID: ((rootID: any): number),
});
return 'Coming soon: Flamegraph';
const chartData = profilingCache.getFlamegraphChartData({
commitDetails,
commitIndex: ((selectedCommitIndex: any): number),
commitTree,
rootID: ((rootID: any): number),
});
const selectedChartNodeIndex = useMemo(
() =>
selectedFiberID === null
? 0
: ((chartData.idToDepthMap.get(selectedFiberID): any): number) - 1,
[chartData, selectedFiberID]
);
const selectedChartNode = useMemo(() => {
if (selectedFiberID === null) {
return chartData.rows[0][0];
}
return ((chartData.rows[selectedChartNodeIndex].find(
chartNode => chartNode.id === selectedFiberID
): any): ChartNode);
}, [chartData, selectedFiberID, selectedChartNodeIndex]);
const itemData = useMemo<ItemData>(
() => ({
chartData,
scaleX: scale(0, selectedChartNode.treeBaseDuration, 0, width),
selectedChartNode,
selectedChartNodeIndex,
selectFiber,
width,
}),
[chartData, selectedChartNode, selectedChartNodeIndex, selectFiber, width]
);
// If a commit contains no fibers with an actualDuration > 0,
// Display a fallback message.
if (chartData.depth === 0) {
return <NoCommitData />;
}
return (
<FixedSizeList
height={height}
innerElementType="svg"
itemCount={chartData.depth}
itemData={itemData}
itemSize={barHeight}
width={width}
>
{CommitFlamegraphListItem}
</FixedSizeList>
);
}

View File

@@ -0,0 +1,102 @@
// @flow
import React, { Fragment, useCallback } from 'react';
import { barHeight, barWidthThreshold } from './constants';
import { getGradientColor } from './utils';
import ChartNode from './ChartNode';
import type { ItemData } from './CommitFlamegraph';
export default function CommitFlamegraphListItem({
data,
index,
style,
}: {
data: ItemData,
index: number,
style: Object,
}) {
const {
chartData,
scaleX,
selectedChartNode,
selectedChartNodeIndex,
selectFiber,
width,
} = data;
const { maxSelfDuration, rows } = chartData;
const handleClick = useCallback(
(event: MouseEvent, id: number) => {
event.stopPropagation();
selectFiber(id);
},
[selectFiber]
);
// List items are absolutely positioned using the CSS "top" attribute.
// The "left" value will always be 0.
// Since height is fixed, and width is based on the node's duration,
// We can ignore those values as well.
const top = parseInt(style.top, 10);
const row = rows[index];
let selectedNodeOffset = scaleX(selectedChartNode.offset, width);
return (
<Fragment>
{row.map(chartNode => {
const {
actualDuration,
didRender,
id,
name,
offset,
selfDuration,
treeBaseDuration,
} = chartNode;
const nodeOffset = scaleX(offset, width);
const nodeWidth = scaleX(treeBaseDuration, width);
// Filter out nodes that are too small to see or click.
// This also helps render large trees faster.
if (nodeWidth < barWidthThreshold) {
return null;
}
// Filter out nodes that are outside of the horizontal window.
if (
nodeOffset + nodeWidth < selectedNodeOffset ||
nodeOffset > selectedNodeOffset + width
) {
return null;
}
let color = 'var(--color-commit-did-not-render)';
let label = name;
if (didRender) {
color = getGradientColor(selfDuration / maxSelfDuration);
label = `${name} (${selfDuration.toFixed(
1
)}ms of ${actualDuration.toFixed(1)}ms)`;
}
return (
<ChartNode
color={color}
height={barHeight}
isDimmed={index < selectedChartNodeIndex}
key={id}
label={label}
onClick={event => handleClick(event, id)}
width={nodeWidth}
x={nodeOffset - selectedNodeOffset}
y={top}
/>
);
})}
</Fragment>
);
}

View File

@@ -42,7 +42,6 @@ export default function CommitRankedListItem({
key={node.id}
label={node.label}
onClick={handleClick}
title={node.title}
width={Math.max(minBarWidth, scaleX(node.value, width))}
x={0}
y={top}

View File

@@ -264,7 +264,7 @@ function updateTree(
id = operations[i + 1];
node = ((nodes.get(id): any): Node);
node.treeBaseDuration = operations[i + 2];
node.treeBaseDuration = operations[i + 2] / 1000; // Convert microseconds back to milliseconds;
debug(
'Update',

View File

@@ -0,0 +1,112 @@
// @flow
import { calculateSelfDuration } from './utils';
import type { CommitDetails, CommitTree, Node } from './types';
export type ChartNode = {|
actualDuration: number,
didRender: boolean,
id: number,
label: string,
name: string,
offset: number,
selfDuration: number,
treeBaseDuration: number,
|};
export type ChartData = {|
depth: number,
idToDepthMap: Map<number, number>,
maxSelfDuration: number,
rows: Array<Array<ChartNode>>,
|};
const cachedChartData: Map<string, ChartData> = new Map();
export function getChartData({
commitDetails,
commitIndex,
commitTree,
rootID,
}: {|
commitDetails: CommitDetails,
commitIndex: number,
commitTree: CommitTree,
rootID: number,
|}): ChartData {
const key = `${rootID}-${commitIndex}`;
if (cachedChartData.has(key)) {
return ((cachedChartData.get(key): any): ChartData);
}
const { nodes } = commitTree;
const { actualDurations } = commitDetails;
const idToDepthMap: Map<number, number> = new Map();
const rows: Array<Array<ChartNode>> = [];
let maxDepth = 0;
let maxSelfDuration = 0;
// Generate flame graph structure using tree base durations.
const walkTree = (
id: number,
parentOffset: number = 0,
currentDepth: number = 1
) => {
idToDepthMap.set(id, currentDepth);
const node = ((nodes.get(id): any): Node);
const name = node.displayName || 'Unknown';
const selfDuration = calculateSelfDuration(id, commitTree, commitDetails);
maxDepth = Math.max(maxDepth, currentDepth);
maxSelfDuration = Math.max(maxSelfDuration, selfDuration);
const chartNode: ChartNode = {
actualDuration: actualDurations.get(id) || 0,
didRender: actualDurations.has(id),
id,
label: `${name} (${selfDuration.toFixed(1)}ms)`,
name,
offset: parentOffset,
selfDuration,
treeBaseDuration: node.treeBaseDuration,
};
if (currentDepth > rows.length) {
rows.push([chartNode]);
} else {
rows[currentDepth - 1].push(chartNode);
}
node.children.forEach(childID => {
const childChartNode = walkTree(childID, parentOffset, currentDepth + 1);
parentOffset += childChartNode.treeBaseDuration;
});
return chartNode;
};
// Skip over the root; we don't want to show it in the flamegraph.
const root = ((nodes.get(rootID): any): Node);
walkTree(root.children[0]);
const chartData = {
depth: maxDepth,
idToDepthMap,
maxSelfDuration,
rows,
};
cachedChartData.set(key, chartData);
return chartData;
}
export function invalidateChartData(): void {
cachedChartData.clear();
}

View File

@@ -8,7 +8,6 @@ export type ChartNode = {|
id: number,
label: string,
name: string,
title: string,
value: number,
|};
@@ -58,7 +57,6 @@ export function getChartData({
id,
label,
name,
title: label,
value: selfDuration,
});
});

View File

@@ -1,6 +1,7 @@
// @flow
export const barHeight = 20;
export const barWidthThreshold = 2;
export const maxBarWidth = 30;
export const minBarHeight = 5;
export const minBarWidth = 5;