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;