怎么进行Cache的性能分析

这篇文章给大家介绍怎么进行Cache的性能分析,内容非常详细,感兴趣的小伙伴们可以参考借鉴,希望对大家能有所帮助。

创新互联公司专注于企业成都全网营销、网站重做改版、天河网站定制设计、自适应品牌网站建设、HTML5建站商城网站开发、集团公司官网建设、外贸营销网站建设、高端网站制作、响应式网页设计等建站业务,价格优惠性价比高,为天河等各大城市提供网站开发制作服务。

Lua5.4-alpha-rc2 已经发布了好一段时间了, 一直没时间去跑跑看性能如何。最近刚好有空,就跑来看看。结果第一段测试代码就把我惊住了。

--a.lua
collectgarbage("stop")
local function foo()
   local a = 3
   for i = 1, 64 * 1024 * 1024 do
       a = i
   end
   print(a)
end
foo()

在 Lua5.3.4 和 Lua5.4-alpha-rc2 上,这段代码运行时间分为0.55,0.42s。

通过`./luac -p -l ./lua ` 可以得知,上段这代码性能热点一定是OP_MOVE,和OP_FORLOOP。因此一定是这两个opcode的执行解释代码有修改。

我仔细对比了一下,关于OP_FORLOOP和OP_MOVE的实现,发现实现上一共有三处优化。

1. vmcase(OP_FORLOOP)的执行代码去掉了’0

2. vmcase(OP_FORLOOP)向回跳转时,偏移量改成了正值,因此将Bx寄存器直接当作无符号数去处理,省了一个符号转换操作。

3. vmcase(OP_FORLOOP)向回跳转时,由直接修改ci->u.savedpc改为了修改一个局部变量pc。通过反汇编得知,修改局部pc可以省掉一次store操作。

经过测试发现,这三处修改都达不到0.13s这么大幅度的提升。

万般无奈的情况下,我使用git bisec测试了从 Lua5.3.4 到 Lua5.4-alpha-rc2的所有变更(这里说所有不准确,因为git bisec是通过二分法查找的)。

最终发现引起性能影响的竟然是下面一段赋值操作的修改。

typedef union Value {
  GCObject *gc;    /* collectable objects */
  void *p;         /* light userdata */
  int b;           /* booleans */
  lua_CFunction f; /* light C functions */
  lua_Integer i;   /* integer numbers */
  lua_Number n;    /* float numbers */
} Value;

#define TValuefields Value value_; int tt_

typedef struct lua_TValue {
  TValuefields;
} TValue;

#define setobj(L,obj1,obj2) \
-{ TValue *io1=(obj1); *io1 = *(obj2); \
+{ TValue *io1=(obj1); const TValue *io2=(obj2); \
+  io1->value_ = io2->value_; io1->tt_ = io2->tt_; \
  (void)L; checkliveness(L,io1); }

两个赋值的作用都是复制一个结构体。只不过由于结构体对齐的存在,直接使用结构体赋值,会多复制了四个字节。

但是,在64bit机器上,如果地址是对齐的,复制4个字节和复制8个字节不应该会有如此大的差异才对。毕竟都是一条指令完成的。为了近一步证明不是多复制4个字节带来的开销,我做了如下测试。

假设修改前的setobj是setobj_X, 修改后的setobj为setobj_Y。然后分别对setobj_X和setobj_Y进行测试tt_类型为char, short, int, long的情况。

测试结果如下:

typeof(tt_)  char       short       int      long
setobj_X     0.55s      0.55s       0.55s    0.41s
setobj_Y     0.52s      0.43s       0.42s    0.42s

从测试结果可以看到setobj_X在tt_类型为long是反而是最快的,这就说明开销并不是多复制4字节造成的。

反汇编之后发现,setobj_X 和 setobj_Y 惟一的差别就是赋值顺序和寻址模式。

汇编如下:

;setobj_X
0x413e10:        shr    r13d,0x17          
0x413e14
:        shl    r13,0x4          
0x413e18
:        mov    rax,QWORD PTR [r15+r13*1] ;value_          
0x413e1c
:        mov    rdx,QWORD PTR [r15+r13*1+0x8] ;tt_          
0x413e21
:        mov    QWORD PTR [rbx],rax          
0x413e24
:        mov    QWORD PTR [rbx+0x8],rdx          
0x413e28
:        mov    rsi,QWORD PTR [rbp+0x28]          
0x413e2c
:        jmp    0x4131a0
         
;setobj_Y          
0x413da8
:        shr    r13d,0x17          
0x413dac
:        shl    r13,0x4          
0x413db0
:        add    r13,r15          
0x413db3
:        mov    eax,DWORD PTR [r13+0x8] ;tt_          
0x413db7
:        mov    DWORD PTR [rbx+0x8],eax          
0x413dba
:        mov    rax,QWORD PTR [r13+0x0] ;value_          
0x413dbe
:        mov    QWORD PTR [rbx],rax          
0x413dc1
:        mov    rax,QWORD PTR [rbp+0x28]          
0x413dc5
:        jmp    0x413170

猜测,难道是赋值顺序打乱了流水线并行,还是寻址模式需要额外的机器周期? 但是他们都无法解释,当我把tt_的类型改为long之后,setobj_X也会变得更快。

种种迹象把矛头指向Cache。 但这时我已经黔驴技穷了,我找不到更多的测试来继续缩小排查范围了。也没有办法进一步确定一定是Cache造成的(我这时还不知道PMU的存在)。

我开始查找《64-ia-32-architectures-optimization-manual》,试图能在这里找到答案。

找来找去,只在3.6.5.1节中找到了关于L1D Cache效率的相关的内容。我又仔细阅读了一下lvm.c的代码,却并没有发现符合产生 Cache 惩罚的条件。(其实这里我犯了一个错误,不然这里我就已经找到答案了。以前看lparse.c中关于OP_FORLOOP部分时不仔细。欠的技术债这里终于还了。)

万般无奈下,我又测试了下面代码,想看看能否进一步缩小推断范围。

--b.lua
collectgarbage("stop")
local function foo()
   local a = 3
   local b = 4
   for i = 1, 64 * 1024 * 1024 do
       a = b
   end
   print(a)
end
foo()

这次测试其实是有点意外的,因为setobj_X版本的luaVM一下子跑的几乎跟setobj_Y版本一样快了。

看起来更像是3.6.5.1节中提到的L1D Cache的惩罚问题了。但是我依然没有找到惩罚的原因。

我把这一测试结果同步到lua的maillist上去(在我反汇编找不到答案后,就已经去maillist上提问了,虽然有进度,但是同样一直没有结论).

这一次maillist上的同学,终于有了进一步答案了。

他指出,在vmcase(OP_FORLOOP)中使用分开赋值的方式更新’i’(一次赋值value_, 一次赋值tt_,这次tt_赋值是store 32bit)。而在vmcase(OP_MOVE)使用的setobj_X赋值时,使用了两次load 64位来读取value_和tt_。

这恰好就是3.6.5.1节中提到的规则(b),因此会有L1D Cache惩罚。

而这时我恰好已经通过perf观察到两个版本的setobj在PMU的l1d_pend_miss.pending_cycles和l1d_pend_miss.pending_cycles_any指标上有显著不同。 两相印证,基本可以90%的肯定就是这个问题。

现在来解释一下,我之前犯的错误。我之前一直认为,一个`for i = 1, 3, 1 do end`一共占三个lua寄存器:一个初始值i,一个最大值3, 暂时称为_m,一个步长1, 暂时称为_s。

但是经过maillist上的同学提醒后,我又仔细看了一下lparse.c,发现其实上面的for一共占四个lua寄存器:初始值1,暂称为_i,最大值_m, 步长_s,及变量i。

每次OP_FORLOOP在执行到最后会同步_i的值到变量i. 代码中的使用的值来自变量i所在的寄存器,而不是_i。

从lparse.c中得知,_i来自R(A), _m来自R(A+1), _s来自R(A+2), i来自R(A+3)。

再来看一下lvm.c中关于vmcase(OP_FORLOOP)的代码:

vmcase(OP_FORLOOP) {
 if (ttisinteger(ra)) {  /* integer loop? */
   lua_Integer step = ivalue(ra + 2);
   lua_Integer idx = intop(+, ivalue(ra), step);
   lua_Integer limit = ivalue(ra + 1);
   if ((0 < step) ? (idx <= limit) : (limit <= idx)) {
       ci->u.l.savedpc += GETARG_sBx(i);  /* jump back */
       chgivalue(ra, idx);  /* update internal index... */
       setivalue(ra + 3, idx);  /* ...and external index */
   }
 }
 ...
 vmbreak;
}

可以很明显看出ra寄存器和(ra+3)的寄存器的赋值方式并不一样。其中chgivalue是只改value_部分,而setivalue是分别对value_和tt_进行赋值。

因此当接下来执行vmcase(OP_MOVE)时,setobj_X对tt_所在的地址,直接读取64位时就就会受到L1D Cache的惩罚。

而我之前犯的错误就是我一直认为修改i的值是通过chgivalue(ra, idx)来实现的。

为了更加确定是L1D Cache中Store-to-Load-Forwarding惩罚造成的开销。我将setivalue改为了chgivalue之后再测试。果然运行时间与setobj_Y的时间相差无几。这下结论已经99%可靠了,那剩下的1%恐怕要问Intel工程师了。

关于怎么进行Cache的性能分析就分享到这里了,希望以上内容可以对大家有一定的帮助,可以学到更多知识。如果觉得文章不错,可以把它分享出去让更多的人看到。


网页标题:怎么进行Cache的性能分析
转载源于:http://scyanting.com/article/jpcsic.html