惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

B
Blog RSS Feed
K
Kaspersky official blog
Forbes - Security
Forbes - Security
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
P
Proofpoint News Feed
G
GRAHAM CLULEY
V
Vulnerabilities – Threatpost
Security Latest
Security Latest
Scott Helme
Scott Helme
S
Securelist
美团技术团队
T
Threat Research - Cisco Blogs
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
S
SegmentFault 最新的问题
W
WeLiveSecurity
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Apple Machine Learning Research
Apple Machine Learning Research
The Cloudflare Blog
AI
AI
L
Lohrmann on Cybersecurity
S
Security Affairs
Cloudbric
Cloudbric
SecWiki News
SecWiki News
爱范儿
爱范儿
雷峰网
雷峰网
Engineering at Meta
Engineering at Meta
C
Cyber Attacks, Cyber Crime and Cyber Security
大猫的无限游戏
大猫的无限游戏
N
News and Events Feed by Topic
I
InfoQ
S
Secure Thoughts
AWS News Blog
AWS News Blog
A
About on SuperTechFans
Schneier on Security
Schneier on Security
酷 壳 – CoolShell
酷 壳 – CoolShell
The Last Watchdog
The Last Watchdog
D
Darknet – Hacking Tools, Hacker News & Cyber Security
C
Check Point Blog
P
Palo Alto Networks Blog
博客园 - 【当耐特】
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Google DeepMind News
Google DeepMind News
Latest news
Latest news
I
Intezer
博客园_首页
C
CXSECURITY Database RSS Feed - CXSecurity.com
V
V2EX
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
L
LangChain Blog
D
Docker

重归混沌的BLOG

给silly实现了一个ernro模块 | 重归混沌的BLOG 给silly实现了一个ernro模块 | 重归混沌的BLOG 第一次在生产环境使用 Vibe Coding | 重归混沌的BLOG 第一次在生产环境使用 Vibe Coding | 重归混沌的BLOG API 设计的艰难抉择 | 重归混沌的BLOG API 设计的艰难抉择 | 重归混沌的BLOG 十年 | 重归混沌的BLOG 十年 | 重归混沌的BLOG 在Go语言中如何使XML加载内存无限趋近于0 | 重归混沌的BLOG 在Go语言中如何使XML加载内存无限趋近于0 | 重归混沌的BLOG 对跨服玩法中的分布式一致性问题进行简单抽象 | 重归混沌的BLOG 对跨服玩法中的分布式一致性问题进行简单抽象 | 重归混沌的BLOG Go语言逃逸分析之slice和map | 重归混沌的BLOG Go语言逃逸分析之slice和map | 重归混沌的BLOG 谈谈观测 | 重归混沌的BLOG 谈谈观测 | 重归混沌的BLOG 写了个AI Agent服务端 | 重归混沌的BLOG 写了个AI Agent服务端 | 重归混沌的BLOG 谈谈代码设计中“严丝合缝” | 重归混沌的BLOG 谈谈代码设计中“严丝合缝” | 重归混沌的BLOG 一次艰难的线上游戏服务器内存排查经历 | 重归混沌的BLOG 一次艰难的线上游戏服务器内存排查经历 如何基于LanguageServerProtocol来编写lint工具 谈谈游戏服务器中RPC模块的设计 谈谈游戏服务器代码抽象 最近碰到的一个分布式一致性问题 谈谈游戏服务器的自动化测试 对Raft协议的一点理解 使用mmap来学习/proc/pid/smaps 2023(完) 再次实现了一个Lua性能分析器 终于给Silly的定时器增加了取消功能 一次虚拟内存排查经历 游戏服务器分布式数据的一种同步的思路 为silly增加了互斥锁 2022(完) Go语言之闭包篇 一例误用unsafe包引起的内存问题 Go语言之内存篇 初识Go语言 重新抽象图形API 给Lua实现了一个数学库 谈谈跨平台图形API的抽象 寻路和Flocking算法的结合 行为树的一种高效实现 内测过程中Shader出现的问题 彻底解决多国语言 谈谈数据库的选型 再谈Lua热更新(终) 初窥Rust 关于游戏服务器的服务拆分 ECS的初步实现 ECS初探 屏幕空间(SreenSpace)的想象力 一些对辐射度量学的理解 深度缓冲和半透明渲染 Mysql的间隙锁 更新一些GPU相关知识 2020 地形渲染之爬过的坑 Lua5.3 GC源码阅读(5) 实现一个数据库存储队列 再学计算机图形学入门 再谈分布式服务架构 游戏上线一个月后的反思 一次并发Bug 双向链表的三种实现 谈谈随机数的使用 再谈性能优化 2019 Lua中的函数式编程 重构登录逻辑 Unity资源管理(续) 谈谈Unity的资源管理 历史之2018 DC3算法 移动平台native代码遭遇的坑 从CPU层面谈谈优化 开卷有益(UNIX编程艺术篇) GC竞争问题 通过Mesh投影来实现贴花系统 谈谈我对数据同步的理解 又一个类型提升引起的Bug Lua5.3 GC源码阅读(4) Lua5.3 GC源码阅读(3) Lua5.3 GC源码阅读(2) Lua5.3 GC源码阅读(1) 三角形光栅化时遇到的坑 一次git事故 再见2017 又一个lua调试器 客户端缓存落地方案 Paxos算法 HTTP服务器的特点 一次性能优化经历 关于CPU分支预测 C程序中让两个不同版本的库共存 实现了一个AOI模块 一个高可伸缩的游戏服务器架构 关于网络协议封装的一些新想法
一次关于Cache的性能分析
重归混沌 · 2019-06-16 · via 重归混沌的BLOG

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对程序性能的影响,终于有了一次深刻的意识。