github.com/pyroscope-io/pyroscope@v0.37.3-0.20230725203016-5f6947968bd0/packages/pyroscope-flamegraph/src/convert/toGraphviz.ts (about)

     1  import type { Profile } from '@pyroscope/models/src';
     2  
     3  import { flamebearersToTree, TreeNode } from './flamebearersToTree';
     4  import { getFormatter } from '../format/format';
     5  
     6  const nodeFraction = 0.005;
     7  const edgeFraction = 0.001;
     8  const maxNodes = 80;
     9  
    10  // have to specify font name here, otherwise renderer won't size boxes properly
    11  // const fontName = "SFMono-Regular, Consolas, Liberation Mono, Menlo, Courier,monospace";
    12  const fontName = '';
    13  
    14  function renderLabels(obj: { [key: string]: string | number }): string {
    15    const labels: string[] = [];
    16    // for (const key of ) {
    17    Object.keys(obj).forEach((key) => {
    18      labels.push(`${key}="${escapeForDot(String(obj[key] || ''))}"`);
    19    });
    20    return `[${labels.join(' ')}]`;
    21  }
    22  
    23  const baseFontSize = 8;
    24  const maxFontGrowth = 16;
    25  
    26  function formatPercent(a: number, b: number): string {
    27    return `${((a * 100) / b).toFixed(2)}%`;
    28  }
    29  
    30  type sampleFormatter = (dur: number) => string;
    31  
    32  // dotColor returns a color for the given score (between -1.0 and
    33  // 1.0), with -1.0 colored green, 0.0 colored grey, and 1.0 colored
    34  // red. If isBackground is true, then a light (low-saturation)
    35  // color is returned (suitable for use as a background color);
    36  // otherwise, a darker color is returned (suitable for use as a
    37  // foreground color).
    38  function dotColor(score: number, isBackground: boolean): string {
    39    // A float between 0.0 and 1.0, indicating the extent to which
    40    // colors should be shifted away from grey (to make positive and
    41    // negative values easier to distinguish, and to make more use of
    42    // the color range.)
    43    const shift = 0.7;
    44  
    45    // Saturation and value (in hsv colorspace) for background colors.
    46    const bgSaturation = 0.1;
    47    const bgValue = 0.93;
    48  
    49    // Saturation and value (in hsv colorspace) for foreground colors.
    50    const fgSaturation = 1.0;
    51    const fgValue = 0.7;
    52  
    53    // Choose saturation and value based on isBackground.
    54    let saturation: number;
    55    let value: number;
    56    if (isBackground) {
    57      saturation = bgSaturation;
    58      value = bgValue;
    59    } else {
    60      saturation = fgSaturation;
    61      value = fgValue;
    62    }
    63  
    64    // Limit the score values to the range [-1.0, 1.0].
    65    score = Math.max(-1.0, Math.min(1.0, score));
    66  
    67    // Reduce saturation near score=0 (so it is colored grey, rather than yellow).
    68    if (Math.abs(score) < 0.2) {
    69      saturation *= Math.abs(score) / 0.2;
    70    }
    71  
    72    // Apply 'shift' to move scores away from 0.0 (grey).
    73    if (score > 0.0) {
    74      score **= 1.0 - shift;
    75    }
    76    if (score < 0.0) {
    77      score = -((-score) ** (1.0 - shift));
    78    }
    79  
    80    let r: number;
    81    let g: number; // red, green, blue
    82    if (score < 0.0) {
    83      g = value;
    84      r = value * (1 + saturation * score);
    85    } else {
    86      r = value;
    87      g = value * (1 - saturation * score);
    88    }
    89    const b: number = value * (1 - saturation);
    90    return `#${Math.floor(r * 255.0)
    91      .toString(16)
    92      .padStart(2, '0')}${Math.floor(g * 255.0)
    93      .toString(16)
    94      .padStart(2, '0')}${Math.floor(b * 255.0)
    95      .toString(16)
    96      .padStart(2, '0')}`;
    97  }
    98  
    99  function renderNode(
   100    format: sampleFormatter,
   101    n: GraphNode,
   102    maxSelf: number,
   103    maxTotal: number
   104  ): string {
   105    const { self } = n;
   106    const { total } = n;
   107  
   108    const name = n.name.replace(/"/g, '\\"');
   109    const dur = format(self);
   110    const fontsize =
   111      baseFontSize + Math.ceil(maxFontGrowth * Math.sqrt(self / maxSelf));
   112    const color = dotColor(total / maxTotal, false);
   113    const fillcolor = dotColor(total / maxTotal, true);
   114  
   115    const label = formatNodeLabel(format, name, self, total, maxTotal);
   116  
   117    const labels = {
   118      label,
   119      id: `node${n.index}`,
   120      fontsize,
   121      shape: 'box',
   122      tooltip: `${name} (${dur})`,
   123      color,
   124      fontcolor: '#000000',
   125      style: 'filled',
   126      fontname: fontName,
   127      // margin: "0.7,0.055",
   128      fillcolor,
   129    };
   130    return `N${n.index} ${renderLabels(labels)}`;
   131  }
   132  
   133  function escapeForDot(str: string) {
   134    return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
   135  }
   136  
   137  function pathBasename(p: string): string {
   138    return p.replace(/.*\//, '');
   139  }
   140  
   141  function formatNodeLabel(
   142    format: sampleFormatter,
   143    name: string,
   144    self: number,
   145    total: number,
   146    maxTotal: number
   147  ): string {
   148    let label = '';
   149    label = `${pathBasename(name)}\n`;
   150  
   151    const selfValue = format(self);
   152    if (self !== 0) {
   153      label = `${label + selfValue} (${formatPercent(self, maxTotal)})`;
   154    } else {
   155      label += '0';
   156    }
   157    let totalValue = selfValue;
   158    if (total !== self) {
   159      if (self !== 0) {
   160        label += '\n';
   161      } else {
   162        label += ' ';
   163      }
   164      totalValue = format(total);
   165      label = `${label}of ${totalValue} (${formatPercent(total, maxTotal)})`;
   166    }
   167  
   168    return label;
   169  }
   170  
   171  function renderEdge(
   172    sampleFormatter: sampleFormatter,
   173    edge: GraphEdge,
   174    maxTotal: number
   175  ): string {
   176    const srcName = edge.from.name.replace(/"/g, '\\"');
   177    const dstName = edge.to.name.replace(/"/g, '\\"');
   178    const edgeWeight = edge.weight; // TODO
   179    const dur = sampleFormatter(edge.weight); // TODO
   180    const weight = 1 + (edgeWeight * 100) / maxTotal;
   181    const penwidth = 1 + (edgeWeight * 5) / maxTotal;
   182    const color = dotColor(edgeWeight / maxTotal, false);
   183    const tooltip = `${srcName} -> ${dstName} (${dur})`;
   184  
   185    const labels = {
   186      label: dur,
   187      weight,
   188      penwidth,
   189      tooltip,
   190      labeltooltip: tooltip,
   191      fontname: fontName,
   192      color,
   193      style: edge.residual ? 'dotted' : '',
   194    };
   195    return `N${edge.from.index} -> N${edge.to.index} ${renderLabels(labels)}`;
   196  }
   197  
   198  type GraphNode = {
   199    name: string;
   200    index: number;
   201    self: number;
   202    total: number;
   203    parents: GraphEdge[];
   204    children: GraphEdge[];
   205  };
   206  
   207  type GraphEdge = {
   208    from: GraphNode;
   209    to: GraphNode;
   210    weight: number;
   211    residual: boolean;
   212  };
   213  
   214  export default function toGraphviz(p: Profile): string {
   215    if (p.metadata.format === 'double') {
   216      return 'diff flamegraphs are not supported';
   217    }
   218  
   219    const tree = flamebearersToTree(p.flamebearer);
   220  
   221    const nodes: string[] = [];
   222    const edges: string[] = [];
   223  
   224    function calcMaxAndSumValues(
   225      n: TreeNode,
   226      maxSelf: number,
   227      maxTotal: number,
   228      sumSelf: number,
   229      sumTotal: number
   230    ): [number, number, number, number] {
   231      n.children.forEach((child) => {
   232        const [newMaxSelf, newMaxTotal] = calcMaxAndSumValues(
   233          child,
   234          maxSelf,
   235          maxTotal,
   236          sumSelf,
   237          sumTotal
   238        );
   239        maxSelf = Math.max(maxSelf, newMaxSelf);
   240        maxTotal = Math.max(maxTotal, newMaxTotal);
   241      });
   242  
   243      maxSelf = Math.max(maxSelf, n.self[0]);
   244      maxTotal = Math.max(maxTotal, n.total[0]);
   245      sumSelf += n.self[0];
   246      sumTotal += n.total[0];
   247  
   248      return [maxSelf, maxTotal, sumSelf, sumTotal];
   249    }
   250  
   251    const [maxSelf, maxTotal, , sumTotal] = calcMaxAndSumValues(tree, 0, 0, 0, 0);
   252    const { sampleRate, units } = p.metadata;
   253    const formatter = getFormatter(maxTotal, sampleRate, units);
   254  
   255    const formatFunc = (dur: number): string => {
   256      return formatter.format(dur, sampleRate, true);
   257    };
   258  
   259    // we first turn tree into a graph
   260    let graphNodes: { [key: string]: GraphNode } = {};
   261    const graphEdges: { [key: string]: GraphEdge } = {};
   262    let nodesTotal = 0;
   263    function treeToGraph(n: TreeNode, seenNodes: string[]): GraphNode {
   264      if (seenNodes.indexOf(n.name) === -1) {
   265        if (!graphNodes[n.name]) {
   266          nodesTotal += 1;
   267          graphNodes[n.name] = {
   268            index: nodesTotal,
   269            name: n.name,
   270            self: n.self[0],
   271            total: n.total[0],
   272            parents: [],
   273            children: [],
   274          };
   275        } else {
   276          graphNodes[n.name].self += n.self[0];
   277          graphNodes[n.name].total += n.total[0];
   278        }
   279      }
   280  
   281      n.children.forEach((child) => {
   282        const childNode = treeToGraph(child, seenNodes.concat([n.name]));
   283        const childKey = `${n.name}/${child.name}`;
   284        if (child.name !== n.name) {
   285          if (!graphEdges[childKey]) {
   286            graphEdges[childKey] = {
   287              from: graphNodes[n.name],
   288              to: childNode,
   289              weight: child.total[0],
   290              residual: false,
   291            };
   292          } else {
   293            graphEdges[childKey].weight += child.total[0];
   294          }
   295          childNode.parents.push(graphEdges[childKey]);
   296          graphNodes[n.name].children.push(graphEdges[childKey]);
   297        }
   298      });
   299      return graphNodes[n.name];
   300    }
   301  
   302    // skip "total" node
   303    tree.children.forEach((child) => {
   304      treeToGraph(child, []);
   305    });
   306  
   307    // next is we need to trim graph to remove small nodes
   308    const nodeCutoff = sumTotal * nodeFraction;
   309    const edgeCutoff = sumTotal * edgeFraction;
   310  
   311    Object.keys(graphNodes).forEach((key) => {
   312      if (graphNodes[key].total < nodeCutoff) {
   313        delete graphNodes[key];
   314      }
   315    });
   316  
   317    // next is we limit total number of nodes
   318  
   319    function entropyScore(n: GraphNode): number {
   320      let score = 0;
   321  
   322      if (n.parents.length === 0) {
   323        score += 1;
   324      } else {
   325        score += edgeEntropyScore(n.parents, 0);
   326      }
   327  
   328      if (n.children.length === 0) {
   329        score += 1;
   330      } else {
   331        score += edgeEntropyScore(n.children, n.self);
   332      }
   333  
   334      return score * n.total + n.self;
   335    }
   336    function edgeEntropyScore(edges: GraphEdge[], self: number) {
   337      let score = 0;
   338      let total = self;
   339      edges.forEach((e) => {
   340        if (e.weight > 0) {
   341          total += Math.abs(e.weight);
   342        }
   343      });
   344  
   345      if (total !== 0) {
   346        edges.forEach((e) => {
   347          const frac = Math.abs(e.weight) / total;
   348          score += -frac * Math.log2(frac);
   349        });
   350        if (self > 0) {
   351          const frac = Math.abs(self) / total;
   352          score += -frac * Math.log2(frac);
   353        }
   354      }
   355      return score;
   356    }
   357  
   358    const cachedScores: { [key: string]: number } = {};
   359    Object.keys(graphNodes).forEach((key) => {
   360      cachedScores[graphNodes[key].name] = entropyScore(graphNodes[key]);
   361    });
   362  
   363    const sortedNodes = Object.values(graphNodes).sort((a, b) => {
   364      const sa: number = cachedScores[a.name];
   365      const sb: number = cachedScores[b.name];
   366      if (sa !== sb) {
   367        return sb - sa;
   368      }
   369      if (a.name !== b.name) {
   370        return a.name < b.name ? -1 : 1;
   371      }
   372      if (a.self !== b.self) {
   373        return sb - sa;
   374      }
   375  
   376      return a.name < b.name ? -1 : 1;
   377    });
   378  
   379    const keptNodes: { [key: string]: GraphNode } = {};
   380    sortedNodes.forEach((n) => {
   381      keptNodes[n.name] = n;
   382    });
   383  
   384    sortedNodes.slice(maxNodes).forEach((n) => {
   385      delete keptNodes[n.name];
   386    });
   387  
   388    // now that we removed nodes we need to create residual edges
   389    function trimTree(n: TreeNode, lastPresentParent: TreeNode | null) {
   390      const isNodeDeleted = !keptNodes[n.name];
   391      n.children.forEach((child) => {
   392        const isChildNodeDeleted = !keptNodes[child.name];
   393        trimTree(child, isNodeDeleted ? lastPresentParent : n);
   394        if (!isChildNodeDeleted && lastPresentParent && isNodeDeleted) {
   395          const edgeKey = `${lastPresentParent.name}/${child.name}`;
   396          graphEdges[edgeKey] ||= {
   397            from: graphNodes[lastPresentParent.name],
   398            to: graphNodes[child.name],
   399            weight: 0,
   400            residual: true,
   401          };
   402  
   403          graphEdges[edgeKey].weight += child.total[0];
   404          graphEdges[edgeKey].residual = true;
   405        }
   406      });
   407    }
   408  
   409    trimTree(tree, null);
   410  
   411    graphNodes = keptNodes;
   412  
   413    function isRedundantEdge(e: GraphEdge) {
   414      const [src, n] = [e.from, e.to];
   415      const seen: { [key: string]: boolean } = {};
   416      const queue = [n];
   417  
   418      while (queue.length > 0) {
   419        const n = queue.shift() as GraphNode;
   420  
   421        for (let i = 0; i < n.parents.length; i += 1) {
   422          const ie = n.parents[i];
   423          if (!(e === ie || seen[ie.from.name])) {
   424            if (ie.from === src) {
   425              return true;
   426            }
   427            seen[ie.from.name] = true;
   428            queue.push(ie.from);
   429          }
   430        }
   431      }
   432      return false;
   433    }
   434  
   435    // remove redundant edges
   436    sortedNodes.reverse().forEach((node) => {
   437      const sortedParentEdges = node.parents.sort((a, b) => b.weight - a.weight);
   438      const edgesToDelete: GraphEdge[] = [];
   439      for (let i = 0; i < sortedParentEdges.length; i += 1) {
   440        const parentEdge = sortedParentEdges[i];
   441        if (!parentEdge.residual) {
   442          break;
   443        }
   444  
   445        if (isRedundantEdge(parentEdge)) {
   446          edgesToDelete.push(parentEdge);
   447          delete graphEdges[`${parentEdge.from.name}/${parentEdge.to.name}`];
   448        }
   449      }
   450      edgesToDelete.forEach((edge) => {
   451        edge.from.children = edge.from.children.filter((e) => e.to !== edge.to);
   452        edge.to.parents = edge.to.parents.filter((e) => e.from !== edge.from);
   453      });
   454    });
   455  
   456    // now we clean up edges
   457    Object.keys(graphEdges).forEach((key) => {
   458      const e = graphEdges[key];
   459      // first delete the ones that no longer have nodes
   460      if (!graphNodes[e.from.name]) {
   461        delete graphEdges[key];
   462      }
   463      if (!graphNodes[e.to.name]) {
   464        delete graphEdges[key];
   465      }
   466      // second delete the ones that are too small
   467      if (e.weight < edgeCutoff) {
   468        delete graphEdges[key];
   469      }
   470    });
   471  
   472    Object.keys(graphNodes).forEach((key) => {
   473      nodes.push(renderNode(formatFunc, graphNodes[key], maxSelf, maxTotal));
   474    });
   475  
   476    Object.keys(graphEdges).forEach((key) => {
   477      edges.push(renderEdge(formatFunc, graphEdges[key], maxTotal));
   478    });
   479  
   480    return `digraph "unnamed" {
   481      fontname= "${fontName}"
   482      ${nodes.join('\n')}
   483      ${edges.join('\n')}
   484    }`;
   485  }