最近需要将 jfr 转换成 pprof,发现转换太耗费性能了,遂做一些性能优化。
将一份 jfr 文件转成 lock,memory, cpu, wall 四种类型的 pprof 的开销:
初始性能:
bash
Benchmark-8 2 833094945 ns/op 461013108 B/op 8881948 allocs/op
实现
代码中需要通过 java 的 class name 得到 filename,我们给它加上一层缓存。
go
func (b *pprofBuilder) getFileName(s string) string {
k := s
if res, ok := b.classNameCache[k]; ok {
return res
}
if i := strings.Index(s, "$"); i != -1 {
s = s[:i]
}
res := s + ".java"
b.classNameCache[k] = res
return res
}
有一块需要将 jvm 的 name 抓换成类名, 比如 [C 需要转换成 char[]。
我们给 dimension 加上 cache。
go
var dimensions = []string{"", "[]", "[][]", "[][][]"}
func getDimension(dimension int) string {
if dimension < len(dimensions) {
return dimensions[dimension]
}
return strings.Repeat("[]", dimension)
}
对切片进行复用:
css
b.results = b.results[:0]
成果:
arduino
// with classname cache and dimension cache and results cache
// Benchmark-8 10 668776975 ns/op 369239214 B/op 6147112 allocs/op
我们原先通过 classname + "." + methodName + args 的形式拼接成 java name。
转换成通过 bytes.buffer 尝试去减小分配:
css
b.b.Reset()
b.b.WriteString(className)
b.b.WriteString(".")
b.b.WriteString(f.Method.Name.String)
if f.Method.Descriptor != nil {
if args := b.parseArgs(f.Method.Descriptor.String); args != "()" {
b.b.WriteString(args)
}
}
name = b.b.String()
成果:
bash
// use bytes.buffer to write it
// Benchmark-8 10 641657292 ns/op 338757497 B/op 5566944 allocs/op
java 中带有 args,我们需要做一层转换
arduino
(ZJ)V -> (boolean, long)
同样的也做一层 cache:
bash
Benchmark-8 10 493109508 ns/op 315108003 B/op 4101661 allocs/op
在生成 args 的时候需要使用字符串拼接:
vbnet
"(" + strings.Join(results, ",") + ")"
这里有两次内存分配,我们可以去除 + 带来的一次内存分配:
go
func (b *pprofBuilder) Join(elems []string, sep string) string {
switch len(elems) {
case 0:
return ""
case 1:
return elems[0]
}
n := len(sep) * (len(elems) - 1)
for i := 0; i < len(elems); i++ {
n += len(elems[i])
}
n += 2
b.b.Reset()
b.b.Grow(n)
b.b.WriteString("(")
b.b.WriteString(elems[0])
for _, s := range elems[1:] {
b.b.WriteString(sep)
b.b.WriteString(s)
}
b.b.WriteString(")")
return b.b.String()
}
但是由于 argscache 的存在,对性能影响不大,去除掉避免让代码逻辑变得复杂:
bash
Benchmark-8 10 498650422 ns/op 312121734 B/op 4101807 allocs/op
我们之前使用 bytes.buffer 去拼接字符串,然后用于 map 中的 key:
ini
name = b.b.String()
if result, ok := b.functionTable[name]; ok {
return result
}
b.functionTable = name
事实上我们在 map 中寻找的时候,只需要一个临时的 string 去计算一个 hash 就行了,因此可以改写成这样:
ini
name = BytesToString(b.b.Bytes())
if result, ok := b.functionTable[name]; ok {
return result
}
name = b.b.String()
b.functionTable = name
完成后:
bash
Benchmark-8 10 438528324 ns/op 239495232 B/op 2975963 allocs/op
原先我们使用 func name + line number 作为 location 的 key:
go
line64 := int64(line)
key := fun.Name + ":" + strconv.FormatInt(line64, 10)
if l, ok := b.locationTable[key]; ok {
但如果我们假设 line number 不会大于 10000 的话,那么 key 可以改为 uint64:
go
key := fun.ID*10000 + uint64(line)
if l, ok := b.locationTable[key]; ok {
return l
}
结果:
bash
Benchmark-8 10 204552827 ns/op 34932547 B/op 459624 allocs/op
对于每一个调用栈, 我们要生成一个 key 用于确认唯一一个调用栈,跟生成 function key 时候的一样,我们也使用 unsafe 去获取 hash。 同时, 给 java 的 pid 套一层 cache:
go
func (b *pprofBuilder) getPid(JavaThreadID int64) string {
return b.pidCache[JavaThreadID]
}
结果:
bash
Benchmark-8 10 161653003 ns/op 3604637 B/op 25385 allocs/op
随后,我们合并 github.com/grafana/jfr... 库的这个 mr:
减少内存分配:github.com/grafana/jfr...
总结
通过将内存分配减少为原先的 1%,我们成功的优化了 80% 的 cpu 消耗。
对比优化前后的内存分配次数火焰图,jfr to pprof 的部分几乎完全抹除了,剩下的部分是读取 jfr 文件本身。
对于这部分,有一个优化思路是将 api 变换成流式读取,这样可以去复用中间的结构体。