From c82a7fb56063f241ea3213f27a536b73da7f70a7 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sun, 17 Mar 2019 09:49:10 -0700 Subject: [PATCH] First pass at flame graph chart --- src/backend/renderer.js | 12 +- src/devtools/ProfilingCache.js | 24 ++++ src/devtools/views/Profiler/ChartNode.js | 4 +- .../views/Profiler/CommitFlamegraph.css | 5 + .../views/Profiler/CommitFlamegraph.js | 111 ++++++++++++++++- .../Profiler/CommitFlamegraphListItem.js | 102 ++++++++++++++++ .../views/Profiler/CommitRankedListItem.js | 1 - .../views/Profiler/CommitTreeBuilder.js | 2 +- .../views/Profiler/FlamegraphChartBuilder.js | 112 ++++++++++++++++++ .../views/Profiler/RankedChartBuilder.js | 2 - src/devtools/views/Profiler/constants.js | 1 + 11 files changed, 363 insertions(+), 13 deletions(-) create mode 100644 src/devtools/views/Profiler/CommitFlamegraphListItem.js create mode 100644 src/devtools/views/Profiler/FlamegraphChartBuilder.js diff --git a/src/backend/renderer.js b/src/backend/renderer.js index f58d34c6b6..9adcdd4af1 100644 --- a/src/backend/renderer.js +++ b/src/backend/renderer.js @@ -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).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); } } diff --git a/src/devtools/ProfilingCache.js b/src/devtools/ProfilingCache.js index bed859d665..a1b9675dad 100644 --- a/src/devtools/ProfilingCache.js +++ b/src/devtools/ProfilingCache.js @@ -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(); diff --git a/src/devtools/views/Profiler/ChartNode.js b/src/devtools/views/Profiler/ChartNode.js index 085228502d..77fa24e1e8 100644 --- a/src/devtools/views/Profiler/ChartNode.js +++ b/src/devtools/views/Profiler/ChartNode.js @@ -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 ( - {title} + {label} 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 ( +
+ + {({ height, width }) => ( + + )} + +
+ ); +} + +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( + () => ({ + 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 ; + } + + return ( + + {CommitFlamegraphListItem} + + ); } diff --git a/src/devtools/views/Profiler/CommitFlamegraphListItem.js b/src/devtools/views/Profiler/CommitFlamegraphListItem.js new file mode 100644 index 0000000000..7769381dfa --- /dev/null +++ b/src/devtools/views/Profiler/CommitFlamegraphListItem.js @@ -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 ( + + {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 ( + handleClick(event, id)} + width={nodeWidth} + x={nodeOffset - selectedNodeOffset} + y={top} + /> + ); + })} + + ); +} diff --git a/src/devtools/views/Profiler/CommitRankedListItem.js b/src/devtools/views/Profiler/CommitRankedListItem.js index d47ff5059a..4ed5500aaf 100644 --- a/src/devtools/views/Profiler/CommitRankedListItem.js +++ b/src/devtools/views/Profiler/CommitRankedListItem.js @@ -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} diff --git a/src/devtools/views/Profiler/CommitTreeBuilder.js b/src/devtools/views/Profiler/CommitTreeBuilder.js index 24b92d665a..4a9dbc5f80 100644 --- a/src/devtools/views/Profiler/CommitTreeBuilder.js +++ b/src/devtools/views/Profiler/CommitTreeBuilder.js @@ -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', diff --git a/src/devtools/views/Profiler/FlamegraphChartBuilder.js b/src/devtools/views/Profiler/FlamegraphChartBuilder.js new file mode 100644 index 0000000000..347fa113b3 --- /dev/null +++ b/src/devtools/views/Profiler/FlamegraphChartBuilder.js @@ -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, + maxSelfDuration: number, + rows: Array>, +|}; + +const cachedChartData: Map = 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 = new Map(); + const rows: Array> = []; + + 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(); +} diff --git a/src/devtools/views/Profiler/RankedChartBuilder.js b/src/devtools/views/Profiler/RankedChartBuilder.js index 0b3115b023..bf673f05d6 100644 --- a/src/devtools/views/Profiler/RankedChartBuilder.js +++ b/src/devtools/views/Profiler/RankedChartBuilder.js @@ -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, }); }); diff --git a/src/devtools/views/Profiler/constants.js b/src/devtools/views/Profiler/constants.js index 61162f61c5..2a8ece16fc 100644 --- a/src/devtools/views/Profiler/constants.js +++ b/src/devtools/views/Profiler/constants.js @@ -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;