2019-12-15

关于Profiler的一些想法

最近写个profiler,写完发觉大概有点不切实际.

开始是基于一个相对简单的想法.
因为一般debug java的一些线上问题无非就是看下哪些是hot method.
suspect之后一层一层顺着堆栈定位下去.

过程大体是相对繁琐的.

所以想着把这段稍微自动化点.
给定一个entry point,把整个execution过程的调用做个统计列出来.
免得再人工一个个去深入.

于是一个直接的想法就是做instrument.
在每个方法的调用前后加上time span,做耗时统计.

这里当时想了两种方式.
一种是在每个invoke*指令前后插入enter/leave的时间戳.
一种是在每个方法的entry/exit做这个时间戳统计.

第一种相对来说,最后生成的字节码会比较繁琐.
而且如果考虑到异常情况的化,这个time span可能还需要针对每个invoke做一个try-catch/异常处理block.

所以选择是在entry/exit插入.

从另一个角度来说的话,选择后者也实现上会相对的leap/优雅一些.

因为就是一个简单直观的rule.
而且具有相对的普适性.
即使再考虑异常情况的话,也又一个比较简洁的框架模式可以套.

比如对于一个
void target(){
// some code
}
形态的method来说.
插入之后大概就是
void target(){
Bridge.enter();
try{
// orignal
Bridge.leave();
}catch(Throwable e){
Bridge.leave();
throw e;
}
}

针对正常和异常都能相对结构简洁优雅地处理设计统计.

这里一个题外话就是Bridge的设计.

之所以叫Bridge主要是实现上用了一个小trick.

考虑到targe JVM一般是不太能够重启的,而attach的instrument agent有时候代码需要做改动.
尤其是开发时候.

所以实现上这里做了个classloader的处理.

agent在host vm里加载的时候会污染一部分host vm的classloader.
也即是agentmain部分的class会在host classloader里存在.

而如果全部agent code都是在这个classloader里的话,那么要达到代码的hot swap是不太可能的.
除非是transform自身.

于是,这里在agentmain的入口里做个简单的壳.
在agent load的时候切换切换class loader,使得每次attach的session使用自己独立的classloader.
以达到避免污染和hot reload的效果.

大致效果上类似于JSP的一些使用.

因为本质上来说,java的class实例是跟当前调用method的所属的class的classloader绑定的.
这样的话,只要创建一个目标classloader的shell class就能让后续的execution的常规class创建绑定在这个session bounded的loader里了.

在处理了这个热加在和基本的字节码处理框架之后,剩下的就是调用链条的crawl了.

直觉上来说,这也是个相对简单的过程.
因为jvm无非就几个invoke指令,scan一些method的bytecode做个筛选然后递归一下就是了.

只是实际麻烦的点就在于这几个invoke指令的语义.
尤其是invoke virtual和invoke interface.

根据jvm specification.
method resolution主要是基于class往上回溯的.
在interface和class method存在同名的情况下,class method的resolve规则相对靠前.
也就是相对的是favor的一方.

对于两者来说,给定一个invoke some_class some_method的指令.
形式上都是做一个virtual dispatch.

是运行时需要根据具体的instance类型做vtable/itable的lookup找到实际bind的method的.

也就是说,在scan到类似invoke some_class some_method的时候,并不能直接对some_class some_method做递归instrument.
因为除了some_class可能并不是concerted的情况意外,还有就是实际是一个sub class override的情形.

因此实际上要递归的对象/目标是从class/type hierarchy中upper bound为some_class的所有子类.

这样的话,就跟最初预期有了第一个意外.

当初预期做stack trace/调用链递归分析的出发点在于尽可能地只trace interested的部分.
也就是只在调用链条内的方法开销.

所以,在具体的tracing统计了,还做了个简单的per thread的stack matching.
保证被profile的方法的call stack里包含想要trace的entry method.
使得并不是所有特定方法的调用耗时都会包括进去,而是只有特定的调用栈的会被统计进来.

而如果作为upper bounded class的话,虽然形式上来说并没有打破这个约束/优化.
但是实际上的结果是会包含引入非常多的class被instrument.

一个测试方法的数据就是大概2000多个类会被触及到.

尽管理论上来说,这里是可以做一个静态分析的去除一部分的.
因为毕竟是实际上又所有被加载类的信息,加上制定了特定堆栈约束.
也就是相当于给定的一个execution state的initial parameter.

基于此做一个静态分析/执行引擎对涉及到的invoke做类型限定推导是有可能的.

只是一个是这个代价稍微有点大和复杂.

因为本质上相当于做个interpreter.

尽管如果写了之后可以进一步作为一个fuzzer的基础.

然而即使有了这个interpreter也并不难100%的限定类型.
因为存在一个动态类型是只有运行时才能确定的.

一个简单的例子就是反射.
以及一些涉及类似serviceloader加载机制的纯运行时特性.

于是,既然这种裁剪方式并没有一个相对完美的效果的话,那就不如不做筛选.
而是直接对所有class做instrument.

这样的话,实现上也更直接,不需要去做invoke scan的递归递归扫描操作.
而是直接无差别的transform,然后再在运行时再根据call stack决定需不需要做accounting.

唯一需要考虑的就是避免在做accepting的时候陷入递归.

因为做call stack check的代码也是被instrument的.
所有如果不对一些类做白名单处理的话,就直接递归了.

白名单的构建也尝试过用invoke scan的方式尽可能地构建一个小而完整的集合.
但问题依然是没办法100%保证确切的类型限定.
最终也是会指向type upper bounded的道路.

所以最终实现是用了限定java.lang和java.util将大多数jdk class排除在外.

在这个方案基础上出来的结果是第二个不预期的情况.

最初的构想是做了stack tracing之后,直接列出整个call stack的每一步的耗时.

而实际的结果是,尽管确实有了完整的call stack的每个step的耗时统计.
但问题在于涉及的method invoke太多.

一个方法trace有时候会展开成数十甚至上百的invoke method.

这个在事后其实也不难理解.

简单考虑一个method有个k个invoke,每个invoke针对m个class.
那么一级就是大概有k*m的调用统计.
再对每个k递归的话,就是理论上指数级的展开.
即使考虑其中可能会有重复的部分.

但理论上越是复杂的函数,最终的展开级数就越多.

所以这个在实际上是没有什么太大意义.

而且另外一个问题是因为是transform几乎所有class.
所以attch的time会明显地跟class数量线性相关.

更主要的是之前提到过的.
transform实际上会触发JIT的de-optimize.

针对所有class做instrument的话,基本上就是整个jvm打回warmup状态.

尽管理论上来说,会eventually JIT的.

但是从这个角度来说,基于instrument的profiler是会有一定程度的失真的.
尤其如果问题的实际不在bytecode层面,是VM本身的一些特性的话.

所以可能确实是基于perf couter和jvmti event的方案会更优雅准确一些.
至少,它们对VM的观察不会影响已有的JIT状态和实际的VM的代码层面允许逻辑.

没有评论:

发表评论

爽文

去看了好东西. 坦白说,多少是带着点挑刺的味道去的. 毕竟打着爱情神话和女性题材的气质,多多少少是热度为先了. 看完之后倒是有些新的想法. 某种程度上来说,现在的年轻人或者说声音就像小叶. 只要说点贴心的话就能哄好. 也是那种可以不用很努力了. 留在自己的舒适区避难所小圈子抱团就...