@samitouri / QOS-React / commits / c82a7fb560

First pass at flame graph chart

Brian Vaughn committed Mar 17, 2019 at 09:49 UTC c82a7fb56063f241ea3213f27a536b73da7f70a7
11 files changed +363 -13
src/backend/renderer.js
+11 -1
@@ -631,10 +631,14 @@ export function attach(
631 }
632
633 if (isProfiling) {
634 + // Tree base duration updates are included in the operations typed array.
635 + // So we have to convert them from milliseconds to microseconds so we can send them as ints.
636 + const treeBaseDuration = Math.floor(fiber.treeBaseDuration * 1000);
637 +
638 const operation = new Uint32Array(3);
639 operation[0] = TREE_OPERATION_UPDATE_TREE_BASE_DURATION;
640 operation[1] = id;
637 - operation[2] = fiber.treeBaseDuration;
641 + operation[2] = treeBaseDuration;
642 addOperation(operation);
643 }
644 }
@@ -702,6 +706,10 @@ export function attach(
706
707 if (isProfiling) {
708 if (treeBaseDuration !== fiber.alternate.treeBaseDuration) {
709 + // Tree base duration updates are included in the operations typed array.
710 + // So we have to convert them from milliseconds to microseconds so we can send them as ints.
711 + const treeBaseDuration = Math.floor(fiber.treeBaseDuration * 1000);
712 +
713 const operation = new Uint32Array(3);
714 operation[0] = TREE_OPERATION_UPDATE_TREE_BASE_DURATION;
715 operation[1] = getFiberID(getPrimaryFiber(fiber));
@@ -1443,6 +1451,8 @@ export function attach(
1451 ((initialTreeBaseDurationsMap: any): Map<number, number>).forEach(
1452 (treeBaseDuration, id) => {
1453 if (idToRootMap.get(id) === rootID) {
1454 + // We don't need to convert milliseconds to microseconds in this case,
1455 + // because the profiling summary is JSON serialized.
1456 initialTreeBaseDurations.push(id, treeBaseDuration);
1457 }
1458 }
src/devtools/ProfilingCache.js
+24
@@ -6,6 +6,10 @@ import {
6 getCommitTree,
7 invalidateCommitTrees,
8 } from 'src/devtools/views/Profiler/CommitTreeBuilder';
9 +import {
10 + getChartData as getFlamegraphChartData,
11 + invalidateChartData as invalidateFlamegraphChartData,
12 +} from 'src/devtools/views/Profiler/FlamegraphChartBuilder';
13 import {
14 getChartData as getRankedChartData,
15 invalidateChartData as invalidateRankedChartData,
@@ -22,6 +26,7 @@ import type {
26 CommitTree as CommitTreeFrontend,
27 ProfilingSummary as ProfilingSummaryFrontend,
28 } from 'src/devtools/views/Profiler/types';
29 +import type { ChartData as FlamegraphChartData } from 'src/devtools/views/Profiler/FlamegraphChartBuilder';
30 import type { ChartData as RankedChartData } from 'src/devtools/views/Profiler/RankedChartBuilder';
31
32 type CommitDetailsParams = {|
@@ -130,6 +135,24 @@ export default class ProfilingCache {
135 store: this._store,
136 });
137
138 + getFlamegraphChartData = ({
139 + commitDetails,
140 + commitIndex,
141 + commitTree,
142 + rootID,
143 + }: {|
144 + commitDetails: CommitDetailsFrontend,
145 + commitIndex: number,
146 + commitTree: CommitTreeFrontend,
147 + rootID: number,
148 + |}): FlamegraphChartData =>
149 + getFlamegraphChartData({
150 + commitDetails,
151 + commitIndex,
152 + commitTree,
153 + rootID,
154 + });
155 +
156 getRankedChartData = ({
157 commitDetails,
158 commitIndex,
@@ -154,6 +177,7 @@ export default class ProfilingCache {
177
178 // Invalidate non-Suspense caches too.
179 invalidateCommitTrees();
180 + invalidateFlamegraphChartData();
181 invalidateRankedChartData();
182
183 this._pendingCommitDetailsMap.clear();
src/devtools/views/Profiler/ChartNode.js
+1 -3
@@ -13,7 +13,6 @@ type Props = {|
13 onClick: Function,
14 onDoubleClick?: Function,
15 placeLabelAboveNode?: boolean,
16 - title: string,
16 width: number,
17 x: number,
18 y: number,
@@ -28,14 +27,13 @@ export default function ChartNode({
27 label,
28 onClick,
29 onDoubleClick,
31 - title,
30 width,
31 x,
32 y,
33 }: Props) {
34 return (
35 <g className={styles.Group} transform={`translate(${x},${y})`}>
38 - <title>{title}</title>
36 + <title>{label}</title>
37 <rect
38 width={width}
39 height={height}
src/devtools/views/Profiler/CommitFlamegraph.css
+5
@@ -0,0 +1,5 @@
1 +.Container {
2 + width: 100%;
3 + height: 100%;
4 + padding: 0.5rem;
5 +}
src/devtools/views/Profiler/CommitFlamegraph.js
+106 -5
@@ -1,16 +1,64 @@
1 // @flow
2
3 -import React, { useContext } from 'react';
3 +import React, { useCallback, useContext, useMemo } from 'react';
4 +import AutoSizer from 'react-virtualized-auto-sizer';
5 +import { FixedSizeList } from 'react-window';
6 import { ProfilerContext } from './ProfilerContext';
7 +import NoCommitData from './NoCommitData';
8 +import CommitFlamegraphListItem from './CommitFlamegraphListItem';
9 +import { barHeight } from './constants';
10 +import { scale } from './utils';
11 import { StoreContext } from '../context';
12
13 import styles from './CommitFlamegraph.css';
14
9 -export default function CommitFlamegraph(_: {||}) {
10 - const { rendererID, rootID, selectedCommitIndex } = useContext(
11 - ProfilerContext
15 +import type { ChartData, ChartNode } from './FlamegraphChartBuilder';
16 +
17 +export type ItemData = {|
18 + chartData: ChartData,
19 + scaleX: (value: number, fallbackValue: number) => number,
20 + selectedChartNode: ChartNode,
21 + selectedChartNodeIndex: number,
22 + selectFiber: (id: number | null) => void,
23 + width: number,
24 +|};
25 +
26 +export default function CommitFlamegraphAutoSizer(_: {||}) {
27 + const { selectFiber } = useContext(ProfilerContext);
28 + const deselectCurrentFiber = useCallback(
29 + event => {
30 + event.stopPropagation();
31 + selectFiber(null);
32 + },
33 + [selectFiber]
34 );
35
36 + return (
37 + <div className={styles.Container} onClick={deselectCurrentFiber}>
38 + <AutoSizer>
39 + {({ height, width }) => (
40 + <CommitFlamegraph height={height} width={width} />
41 + )}
42 + </AutoSizer>
43 + </div>
44 + );
45 +}
46 +
47 +function CommitFlamegraph({
48 + height,
49 + width,
50 +}: {|
51 + height: number,
52 + width: number,
53 +|}) {
54 + const {
55 + rendererID,
56 + rootID,
57 + selectedCommitIndex,
58 + selectFiber,
59 + selectedFiberID,
60 + } = useContext(ProfilerContext);
61 +
62 const { profilingCache } = useContext(StoreContext);
63
64 const profilingSummary = profilingCache.ProfilingSummary.read({
@@ -31,5 +79,58 @@ export default function CommitFlamegraph(_: {||}) {
79 rootID: ((rootID: any): number),
80 });
81
34 - return 'Coming soon: Flamegraph';
82 + const chartData = profilingCache.getFlamegraphChartData({
83 + commitDetails,
84 + commitIndex: ((selectedCommitIndex: any): number),
85 + commitTree,
86 + rootID: ((rootID: any): number),
87 + });
88 +
89 + const selectedChartNodeIndex = useMemo(
90 + () =>
91 + selectedFiberID === null
92 + ? 0
93 + : ((chartData.idToDepthMap.get(selectedFiberID): any): number) - 1,
94 + [chartData, selectedFiberID]
95 + );
96 +
97 + const selectedChartNode = useMemo(() => {
98 + if (selectedFiberID === null) {
99 + return chartData.rows[0][0];
100 + }
101 + return ((chartData.rows[selectedChartNodeIndex].find(
102 + chartNode => chartNode.id === selectedFiberID
103 + ): any): ChartNode);
104 + }, [chartData, selectedFiberID, selectedChartNodeIndex]);
105 +
106 + const itemData = useMemo<ItemData>(
107 + () => ({
108 + chartData,
109 + scaleX: scale(0, selectedChartNode.treeBaseDuration, 0, width),
110 + selectedChartNode,
111 + selectedChartNodeIndex,
112 + selectFiber,
113 + width,
114 + }),
115 + [chartData, selectedChartNode, selectedChartNodeIndex, selectFiber, width]
116 + );
117 +
118 + // If a commit contains no fibers with an actualDuration > 0,
119 + // Display a fallback message.
120 + if (chartData.depth === 0) {
121 + return <NoCommitData />;
122 + }
123 +
124 + return (
125 + <FixedSizeList
126 + height={height}
127 + innerElementType="svg"
128 + itemCount={chartData.depth}
129 + itemData={itemData}
130 + itemSize={barHeight}
131 + width={width}
132 + >
133 + {CommitFlamegraphListItem}
134 + </FixedSizeList>
135 + );
136 }
src/devtools/views/Profiler/CommitFlamegraphListItem.js new
+102
@@ -0,0 +1,102 @@
1 +// @flow
2 +
3 +import React, { Fragment, useCallback } from 'react';
4 +import { barHeight, barWidthThreshold } from './constants';
5 +import { getGradientColor } from './utils';
6 +import ChartNode from './ChartNode';
7 +
8 +import type { ItemData } from './CommitFlamegraph';
9 +
10 +export default function CommitFlamegraphListItem({
11 + data,
12 + index,
13 + style,
14 +}: {
15 + data: ItemData,
16 + index: number,
17 + style: Object,
18 +}) {
19 + const {
20 + chartData,
21 + scaleX,
22 + selectedChartNode,
23 + selectedChartNodeIndex,
24 + selectFiber,
25 + width,
26 + } = data;
27 + const { maxSelfDuration, rows } = chartData;
28 +
29 + const handleClick = useCallback(
30 + (event: MouseEvent, id: number) => {
31 + event.stopPropagation();
32 + selectFiber(id);
33 + },
34 + [selectFiber]
35 + );
36 +
37 + // List items are absolutely positioned using the CSS "top" attribute.
38 + // The "left" value will always be 0.
39 + // Since height is fixed, and width is based on the node's duration,
40 + // We can ignore those values as well.
41 + const top = parseInt(style.top, 10);
42 +
43 + const row = rows[index];
44 +
45 + let selectedNodeOffset = scaleX(selectedChartNode.offset, width);
46 +
47 + return (
48 + <Fragment>
49 + {row.map(chartNode => {
50 + const {
51 + actualDuration,
52 + didRender,
53 + id,
54 + name,
55 + offset,
56 + selfDuration,
57 + treeBaseDuration,
58 + } = chartNode;
59 +
60 + const nodeOffset = scaleX(offset, width);
61 + const nodeWidth = scaleX(treeBaseDuration, width);
62 +
63 + // Filter out nodes that are too small to see or click.
64 + // This also helps render large trees faster.
65 + if (nodeWidth < barWidthThreshold) {
66 + return null;
67 + }
68 +
69 + // Filter out nodes that are outside of the horizontal window.
70 + if (
71 + nodeOffset + nodeWidth < selectedNodeOffset ||
72 + nodeOffset > selectedNodeOffset + width
73 + ) {
74 + return null;
75 + }
76 +
77 + let color = 'var(--color-commit-did-not-render)';
78 + let label = name;
79 + if (didRender) {
80 + color = getGradientColor(selfDuration / maxSelfDuration);
81 + label = `${name} (${selfDuration.toFixed(
82 + 1
83 + )}ms of ${actualDuration.toFixed(1)}ms)`;
84 + }
85 +
86 + return (
87 + <ChartNode
88 + color={color}
89 + height={barHeight}
90 + isDimmed={index < selectedChartNodeIndex}
91 + key={id}
92 + label={label}
93 + onClick={event => handleClick(event, id)}
94 + width={nodeWidth}
95 + x={nodeOffset - selectedNodeOffset}
96 + y={top}
97 + />
98 + );
99 + })}
100 + </Fragment>
101 + );
102 +}
src/devtools/views/Profiler/CommitRankedListItem.js
-1
@@ -42,7 +42,6 @@ export default function CommitRankedListItem({
42 key={node.id}
43 label={node.label}
44 onClick={handleClick}
45 - title={node.title}
45 width={Math.max(minBarWidth, scaleX(node.value, width))}
46 x={0}
47 y={top}
src/devtools/views/Profiler/CommitTreeBuilder.js
+1 -1
@@ -264,7 +264,7 @@ function updateTree(
264 id = operations[i + 1];
265
266 node = ((nodes.get(id): any): Node);
267 - node.treeBaseDuration = operations[i + 2];
267 + node.treeBaseDuration = operations[i + 2] / 1000; // Convert microseconds back to milliseconds;
268
269 debug(
270 'Update',
src/devtools/views/Profiler/FlamegraphChartBuilder.js new
+112
@@ -0,0 +1,112 @@
1 +// @flow
2 +
3 +import { calculateSelfDuration } from './utils';
4 +
5 +import type { CommitDetails, CommitTree, Node } from './types';
6 +
7 +export type ChartNode = {|
8 + actualDuration: number,
9 + didRender: boolean,
10 + id: number,
11 + label: string,
12 + name: string,
13 + offset: number,
14 + selfDuration: number,
15 + treeBaseDuration: number,
16 +|};
17 +
18 +export type ChartData = {|
19 + depth: number,
20 + idToDepthMap: Map<number, number>,
21 + maxSelfDuration: number,
22 + rows: Array<Array<ChartNode>>,
23 +|};
24 +
25 +const cachedChartData: Map<string, ChartData> = new Map();
26 +
27 +export function getChartData({
28 + commitDetails,
29 + commitIndex,
30 + commitTree,
31 + rootID,
32 +}: {|
33 + commitDetails: CommitDetails,
34 + commitIndex: number,
35 + commitTree: CommitTree,
36 + rootID: number,
37 +|}): ChartData {
38 + const key = `${rootID}-${commitIndex}`;
39 +
40 + if (cachedChartData.has(key)) {
41 + return ((cachedChartData.get(key): any): ChartData);
42 + }
43 +
44 + const { nodes } = commitTree;
45 + const { actualDurations } = commitDetails;
46 +
47 + const idToDepthMap: Map<number, number> = new Map();
48 + const rows: Array<Array<ChartNode>> = [];
49 +
50 + let maxDepth = 0;
51 + let maxSelfDuration = 0;
52 +
53 + // Generate flame graph structure using tree base durations.
54 + const walkTree = (
55 + id: number,
56 + parentOffset: number = 0,
57 + currentDepth: number = 1
58 + ) => {
59 + idToDepthMap.set(id, currentDepth);
60 +
61 + const node = ((nodes.get(id): any): Node);
62 + const name = node.displayName || 'Unknown';
63 +
64 + const selfDuration = calculateSelfDuration(id, commitTree, commitDetails);
65 +
66 + maxDepth = Math.max(maxDepth, currentDepth);
67 + maxSelfDuration = Math.max(maxSelfDuration, selfDuration);
68 +
69 + const chartNode: ChartNode = {
70 + actualDuration: actualDurations.get(id) || 0,
71 + didRender: actualDurations.has(id),
72 + id,
73 + label: `${name} (${selfDuration.toFixed(1)}ms)`,
74 + name,
75 + offset: parentOffset,
76 + selfDuration,
77 + treeBaseDuration: node.treeBaseDuration,
78 + };
79 +
80 + if (currentDepth > rows.length) {
81 + rows.push([chartNode]);
82 + } else {
83 + rows[currentDepth - 1].push(chartNode);
84 + }
85 +
86 + node.children.forEach(childID => {
87 + const childChartNode = walkTree(childID, parentOffset, currentDepth + 1);
88 + parentOffset += childChartNode.treeBaseDuration;
89 + });
90 +
91 + return chartNode;
92 + };
93 +
94 + // Skip over the root; we don't want to show it in the flamegraph.
95 + const root = ((nodes.get(rootID): any): Node);
96 + walkTree(root.children[0]);
97 +
98 + const chartData = {
99 + depth: maxDepth,
100 + idToDepthMap,
101 + maxSelfDuration,
102 + rows,
103 + };
104 +
105 + cachedChartData.set(key, chartData);
106 +
107 + return chartData;
108 +}
109 +
110 +export function invalidateChartData(): void {
111 + cachedChartData.clear();
112 +}
src/devtools/views/Profiler/RankedChartBuilder.js
-2
@@ -8,7 +8,6 @@ export type ChartNode = {|
8 id: number,
9 label: string,
10 name: string,
11 - title: string,
11 value: number,
12 |};
13
@@ -58,7 +57,6 @@ export function getChartData({
57 id,
58 label,
59 name,
61 - title: label,
60 value: selfDuration,
61 });
62 });
src/devtools/views/Profiler/constants.js
+1
@@ -1,6 +1,7 @@
1 // @flow
2
3 export const barHeight = 20;
4 +export const barWidthThreshold = 2;
5 export const maxBarWidth = 30;
6 export const minBarHeight = 5;
7 export const minBarWidth = 5;