一次关于Cache的性能分析

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<step’的判断。(由于一次for循环期间,step的符号总是固定的,因此cpu分支预测成功率是100%)

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); }

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

但是,在64位机器上,如果地址是对齐的,复制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 32位)。而在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工程师了。

BTW,这次分析其实断断续续执行了4天。对于Cache对程序性能的影响,终于有了一次深刻的意识。

从CPU层面谈谈优化

大多数时间,大家都在从设计和算法上优化效率(这类优化往往效果比较明显,比如一个二分查找可以轻易将时间复杂度降低为lg(n))。但是在实现上,却很少有人注重实现效率,而理由是反正每年都会有更高频率的CPU出现,我何必花那个心思呢(Java程序员尤其擅长使用这个理由@_@)。

我个人不是完全同意上面的说辞,在不影响代码的可读性和花费不高的代价下,我习惯性的会写最我当前所知道的性能最高的写法。

在很早以前,我一度只有在编写汇编时才可以计算出汇编条数。随着代码量的增加,我慢慢意识到,这种想法是错误的。即使在写C语言时,其实生成的汇编也时有迹可寻的。

程序中所有代码一般都分为三个步骤(有些汇编指令会合并其中的某几步)。

1. 从内存读到寄存器(有可能直接从cache读)
2. 操作寄存器进行运算,并将结果保存在寄存器
3. 将结果写入内存(有可能会同时写入cache)

一般步骤1和3是对称的,只要步骤1的效率高,步骤3会同样高效。因此下面只分析步骤1和步骤2。

先来定义几个数据结构:

struct foo {
    int b;
    int a;
};
struct bar {
    struct foo f;
    struct foo *fp; //assume fp = &f
};

影响访问内存效率的第一个因素,汇编条数:

int access1(struct bar *b)
{
    return b->f.a;
}
int access2(struct bar *b)
{
    return b->fp->a;
}

在上面代码中access1比access2快一倍,这是因为结构体嵌套,不论嵌套多少层,其子成员偏移量都是常量。因此`b->f.a` 等价于`int a = *(int*)((unsigned char *)b + 4)`,所以这句代码翻译成汇编就是`movl 4(%rdi),%eax`(这里%rdi就是b的值)。

而access2就必须要经过两次内存访问, `struct foo *p = ((unsigned char *)b + 8); int a = *(int)((unsigned char *)p + 4)`。 翻译成汇编就成了`movq 8(%rdi),%rax, movl 4(%rax), %eax`(这里%rdi同样是参数b)。

因此当你写出类似下面代码时请慎重。

b->fp->a = xx;
b->fp->b = xx;
b->fp->c = xx;
b->fp->d = xx;
b->fp->e = xx;

反观下面的代码,除了代码前缀长一点,却并不会产生什么问题。

b->fp.a = xx;
b->fp.b = xx;
b->fp.c = xx;
b->fp.d = xx;
b->fp.e = xx;

影响访问内存效率的第二个因素, 访问内存的频率。 也许很多人都写过类似下面的代码:

void test(struct bar *b)
{
    int i;
    for (i = 0; i < b->foo.a; i++)
        do_something()
}

编译器在执行优化时,一般不会使用寄存器缓存指针指向的内容和函数调用的返回结果(这个不同的编译器实现可能不太一样,至少我使用的GCC在O2的情况下并不会做此优化),我称之为指针不可优化原则。因为从理论上来讲内存是整个进程共享的,而进程中到底会有多少个线程编译器并不知晓,它并不知道在执行for期间,b->foo.a是否会被其他线程修改。函数调用也是同理。

这也意味着每执行一次do_somthing之后都会先去访问b->foo.a的值,再来比较。这会增加访存次数, 即使有cache的存在,也没有直接访问寄存器来的快。因此这时我们可以向编译器做个暗示(增加一个局部变量,告诉编译器,我们需要的这个值,在for循环期间不会变化),比如像下面这样。

void test(struct bar *b)
{
    int i, n = b->foo.a;
    for (i = 0; i < n; i++)
        do_something()
}

影响访问内存效率的第三个因素, CPU CACHE。

数据结构的设计与CPU CACHE的命中率其实是息息相关。

由于Cache要远小于内存,因此一般会采用某种映射算法进行映射(是的,上学时“计算机组成原理”上讲的都是真的)。

然而不论CPU采用何种算法,Cache line的概念是不变的。即在Cache miss时,是按Cache line的模式来加载的.

比如在X86架构上Cache line一般为64字节,我们在访问内存地址0x100时,CPU会自动将0x100 ~ 0x140的内存全部载入Cache。

假如我们需要频繁在一个链表中,查出某几个结点进行数据修改,下面的数据结构就是低效的。

struct node {
    struct node *next;
    int select;
    char buffer[128];
};
void test(struct node *head, int select)
{
    struct node *h;
    for (h = head; h != NULL; h = h->next) {
        if (h->select == select) {
            cook(h->buffer, sizeof(h->buffer));
            break;
        }
    }
}

很显然,从上面的算法来看,在for循环时我们现关心node.next 和 node.select字段,node.buffer只有在最后一步时才会访问,而上面的代码每访问一次h->select的值,都会使Cache line充满了node.buffer的值,而这个值我们是不需要的。

因此我们需要优化struct node的内存布局,以使链表中所有的node.next, node.select 尽可能的排布在一起,以便可以充分使用Cache line的预加载功能。

最后,再简单看一下运算过程中的两个优化(并不是只有两个,而是我只会这几个:D)

一个是比较功能,在所有比较中,与0比较是最快的,因为大部分CPU的指令都会影响ZF标准位。比如下面代码:

void test1(struct foo *f)
{
	if ((f->a & 0xf) == 0)
		do_something();
}
int test2(struct foo *f)
{
	if ((f->a & 0xf) == 1)
		do_something();
}

test1函数比test2函数快3倍,因为test1函数可以直接用test指令然后判断ZF标准位,而test2需要先将f->a取出来,然后做and, 最后做compare操作。也许直接上汇编会更能说明问题。

//test1
testb	$15, 4(%rdi)
je	LBB3_2
//test2
movl	4(%rdi), %eax
andl	$15, %eax
cmpl	$1, %eax
jne	LBB4_1

再一个就是switch功能

在写switch语句时,如果case的值是连续的, 则编译器会采用跳转表的形式来直接jmp, 时间复杂度为O(1)。

如果值是不连续的,编译器默认会采用二分查找法来进行,时间复杂度为O(lgn)。因此如果有可能,应该尽可能将case的值定义为连续。

使用缓存优化数据请求

上一篇场景之后,事情还没有完。

我有一堆struct obj对象(数量级可能为千级), 客户端需要频繁拉取这些信息中的一部分去显示(比如,当切换标签页时)。

由于这一操作可能会很频繁,而struct obj对象并不算小,如果每一次都重新拉取全部数据,有点让人不舒服,而且对流量也是一种很大的浪费。因此就琢磨着怎么去优化整个过程,以使在此过程中使数据传输量最小。

第一反应,肯定就是对整个列表加一个version字段,每当有obj对象改变时,version加1,当客户端进行拉取时,拿着他本地存储的version值向服务端要列表,如果服务端的version没有比客户端version更新,则直接返回空即可(ps.之所以最开始想到这个办法,是因为有先例 :D)。

但是仔细分析一下整个场景,就会发现这个方法并不太适用这些场景。

一方面,所有obj对象共享着同一个version字段,就意味着只要有任何一个obj对象变动,version字段都会自增。因此理论上obj对象越多,version就变动越频繁,当obj多到一定程度,version机制基本上就形同虚设。

另一方面,由于客户端只需要拉取所有obj对象中的一部分,所有的obj对象共享同一个version也显得有些太过浪费。因此这个方案很容易就被放弃了。

另一种解决方案是,客户端在本地实现一个cache模块,当cache为空时向服务器拉取所需信息。此后再切换标签, 直接从本地cache模块取数据。只有当通过本地cache进行操作(如:通过某按钮根据cache中的obj对象的状态,向服务器发送请求)失败时,再清空本地cache重新从服务端获取。

然而,这种方案有另外一个坏处,就是用户每隔一段时间必然要出现一次错误。对用户来讲似乎不太友好。

经过仔细思考之后,对方案一和方案二进行了综合。来同时去除两种方案的所带来的弊端。

首先将版本号与每一个obj对象进行关联,这样就可以精确指示出某一个对象是否比客户端新。

再将拉取列表命令拆分成两条命令,pull_list和pull_info。

在客户端需要拉取列表时(比如切换标签页), 直接调用pull_list,而pull_list返回一个item列表。item结构体如下:

struct item {
int id;
int version;
};

可以看出这个item极其简单,就算是频繁拉取,数据量也还可以接受。

客户端依然会在本地维护一个cache, 而这个cache的数据结构可能类似于std::unordered_map<int, struct obj>一样的数据结构。

当客户端收到pull_list的回应之后,拿着item列表去cache中查询,确认cache中是否存在这些数据。如果数据存在,则检查服务端返回obj对象的version字段是否比cache中新,如果较新则从cache中删除些obj对象。

然后拿着cache不存在或失效的id列表使用pull_info命令向服务器请求数据。由于在相对短时间段内不可能所有obj对象都会有状态改变。因此,在客户端频繁拉取信息时,cache必然有极高的命中率,这也意味着会极大的节省数据流量开销。

此外,此方案还存在一个问题,那就是列表是随时动态变化的,而其中的obj对象还会随着某些特殊的操作或时间的流逝而消亡。

那么客户端如何才能感知这些变化, 并从cache中删除这些已经不存在的obj对象呢。

遗憾的是,客户端似乎真的没有办法来感知这些变化。

为了解决这个问题,我们做了一个约定,假设客户端一次拉取的列表为N,当客户端cache中obj对象的个数大于2N时,就意味着cache中至少有一半对象可能都已经消亡了(ps.只是有可能)。这时侯直接把cache清空,然后从新开始cache。

BTW, 其实约定为2N实为无奈之举,按我最初的想法,应该是用一个类似weak table的数据结构来cache。这样当内存不够时,可以由GC来自动释放这些内存,来保证客户端依然能够顺利流畅的运行。在Java中可以用WeakHashTable来代替,可遗憾的是在C#中并无此类型数据结构。

内存数据库和进程内cache

上家公司是做TPS游戏的,因此其实对玩家数据的操作其实不是很频繁,而且由于当时服务器的数据库部分是单独剥离开给指定的人维护的。因此对于cache这一块,从来没有仔细的考虑过。新公司的游戏需要与离线玩家进行战斗,因此在写业务逻辑时是需要cache支持的。但是总觉得cache设计之初的定位有些问题,由此引发了一些思考。


最初我一直在纠结于内存数据库,我觉得诸如redis这种内存数据库已经把所有数据内容都常驻内存了。那么设计一个cache真的有必要么?直接随用随取不就行了么。在服务器进程中再cache一份冗余数据, 是否真的有必要呢? 如果服务器进程中有必要cache一份数据那么内存数据库的存在是为了解决什么问题呢?

最终我发现我被内存数据库这几个字给带偏了,内存数据中数据常驻内存是不假, 但是他是以独立进程存在的。也就是说服务器想要获取内存数据库中的数据,首先需要通过socket发送请求,然后数据库进程接收到请求后取出相应数据通过socket返回给服务器进程。 这中间牵涉到系统调用,上下文切换引cpu cache大面积失效,网络传输延迟等各种因素的存在。因为相对于服务器进程直接读写本地内存来讲,性能相差了不止一个数量级。

那么即然如此,直接用服务器+mysql不就好了嘛。为什么会有memcache和redis的出现呢?他们的一定是为了解决某类问题才出现的。

首先数据库的作用是容灾,即在服务器进程出现bug崩溃时,可以将玩家丢失数据的代价降到最低。而游戏服务器本质上就是一直在操作玩家数据(所以才说游戏服务器一般属于io密集型,所以才越来越流行使用脚本来编写业务逻辑),因此数据库的写入和读取速度在很大程度上会影响服务器程序的性能。由于mysql中所有DB数据都是存入硬盘的,读写性能很容易成为瓶颈,有好多服务器为了提高mysql的读写性能,都会专门配备ssd。

而memcache/redis的机制是把数据全部cache进内存的,因此在读写性能上要比mysql出彩不少。但是由于memcache没有数据落地功能。因此一般是挂在mysql前面做缓存使用。

换句话来说,内存数据库也是数据库,他的作用也是用来容灾的,之所以会出现主要是为了解决在大规模频繁读写数据库时的性能瓶颈。因此内存数据库并不能替换进程内cache。


那么进程内cache主要是用来解决什么问题呢?

从cpu的cache发展历史来看,程序一般有一个特点。那就是读的频率要远远大于写的频率。

如果读和写都直接穿透到数据库的话,由于上下文切换,网络传输延迟等原因,与仅仅在写数据时才穿透数据库,而读直接读取时程内cache的数据相比,性能应该会差上几个数量级。

因此进程内cache实现最简单的一种方式就是write through。当然,也明一些其他的设计方式,比如在进程内cache对数据置脏标志位,在写入关键数据时,会将此玩家脏数据写入DB,无疑这种方式效率最高,但实现起来与业务逻辑偶合度也会更高。

由于有了进程内cache, 因此在对数据库结构设计时,有时候可以存在更多的优化空间。

比如有些逻辑中需要数据A, 而数据A是通过数据B查表或计算就可以得到的。 这个时候就没有必要将数据A存入数据库,也没有必要逻辑代码使用数据A时每次都去计算一下。仅仅在进程序内cache加载数据完成的那一时刻去计算一次并放入进程内cache即可。这样即提高了逻辑的运行,又减少了数据库的占用空间。最重要的时不会出现由于程序Bug导至A与B不致的情况。况且将A存入数据库然后直接从数据库加到到进程内cache的网络开销并不一定比进程内cache加载完成后从B算出A的值会少。

cache命中率对效率的影响

趁着去买菜的空档, 突然间想起来cache命中率失败的代价从来还没测过, 随后又想到网上很多人争论如果两个for循环嵌套, 到底是大循环放在外面效率高还是小循环放在外面效率高. 虽然有大牛说不同情况不同分析, 但到底为什么却没有分析.

于是就for循环与cache命中率写了一段测试代码访问1G的数据来测了一下性能, 竟然有1倍之多.

代码如下:

#include <Windows.h>
#include "assist.h"

//cache line 64 byte
//cache size 4Mbyte
typedef unsigned char cache_line_t[64];

cache_line_t cache_line [1024 * 1024 * 1024 / sizeof(cache_line_t)];

#define FAST 0

int _tmain(int argc, _TCHAR* argv[])
{
int times;
int i, j;
int tmp;

CACL_TAKES_TIME_BEGIN(aa);

#if FAST
for (j = 0; j < ARRAY_SIZE(cache_line); j++) { for (i = 0; i < 64; i++) { tmp = cache_line[j][i]; } } #else for (i = 0; i < 64; i++) { for (j = 0; j < ARRAY_SIZE(cache_line); j++) { tmp = cache_line[j][i]; } } #endif printf("takes:%dmsn", CACL_TAKES_TIME_END(aa)); return 0; }

具体原因可以参考这篇文章. OK买菜去.

------------------------------------------------------------
<<代码大全>>p623中讲到把最忙循环放在最内层, 是因为for循环第一次是需要初始化变量的, 如果最多层越少, 那么i变量被初始为0的次数就越少. 但是从上面的例子表明并非是这样, 这说明当数据量到达一定程度之后, 就要权衡是i被初始化的次数花的代价高还是cache命中率降低的代价更高.

这再次说明了一个道理, 这个世界上没有银弹, 同样说明不加思索的拿来主义在一定程度上是程序效率的拦路虎.