Skip to content

Commit 036c87e

Browse files
authored
Differential Flame Graphs (async-profiler#1553)
1 parent 15b1161 commit 036c87e

File tree

12 files changed

+229
-21
lines changed

12 files changed

+229
-21
lines changed

.assets/html/flamegraph.html

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,11 @@ <h1>CPU profile</h1>
7575
// SPDX-License-Identifier: Apache-2.0
7676
'use strict';
7777
let root, px, pattern;
78-
let level0 = 0, left0 = 0, width0 = 0;
78+
let level0 = 0, left0 = 0, width0 = 0, d = 0;
7979
let nav = [], navIndex, matchval;
8080
let inverted = false;
81+
const U = undefined;
82+
const maxdiff = -1;
8183
const levels = Array(36);
8284
for (let h = 0; h < levels.length; h++) {
8385
levels[h] = [];
@@ -111,10 +113,18 @@ <h1>CPU profile</h1>
111113
return '#' + (p[0] + ((p[1] * v) << 16 | (p[2] * v) << 8 | (p[3] * v))).toString(16);
112114
}
113115

116+
function getDiffColor(diff) {
117+
if (diff === U) return '#ffdd33';
118+
if (diff === 0) return '#e0e0e0';
119+
const v = Math.round(128 * (maxdiff - Math.abs(diff)) / maxdiff) + 96;
120+
return diff > 0 ? 'rgb(255,' + v + ',' + v + ')' : 'rgb(' + v + ',' + v + ',255)';
121+
}
122+
114123
function f(key, level, left, width, inln, c1, int) {
115124
levels[level0 = level].push({level, left: left0 += left, width: width0 = width || width0,
116-
color: getColor(palette[key & 7]), title: cpool[key >>> 3],
117-
details: (int ? ', int=' + int : '') + (c1 ? ', c1=' + c1 : '') + (inln ? ', inln=' + inln : '')
125+
color: maxdiff >= 0 ? getDiffColor(d) : getColor(palette[key & 7]),
126+
title: cpool[key >>> 3],
127+
details: (d ? (d > 0 ? ', +' : ', ') + d : '') + (int ? ', int=' + int : '') + (c1 ? ', c1=' + c1 : '') + (inln ? ', inln=' + inln : '')
118128
});
119129
}
120130

.assets/images/flamegraph_diff.png

78.2 KB
Loading

.licenserc.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ header:
1515
- 'src/jattach'
1616
- 'src/res'
1717
- '**/MANIFEST.MF'
18+
- 'test/**/*.collapsed'
1819
license:
1920
content: |
2021
Copyright The async-profiler authors

docs/ConverterUsage.md

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ Conversion options:
4343
4444
# otlp: OpenTelemetry profile format.
4545
46+
Differential Flame Graph:
47+
--diff <base-profile> <new-profile>
4648
4749
JFR options:
4850
--cpu Generate only CPU profile during conversion
@@ -120,17 +122,45 @@ jfrconv --cpu foo.jfr
120122

121123
for HTML output as HTML is the default format for conversion from JFR.
122124

123-
#### Flame Graph options
125+
### Flame Graph options
124126

125127
To add a custom title to the generated Flame Graph, use `--title`, which has the default value `Flame Graph`:
126128

127129
```
128130
jfrconv --cpu foo.jfr foo.html -r --title "Custom Title"
129131
```
130132

131-
### Other formats
133+
### Differential Flame Graph
132134

133-
`jfrconv` supports converting a JFR file to `collapsed`, `pprof`, `pb.gz` and `heatmap` formats as well.
135+
To find performance regressions, it may be useful to compare current profile
136+
to a previous one that serves as a baseline. Differential Flame Graph
137+
visualizes such a comparsion with a special color scheme:
138+
139+
- Red color denotes frames with more samples comparing to the baseline (i.e. regression);
140+
- Blue is for frames with less samples;
141+
- Yellow are new frames that were absent in the baseline.
142+
143+
The more intense the color, the larger the delta.
144+
For each different frame, the delta value is displayed in a tooltip.
145+
146+
![](/.assets/images/flamegraph_diff.png)
147+
148+
Differential Flame Graph takes the shape of the current profile:
149+
all frames have exactly the same size as in the normal Flame Graph.
150+
This means, frames that exist only in the base profile will not be visible.
151+
To see such frames, create another differential Flame Graph,
152+
swapping the base and the current input file.
153+
154+
To create differential Flame Graph, run `jfrconv --diff` with two input files:
155+
basline profile and new profile. Both files can be in JFR, HTML, or collapsed format.
156+
Other converter options work as usual.
157+
158+
```
159+
jfrconv --cpu --diff baseline.jfr new.jfr diff.html
160+
```
161+
162+
Output file name is optional. If omitted, `jfrconv` takes the name
163+
of the second input file, replacing its extension with `.diff.html`.
134164

135165
## Standalone converter examples
136166

src/converter/one/convert/Arguments.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public class Arguments {
2424
public boolean help;
2525
public boolean reverse;
2626
public boolean inverted;
27+
public boolean diff;
2728
public boolean cpu;
2829
public boolean cpuTime;
2930
public boolean wall;

src/converter/one/convert/FlameGraph.java

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ public class FlameGraph implements Comparator<Frame> {
2020
private static final String[] FRAME_SUFFIX = {"_[0]", "_[j]", "_[i]", "", "", "_[k]", "_[1]"};
2121
private static final byte HAS_SUFFIX = (byte) 0x80;
2222
private static final int FLUSH_THRESHOLD = 15000;
23+
private static final long NEW_FRAME_DIFF = Long.MIN_VALUE;
2324
private static final Pattern TID_FRAME_PATTERN = Pattern.compile("\\[(.* )?tid=\\d+]");
2425

2526
private final Arguments args;
@@ -29,11 +30,14 @@ public class FlameGraph implements Comparator<Frame> {
2930

3031
private String title = "Flame Graph";
3132
private int[] order;
33+
private int[] cpoolMap;
3234
private int depth;
3335
private int lastLevel;
3436
private long lastX;
3537
private long lastTotal;
38+
private long lastDiff;
3639
private long mintotal;
40+
private long maxdiff = -1;
3741

3842
public FlameGraph(Arguments args) {
3943
this.args = args;
@@ -90,6 +94,8 @@ public void parseHtml(Reader in) throws IOException {
9094
while (!br.readLine().isEmpty()) ;
9195

9296
for (String line; !(line = br.readLine()).isEmpty(); ) {
97+
if (line.startsWith("d=")) continue; // artifact of a differential flame graph
98+
9399
StringTokenizer st = new StringTokenizer(line.substring(2, line.length() - 1), ",");
94100
int nameAndType = Integer.parseInt(st.nextToken());
95101

@@ -109,12 +115,10 @@ public void parseHtml(Reader in) throws IOException {
109115

110116
int titleIndex = nameAndType >>> 3;
111117
byte type = (byte) (nameAndType & 7);
112-
if (st.hasMoreTokens() && (type <= TYPE_INLINED || type >= TYPE_C1_COMPILED)) {
113-
type = TYPE_JIT_COMPILED;
114-
}
118+
byte normalizedType = type <= TYPE_INLINED || type >= TYPE_C1_COMPILED ? TYPE_JIT_COMPILED : type;
115119

116-
Frame f = level > 0 || needRebuild ? new Frame(titleIndex, type) : root;
117-
f.self = f.total = total;
120+
Frame f = level > 0 || needRebuild ? new Frame(titleIndex, normalizedType) : root;
121+
fillFrameCounters(f, type, total);
118122
if (st.hasMoreTokens()) f.inlined = Long.parseLong(st.nextToken());
119123
if (st.hasMoreTokens()) f.c1 = Long.parseLong(st.nextToken());
120124
if (st.hasMoreTokens()) f.interpreted = Long.parseLong(st.nextToken());
@@ -177,6 +181,26 @@ public void addSample(CallStack stack, long ticks) {
177181
depth = Math.max(depth, stack.size);
178182
}
179183

184+
public void diff(FlameGraph base) {
185+
// Build a map that translates this cpool keys to the base flamegraph's cpool keys
186+
cpoolMap = Arrays.stream(cpool.keys()).mapToInt(title -> base.cpool.getOrDefault(title, -1)).toArray();
187+
diff(base.root, root);
188+
}
189+
190+
private void diff(Frame base, Frame current) {
191+
current.diff = base == null ? NEW_FRAME_DIFF : current.self - base.self;
192+
maxdiff = Math.max(maxdiff, Math.abs(current.diff));
193+
194+
for (Frame child : current.values()) {
195+
Frame baseChild = base == null ? null : base.get(translateKey(child.key));
196+
diff(baseChild, child);
197+
}
198+
}
199+
200+
private int translateKey(int key) {
201+
return cpoolMap[key & TITLE_MASK] | (key & ~TITLE_MASK);
202+
}
203+
180204
public void dump(OutputStream out) throws IOException {
181205
try (PrintStream ps = new PrintStream(out, false, "UTF-8")) {
182206
dump(ps);
@@ -205,6 +229,9 @@ public void dump(PrintStream out) {
205229
tail = printTill(out, tail, "/*inverted:*/false");
206230
out.print(args.reverse ^ args.inverted);
207231

232+
tail = printTill(out, tail, "/*maxdiff:*/-1");
233+
out.print(maxdiff);
234+
208235
tail = printTill(out, tail, "/*depth:*/0");
209236
out.print(depth);
210237

@@ -239,6 +266,15 @@ private void printCpool(PrintStream out) {
239266
}
240267

241268
private void printFrame(PrintStream out, Frame frame, int level, long x) {
269+
StringBuilder sb = outbuf;
270+
if (frame.diff != lastDiff) {
271+
if (frame.diff == NEW_FRAME_DIFF) {
272+
sb.append("d=U\n");
273+
} else {
274+
sb.append("d=").append(frame.diff).append('\n');
275+
}
276+
}
277+
242278
int nameAndType = order[frame.getTitleIndex()] << 3 | frame.getType();
243279
boolean hasExtraTypes = (frame.inlined | frame.c1 | frame.interpreted) != 0 &&
244280
frame.inlined < frame.total && frame.interpreted < frame.total;
@@ -250,7 +286,7 @@ private void printFrame(PrintStream out, Frame frame, int level, long x) {
250286
func = 'n';
251287
}
252288

253-
StringBuilder sb = outbuf.append(func).append('(').append(nameAndType);
289+
sb.append(func).append('(').append(nameAndType);
254290
if (func == 'f') {
255291
sb.append(',').append(level).append(',').append(x - lastX);
256292
}
@@ -270,6 +306,7 @@ private void printFrame(PrintStream out, Frame frame, int level, long x) {
270306
lastLevel = level;
271307
lastX = x;
272308
lastTotal = frame.total;
309+
lastDiff = frame.diff;
273310

274311
Frame[] children = frame.values().toArray(EMPTY_FRAME_ARRAY);
275312
Arrays.sort(children, this);
@@ -291,6 +328,9 @@ private void printFrameCollapsed(PrintStream out, Frame frame, String[] strings)
291328
sb.append(strings[frame.getTitleIndex()]).append(FRAME_SUFFIX[frame.getType()]);
292329
if (frame.self > 0) {
293330
int tmpLength = sb.length();
331+
if (maxdiff >= 0) {
332+
sb.append(' ').append(frame.diff == NEW_FRAME_DIFF ? 0 : frame.self - frame.diff);
333+
}
294334
out.print(sb.append(' ').append(frame.self).append('\n'));
295335
sb.setLength(tmpLength);
296336
}
@@ -328,6 +368,21 @@ private boolean excludeStack(CallStack stack) {
328368
return include != null;
329369
}
330370

371+
private static void fillFrameCounters(Frame frame, byte type, long ticks) {
372+
frame.self = frame.total = ticks;
373+
switch (type) {
374+
case TYPE_INTERPRETED:
375+
frame.interpreted = ticks;
376+
break;
377+
case TYPE_INLINED:
378+
frame.inlined = ticks;
379+
break;
380+
case TYPE_C1_COMPILED:
381+
frame.c1 = ticks;
382+
break;
383+
}
384+
}
385+
331386
private Frame addChild(Frame frame, String title, byte type, long ticks) {
332387
frame.total += ticks;
333388

src/converter/one/convert/Frame.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@ public class Frame extends HashMap<Integer, Frame> {
1616
public static final byte TYPE_KERNEL = 5;
1717
public static final byte TYPE_C1_COMPILED = 6;
1818

19-
private static final int TYPE_SHIFT = 28;
19+
static final int TYPE_SHIFT = 28;
20+
static final int TITLE_MASK = (1 << TYPE_SHIFT) - 1;
2021

2122
final int key;
2223
long total;
2324
long self;
25+
long diff;
2426
long inlined, c1, interpreted;
2527

2628
private Frame(int key) {
@@ -36,7 +38,7 @@ Frame getChild(int titleIndex, byte type) {
3638
}
3739

3840
int getTitleIndex() {
39-
return key & ((1 << TYPE_SHIFT) - 1);
41+
return key & TITLE_MASK;
4042
}
4143

4244
byte getType() {

src/converter/one/convert/Main.java

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import java.io.File;
99
import java.io.FileInputStream;
10+
import java.io.FileOutputStream;
1011
import java.io.IOException;
1112

1213
public class Main {
@@ -18,7 +19,7 @@ public static void main(String[] argv) throws Exception {
1819
return;
1920
}
2021

21-
if (args.files.size() == 1) {
22+
if (args.files.size() == (args.diff ? 2 : 1)) {
2223
args.files.add(".");
2324
}
2425

@@ -35,6 +36,34 @@ public static void main(String[] argv) throws Exception {
3536
}
3637
}
3738

39+
if (args.diff) {
40+
if (fileCount != 2) {
41+
throw new IllegalArgumentException("--diff option requires two input files");
42+
}
43+
if (!"html".equals(args.output) && !"collapsed".equals(args.output)) {
44+
throw new IllegalArgumentException("--diff option requires html or collapsed output format");
45+
}
46+
47+
args.norm = true; // don't let random IDs in class names spoil comparison
48+
49+
String input1 = args.files.get(0);
50+
String input2 = args.files.get(1);
51+
String output = isDirectory ? new File(lastFile, replaceExt(input2, "diff." + args.output)).getPath() : lastFile;
52+
53+
System.out.print("Converting " + getFileName(input2) + " vs " + getFileName(input1) + " -> " + getFileName(output) + " ");
54+
System.out.flush();
55+
56+
long startTime = System.nanoTime();
57+
FlameGraph base = parseFlameGraph(input1, args);
58+
FlameGraph current = parseFlameGraph(input2, args);
59+
current.diff(base);
60+
current.dump(new FileOutputStream(output));
61+
long endTime = System.nanoTime();
62+
63+
System.out.print("# " + (endTime - startTime) / 1000000 / 1000.0 + " s\n");
64+
return;
65+
}
66+
3867
for (int i = 0; i < fileCount; i++) {
3968
String input = args.files.get(i);
4069
String output = isDirectory ? new File(lastFile, replaceExt(input, args.output)).getPath() : lastFile;
@@ -106,6 +135,7 @@ private static void usage() {
106135
" -o --output FORMAT Output format: html, collapsed, pprof, pb.gz, heatmap, otlp\n" +
107136
" -I --include REGEX Include only stacks with the specified frames\n" +
108137
" -X --exclude REGEX Exclude stacks with the specified frames\n" +
138+
" --diff Create differential Flame Graph from two input files\n" +
109139
"\n" +
110140
"JFR options:\n" +
111141
" --cpu CPU profile (ExecutionSample)\n" +

src/res/flame.html

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,11 @@ <h1>/*title:*/</h1>
7575
// SPDX-License-Identifier: Apache-2.0
7676
'use strict';
7777
let root, px, pattern;
78-
let level0 = 0, left0 = 0, width0 = 0;
78+
let level0 = 0, left0 = 0, width0 = 0, d = 0;
7979
let nav = [], navIndex, matchval;
8080
let inverted = /*inverted:*/false;
81+
const U = undefined;
82+
const maxdiff = /*maxdiff:*/-1;
8183
const levels = Array(/*depth:*/0);
8284
for (let h = 0; h < levels.length; h++) {
8385
levels[h] = [];
@@ -111,10 +113,18 @@ <h1>/*title:*/</h1>
111113
return '#' + (p[0] + ((p[1] * v) << 16 | (p[2] * v) << 8 | (p[3] * v))).toString(16);
112114
}
113115

116+
function getDiffColor(diff) {
117+
if (diff === U) return '#ffdd33';
118+
if (diff === 0) return '#e0e0e0';
119+
const v = Math.round(128 * (maxdiff - Math.abs(diff)) / maxdiff) + 96;
120+
return diff > 0 ? 'rgb(255,' + v + ',' + v + ')' : 'rgb(' + v + ',' + v + ',255)';
121+
}
122+
114123
function f(key, level, left, width, inln, c1, int) {
115124
levels[level0 = level].push({level, left: left0 += left, width: width0 = width || width0,
116-
color: getColor(palette[key & 7]), title: cpool[key >>> 3],
117-
details: (int ? ', int=' + int : '') + (c1 ? ', c1=' + c1 : '') + (inln ? ', inln=' + inln : '')
125+
color: maxdiff >= 0 ? getDiffColor(d) : getColor(palette[key & 7]),
126+
title: cpool[key >>> 3],
127+
details: (d ? (d > 0 ? ', +' : ', ') + d : '') + (int ? ', int=' + int : '') + (c1 ? ', c1=' + c1 : '') + (inln ? ', inln=' + inln : '')
118128
});
119129
}
120130

0 commit comments

Comments
 (0)