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 }