• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright (C) 2018 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import {searchSegment} from '../base/binary_search';
16import {cropText} from '../common/canvas_utils';
17
18import {CallsiteInfo} from '../common/state';
19
20interface Node {
21  width: number;
22  x: number;
23  nextXForChildren: number;
24  size: number;
25}
26
27interface CallsiteInfoWidth {
28  callsite: CallsiteInfo;
29  width: number;
30}
31
32const NODE_HEIGHT_DEFAULT = 15;
33
34export const HEAP_PROFILE_COLOR = 'hsl(224, 45%, 70%)';
35export const HEAP_PROFILE_HOVERED_COLOR = 'hsl(224, 45%, 55%)';
36
37export function findRootSize(data: CallsiteInfo[]) {
38  let totalSize = 0;
39  let i = 0;
40  while (i < data.length && data[i].depth === 0) {
41    totalSize += data[i].totalSize;
42    i++;
43  }
44  return totalSize;
45}
46
47export interface NodeRendering {
48  totalSize?: string;
49  selfSize?: string;
50}
51
52export class Flamegraph {
53  private isThumbnail = false;
54  private nodeRendering: NodeRendering = {};
55  private flamegraphData: CallsiteInfo[];
56  private maxDepth = -1;
57  private totalSize = -1;
58  private textSize = 12;
59  // Key for the map is depth followed by x coordinate - `depth;x`
60  private graphData: Map<string, CallsiteInfoWidth> = new Map();
61  private xStartsPerDepth: Map<number, number[]> = new Map();
62
63  private hoveredX = -1;
64  private hoveredY = -1;
65  private hoveredCallsite?: CallsiteInfo;
66  private clickedCallsite?: CallsiteInfo;
67
68  private startingY = 0;
69
70  constructor(flamegraphData: CallsiteInfo[]) {
71    this.flamegraphData = flamegraphData;
72    this.findMaxDepth();
73  }
74
75  private findMaxDepth() {
76    this.maxDepth = Math.max(...this.flamegraphData.map(value => value.depth));
77  }
78
79  hash(s: string): number {
80    let hash = 0x811c9dc5 & 0xfffffff;
81    for (let i = 0; i < s.length; i++) {
82      hash ^= s.charCodeAt(i);
83      hash = (hash * 16777619) & 0xffffffff;
84    }
85    return hash & 0xff;
86  }
87
88  generateColor(name: string, isGreyedOut = false): string {
89    if (this.isThumbnail) {
90      return HEAP_PROFILE_COLOR;
91    }
92    if (isGreyedOut) {
93      return '#d9d9d9';
94    }
95    if (name === 'unknown' || name === 'root') {
96      return '#c0c0c0';
97    }
98    const hue = this.hash(name);
99    return `hsl(${hue}, 50%, 65%)`;
100  }
101
102  /**
103   * Caller will have to call draw method ater updating data to have updated
104   * graph.
105   */
106  updateDataIfChanged(
107      nodeRendering: NodeRendering, flamegraphData: CallsiteInfo[],
108      clickedCallsite?: CallsiteInfo) {
109    this.nodeRendering = nodeRendering;
110    this.clickedCallsite = clickedCallsite;
111    if (this.flamegraphData === flamegraphData) {
112      return;
113    }
114    this.flamegraphData = flamegraphData;
115    this.clickedCallsite = clickedCallsite;
116    this.findMaxDepth();
117    // Finding total size of roots.
118    this.totalSize = findRootSize(flamegraphData);
119  }
120
121  draw(
122      ctx: CanvasRenderingContext2D, width: number, height: number, x = 0,
123      y = 0, unit = 'B') {
124    // TODO(taylori): Instead of pesimistic approach improve displaying text.
125    const name = '____MMMMMMQQwwZZZZZZzzzzzznnnnnnwwwwwwWWWWWqq$$mmmmmm__';
126    const charWidth = ctx.measureText(name).width / name.length;
127    const nodeHeight = this.getNodeHeight();
128    this.startingY = y;
129
130    if (this.flamegraphData === undefined) {
131      return;
132    }
133    // For each node, we use this map to get information about it's parent
134    // (total size of it, width and where it starts in graph) so we can
135    // calculate it's own position in graph.
136    const nodesMap = new Map<number, Node>();
137    let currentY = y;
138    nodesMap.set(-1, {width, nextXForChildren: x, size: this.totalSize, x});
139
140    // Initialize data needed for click/hover behaivior.
141    this.graphData = new Map();
142    this.xStartsPerDepth = new Map();
143
144    // Draw root node.
145    ctx.fillStyle = this.generateColor('root', false);
146    ctx.fillRect(x, currentY, width, nodeHeight);
147    ctx.font = `${this.textSize}px Roboto Condensed`;
148    const text = cropText(
149        `root: ${
150            this.displaySize(
151                this.totalSize, unit, unit === 'B' ? 1024 : 1000)}`,
152        charWidth,
153        width - 2);
154    ctx.fillStyle = 'black';
155    ctx.fillText(text, x + 5, currentY + nodeHeight - 4);
156    currentY += nodeHeight;
157
158
159    for (let i = 0; i < this.flamegraphData.length; i++) {
160      if (currentY > height) {
161        break;
162      }
163      const value = this.flamegraphData[i];
164      const parentNode = nodesMap.get(value.parentId);
165      if (parentNode === undefined) {
166        continue;
167      }
168
169      const isClicked = !this.isThumbnail && this.clickedCallsite !== undefined;
170      const isFullWidth =
171          isClicked && value.depth <= this.clickedCallsite!.depth;
172      const isGreyedOut =
173          isClicked && value.depth < this.clickedCallsite!.depth;
174
175      const parent = value.parentId;
176      const parentSize = parent === -1 ? this.totalSize : parentNode.size;
177      // Calculate node's width based on its proportion in parent.
178      const width =
179          (isFullWidth ? 1 : value.totalSize / parentSize) * parentNode.width;
180
181      const currentX = parentNode.nextXForChildren;
182      currentY = y + nodeHeight * (value.depth + 1);
183
184      // Draw node.
185      const name = this.getCallsiteName(value);
186      ctx.fillStyle = this.generateColor(name, isGreyedOut);
187      ctx.fillRect(currentX, currentY, width, nodeHeight);
188
189      // Set current node's data in map for children to use.
190      nodesMap.set(value.id, {
191        width,
192        nextXForChildren: currentX,
193        size: value.totalSize,
194        x: currentX
195      });
196      // Update next x coordinate in parent.
197      nodesMap.set(value.parentId, {
198        width: parentNode.width,
199        nextXForChildren: currentX + width,
200        size: parentNode.size,
201        x: parentNode.x
202      });
203
204      // Thumbnail mode doesn't have name on nodes and click/hover behaviour.
205      if (this.isThumbnail) {
206        continue;
207      }
208
209      // Draw name.
210      ctx.font = `${this.textSize}px Roboto Condensed`;
211      const text = cropText(name, charWidth, width - 2);
212      ctx.fillStyle = 'black';
213      ctx.fillText(text, currentX + 5, currentY + nodeHeight - 4);
214
215      // Draw border around node.
216      ctx.strokeStyle = 'white';
217      ctx.beginPath();
218      ctx.moveTo(currentX, currentY);
219      ctx.lineTo(currentX, currentY + nodeHeight);
220      ctx.lineTo(currentX + width, currentY + nodeHeight);
221      ctx.lineTo(currentX + width, currentY);
222      ctx.moveTo(currentX, currentY);
223      ctx.lineWidth = 1;
224      ctx.closePath();
225      ctx.stroke();
226
227      // Add this node for recognizing in click/hover.
228      // Map graphData contains one callsite which is on that depth and X
229      // start. Map xStartsPerDepth for each depth contains all X start
230      // coordinates that callsites on that level have.
231      this.graphData.set(
232          `${value.depth};${currentX}`, {callsite: value, width});
233      const xStarts = this.xStartsPerDepth.get(value.depth);
234      if (xStarts === undefined) {
235        this.xStartsPerDepth.set(value.depth, [currentX]);
236      } else {
237        xStarts.push(currentX);
238      }
239    }
240
241    if (this.hoveredX > -1 && this.hoveredY > -1 && this.hoveredCallsite) {
242      // Draw the tooltip.
243      const lines: string[] = [];
244      let lineSplitter: LineSplitter;
245      const nameText = this.getCallsiteName(this.hoveredCallsite);
246      lineSplitter =
247          splitIfTooBig(nameText, width, ctx.measureText(nameText).width);
248      let textWidth = lineSplitter.lineWidth;
249      lines.push(...lineSplitter.lines);
250
251      const mappingText = this.hoveredCallsite.mapping;
252      lineSplitter =
253          splitIfTooBig(mappingText, width, ctx.measureText(mappingText).width);
254      textWidth = Math.max(textWidth, lineSplitter.lineWidth);
255      lines.push(...lineSplitter.lines);
256
257      if (this.nodeRendering.totalSize !== undefined) {
258        const percentage =
259            this.hoveredCallsite.totalSize / this.totalSize * 100;
260        const totalSizeText = `${this.nodeRendering.totalSize}: ${
261            this.displaySize(
262                this.hoveredCallsite.totalSize,
263                unit,
264                unit === 'B' ? 1024 : 1000)} (${percentage.toFixed(2)}%)`;
265        lineSplitter = splitIfTooBig(
266            totalSizeText, width, ctx.measureText(totalSizeText).width);
267        textWidth = Math.max(textWidth, lineSplitter.lineWidth);
268        lines.push(...lineSplitter.lines);
269      }
270
271      if (this.nodeRendering.selfSize !== undefined &&
272          this.hoveredCallsite.selfSize > 0) {
273        const selfPercentage =
274            this.hoveredCallsite.selfSize / this.totalSize * 100;
275        const selfSizeText = `${this.nodeRendering.selfSize}: ${
276            this.displaySize(
277                this.hoveredCallsite.selfSize,
278                unit,
279                unit === 'B' ? 1024 : 1000)} (${selfPercentage.toFixed(2)}%)`;
280        lineSplitter = splitIfTooBig(
281            selfSizeText, width, ctx.measureText(selfSizeText).width);
282        textWidth = Math.max(textWidth, lineSplitter.lineWidth);
283        lines.push(...lineSplitter.lines);
284      }
285
286      const rectWidth = textWidth + 16;
287      const rectXStart = this.hoveredX + 8 + rectWidth > width ?
288          width - rectWidth - 8 :
289          this.hoveredX + 8;
290      const rectHeight = nodeHeight * (lines.length + 1);
291      const rectYStart = this.hoveredY + 4 + rectHeight > height ?
292          height - rectHeight - 8 :
293          this.hoveredY + 4;
294
295      ctx.font = '12px Roboto Condensed';
296      ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
297      ctx.fillRect(rectXStart, rectYStart, rectWidth, rectHeight);
298      ctx.fillStyle = 'hsl(200, 50%, 40%)';
299      ctx.textAlign = 'left';
300      for (let i = 0; i < lines.length; i++) {
301        const line = lines[i];
302        ctx.fillText(line, rectXStart + 4, rectYStart + (i + 1) * 18);
303      }
304    }
305  }
306
307  private getCallsiteName(value: CallsiteInfo): string {
308    return value.name === undefined || value.name === '' ? 'unknown' :
309                                                           value.name;
310  }
311
312  private displaySize(totalSize: number, unit: string, step = 1024): string {
313    if (unit === '') return totalSize.toLocaleString();
314    if (totalSize === 0) return `0 ${unit}`;
315    const units = [
316      ['', 1],
317      ['K', step],
318      ['M', Math.pow(step, 2)],
319      ['G', Math.pow(step, 3)]
320    ];
321    let unitsIndex = Math.trunc(Math.log(totalSize) / Math.log(step));
322    unitsIndex = unitsIndex > units.length - 1 ? units.length - 1 : unitsIndex;
323    const result = totalSize / +units[unitsIndex][1];
324    const resultString = totalSize % +units[unitsIndex][1] === 0 ?
325        result.toString() :
326        result.toFixed(2);
327    return `${resultString} ${units[unitsIndex][0]}${unit}`;
328  }
329
330  onMouseMove({x, y}: {x: number, y: number}) {
331    this.hoveredX = x;
332    this.hoveredY = y;
333    this.hoveredCallsite = this.findSelectedCallsite(x, y);
334    const isCallsiteSelected = this.hoveredCallsite !== undefined;
335    if (!isCallsiteSelected) {
336      this.onMouseOut();
337    }
338    return isCallsiteSelected;
339  }
340
341  onMouseOut() {
342    this.hoveredX = -1;
343    this.hoveredY = -1;
344    this.hoveredCallsite = undefined;
345  }
346
347  onMouseClick({x, y}: {x: number, y: number}): CallsiteInfo|undefined {
348    if (this.isThumbnail) {
349      return undefined;
350    }
351    const clickedCallsite = this.findSelectedCallsite(x, y);
352    // TODO(b/148596659): Allow to expand [merged] callsites. Currently,
353    // this expands to the biggest of the nodes that were merged, which
354    // is confusing, so we disallow clicking on them.
355    if (clickedCallsite === undefined || clickedCallsite.merged) {
356      return undefined;
357    }
358    return clickedCallsite;
359  }
360
361  private findSelectedCallsite(x: number, y: number): CallsiteInfo|undefined {
362    const depth = Math.trunc((y - this.startingY) / this.getNodeHeight()) -
363        1;  // at 0 is root
364    if (depth >= 0 && this.xStartsPerDepth.has(depth)) {
365      const startX = this.searchSmallest(this.xStartsPerDepth.get(depth)!, x);
366      const result = this.graphData.get(`${depth};${startX}`);
367      if (result !== undefined) {
368        const width = result.width;
369        return startX + width >= x ? result.callsite : undefined;
370      }
371    }
372    return undefined;
373  }
374
375  searchSmallest(haystack: number[], needle: number): number {
376    haystack = haystack.sort((n1, n2) => n1 - n2);
377    const [left, ] = searchSegment(haystack, needle);
378    return left === -1 ? -1 : haystack[left];
379  }
380
381  getHeight(): number {
382    return this.flamegraphData.length === 0 ?
383        0 :
384        (this.maxDepth + 2) * this.getNodeHeight();
385  }
386
387  getNodeHeight() {
388    return this.isThumbnail ? 1 : NODE_HEIGHT_DEFAULT;
389  }
390
391  enableThumbnail(isThumbnail: boolean) {
392    this.isThumbnail = isThumbnail;
393  }
394}
395
396export interface LineSplitter {
397  lineWidth: number;
398  lines: string[];
399}
400
401export function splitIfTooBig(
402    line: string, width: number, lineWidth: number): LineSplitter {
403  if (line === '') return {lineWidth, lines: []};
404  const lines: string[] = [];
405  const charWidth = lineWidth / line.length;
406  const maxWidth = width - 32;
407  const maxLineLen = Math.trunc(maxWidth / charWidth);
408  while (line.length > 0) {
409    lines.push(line.slice(0, maxLineLen));
410    line = line.slice(maxLineLen);
411  }
412  lineWidth = Math.min(maxWidth, lineWidth);
413  return {lineWidth, lines};
414}
415