0%

前言

最近被拉去分析了 kubernetes ingress-nginx 的一个 segfault issue,最后发现是我自己多年前手残留下的低级 bug (果然出来混迟早是要还的)。

虽然是一个足以写进教科书的低级 bug(没有判断边界,数组越界),但是有一些排查方法,还是值得分享一下的。谁还没有个手残的时候呢,汗。

问题描述

所有信息都在 kubernetes ingress-nginx 的这个 issue 里:https://github.com/kubernetes/ingress-nginx/issues/6896

汇总下来,有这么几个信息:

  1. 从 0.43.0 升级到 0.44.0,会偶尔出现 Segmentation Fault
  2. 从调用栈看,是从 LuaJIT 通过 ffi 调用 C

segfault

对于 segfault 的错误,可以分两种情况:

  1. bug 代码,产生了一个非法的读/写,这个时候立即就产生了 segfault
  2. bug 代码,产生了一个错误的写,但是并没有发生 sefault,而是后续没有 bug 的代码,在执行过程中,因为之前写入的错误数据,从而触发了 segfault

对于 1,容易复现,也很好搞定,直接根据调用栈查代码就行,多见于开发环境。
对于 2,其中包含了两个时间点:
1> bug 代码产生了错误的写
2> 正常代码使用了错误的数据,产生了 segfault

如果运气好的话,这两个时间点,离得不远,还是比较好排查的。
但是,这两个时间点离得特别远的话,那就很难查了,特别还有可能是连环 bug。因为理论上来而言,所有其他所有代码都值得怀疑,只要是写的进程内的合法地址,操作系统就不会抱怨。

不过,好在,实际上而言,也还是有一些规律可循。

分析过程

一开始,尝试让反馈问题的人提供 core 文件,但是争取了一两周,包括提供了分析思路以表诚意,甚至愿意签 NDA,并没有人愿意提供。

缺失的调用栈

回到问题本身,issue 中有提供调用栈,其中关键的是这两帧:

1
2
#2  __libc_free (p=0x7f03273ec4d0) at src/malloc/mallocng/free.c:110
#3 0x00007f0327811f7b in lj_vm_ffi_call () from /usr/local/lib/libluajit-5.1.so.2

直接从调用栈来看,这个表示 ffi 在调用 free 函数,但是以我的个人经验而言,通常我们并不会通过 ffi 直接调用 free,所以怀疑是中间有调用栈缺失了。

lj_vm_ffi_call 是 LuaJIT 中手写汇编实现的一个函数,通过 disas 分析其中的机器指令,可以看到这个关键指令:

1
call   QWORD PTR [rbx]

同时由于 rbx 是 callee-saved 寄存器,我们通过 frame 3,gdb 会帮我们恢复出当时 rbx 的值,然后我们通过 info symbol *(long *)$rbx 就可以知道 ffi 当时在调用哪个函数。当时得到的反馈信息:

1
2
3
4
5
(gdb) frame 3
#3 0x00007f260c3cdf7b in lj_vm_ffi_call () from /usr/local/lib/libluajit-5.1.so.2

(gdb) info symbol *(long *)$rbx
chash_point_sort in section .text of /usr/local/lib/lua/librestychash.so

好家伙,chash_point_sort 不是我写的 resty.chash 里的函数么,更得抓紧修复了。

分析代码

通过看代码, chash_point_sort 中有且仅有一处调用 free,所以 gdb 显示的调用栈也是对的,只是少了中间关键的一帧。
同时,发生错误的范围,基本可以锁定在 chash_point_sort 的内部了。这个函数内申请了一块临时内存,又在最后阶段完成了释放。

这个属于运气比较好,写坏内存的点,与发生 segfault 的点,离得不远,发生在同一个函数内。同时因为是我自己写的代码,虽然年代有些久远,不过也还是能想得起来,所以最终没花多久就修复了,倒是为了构造可以在 ci 上跑的测试,花了不少时间。

具体的 bug 其实很简单,没有判断边界,数组越界了,写坏了内存。

细究根源

至此,肯定是修了一个 bug,只是还需要搞清楚,为什么以前的版本没有发生 segfault,因为 resty.chash 这期间也没有变更。

通过追踪 musl-libc 的变更历史,发现 musl-libc 去年搞了一个新的 malloc 实现,在 chunk 的末尾放了一些元数据,这个会更容易让 chash_point_sort 触发越界的条件 ,而且因为是新实现的,加了非常多的 assert 检查,所以就暴露了这个 bug。

仔细想想,其实有些时候系统的稳定性,并没有我们以为的那么好。这么低级的 bug,竟然在那么多的生产环境跑了这么多年了。

总结

有一些经验,还是值得记录一下的:

  1. 调用栈不全/准的时候,分析下机器指令,充分利用好寄存器的数据,尤其是那些 callee-saved
  2. 排查问题,多自己推理,多数时候盯着代码看,是更高效的笨办法。
    之前看过云风的一篇文章,他是推荐尽量不要依赖调试器,多让代码在脑子里运行,确实是有道理的。
  3. 写代码的时候,必要的 assert 还是需要的,这是个好习惯。
    之前分析 LuaJIT segfault 的时候,看 LuaJIT 代码中也充斥着很多关键的 assert。

接触开源

我从接触计算机开始,就接触到了开源。

大一在宏福校区,那会算是北京正经的郊区,周边确实没啥好玩的,大家都热衷于参加各种社团活动。我也不例外,不过我参加的活动中,开源相关的活动更多一些,校内校外的都热衷于参加。

当时参加的活动中,给我印象最深刻的有三个人。

邓楠,应该是比我大两届的师兄,很牛的技术极客。当时在校就可以接一些知名互联网公司项目,能够赚外快,很是羡慕。他也很喜欢开讲座,搞分享。我记得当时他有一个讲座,分享的大致是信任链这么个主题,多年后的我回想起来,那应该就是区块链的分享。

王开源,他来学校搞了一次分享,印象中 500 人的会议室都挤满了。讲起当时比尔盖茨来中国演讲的时候,他跑上去砸场子,也让我见识了还有这样为开源疯狂的人。

Richard Stallman,长胡子美国大叔,自由软件的发起人,随身背着个小笔记本,挂在肚子前面敲代码,据说 CPU 是龙芯的。参加过两次他的分享会,还买过一本他的书,有他的亲笔签名(happy hacking)。当时问过他一个问题,“opensource” 和 “free software” 会共存么?他的回答也很直白,只有 “free software” 才有未来,这大叔对自由的追求真的是非常极致 …

除此之外,还记得北师大的一个女老师,貌似是 ”庄“ 老师,还有一个在中国上班的”挪威“程序员…

不过,当时参加的分享活动,讨论的比较多的是意识形态上的东西,很少有具体的适合我参加的开源项目(主要也是当时自己太弱)。

Open Source VS Free Software

首先 Open Source 与 Free Software 肯定还是有区别的。虽然现在提 Free Software 的人明显不多了,但是从根上来说,还是有些区别的。

以我浅显的理解,Free Software 的初衷是技术极客对于自由的极致追求。
设想一下,如果自己使用的商业软件有不爽的地方,然而受制于商业软件的版权限制,又不可以自己破解修改,对于技术大神来说,我想应该是吃了苍蝇的感觉,而且可能是每天都需要吃,确实很让人沮丧。

Free software 具体点来说,是指 GPL 严格的许可证协议。这种协议是带病毒传播性质的,如果对使用这种协议的软件做了修改,那么修改本身也必须继承相同的协议,也必须开源。
这一点,很大程度上限制了 GPL 软件的商业化运作。实际上,使用 GPL 协议的软件,商业化成功的模式,估计也只有红帽这一个典型。

Open Source 相对来说就宽松多了,除了 GPL 这种严格的许可证协议之外,还有很多宽松很多的许可证协议。大部分的协议,允许你自己的修改,并不开源,这样就非常适合,使用开源软件搞一个定制版的商业软件,在商业上的想象空间就大很多了。

对开源的理解

不管是 Free Software 也好,Open Source 也罢,我觉得其内核还是非常吸引人的。印象中,当年在讨论开源意识形态流的时候,可是跟切格瓦拉,共产主义,天下大同,这种高大上的词汇一起讨论的。

人人为我,我为人人,分享精神,我觉得是开源精神的内核。

这也是为什么开源可以「共产主义」拉上关系的基础,参与开源项目的人,得是有助人为乐的精神,不能只想着自己的营营小利。

开源是一种开放的协作方式,参与者们通常是自愿加入,秉着分享互助的精神,一起参与项目的建设。这个协作方式,可以对抗内卷,很好的提高整个社会的生产效率。

开源商业化

那么搞开源的都是活雷锋么?肯定也不是,参与开源项目也是有很多现实收益的,自我价值的实现,商业利益的实现,都可以通过开源项目来达成。简单点来说,开源也是一个名利场,追名逐利才更是人性使然。

说到开源商业化,肯定有人吐槽这种粘上了铜臭味的开源,吐槽开源不够纯粹了,背地里还是图着自己的商业利益。

依我之见,开源商业化肯定是好事,有资本/商业利益的驱动,可以更快的促进整个开源的发展。对于大多数的开源项目,没有商业公司在支持,估计大概率只能靠零星的赞助维持,对于项目的维护者而言,估计也大部分只有“名”上的收益,没有一点用爱发电的精神,其实比较难长期的维持。

至于商业公司的谋利属性,我们理解就行。就市场需求而言,如果有公司使用了一个开源项目,这个公司其实是有足够的动力,付费来购买商业公司的支持,毕竟对于非本公司主营业务的,成熟的商业逻辑肯定是花钱搞定,只要价钱合理。

纵观现在的开源项目,似乎也只有比较底层的基础设施,比如 Linux 操作系统这种的,才会走到捐款 + 基金会这种形式。这种基金会充当了中间角色,不受某一家公司的控制,也没有了盈利属性,就是大家共同协作一个载体。

追逐商业利益,与开源的内在精神,其实并不冲突,是可以共存促进的。一定要那么”纯粹“的开源么?我觉得大可不必。

背景

WebAssembly 简称 Wasm,最早起源于前端技术。
即使在有了 JIT 加持之后,js 在大计算量的场景,性能还是不够理想,经过了 asm.js 的尝试,最后以 Wasm 定型,得到了四大浏览器的支持。
最初的 Wasm 主要是应用于 WEB 应用,后续随着 WASI 的诞生,又扩展到了更宽的场景,比如服务端技术。

Wasm 是什么

Wasm 并不是一门常规意义上的语言,而只是一个基于栈式虚拟机的二进制指令标准。
比如,Lua 是一门语言,因为其具有可编程能力,而 Lua 字节码,则几乎不具备可编程能力(一定要手写也不是不可以)。
Wasm 就类似于 Lua 字节码这种位置,只是它更相对更底层一些,适用范围也更广。

Wasm 设计目的,就是成为其他语言的编译目标,目前支持比较好的有 C/Rust 等。

Wasm 如何运行

由于 Wasm 只是一个标准,具体的执行是由虚拟机来完成的,而虚拟机的实现就又有很多个,类似于官方 Lua 与 LuaJIT 这种。
具体的运行方式也有多种:interpreter,JIT,AOT。比较有意思的是,在 Wasm 圈里,似乎 AOT 技术相对其他语言更流行一些。

具体的虚拟机实现细节,我们可以以后再介绍了。

Wasm 的特点

Wasm 有优秀的设计理念,有其明显的优势,不过优势有时候也需要付出一些代价。

高性能

这是 Wasm 的设计初衷之一,是有接近 native 性能的,当然也依赖虚拟机的具体实现。
从指令设计上而言,Wasm 足够底层,简单,所以理论上是可以接近 native 性能的。

内存安全

Wasm 被设计为内存安全的,尤其在 WEB 场景,很多时候执行的代码都不知道来自谁,底层安全是很重要的。
具体而言,Wasm 的内存模型很简单,只有一个 linear memory,Wasm 能操作的内存的读写都发生在这个 memory 范围内。
Wasm 是不会出现指针飞来飞去的,有的只是 offset,目的是恶意的 Wasm 执行的时候,也不可能读写进程内任意的数据内存。

当然咯,代价也是有的,灵活性会有一些折扣,很多时候需要多一次内存拷贝。

沙箱

Wasm 是运行在一个沙箱环境,其所具备的能力是受限的,需要的一些外部调用,是外面的宿主提供给它的。
比如 Wasm 需要读文件,那也是需要运行 Wasm 的宿主环境,给其提供对应的 API 才可以的。

跨语言

跨语言是 Wasm 的一大亮点,依我之见,可以某种程度上的降低语言之争(语言之争,其实也是个蛮有意思的话题,得空可以聊一聊)。

Wasm 作为中间的标准产物,可以对接两头的开发者:

  1. 上层的应用开发者
  2. 底层的服务开发者

底层服务开发者,只需要为其提供运行 Wasm 的沙箱环境,包括运行 Wasm 的虚拟机,以及暴露服务的某些能力在沙箱中。
上层应用开发者,则可以选择自己喜欢的语言,以及对应语言的 Wasm-SDK(对应暴露在沙箱中的服务基础能力)即可生成 Wasm。

理论上是一个美好的方案。

最后

Wasm 也是一种嵌入式的方案,某种程度上跟 Lua 很类似。

依我之见,少年期的 Wasm 还有比较长的一段路要走

  1. 底层的能力还有待增强,比如带 GC 的语言,生成 Wasm 就是一个难题。
  2. 周边生态也有待发展,目前还属于初级阶段,虽然能看到一些设计雏形。

上一篇 我们说到,JIT 是 LuaJIT 的性能杀手锏。这篇我们就介绍一下 JIT。

Just-in-time 即时编译技术,在 LuaJIT 里的具体使用是:将 Lua byte code 即时编译为机器指令,也就是不再需要解释执行 Lua bytecode,直接执行即时编译产生的机器指令。
也就是说,解释模式,和 JIT 模式的输入源是一样的,都是 Lua byte code。相同的字节码输入,两种模式却可以有跑出明显的性能区别(一个数量级的区别,也是比较常见的),这个还是很需要功力的。

JIT 可以分为这么几个步骤:

  1. 计数,统计有哪些热代码
  2. 录制,录制热代码路径,生成 SSA IR code
  3. 生成,SSA IR code 优化生成机器指令
  4. 执行新生成的机器指令

JIT 编译对象

在进一步展开之前,先介绍一个基本概念。
LuaJIT 的 JIT 是基于 trace 的,意味着一段 byte code 执行流,而且是可以跨越函数的。
相比较而言,Java 的 JIT 是基于 method 的,虽然也有函数内联,但是限制比较大(只有很小的函数才会被内联 JIT 编译)。

个人认为,tracing JIT 在理论上来说,可以比 method base JIT 有更大的发挥空间,如果只是某些 case 跑分,应该可以更厉害。
不过工程实现复杂程度要高不少,所以最终的实际工业效果,也难说(影响 JIT 效果的,还有很多其他因素,比如优化器等等)。

比如这个小示例:

1
2
3
4
5
6
7
8
9
10
11
12
local debug = false
local function bar()
return 1
end

local function foo()
if debug then
print("some debug log works")
end

return bar() + 1
end

foo() 函数被 JIT 编译的时候,有两个明显的优点:

  1. print("some debug log works") 这一行因为没有真实的执行,所以 trace 字节流里不会有它,也就是压根不会为其生成机器码,所以生成的机器码可以更小(生成的机器码越小,CPU cache 命中率越高)
  2. bar() 会被内联编译,并不会有函数调用的开销(是的,在机器指令层面,函数调用的开销其实也需要考量的)

计数

接下来,我们挨个介绍 JIT 的各个阶段。
计数比较容易理解,JIT 的一大特点即是:只编译热点代码(全盘编译的话,也就成了 AOT 了)。

通常的 JIT 计数有两个统计入口:

  1. 函数调用,当某个函数执行次数达到某个阈值,触发 JIT 编译这个函数
  2. 循环次数,当某个循环体执行次数达到某个阈值,触发 JIT 编译这个循环体

也就是统计出 热函数 和 热循环。

不过 LuaJIT 是基于 trace 的,也就有 trace 中途退出的情况,此时还有第三个 trace exit 的统计:
如果某个 trace 经常从某个 snap exit,从这个 snap 开始 JIT 编译(snap 我们后面再介绍),生成一条 side trace。

录制

当某个函数/循环足够热了之后,JIT compiler 就开始工作了。
第一步录制,录制的核心流程是:一边解释执行,一边生成 IR code。

具体过程是:

  1. 通过修改 DISPATCH,添加字节码解释执行的 hook
  2. 在 hook 中,为当前执行的 byte code,生成对应的 IR code,也会有判断来 完成/提前终止 录制
  3. 继续解释执行 byte code

从开始录制,到录制完成,这个就是 trace 的基本单元,期间解释执行的字节码流,就是这个 trace 需要加速的执行流。

因为录制的是真实的执行流,对于分支代码,trace 当然也不会假设以后每次执行都肯定会进入当前这个分支,而是会在 IR 中加入守卫(guard)。
并且会在合适的时机记录快照(snapshot),snapshot 里会包含一些上下文信息。
如果后续执行过程中,从这个 snapshot 退出的话,会从 snapshot 里恢复上下文信息。

补充细节:
并不是所有的 byte code 都是可以被 JIT 的(具体可以看 LuaJIT NYI)。
碰到了 NYI,LuaJIT 还有 stitch 的能力。比如 FUNCC 是支持 stich 的,那么在 FUNCC 前后的代码,会被录制为两条 trace。最终会是这样效果,JIT 执行 trace1 的机器码 => 解释执行 FUNCC => JIT 执行 trace2 的机器码。把两条 trace 黏合起来,就是 stitch 的效果。

生成

有了 IR code 等信息之后,就可以为其优化生成机器码。

这里有分为两步:

  1. 针对 IR code 的优化
    LuaJIT 的 IR code 也是 static single assignment form(SSA),常见的优化器中间表示码。可以应用很多的优化算法来优化,比如常见的死代码消除,循环变量外提等等。

  2. 从 IR code 生成机器指令
    这部分主要两个工作:寄存器分配,根据 IR 操作翻译为机器指令,比如 IR ADD 翻译为 机器的 ADD 指令。

针对 IR 里的守卫(guard),会生成 if … jump 逻辑的指令,jump 后的桩(stub)指令会完成从某个 snapshot 退出。

这里我们可以明白了,JIT 生成的机器码可以更加高效的原因:

  1. 根据录制时的执行流假设,可以生成 CPU 分支预测友好的指令,理想情况下,CPU 就相当于顺序执行指令
  2. 针对 SSA IR code 有优化
  3. 更高效的使用寄存器(此时没有解释器自身的状态记录负担,可以使用的寄存器更多了)

执行

生成了机器指令之后,会修改的字节码,比如 FUNCF 会改为 JFUNCF traceno
下一次解释器执行 JFUNCF 的时候,会跳转到 traceno 对应的机器指令,也就完成从解释模式到 JIT 模式的切换,这也是进入 JIT 指令执行的主要方式。

而退出 trace 则有两种方式:
1 正常执行完毕退出,此时会恢复到解释模式继续执行
2 trace 中的 guard 失败,会从 trace 中途退出,此时会先根据对应的 snapshot 恢复上下文,然后再解释执行

另外,从 trace 中途退出的情况,也会有退出次数的统计。
如果某个 snapshot 的退出次数达到 hotside 的阈值,则会从这个 snapshot 开始生成一条 sidetrace。
下一次从这个 snapshot 退出的时候,直接就 jump 到这个 side trace 了。

这样下来,对于有分支的热代码,也会是全 JIT 覆盖的效果,但是并不是一开始就全覆盖,而是按需的分步进行。

最后

Lua 作为一门嵌入式小语言,本身是比较精致轻巧的,LuaJIT 的实现也是继承了这些特点。
在单线程环境下,JIT compiler 占用的是当前工作流的时间,JIT compiler 自身的效率也是很重要的。
JIT compiler 长时间阻塞工作流也是不能接受的,这里平衡也是很重要的。

相比较而言,java 的 JIT compiler 是单独的 JIT 编译线程完成的,可以做更加深度的优化,java 的 c2 JIT compiler 就应用了相对比较重的优化。

JIT 是很牛的技术,能了解其运行的基本过程/原理,也是很解惑的事情。

听说 JS 的 v8 引擎,还有 deoptimization 的过程,这个还挺好奇的,得空可以学习学习的。

上一篇「Lua 代码是如何跑起来的」 中,我们介绍了标准 Lua 虚拟机是如何运行 Lua 代码的。

今天我们介绍 Lua 语言的另外一个虚拟机实现 LuaJIT,LuaJIT 使用的 lua 5.1 的语言标准(也可以兼容 lua 5.2)。意味着同样一份遵守 lua 5.1 标准的代码,既可以用标准 lua 虚拟机来跑,也可以用 LuaJIT 来跑。

LuaJIT 主打高性能,接下来我们看看 LuaJIT 是如何提高性能的。

解释模式

首先,LuaJIT 有两种运行模式,一种是解释模式,这个跟标准 Lua 虚拟机是类似的,不过也有改进的地方。

首先,跟标准 Lua 虚拟机一样,Lua 源代码是被编译为字节码(byte code),然后一个个的解释执行这些字节码。
但是,编译出来的字节码,并不是跟标准 Lua 一样,只是类似。
模式上来说,LuaJIT 也是基于虚拟寄存器的,虽然具体实现方式上有所区别。

解释执行字节码

从 Lua 源码到字节码,其实差异不大,但是解释执行字节码,LuaJIT 的改进动作就比较大了。

Lua 解释执行字节码,是在 luaV_execute 这个 C 函数里实现的,而 LuaJIT 则是通过手写汇编来实现的。
通常,我们会简单的认为手写汇编就会更高效,不过也得看写代码的质量。

对比最终生成的机器码

这次我们通过实际对比双方最终生成的机器码,体验下手写的汇编是如何做到高效的。

我们对比「字节码解析」这部分的实现。
首先,Lua 和 LuaJIT 的字节码,都是 32 位定长的。字节码解析的基本逻辑即是:
从虚拟机内部维护的 PC 寄存器,读取 32 位长的字节码,然后解析出 OP 操作码,以及对应的操作参数。

LuaJIT

下面 LuaJIT 源码中的「字节码解析」的源代码,
这里并不是裸写的汇编代码,为了提高可阅读性,用到了一些宏。

1
2
3
4
5
mov RCd, [PC]
movzx RAd, RCH
movzx OP, RCL
add PC, 4
shr RCd, 16

最终在 x86_64 上生成的机器指令如下,非常的简洁。

1
2
3
4
5
mov    eax,DWORD PTR [rbx]  # rbx 里存储的是 PC 值,读取 32 位字节码到 eax 寄存器
movzx ecx,ah # 9-16 位,是操作数 A 的值
movzx ebp,al # 低 8 位是 OP 操作码
add rbx,0x4 # PC 指向下一个字节码
shr eax,0x10 # 右移 16 位,是操作数 C 的值
Lua

在 Lua 的 luaV_execute 函数中,大致是有这些 C 源代码来完成「字节码解析」的部分工作。

1
2
3
const Instruction i = *pc++;
ra = RA(i);
GET_OPCODE(i)

经过 gcc 编译之后,我们从可执行文件中,可以找到如下相对应的机器指令。
因为 gcc 是对整个函数进行通盘优化,所以指令的顺序并不是那么直观,寄存器使用也不是那么统一,所以看起来会有点乱。
如下是我摘出来的机器指令,为了方便阅读,顺序也经过了调整,没有保持原始的顺序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
mov    ebx,DWORD PTR [r14]   # r14 里存储的是 PC 值,读取 32 位字节码到 ebx 寄存器
lea r12,[r14+0x4] # PC 指向下一个字节码,存入 r12
mov r14,r12 # 后续再复制到 r14(因为 r14 中间还有其他用途)

mov edx,ebx # 复制 edx 到 eax
and edx,0x3f # 低 6 位是 OP 操作码

# 7-14 位是操作数 A 的值
mov eax,ebx # 复制 ebx 到 eax
shr eax,0x6 # 右移 6 位
movzx eax,al # 此时的低 8 位是操作数 A 的值

# 此时对应操作数的使用,不属于字节码解析了,但是是 RA(i) 里的实现
shl rax,0x4 # rax * 16
lea r9,[r11+rax*1] # r11 是 BASE 的值,取操作 A 对应 Lua
栈上的地址

对比分析

字节码解析,是 Lua 中最基础的操作。
通过对比最终生成的机器码,我们明显可以看到 LuaJIT 的实现可以更加高效。

手写汇编可以更好的利用寄存器,不过,也不完全是因为手写汇编的原因。
LuaJIT 从字节码设计上,就考虑到了高效,OP code 直接是 8 位,这样可以直接利用 al 这种 CPU 硬件提供的低 8 位能力,可以省掉一些位操作指令。

JIT

Just-In-Time 是 LuaJIT 运行 Lua 代码的另一种模式,也是 LuaJIT 的性能杀手锏。
主要原理是动态生成更加功效的机器指令,从而提升运行时性能。

这个我们下一篇再继续…

tmate 是什么

tmate 可以用于远程分享终端,实乃远程协同办公之神器。

自主部署

通常大家都是使用 tmate.io 的服务,不过墙外的服务, 300 多ms 的延时,会有明显可感知的延时,以及不可靠因素。

近期花了些时间,自己部署了一套 tmate 服务,把过程记录一下。

服务端

tmate 有提供 docker 镜像,所以部署还是挺方便的。

  1. 生成 keys

    1
    2
    3
    git clone git@github.com:tmate-io/tmate-ssh-server.git
    cd tmate-ssh-server
    ./create_keys.sh

    会在当前目录生成 keys 目录,并且会得到如下的输出,这些可以先记录下来,后续会用到。

    1
    2
    3
    4
    set -g tmate-server-host localhost
    set -g tmate-server-port 22
    set -g tmate-server-rsa-fingerprint SHA256:JOhvYoiBO0kVltwqKHTgKVCfvyajWV/cGwNXSyTNRcE
    set -g tmate-server-ed25519-fingerprint SHA256:+N/UZ8BNfTVNoOQjH4yEe0GoTFpapu/I0G4c6RI7HWo
  2. 启动 docker 服务

    1
    2
    3
    4
    5
    6
    7
    8
    docker run -d --rm \
    -p 8022:8022 \
    -v `pwd`/keys:/etc/tmate/keys \
    --env SSH_KEYS_PATH='/etc/tmate/keys' \
    --env SSH_HOSTNAME=tmate.uncledou.site \
    --env SSH_PORT_LISTEN=8022 \
    --cap-add SYS_ADMIN \
    tmate/tmate-ssh-server

    就这么简单,tmate 的服务端就部署好了。

客户端

  1. 安装 tmate 软件
    tmate 在很多主流仓库里都有,可以先尝试直接用当前系统的 aptyum 之类的安装。 如果不幸没有的话,也可以用 tmate 编译好的二进制,在 github release 里可以下载到二进制包,比如常见的 x86_64 机器上,就是这个 tmate-2.4.0-static-linux-amd64.tar.xz
  2. 配置 .tmate.conf
    把客户端部署第一步的输出,写入到本机的 ~/.tmate.conf 文件。注意其中的 hostport 需要改成自助部署的,比如我这里是这样的:
    1
    2
    3
    4
    set -g tmate-server-host tmate.uncledou.site
    set -g tmate-server-port 8022
    set -g tmate-server-rsa-fingerprint SHA256:JOhvYoiBO0kVltwqKHTgKVCfvyajWV/cGwNXSyTNRcE
    set -g tmate-server-ed25519-fingerprint SHA256:+N/UZ8BNfTVNoOQjH4yEe0GoTFpapu/I0G4c6RI7HWo

开始飞吧

至此 tmate 客户端就安装配置好了,执行 tmate 命令就可以得到类似的输出:

1
2
3
4
5
6
7
8
Tip: if you wish to use tmate only for remote access, run: tmate -F                                                          [0/0]
To see the following messages again, run in a tmate session: tmate show-messages
Press <q> or <ctrl-c> to continue
---------------------------------------------------------------------
Connecting to tmate.uncledou.site...
Note: clear your terminal before sharing readonly access
ssh session read only: ssh -p8022 ro-RyrBkE6v5rAhpqP6FN6mCWgm2@tmate.uncledou.site
ssh session: ssh -p8022 XtydPFjTpbaz8eXxB77RGZeNZ@tmate.uncledou.site

对方执行 ssh -p8022 XtydPFjTpbaz8eXxB77RGZeNZ@tmate.uncledou.site ,即可开始与你一起共享这个终端了。

如果你不想用 tmate.io 的服务,也不想自己部署,也可以使用我这套部署好的。按照我的部署,从客户端开始即可。

go ahead, happy hacking :)

上一篇「C 代码是如何跑起来的」中,我们了解了 C 语言这种高级语言是怎么运行起来的。

C 语言虽然也是高级语言,但是毕竟是很 “古老” 的语言了(快 50 岁了)。相比较而言,C 语言的抽象层次并不算高,从 C 语言的表达能力里,还是可以体会到硬件的影子。

1
旁白:通常而言,抽象层次越高,意味着程序员的在编写代码的时候,心智负担就越小。

今天我们来看下 Lua 这门相对小众的语言,是如何跑起来的。

解释型

不同于 C 代码,编译器将其直接编译为物理 CPU 可以执行的机器指令,CPU 执行这些机器执行就行。

Lua 代码则需要分为两个阶段:

  1. 先编译为字节码
  2. Lua 虚拟机解释执行这些字节码
1
旁白:虽然我们也可以直接把 Lua 源码作为输入,直接得到执行输出结果,但是实际上内部还是会分别执行这两个阶段

字节码

「CPU 提供了什么」 中,我们介绍了物理 CPU 的两大基础能力:提供一系列寄存器,能执行约定的指令集。

那么类似的,Lua 虚拟机,也同样提供这两大基础能力:

  1. 虚拟寄存器
  2. 执行字节码
1
旁白:Lua 寄存器式虚拟机,会提供虚拟的寄存器,市面上更多的虚拟机是栈式的,没有提供虚拟寄存器,但是会对应的操作数栈。

我们来用如下一段 Lua 代码(是的,逻辑跟上一篇中的 C 代码一样),看看对应的字节码。用 Lua 5.1.5 中的 luac 编译可以得到如下结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ ./luac -l simple.lua

main <simple.lua:0,0> (12 instructions, 48 bytes at 0x56150cb5a860)
0+ params, 7 slots, 0 upvalues, 4 locals, 4 constants, 1 function
1 [4] CLOSURE 0 0 ; 0x56150cb5aac0
2 [6] LOADK 1 -1 ; 1 # 将常量区中 -1 位置的值(1) 加载到寄存器 1 中
3 [7] LOADK 2 -2 ; 2 # 将常量区中 -2 位置的值(2) 加载到寄存器 1 中
4 [8] MOVE 3 0 # 将寄存器 0 的值,挪到寄存器 3
5 [8] MOVE 4 1
6 [8] MOVE 5 2
7 [8] CALL 3 3 2 # 调用寄存器 3 的函数,寄存器 4,和寄存器 5 作为两个函数参数,返回值放入寄存器 3 中
8 [10] GETGLOBAL 4 -3 ; print
9 [10] LOADK 5 -4 ; "a + b = "
10 [10] MOVE 6 3
11 [10] CALL 4 3 1
12 [10] RETURN 0 1

function <simple.lua:2,4> (3 instructions, 12 bytes at 0x56150cb5aac0)
2 params, 3 slots, 0 upvalues, 2 locals, 0 constants, 0 functions
1 [3] ADD 2 0 1 # 将寄存器 0 和 寄存器 1 的数相加,结果放入寄存器 2 中
2 [3] RETURN 2 2 # 将寄存器 2 中的值,作为返回值
3 [4] RETURN 0 1

稍微解释一下:

  1. 不像 CPU 提供的物理集群器,有不同的名字,字节码的虚拟寄存器,是没有名字的,只有数字编号。逻辑上而言,每个函数有独立的寄存器,都是从序号 0 开始的(实际上会有部分的重叠复用)
  2. Lua 字节码,也提供了定义函数,执行函数的能力
  3. 以上的输出结果是方便人类阅读的格式,实际上字节码是以非常紧凑的二进制来编码的(每个字节码,定长 32 比特)

执行字节码

Lua 虚拟机

Lua 虚拟机是一个由 C 语言实现的程序,输入是 Lua 字节码,输出是执行这些字节码的结果。

对于字节码中的一些抽象,则是在 Lua 虚拟机中来具体实现的,比如:

  1. 虚拟寄存器
  2. Lua 变量,比如 table

虚拟寄存器

对于字节码中用到的虚拟寄存器,Lua 虚拟机是用一段连续的物理内存来模拟。

具体来说:
因为 Lua 变量,在 Lua 虚拟机内部,都是通过 TValue 结构体来存储的,所以实际上虚拟寄存器,就是一个 TValue 数组。

例如下面的 MOVE 指令:

1
MOVE 3 0

实际上是完成一个 TValue 的赋值,这是 Lua 5.1.5 中对应的 C 代码:

1
2
3
4
#define setobj(L,obj1,obj2) \
{ const TValue *o2=(obj2); TValue *o1=(obj1); \
o1->value = o2->value; o1->tt=o2->tt; \
checkliveness(G(L),o1); }

其对应的关键机器指令如下:(主要是通过 mov 机器指令来完成内存的读写)

1
2
3
4
mov    rax,QWORD PTR [rsi]
mov QWORD PTR [r9+0x10],rax
mov eax,DWORD PTR [rsi+0x8]
mov DWORD PTR [r9+0x18],eax

执行

Lua 虚拟机的实现中,有这样一个 for (;;) 无限循环(在 luaV_execute 函数中)。
其核心工作跟物理 CPU 类似,读取 pc 地址的字节码(同时 pc 地址 +1),解析操作指令,然后根据操作指令,以及对应的操作数,执行字节码。
例如上面我们解释过的 MOVE 字节码指令,也就是在这个循环中执行的。其他的字节码指令,也是类似的套路来完成执行的。

pc 指针也只是一个 Lua 虚拟机位置的内存地址,并不是物理 CPU 中的 pc 寄存器。

函数

几个基本点:

  1. Lua 函数,可以简单的理解为一堆字节码的集合。
  2. Lua 虚拟机里,也有栈帧的,每个栈帧实际就是一个 C struct 描述的内存结构体。

执行一个 Lua 函数,也就是执行其对应的字节码。

总结

Lua 这种带虚拟机的语言,逻辑上跟物理 CPU 是很类似的。生成字节码,然后由虚拟机来具体执行字节码。

只是多了一层抽象虚拟,字节码解释执行的效率,是比不过机器指令的。

物理内存的读写速度,比物理寄存器要慢几倍甚至几百倍(取决于是否命中 CPU cache)。
所以 Lua 的虚拟寄存器读写,也是比真实寄存器读写要慢很多的。

不过在 Lua 语言的另一个实现 LuaJIT 中,这种抽象还是有很大机会来优化的,核心思路跟我们之前在 「C 代码是如何跑起来的」 中看到的 gcc 的编译优化一样,尽量多的使用寄存器,减少物理内存的读写。

关于 LuaJIT 确实有很多很牛的地方,以后我们再分享。

上一篇「CPU 提供了什么」中,我们了解了物理的层面的 CPU,为我们提供了什么。

本篇,我们介绍下高级语言「C 语言」是如何在物理 CPU 上面跑起来的。

C 语言提供了什么

C 语言作为高级语言,为程序员提供了更友好的表达方式。在我看来,主要是提供了以下抽象能力:

  1. 变量,以及延伸出来的复杂结构体
    我们可以基于变量来描述复杂的状态。
  2. 函数
    我们可以基于函数,把复杂的行为逻辑,拆分到不同的函数里,以简化复杂的逻辑以。以及,我们可以复用相同目的的函数,现实世界里大量的基础库,简化了程序员的编码工作。

示例代码

构建一个良好的示例代码,可以很好帮助我们去理解。
下面的示例里,我们可以看到 变量函数 都用上了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include "stdio.h"

int add (int a, int b) {
return a + b;
}

int main () {
int a = 1;
int b = 2;
int c = add(a, b);

printf("a + b = %d\n", c);

return 0;
}

编译执行

毫无意外,我们得到了期望的 3

1
2
3
$ gcc -O0 -g3 -Wall -o simple simple.c
$ ./simple
a + b = 3

汇编代码

我们还是用 objdump 来看看,编译器生成了什么代码:

  1. 变量
    局部变量,包括函数参数,全部被压入了 里。
  2. 函数
    函数本身,被单独编译为了一段机器指令
    函数调用,被编译为了 call 指令,参数则是函数对应那一段机器指令的第一个指令地址。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
$ objdump -M intel -j .text -d simple

# 截取其中最重要的部分

000000000040052d <add>:
40052d: 55 push rbp
40052e: 48 89 e5 mov rbp,rsp
400531: 89 7d fc mov DWORD PTR [rbp-0x4],edi
400534: 89 75 f8 mov DWORD PTR [rbp-0x8],esi
400537: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
40053a: 8b 55 fc mov edx,DWORD PTR [rbp-0x4]
40053d: 01 d0 add eax,edx
40053f: 5d pop rbp
400540: c3 ret

0000000000400541 <main>:
400541: 55 push rbp
400542: 48 89 e5 mov rbp,rsp
400545: 48 83 ec 10 sub rsp,0x10
400549: c7 45 fc 01 00 00 00 mov DWORD PTR [rbp-0x4],0x1
400550: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2
400557: 8b 55 f8 mov edx,DWORD PTR [rbp-0x8]
40055a: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
40055d: 89 d6 mov esi,edx
40055f: 89 c7 mov edi,eax
400561: e8 c7 ff ff ff call 40052d <add>
400566: 89 45 f4 mov DWORD PTR [rbp-0xc],eax
400569: 8b 45 f4 mov eax,DWORD PTR [rbp-0xc]
40056c: 89 c6 mov esi,eax
40056e: bf 20 06 40 00 mov edi,0x400620
400573: b8 00 00 00 00 mov eax,0x0
400578: e8 93 fe ff ff call 400410 <printf@plt>
40057d: b8 00 00 00 00 mov eax,0x0
400582: c9 leave
400583: c3 ret
400584: 66 2e 0f 1f 84 00 00 nop WORD PTR cs:[rax+rax*1+0x0]
40058b: 00 00 00
40058e: 66 90 xchg ax,ax

函数内的局部变量,为什么会放入栈空间呢?

这个刚好和局部变量的作用域关联起来了:

  1. 函数执行结束,返回的时候,局部变量也应该失效了
  2. 函数返回的时候,刚好要恢复栈高度到上一个调用者函数。

这样的话,只需要栈高度恢复,也就意味着被调用函数的所有的临时变量,全部失效了。

函数内的局部变量,一定会放入栈空间吗?

答案是,不一定。
上面我们是通过 -O0 编译的,接下来,我们看下 -O1 编译生成的机器码。

此时的局部变量直接放在寄存器里了,不需要写入到栈空间了。
不过,此时 main 都已经不再调用 add 函数了,因为已经被 gcc 内联优化了。
好吧,构建个合适的用例也不容易。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
000000000040052d <add>:
40052d: 8d 04 37 lea eax,[rdi+rsi*1]
400530: c3 ret

0000000000400531 <main>:
400531: 48 83 ec 08 sub rsp,0x8
400535: be 03 00 00 00 mov esi,0x3
40053a: bf f0 05 40 00 mov edi,0x4005f0
40053f: b8 00 00 00 00 mov eax,0x0
400544: e8 c7 fe ff ff call 400410 <printf@plt>
400549: b8 00 00 00 00 mov eax,0x0
40054e: 48 83 c4 08 add rsp,0x8
400552: c3 ret
400553: 66 2e 0f 1f 84 00 00 nop WORD PTR cs:[rax+rax*1+0x0]
40055a: 00 00 00
40055d: 0f 1f 00 nop DWORD PTR [rax]

禁止内联优化

我们用如下命令,关闭 gcc 的内联优化:

1
gcc -fno-inline -O1 -g3 -Wall -o simple simple.c

再来看下汇编代码,此时的机器码就符合理想的验证结果了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
000000000040052d <add>:
40052d: 8d 04 37 lea eax,[rdi+rsi*1]
400530: c3 ret

0000000000400531 <main>:
400531: 48 83 ec 08 sub rsp,0x8
400535: be 02 00 00 00 mov esi,0x2
40053a: bf 01 00 00 00 mov edi,0x1
40053f: e8 e9 ff ff ff call 40052d <add>
400544: 89 c6 mov esi,eax
400546: bf f0 05 40 00 mov edi,0x4005f0
40054b: b8 00 00 00 00 mov eax,0x0
400550: e8 bb fe ff ff call 400410 <printf@plt>
400555: b8 00 00 00 00 mov eax,0x0
40055a: 48 83 c4 08 add rsp,0x8
40055e: c3 ret
40055f: 90 nop

总结

  1. 对于 C 语言的变量,编译器会为其分配一段内存空间来存储
    函数内的局部变量,放入栈空间是理想的映射方式。不过编译的优化模式下,则会尽量使用寄存器来存储,寄存器不够用了,才会使用栈空间。
    全局变量,则有对应的内存段来存储,这个以后可以再聊。
  2. 对于 C 语言的函数,编译器会编译为独立的一段机器指令
    调用该函数,则是执行 call 指令,意思是接下来跳转到执行这一段机器指令。

最近有一个有趣的发现,调整了一行 Lua 代码的顺序,执行时间却少了接近一半 :)

现场案例

情况下面这个 lua 脚本 order-1.lua

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
local function f2 (...)
return select('#', ...)
end

local function f1 (...)
local l = select('#', ...)
local m = 0
for i = 1, l do
m = m + select(i, ...)
end

local n = f2(...)

return m + n
end

local n = 0
for i = 1, 1000 * 1000 * 100 do
n = n + f1(1, 2, 3, 4, 5)
end

print("n: ", n)

执行时间为 6.3s

1
2
3
4
5
6
$ time luajit order-1.lua
n: 2000000000

real 0m6.343s
user 0m6.342s
sys 0m0.000s

如果将其中的 f1 函数实现,调整一下顺序:

1
2
3
4
5
6
7
8
9
10
11
local function f1 (...)
local n = f2(...)

local l = select('#', ...)
local m = 0
for i = 1, l do
m = m + select(i, ...)
end

return m + n
end

这个改动是将 n 的计算放到 m 计算的前面。
从逻辑上来说,mn 两个是并没有顺序依赖,先算哪一个都一样的,但是执行时间却少了将近一半:

1
2
3
4
5
6
$ time luajit order-2.lua
n: 2000000000

real 0m3.314s
user 0m3.312s
sys 0m0.002s

原因分析

首先肯定不是什么诡异问题,计算机可是人类最真实的伙伴了,哈哈 😄

这次是 Lua 这种高级语言,也不是 上次那种 CPU 指令级 的影响了。

tracing JIT

这次是因为 LuaJIT 的 tracing JIT 技术的影响。

不像 Java 那种 method based JIT 技术,是按照函数来即时编译的。LuaJIT 是按照 trace 来即时编译的,trace 对应的是一串代码执行路径。
LuaJIT 会把热的代码路径直接即时编译生成机器码,一串热的代码路径也就是一个 trace。同时 trace 也不是无限长的,LuaJIT 有一套机制来控制 trace 的开始结束(以后找时间再详细记录一篇的)。

具体来说是这样子的,因为在 order-1.lua 里,TRACE 1m 计算的那个 for 循环处则停止了,当 TRACE 2 开始的时候,LuaJIT 还不支持这种情况下即时编译 (还处于 NYI 状态)VARG 这个字节码(也就是对应的 ...)。

所以,导致了这部分代码不能被 JIT,回归到了 interpreter 模式,所以导致了这么大的性能差异。

如下,我们可以在 LuaJIT 输的日志中看到 NYI: bytecode 71 这个关键信息。

1
2
3
4
5
6
7
8
$ luajit -jdump=bitmsr order-1.lua

...

---- TRACE 2 start 1/3 order.lua:13
0016 UGET 2 0 ; f2 (order.lua:13)
0017 VARG 4 0 0 (order.lua:13)
---- TRACE 2 abort order.lua:13 -- NYI: bytecode 71

总结

调整了 Lua 代码顺序,影响了 LuaJIT 中 trace 的生成,导致了有字节码没法被 JIT,这部分回退到了解释模式,从而导致了较大的性能差异。

感慨一下

JIT 技术还是蛮好玩,不过需要学习掌握的东西也挺多的。

以我目前的理解,tracing JIT 算是很牛的 JIT 技术了,有其明显的优势。不过任何一项技术,总是少不了非常多的人力投入。
即使像 Lua 这种小巧的语言,也还是有不少的 NYI 没有被 JIT 技术。
像 Java 这种重型语言,JIT 这方面的技术,怕是需要很多大牛才堆出来的。

前言

前文 <一行机器指令感受下内存操作到底有多慢> 中,我们体验到了 CPU 流水线阻塞带来的数量级性能差异。当时只是根据机器码,分析推断出来的,这次我们做一些更小的实验来分析验证。

动手之前,我们先了解一些背景。在 <CPU 提供了什么> 一文中介绍过,CPU 对外提供了运行机器指令的能力。那 CPU 又是如何执行机器指令的呢?

CPU 是如何执行机器指令的

一条机器指令,在 CPU 内部也分为好多个细分步骤,逻辑上来说可以划分为这么五个阶段:

  1. 获取指令
  2. 解析指令
  3. 执行执行
  4. 访问内存
  5. 结果写回

流水线作业

例如连续的 ABCD 四条指令,CPU 并不是先完整的执行完 A,才会开始执行 B;而是 A 取指令完成,则开始解析指令 A,同时继续取指令 B,依次类推,形成了流水线作业。

理想情况下,充分利用 CPU 硬件资源,也就是让流水线上的每个器件,一直保持工作。然而实际上,因为各种原因,CPU 没法完整的跑满流水线。

比如:

  1. 跳转指令,可能跳转执行另外的指令,并不是固定的顺序执行。
    例如这样的跳转指令,可能接下来就需要跳转到 400553 的指令。
1
je    400553

对于这种分支指令,CPU 有分支预测技术,基于之前的结果预测本次分支的走向,尽量减少流水线阻塞。
2. 数据依赖,后面的指令,依赖前面的指令。
例如下面的两条指令,第二条指令的操作数 r8 依赖于第一条指令的结果。

1
2
mov    r8,QWORD PTR [rdi]
add r8,0x1

这种时候,CPU 会利用操作数前推技术,尽量减少阻塞等待。

多发射

现代复杂的 CPU 硬件,其实也不只有一条 CPU 流水线。简单从逻辑上来理解,可以假设是有多条流水线,可以同时执行指令,但是也并不是简单的重复整个流水线上的所有硬件。

多发射可以理解为 CPU 硬件层面的并发,如果两条指令没有前后的顺序依赖,那么是完全可以并发执行的。CPU 只需要保证执行的最终结果是符合期望的就可以,其实很多的性能优化,都是这一个原则,通过优化执行过程,但是保持最终结果一致。

实践体验

理论需要结合实践,有实际的体验,才能更清晰的理解原理。

这次我们用 C 内联汇编来构建了几个用例来体会这其中的差异。

基准用例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <stdio.h>

void test(long *a, long *b, long *c, long *d) {
__asm__ (
"mov r8, 0x0;"
"mov r9, 0x0;"
"mov r10, 0x0;"
"mov r11, 0x0;"
);

for (long i = 0; i <= 0xffffffff; i++) {
}

__asm__ (
"mov [rdi], r8;"
"mov [rsi], r9;"
"mov [rdx], r10;"
"mov [rcx], r11;"
);
}

int main(void) {
long a = 0;
long b = 0;
long c = 0;
long d = 0;

test(&a, &b, &c, &d);

printf("a = %ldn", a);
printf("b = %ldn", b);
printf("c = %ldn", c);
printf("d = %ldn", d);

return 0;
}

我们用如下命令才执行,只需要 1.38 秒。
注意,需要使用 -O1 编译,因为 -O0 下,基准代码本身的开销也会很大。

1
2
3
4
5
6
7
8
9
10
$ gcc -masm=intel -std=c99 -o asm -O1 -g3 asm.c
$ time ./asm
a = 0
b = 0
c = 0
d = 0

real 0m1.380s
user 0m1.379s
sys 0m0.001s

以上的代码,我们主要是构建了一个空的 for 循环,可以看下汇编代码来确认下。
一下是 test 函数对应的汇编,确认空的 for 循环代码没有被编译器优化掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
000000000040052d <test>:
40052d: 49 c7 c0 00 00 00 00 mov r8,0x0
400534: 49 c7 c1 00 00 00 00 mov r9,0x0
40053b: 49 c7 c2 00 00 00 00 mov r10,0x0
400542: 49 c7 c3 00 00 00 00 mov r11,0x0
400549: 48 b8 00 00 00 00 01 movabs rax,0x100000000
400550: 00 00 00
400553: 48 83 e8 01 sub rax,0x1 // 在 -O1 的优化下,变成了 -1 操作
400557: 75 fa jne 400553 <test+0x26>
400559: 4c 89 07 mov QWORD PTR [rdi],r8
40055c: 4c 89 0e mov QWORD PTR [rsi],r9
40055f: 4c 89 12 mov QWORD PTR [rdx],r10
400562: 4c 89 19 mov QWORD PTR [rcx],r11
400565: c3 ret

加入两条简单指令

这次我们在 for 循环中,加入了 “加一” 和 “写内存” 的两条指令。

1
2
3
4
5
6
for (long i = 0; i <= 0xffffffff; i++) {
__asm__ (
"add r8, 0x1;"
"mov [rdi], r8;"
);
}

本次执行时间,跟基础测试基本无差别。
说明新加入的两条指令,和基准测试用的空 for 循环,被“并发” 执行了,所以并没有增加执行时间。

1
2
3
4
5
6
7
8
9
10
$ gcc -masm=intel -std=c99 -o asm -O1 -g3 asm.c
$ time ./asm
a = 4294967296
b = 0
c = 0
d = 0

real 0m1.381s
user 0m1.381s
sys 0m0.000s

再加入内存读

这个例子,也就是上一篇中优化 LuaJIT 时碰到的情况。
新加入的内存读,跟原有的内存写,构成了数据依赖。

1
2
3
4
5
6
7
for (long i = 0; i <= 0xffffffff; i++) {
__asm__ (
"mov r8, [rdi];"
"add r8, 0x1;"
"mov [rdi], r8;"
);
}

再来看执行时间,这次明显慢了非常多,是的,流水线阻塞的效果就是这么感人😅

1
2
3
4
5
6
7
8
9
10
$ gcc -masm=intel -std=c99 -o asm -O1 -g3 asm.c
$ time ./asm
a = 4294967296
b = 0
c = 0
d = 0

real 0m8.723s
user 0m8.720s
sys 0m0.003s

更多的类似指令

这次我们加入另外三组类似的指令,每一组都构成一样的数据依赖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
for (long i = 0; i <= 0xffffffff; i++) {
__asm__ (
"mov r8, [rdi];"
"add r8, 0x1;"
"mov [rdi], r8;"
"mov r9, [rsi];"
"add r9, 0x1;"
"mov [rsi], r9;"
"mov r10, [rdx];"
"add r10, 0x1;"
"mov [rdx], r10;"
"mov r11, [rcx];"
"add r11, 0x1;"
"mov [rcx], r11;"
);
}

我们再看执行时间,跟上一组几乎无差别。因为 CPU 的乱序执行,并不会只是在那里傻等。
反过来说,其实流水线阻塞也不是那么可怕,有指令阻塞的时候,CPU 还是可以干点别的。

1
2
3
4
5
6
7
8
9
10
$ gcc -masm=intel -std=c99 -o asm -O1 -g3 asm.c
$ time ./asm
a = 4294967296
b = 4294967296
c = 4294967296
d = 4294967296

real 0m8.675s
user 0m8.674s
sys 0m0.001s

总结

现代 CPU 几十亿个的晶体管,不是用来摆看的,内部其实有非常复杂的电路。
很多软件层面常见的优化技术,在 CPU 硬件里也是有大量使用的。

流水线,多发射,这两个在我个人看来,是属于很重要的概念,对于软件工程师来说,也是需要能深入理解的。不仅仅是理解这个机器指令,在 CPU 上是如何执行,也是典型的系统构建思路。
最近有学习一些分布式事务的知识,其实原理上跟 CPU 硬件系统也是非常的类似。

多动手实践,还是很有好处的。

其他知识点:

  1. CPU 的 L1 cache,指令和数据是分开缓存的,这样不容易缓存失败。

参考资料:

https://techdecoded.intel.io/resources/understanding-the-instruction-pipeline