现代程序调优的心智模型¶
标题:Mental models for modern program tuning
日期:2016/06/23
作者:Andi Kleen
链接:https://www.youtube.com/watch?v=-Ynm2eHGAOU
注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。
备注:pmu-tools 作者教你做事。遇到平坦图该怎么办,工具层面你至少要会 topdown。
大家好,我想向大家介绍来自英特尔的 Andy Kleen,他是一位资深的 Linux 内核黑客。他参与了 x86-64 的初始移植工作,并曾担任 x86-64 移植的维护者,他在内核领域做出了很多贡献。
好的,今天我想谈谈现代程序调优的心智模型,我需要补充一下,我主要讨论的是缓存性能。我将要谈论的一些事情,如果你有缓存调优的经验,可能已经知道了,但我也会加入一些新内容。同时,我还会讲如何校准它,也就是说,一旦你有了心智模型,如何根据现实情况——即实际发生的情况——来校准它。我还会介绍一些新技术。我应该补充一下,这些技术有些是英特尔特有的,主要是因为这是我的工作内容,但也许它们也对其他方面有用。
程序性能的两种视角¶
好的,首先,我们如何看待程序性能?一方面,你必须思考程序本身,基本上有两种模型可以采用。第一种是你从宏观角度思考,比如大的模块和大的算法,这是高层次的视角。另一种方式是,你可以把它想象成更像一支“蚂蚁军团”。是的,就是大量微小的操作,而程序性能的目标实际上是让这些独立的“蚂蚁”作为一个整体快速运行。我认为这是一个非常重要的观点,因为人们常常忽略如何让小事情变快,而只关注大事情。当然,我并不是说你应该忽略那些大事,宏观算法等固然重要,但也请思考那些微小之处。
你可能以前见过这句话,这是 Donald Knuth 在 1974 年说的:
过早的优化是万恶之源。
但如果你看看他实际写的内容,后面还有一句:“然而,我们不应该放弃在那些关键的 3% 上下功夫的机会。” 他基本上是说,不要去优化程序的大部分,而是要关注那 3% 的关键部分。问题在于,真的只有 3% 吗?因为如果你审视一个真实的应用程序,你常常会得到类似下面这样的东西。
这被称为“扁平性能剖析图”(flat performance profile)。这种情况非常糟糕,因为在扁平的性能剖析图中,没有任何东西是突出的。你可以看到,图中最大的部分大约占 3%,而大部分都低于 1%。这里并没有一个真正可以识别的单一瓶颈。例如,Brendan 昨天提到他喜欢 CPU 密集型问题。但如果你的 CPU 密集型问题是这样的扁平剖析图,那就很难下手了,至少从表面上看是这样。
不幸的是,这种扁平剖析图在复杂的工作负载中出奇地常见。当然,有些情况很简单,比如你在做高性能计算时,通常会有一些非常“热”的循环,你可以真正地去优化这些循环。但是,如果你面对的是非常复杂的代码,那么你常常会得到扁平的性能剖析图。
所以,真正的策略是,你在编写代码时,就必须在某种程度上意识到性能问题,无论是从“蚂蚁”的微观层面还是从宏观层面。至少对于关键路径是这样。当然,你得想办法找出关键路径。问题在于,如果你完全忽略它——也就是说,在最初编写和设计代码时完全不考虑性能——那么之后通常需要做太多的改动才能让它变快。而如果你有一个扁平的性能剖析图,事情就变得非常困难,因为你没有一个可以集中的焦点,问题遍布于整个程序中。
因此,我认为一种可行的方法是,你需要有一个关于性能的心智模型,并用它来思考。然后,至少要应用这个心智模型。当然,有些东西可能并不关键,比如如果只是程序的国际化代码,你可能不需要对它进行太多优化。但关键之一是你至少需要有一个模型,并提前考虑性能。
之后,当你得到性能结果时——也就是当你运行它、进行剖析、测量等等——你也要用这个模型来理解这些性能结果意味着什么。
建立心智模型¶
那么,你如何构建一个心智模型呢?这里基本上有两个问题。第一个问题,这是一台 C64(Commodore 64),它是一台非常简单的计算机。就像我刚开始接触计算时,人们实际使用的那种。有趣的是,当时确实有人声称他们可以完全理解这个系统。有些人真的了解这个系统的方方面面,可以优化所有东西。
但问题是,今天的系统要复杂得多。所以,没有人能够完全理解整个系统。它太复杂了,从硬件的运作方式到不同的软件层,再到不同的外设和 I/O,所有的一切。这真的非常复杂。但你面临的问题是,你必须以某种方式将一个足够简单的性能模型装进你的大脑。这才是真正的问题:你如何将事物简化到足以让你的大脑在开始“内存交换”(swapping,指大脑不堪重负)之前记住它。
这里有一个例子,你可能以前见过这张表。它来自 Peter Norvig,我想它已经在互联网上流传了好几年了。它基本上列出了基本操作的不同基础延迟。实际上,这张表有点过时了,数字也不再完全准确。事实上,它们一直在变化。所以我并不建议直接看这些数字,但我认为重要的是看数量级。所以,每次你做某件事时,你应该清楚自己处于哪个数量级。我是在处理一个一级缓存未命中(非常快),还是一个单一操作,比如一条加法指令?或者我是在处理一个真正的缓存未命中(很慢)?或者我是在处理一个跨节点的延迟(非常非常慢)?你至少需要知道你处于哪个数量级上。这才是这里真正重要的部分。
顺便说一下,你看到它为什么过时的原因之一是,例如,它仍然在谈论二级缓存引用,而没有提到三级缓存。大多数现代系统都有三级缓存等等。所以你可以看到这一点。再说一次,我真的不建议去尝试记住你正在调优的系统的这些确切数字。因为问题是它们一直在变。当然,有些数字很稳定,比如一级缓存引用不会变得更快。但其他数字通常会有一些变化,通常是变得更好,有时在新一代产品中会变得更糟。而且,每年都有新一代的 CPU 问世。所以我认为知道绝对数字没有那么大用处。真正重要的是数量级。
另一件事是,了解你的关键瓶颈。这非常重要,因为为一个并不拖累你的东西进行优化是没有用的。我在这里主要谈论的是 CPU 密集型(CPU-bound)的情况。当然,你也可能受限于其他东西。所以在你开始大量优化 CPU 之前,你需要确保你没有受限于其他东西。你可能受限于 I/O、网络、GPU。昨天 Brendan 就如何看待这些问题做了一个很棒的演讲,所以我不会重复。我在这里真正关注的是 CPU 密集型的情况。也就是说,应用程序在计算,而不是在等待什么东西。它在执行指令,而这正是拖慢它的原因。
那么,什么是 CPU 密集型?在一个非常高的层次上,你可以把它分为三件事。
纯计算:它在计算某些东西,这需要一些时间。这一点我们理解得很好。我们有很多理论来指导如何做,比如我们有算法的 O(N) 表示法,我们可以做各种各 样的优化。所以如何最小化计算等等,是大家很了解的。
内存访问:另一件可能非常慢并且经常被忽视的事情是访问内存。这基本上就是缓存未命中。它已经慢了很多,至少慢了两个数量级,有时甚至是三个数量级。
通信:另一件真正关键的事情是当你进行扩展(scaling up)时。因为获得越来越多计算能力的唯一方法是使用更多的核心。如果你进行扩展,到某个点,除非你的任务是所谓的“易于并行”(embarrassingly parallel)——也就是没有任何通信和相互依赖——否则,一旦你有任何依赖关系,你就会有不同核心之间的通信。然后你就要开始担心通信问题了。这正是阻止你完成更多工作、进行扩展的另一个因素。
测量技术¶
现在,让我稍微谈谈测量。只是一些非常基础的东西。基本上,在高层次上,当你想测量 CPU 或软件时,你可以使用三种不同的技术。
计数 (Counting):你只需在某个地方添加一些埋点,然后计算事件。CPU 也可以做到这一点。CPU 有几十到几百个不同的性能事件,可以用来计数。基本上,计数告诉你发生了什么,因为它计算了特定事件的所有发生次数,但不一定告诉你发生在哪里。所以你不知道你程序中的问题出在何处。通常,看绝对数字不是那么有用,你通常想要的是比率。例如,每条指令的周期数(CPI),或者每条指令的缓存未命中数等等。所以,这通常是一个关于比率的游戏。
采样 (Sampling):这是你可以使用的另一种技术,也是人们使用分析器(profiler)时最常用的。他们做的第一件事就是使用分析器进行采样。通常,标准的采样是基于时间或周期。你只需采样时钟嘀嗒的速度或持续时间。然后每次你取一个样本,基于此创建一个直方图,然后你大概就能看到时间花在了哪里。所以它粗略地告诉你“哪里”出了问题,但不总是告诉你“是什么”问题。因为即使你知道时间花在了那里,你实际上并不知道为什么它这么慢。为什么花了那么长时间?你可以猜测。可能是我的算法不好,或者是一些神秘的东西,一些我不太理解的硬件行为。所以它粗略地告诉你“哪里”,但不总是“是什么”。
我还应该说,是“粗略地”告诉你哪里,因为它并不总是命中确切的位置。我稍后会详细说明。另一件事是,通常人们做性能分析时,只用周期(即时间)。但实际上你可以针对很多其他事情进行分析。你可以分析缓存未命中,可以分析分支预测错误以及各种各样的事情。通常,尤其是当你有一个扁平的剖析图时,这样做更有用。因为,例如,你发现你的瓶颈可能是缓存未命中。所以你实际上应该在各处寻找缓存未命中。直接对缓存未命中进行分析比仅仅对时间进行分析要好得多。
我有时把这比作超级英雄。我想 Linus Torvalds 有一句名言,他说:“我只需要对周期进行分析,因为我只要看看时间花在哪里,我就能确切地知道为什么它慢。” 所以,如果你是超级英雄,你可能可以做到。但我们大多数人不是那样的。我不是超级英雄。所以我通常需要更多的帮助。因此,最好是针对其他事件进行采样。
追踪 (Tracing):第三种技术,更多地用于软件,但也越来越多地可以用在硬件上,那就是追踪。你实际上追踪某样东西在一段时间内所做的一切。缺点当然是它会产生大量数据。它基本上成了一个大数据问题。所以你无法分析你看到的所有东西。它能非常非常准确地告诉你发生了什么,但不一定告诉你“是什么”,因为它通常只是正在发生事情的一个子集。但它也可以非常有用。但你必须知道如何应用它,因为你不能用得太久,数据量太大了。
采样中的问题¶
我之前已经提到过,采样的一个问题叫做采样偏移 (sampling skid)。基本问题是,即使你正在对某事进行采样,触发样本也需要一些时间。在现代 CPU 中,这个问题要严重得多,因为现代 CPU 是高度乱序执行的。它们并行地做很多事情。实际上,触发样本需要一些时间。这意味着,即使你在程序的某个点看到了一个样本,实际的问题可能在别的地方。它可能在那之前或之后。这真的取决于事件本身。有些事件可以被更精确地采样,而其他事件则不能那么精确。
有一项技术确实很有帮助,这是一项英特尔技术,叫做精确的基于事件的采样 (Precise Event-Based Sampling, PEBS)。CPU 有一些特殊的硬件支持,可以更准确地触发样本,而不是依赖于需要一些时间的中断。它在内部跟踪指令,然后触发样本并写入一些相关数据。所以,如果你能使用 PEBS,你的采样偏移会小得多。但问题是它并不总是有效。例如,目前它在虚拟机中不受支持。未来可能会有所改变,但现在不行。例如,如果你使用 perf record
,这是 Linux 的 perf 分析工具,你通常只需在事件后面加上 pp
。你也可以使用其他工具。所以这是需要注意的一点:采样并不总是准确的。
另一个问题,这很有趣,是我们能采样的速度通常是有限的。因为如果你再次从数量级的角度思考,采样中断至少要花费 100,000 个周期,有时甚至更昂贵。虽然有些方式可以便宜一点,但它是一件相当昂贵的事情。问题是,你不能以非常高的频率进行采样。因为如果你以非常高的频率采样,你除了采集样本之外什么也做不了。你的程序将无法取得进展。
但问题是,如果你有一个工作负载,它做了很多小事情,比如很多小事件,做一件事,再做另一件事。如果这些事件是周期性的,并且恰好落在采样间隔之间,你实际上可能会错过一些东西。因为根据基本的采样定理,你必须以足够快的速度采样才能捕捉到所有东西。这可能是一个大问题。再说一次,如果你的工作负载有定义明确的循环,并且在同一个地方做了很多事情,那就不是问题,因为你肯定会命中它,不会有这个问题。但如果你处理的是事件驱动的、带有小型周期性事件的系统,这就可能成为一个大问题。
我们一直在使用的一种技术,或者说可以被使用的一种技术,是追踪。但另一种技术,同样是使用 PEBS,实际上有一种方法可以在没有中断的情况下运行它。CPU 直接将关于采样发生的信息写入一个缓冲区。通过这种方式,我们能够获得比平时高 10 倍的采样频率。我们用这种方法在一些问题上取得了很好的结果。这里的缺点是它不能与某些东西结合使用。例如,人们通常想要的是调用图(call graph)。你想要看到采样发生时的调用者是谁。这样你才能知道发生了什么。否则你可能只看到,比如说 memcpy
,但你不知道是谁在调用 memcpy
,所以你无能为力。目前的缺点是它不支持调用图。所以这里有一些权衡。
在较新的内核中,例如在 Linux 内核 4.1 中,我们用 Linux Perf 实现了这个功能。通过设置一个固定的采样频率并禁用时间选项,你就可以做到这一点。
顶层(Top-Down)分析方法¶
我提到的另一个问题是,当你进行计数时,人们经常使用比率。例如,这是来自 perf stat
的输出,它用 Perf 进行计数。你可以看到它计算了缓存引用和缓存未命中之间的比率。这是一个非常简单的程序,只是一个斐波那契数列。它基本上是完全计算密集型的,在其热循环中几乎不进行任何内存访问。但你仍然看到,它的缓存命中率相当低。实际上,这仅仅来自于国际化代码。因为花费大部分周期的实际代码并没有做太多的内存引用。
所以这里的问题是,这很有误导性。因为你看着这个比率,会想:“嘿,这个程序有缓存问题,我们需要优化缓存。” 但这完全是错的。因为你只是在看比率。当然,你可能在这里得到一个提示,因为你看到缓存引用的实际数量非常小。所以也许这不是问题所在。但在更复杂的情况下,事情可能会更复杂。
我们避免这个问题的一种方法,基本上是一种叫做顶层分析方法 (Top-down methodology) 的东西。它是由 Ahmad Yassin 开发的,在较新的英特尔 CPU(如 Sandy Bridge 或更高版本)中得到了支持。Top-down 的基本思想是分层地分解问题。你从顶层开始。你一开始看到的是四个顶层节点:
前端瓶颈 (Front-end Bound):指令解码问题。
错误推测 (Bad Speculation):分支预测问题。
后端瓶颈 (Back-end Bound):从计算到访问内存的任何事情。
引退 (Retiring):这是好的周期,意味着没有特定的瓶颈。
然后,一旦你确定了顶层类别,你就可以进一步分解。你可以说,好吧,如果是后端瓶颈,我再看看,它要么是核心瓶颈 (Core Bound),即存在一些计算问题;要么是内存瓶颈 (Memory Bound),即内存子系统有问题。然后它还可以被进一步分解到不同的层次。这是一个分层系统。
我应该补充一下,这只是节点的一个子集。实际的 Top-down 可以做得更多。我这里只展示了与缓存相关的节点。但基本方式是这样的。这里基本上只关注缓存。你可以看到,主要的缓存问题可以是计算过程中的内存瓶颈。也就是说,它在进行计算,但没有真正得到数据。另一种可能发生的情况是,你有前端瓶颈,这意味着 CPU 无法足够快地读取指令。这通常发生在你有一个非常大的程序时,这个大程序本身因为它太大了而把缓存给冲刷掉了。
所以这基本上是避免这个问题的一种方法。首先,你分层地分解出瓶颈是什么。然后,你只看真正重要的比率。在这种情况下,如果我测量斐波那契数列,我会看到它是核心瓶颈。所以我忽略缓存相关的比率,因为它并不重要。
这里有一个例子,我有一个工具,它是 PMU-Tools 的一部分,这是我运行的一个开源项目。这个工具实现了 Top-down。例如,我在这里测量了一个程序。这实际上是另一个程序,不是斐波那契,但你可以看到输出。它显示程序是后端瓶颈,内存瓶颈。在这种情况下,这是一个内存瓶颈问题。然后它实际上计算出了瓶颈所在。对于这种情况,我只会关注与内存瓶颈相关的问题,而不会看别的。
另一种看待它的方式,当然,可以用时间序列来做。这就是这里的图。如果你有一个更复杂的工作负载,你通常可以看到它随时间如何发展。它是受限于缓存,还是受限于计算等等。所以你可以用不同的方式来使用它。我认为这非常重要。因为专注于那些不是你瓶颈的事情是没有意义的。所以,如果你首先找出你的瓶颈是什么,然后再从那里着手,效率会高得多。
心智模型:性能单元与缓存¶
回到心智模型。我认为心智模型中一个非常重要的概念是你的基本性能单元 (basic performance unit)。我通常称之为,如果你比这个单位更小,速度就不会再快了。
对于缓存,这通常是 64 字节。有时会更大,但通常是 64 字节。
如果你谈论的是分页,那通常是 4KB,但现在越来越多的是 2MB。
如果你谈论其他事情,比如网络 I/O,那么你谈论的是至少几百字节的单个数据包。或者如果你正在做一个 TCP 连接,甚至是几千字节。
如果你做块 I/O,它也变得越来越大。比如在许多现代 I/O 子系统中,读取 512 字节和读取 2MB 的速度没有区别。
这在你做程序的高层设计时非常重要。因为如果你只以小块的方式做事,小于最小性能单元,你就在浪费时间。因为你获取了你不需要的东西。所以做聚类(clustering)之类的事情会非常有用。
接下来是简化的缓存模型。因为通常当你思考缓存时,你可能会看到那些带有 MESI、MESIF 等各种缓存状态的图表。我发现它们其实并不那么直观,即使我能用它们。但通常在做性能调优时,我使用一个简化的缓存模型。 基本上,对于基础的东西,首先把内存想象成一个由 64 字节缓存行组成的数组。这些 64 字节的缓存行中的任何一个都可以处于不同的状态。在高层次上,有两种高级状态。
本地 (Local):这意味着它没有被其他东西共享。在这种情况下,它可以是:
临时的 (Temporal):意味着你最近使用过它,它能放进你的缓存里。所以它是热的,速度快。
可预测的 (Predictable):意味着它不是最近使用的,但是它是可预测的。所以硬件可以预测你将要访问它。在这种情况下,它也可以更快地把它给你。
冷的 (Cold):它实际上必须从内存中读取,非常慢。随机访问是灾难性的。
通信 (Communication):在这种情况下,第一种是共享只读 (Shared Read-Only)。这是简单的情况。因为如果是共享只读,那么数据会被分发或复制到不同的缓存中,它基本上就像本地数据一样。所以它可以是热的,很容易是临时的。而通信中真正有趣的情况是弹跳 (Bouncing)。当你和其他人通信时,你写了一个东西,另一个人在读它;或者反过来;或者两人都在写。那么两者之间就必须有消息传递。
让我更详细地讨论一下不同的情况。
临时的 (Temporal):我们最近使用过的,能放进缓存和我们的工作集里的东西。再次强调,要提前弄清楚什么能放进你的工作集里是相当困难的。因为有些人有缓存模型之类的,但它们非常复杂。通常你只能想,我尽量让东西小一点。然后我再去调整。最好的缓存模型通常是有一个旋钮(knob),在你写完程序后可以调整。因为要提前预测你的临时工作集真的很难。但再说一次,最好的性能是你希望你的大部分数据,至少是频繁使用的数据,是临时的。因为这样你能得到最好的性能。
冷的 (Cold):它不是临时的,要么太大了,要么在发生缓存抖动(trashing)。它可能非常慢,可能慢好几个数量级。实际上,这里有一些可能的优化,我不会深入讨论。但其中之一是,例如,如果你有一个多插槽系统或 NUMA 系统,你可以做 NUMA 局部性优化,使得本地插槽上的一些内存比远程插槽上的内存更快。但也存在更现代的系统,例如即将推出的 CM5,它有快内存和慢内存。所以情况也可能不同。
在 NUMA CPU 中还有另一个技巧,但我其实不能真的建议非常激进地使用它,那就是流式存储 (Streaming Stores)。因为基本问题是,如果你流式处理大量数据,你往往会污染你的缓存。如果你只使用一次数据,你就会丢失你最近的工作集。可以做的是,要么由编译器生成,要么使用特殊的内在函数(intrinsics),基本上是告诉 CPU:“不要缓存这个,直接从内存读取或直接存储到内存。” 问题是,如果你真的重用了它,它就不在缓存里了。所以如果你写了一些流式数据然后马上读回来,会非常非常慢。因为它保证会是一个非常糟糕的缓存未命中。但它在某些情况下可能有用,特别是如果你在做像 DSP(数字信号处理)那样的事情。你有一个循环,遍历数据,做一些类似的事情。流式处理有用的另一个情况是,当你知道你无论如何都会有缓存未命中,因为你要做 I/O。你在和 I/O 设备通信,而 I/O 设备可能会使缓存失效。所以有些情况下它是有用的。但再说一次,你必须小心。也许最好把它留给编译器,因为有时编译器能搞定这个。
可预测的 (Predictable):顺便说一下,这是我发明的状态之一,因为它通常不在 MESI 状态中,但我认为这是一个非常有用的概念。可预测意味着它很大,并且不是热的,所以它不是缓存命中。但它有一个可预测的访问模式。然后它的可预测性足以让硬件实际上弄清楚。所以硬件可以跟着你,并开始在后台进行预取(prefetching)并为你获取它。所以你实际上不必等待通常从内存中读取它所需的全程时间。实际上,它并不总能完全隐藏完整的内存延迟,这取决于数据集有多大。所以它可能仍然比热数据慢一点。它可能比热的或临时的数据慢一些。但它比完全随机访问的冷数据要好得多。
再次强调,预测并不那么容易,但通常在一个非常高的层次上,数组通常比指针好得多。因为通常当你使用链表之类的东西时,它们往往在第一次分配时是可预测的。但然后你释放了一些内存,然后重新分配它,分配器的空闲列表被重新排序了。下一次它就不再有序了,然后硬件就无法预测了,因为不再有可预测的模式了。因为它不是基于指针来预测,它只基于地址来预测。所以从高层次上讲,就是这样。当然,你也可以把数组搞砸,如果你完全随机地访问数组,没人能预测这个。但如果你做顺序访问、跨步访问之类的事情,效果会很好。
关于预取器(prefetchers)多说几句。它们通常只对大量数据有效。所以它需要一个训练期。所以在开始时通常会慢一些。但随着时间的推移,当你遵循模式时,它会变快。它们支持跨步(strides),这意味着即使你比如每隔 n 个缓存行访问一次,而不是每个缓存行都访问,它也能支持。通常它们可以向前和向后预取。它们可以处理一些复杂的模式,但不是太复杂的模式。并且也支持多个流。数量不是很大,但也不是很小。所以如果你的模式太复杂,上下文太多,你可能会把预取器搞垮,然后你就会变慢。但只要你保持在一定范围内,你应该就没问题。
软件缓存与设计模式¶
我应该补充一下,缓存也可以存在于其他地方。软件中通常有软件缓存。例如,Linux 内核有一个文件缓存(file cache),用于缓存文件数据。这里有一个有趣的技术。例如,如果你在编写一个基于事件的软件。比如,你在流式传输一个在磁盘上或已缓存的文件。你收到请求,然后发送一个块,再发送下一个块。比如视频流之类的。
基本上有两种基本方法可以在事件循环中实现这一点。一种方法是,我有一个指向该文件的共享文件描述符。我只是每次都做 pread
并传递偏移量。我的每个客户端都得到同一个共享文件描述符。然后我自己处理偏移量。但你可以实现的另一种方式是,为每个客户端创建一个新的文件描述符,一个私有的文件 anscriptor。然后我只让内核管理偏移量。
如果你对这两种方法进行基准测试,你会发现如果数据足够大,实际上第二种技术性能要好得多。因为原因是内核也有一个预取器。而预取器是与文件描述符绑定的。所以如果你在不同的流中重用相同的文件描述符,你就会把那个预取器搞乱,它会因为无法理解你的访问模式而自行禁用。但如果你实际上在那个文件描述符上遵循一个可预测的访问模式,你就能得到预取。所以内核在后台已经获取了数据。当你进行 I/O 时,如果数据还没有在缓存中,你就可以获得更好的性能。
在思考缓存时,另一件事是我们也可以使用热循环模型 (hot loop model)。这确实是经典模型。我想这可能是 Knuth 写下他那句名言时所想的。我们有一个热循环,我们了解关于这个热循环的一切。我们在分析器中看到了它,我们有完整的源代码等等。有很多技术可以在那里获得更好的缓存效率。实际上有一种叫做缓存分块 (cache blocking) 的技术,你重组循环,使得数据访问模式总是以更大的块进行。有很多技巧可以做到这一点。
在很多情况下,编译器实际上已经为此进行了优化,因为这是高性能计算代码中的经典模式。所以通常如果你开启足够高的优化级别,它可能能够自动做到这一点。比如重组你的循环,让它更适合缓存。如果这能奏效,那真的很好。因为别人为你做了工作,你不需要自己做。不幸的是,很多软件不是这样的。你没有那个热循环。问题遍布各处。所以它可能有用,但通常热循环模型并不那么有效。
另一件事,根据我的经验更常见,尤其是我经常在 Linux 内核上工作。在 Linux 内核中,一种思考它的方式是像一个库。它有系统调用,它被别人调用,但它代表别人做事。所以它在很大程度上是一个库。还有其他的库,比如你可能有一个为应用程序提供数据的数据库库,或者做其他事情的库。所以它被随机的其他代码调用。你通常不知道是什么代码在调用你,因为你是一个通用库。
问题是,其他代码已经有它自己的缓存足迹(footprint)了。所以即使你优化了,比如说你优化了你的库,你完美地使用了缓存。但然后有别人从另一个也完美使用缓存的代码中定期调用它。那么,你们就用了两倍的缓存,就会发生缓存抖动。这真的很糟糕。
这通常就像,你可以在写代码时做权衡。你可以权衡说,我多计算一些,或者我查表,或者有我自己的缓存。这通常是关于足迹的权衡。你可以说,好吧,我用更多的缓存,我负担得起。但问题是,这是一种公地悲剧 (tragedy of the commons)。因为如果你用了太多的缓存,另一个人也用了太多的缓存,那么谁也得不到好的缓存。就会发生抖动。如果两者加起来能放进缓存里,那么一切都会是临时的,并且很好。
这真的很糟糕,因为它被称为不可组合问题 (non-composable problem)。所以不是说你看一个单一的东西就能决定它。而是你真的需要把所有东西放在一起看。这些是计算机科学中最难的问题,你必须看全局,而不是只看单一的东西。实际上,我就这个话题写了一篇长文,关于如何思考这个问题,如果你感兴趣,可以在我的博客上找到。
但是,要做出对缓存友好的库,首先,这实际上是 Linux 内核经常使用的一种技术,你可能会假设你是缓存冷的。你做的任何事情都不是临时的,因为你被某个刚刚清空了所有缓存的人调用了。在这种情况下,你唯一能做的就是最小化足迹。尝试使用尽可能少的不同缓存行。但再说一次,可能存在权衡。例如,如果你做某件事,访问一些缓存行或一些内存,从而避免了一次非常昂贵的 I/O,比如网络传输之类的,那这是件好事。但如果与做一些可能更便宜的计算相比,那就是一件坏事。所以你真的必须意识到,我在为哪个数量级进行优化。
一些非常基础的技术是:
聚集热点字段:在数据结构中聚集热点字段。现在,如果你用像 Java 这样的语言,这几乎是不可能的,因为你不知道它的顺序。但在 C 和其他语言中,你可以做到。
支持大版本和小版本:我认为这里真正有效的一种技术是,如果你有一个可调参数。你在写程序时,你真的不知道完整的缓存足迹是多少,因为那太复杂了。所以你无法真正预测它。即使如此,它也一直在变,因为有人改了什么东西,用了更多的缓存。这真的很难。但如果你有一个旋钮,你以后可以调整它。你甚至可以做一个自动调优算法,说,好吧,我为我的不同库有不同的旋钮,从大到小。然后我只尝试梯度下降和其他优化算法来找到不同旋钮的组合,这实际上能让东西尽可能好地放进缓存里。
但还有其他方法。所以我认为这是一种非常有用的思考方式。但如果你做不到,那就尽量节俭。尽量最小化足迹。这就是对缓存友好的库。
缓存着色与其他问题¶
另一件需要注意的关于缓存的事情是,有一种叫做缓存着色 (cache coloring) 的东西。我不想在这里深入细节。但基本上问题是,由于硬件中缓存的实现方式,一个给定的地址只能被缓存在有限数量的位置。这意味着如果你有特定的模式,比如你如何布局你的数据结构,你可能没有完全使用缓存。因为它只最终出现在一些位置,而不是其他位置。所以缓存的一部分没有被使用。
例如,这里有一个经典的错误,对缓存非常糟糕。假设你有页面,比如 4KB 的页面或者你的页面大小。你在里面放了一些元数据。你把元数据放在开头。比如总有一个指向下一页的指针之类的。问题是,因为这总是相对于 4K 的一个给定偏移量,它的缓存效果很差。所以如果你这样做,你通常只使用了,比如八分之一,这取决于缓存的实现。但你只使用了你缓存的一部分。
所以这里你可以使用的一个技巧是,你可以使用打包的元数据 (packed metadata)。如果你的问题是你实际上经常处理元数据,那就把它们放在一个单独的对象里,这个对象是紧凑打包的。所以它能很好地使用缓存。然后再有另一个指针指向页面。但当然,你必须以正确的方式来做。我不是说你应该到处放额外的指针。在许多其他情况下,直接嵌入数据可能更好。但如果你有缓存瓶颈问题,当你在处理这些元数据结构时,这是你可以使用的一种技术。所以这必须是一个权衡。
如何发现缓存问题¶
那么,我们如何找到缓存问题呢?
首先当然是确保你真的有缓存问题。所以你用像 Top-down 这样的方法,你至少看到你是内存瓶颈,甚至可能是在某个缓存级别上受限。
接下来是我之前展示的,你可以计数缓存未命中。只看缓存命中率之类的事情。
再下一步可能是对缓存未命中进行采样。例如,在这种情况下,我对 L3 缓存未命中进行采样。我应该补充一下,我在这里使用的是 Linux Perf,它在 Linux 上运行。但 Linux Perf 没有完整的事件列表。所以我实际上有一个特殊的工具叫做
OCPerf
,它也是 PMU-Tools 的一部分。所以这是我的工具包的一部分。用 PMU-Tools 和OCPerf
,你就可以直接指定,在这种情况下是mem_load_uops_l3_miss_retired.local_dram
事件,然后它会找到它。另一件你也可以做的事情是,你可以采样地址。这是另一个使用 PEBS 的更高级的功能。但它基本上做的是,可以生成一个事件图,以及一个你正在访问的地址图。它实际上还会告诉你,每次访问命中了哪个缓存级别。
关于这项技术有一些需要注意的地方,你必须小心一点。第一个问题是,理论上看到一个地址图真的很好。所以你实际上可以可视化你程序的访问模式。但首先它只是采样的,所以你只看到每 n 次中的一次。另一个问题是,如果你命中了一个静态变量或一个全局变量,那么工具可以弄清楚它在那个全局变量里,或者在那个代码里之类的。但通常,现代程序通常在堆上有很多数据,一些分配的结构。而工具不理解堆。所以它们实际上只会告诉你它在堆的某个地方,但它不会告诉你,是那个对象,那种类型。所以解析起来可能有点难。但在某些情况下是可能的。通常你需要一些定制的工具。在某些情况下,你可以把它可视化,然后试着看出点什么。但它不是那么容易使用。
前端问题与嘈杂邻居¶
当你有前端问题 (front-end problem) 时,这是一个特殊情况,但它会发生,特别是在非常大的程序上。基本上,前端问题意味着程序代码太大,以至于 CPU 在冲刷缓存,只是为了尝试读取可执行文件。这在大型应用程序中其实出奇地常见。
有一些方法,虽然很难,但通常这是一个遍布各处的问题。因为如果没有一个单一的热循环,它就不会是前端缓存问题,因为那个热循环会被缓存。所以这通常是那种遍布各处的问题之一,它们非常难处理。
首先,你可以通过在 Top-down 中查看它是前端瓶颈来检查它是否是个问题。
一种有效的技术,并且可以由编译器自动完成,是使用配置文件反馈 (Profile Feedback)。分析器将热代码和冷代码分成不同的段(sections)。然后,这就更好地利用了指令缓存。因为通常只有热代码被执行,而不是冷代码。所以冷代码就不在你的缓存里了,这有很大帮助。
另一个有些常见的问题是所谓的嘈杂邻居问题 (noisy neighbor problem)。这尤其发生在你运行多个作业时,特别是在同一系统上运行不同作业时,因为缓存是共享的。可能会发生的是,系统上执行的某个其他作业使用了缓存,并导致了问题。问题通常取决于你把什么东西放在一起。如果你有一个内存瓶颈的家伙,问题不大。但问题发生在你把两个都…
另一种方式是,这是英特尔的一个新功能。它在 Haswell 这一代中部分引入了第一个版本,然后在 Broadwell 这一代中得到了增强。它可以做缓存占用监控。所以你基本上可以直接监控它用了多少缓存,并基于此做你的决定。所以基本上不要把两个会一起冲刷缓存的家伙组合在一起。这是做它的一种方式。
在 Broadwell 中他们还增加了缓存占用强制执行。所以你实际上可以把缓存的一部分分配给特定的进程。除了批处理计算之外,这对于延迟敏感的应用程序也很有趣。因为你可能遇到的一个大延迟就是缓存未命中。所以如果你把缓存的一部分分配给应用程序,你可能会有更少的尾延迟之类的。
这里有一个如何做 L3 占用测量的例子。这是我谈到的在 Haswell 上的新功能。在之前这是不可能做到的。所以这真的像一个独特的新能力。除了在模拟器中运行一个缓存模型之外,没有其他方法。但那些东西通常不那么准确。所以真的没有好方法来实际找出你用了多少缓存。
在这种情况下,我测量的是一个简单的测试工具叫做 MultiChase
。它在这里测量,开始时缓存占用非常高,然后随着它进入稳态而下降。这里就是这样。顺便说一下,在你问之前,它显示了小数位的字节数,这有点出乎意料。我想这目前是工具里的一个 bug。但据我所知,这只是一个小错误。所以它不会非常影响测量。但这使用的是 Linux Perf,它需要一个相当新的 Linux 内核,至少是 4.1。基于这一点,我们可以做协同部署(co-placement)。我们测量不同工作负载的缓存占用。然后我们选择我们的协同部署方案,然后它们就不必冲刷缓存了。这样可以很好地利用集群等等。
通信:将缓存交互视为网络¶
刚才基本上谈的是如何在缓存是本地的情况下使用它们。现在是通信。这是一个非常有趣的问题。基本上,当一个核心访问一个被另一个核心写入的缓存时,通信就发生了。所以通常是多个家伙共享数据,传递消息。
问题是,因为通常,特别是当人们谈论像 Rust 或 Go 这样的语言时,他们经常说:“好吧,我们不怎么做共享内存。我们只传递消息。” 但在我看来,这实际上是错误的思考方式。因为即使你有共享内存,你写到一个缓存,然后别人用了,那也是消息传递。因为有一条消息通过网络传输。这是一个非常快的网络,但它是一个网络。它的行为就像一个网络。所以,在你的心智模型中,真正重要的事情是,如果你有通信,如果你在弹跳缓存行,那就是消息传递。它就像一个网络。
当然,一件事是如何找到它。如果你有这个问题,你同样可以检查 Top-down。它有一个专门的节点叫做拥塞访问 (congested accesses)。所以如果你没有拥塞访问,那你就不需要关心它,因为它不是问题。还有一些其他的采样事件。这是一个非常有用的事件,我真的可以推荐。如果你有这个问题,它叫做 x_snoop.hitm
。基本上,它能找到一个在另一个 CPU 的缓存中被修改过的缓存行。这是典型的你正在获取一个别人最近写过的缓存行的情况。所以它必须直接从另一个人那里传输过来。对于远程插槽也有类似的事件。所以你需要两个不同的采样,一个用于本地插槽,一个用于远程插槽。我应该补充一下,不幸的是,事件名称在不同的英特尔代际中发生了变化。所以可能有类似的名字。但它至少包含 x_snoop.hitm
,这是需要寻找的重要部分。然后你就能实际找到它们发生在哪里。
当你做通信时,首先要考虑的是延迟级别。
一件事是,如果你有超线程(SMT,同步多线程),那么在同一个核心上与另一个线程通信非常快,因为它们共享一切。所以是同一个缓存。当然,它不是免费的,但它是一个非常低的数量级。所以那非常快。
下一个级别是到同一个插槽中的另一个核心。实际上,现代 CPU 往往非常复杂。所以实际上存在延迟差异,因为它们有一个环形总线(ring)。所以这取决于你经过了多少个环形总线站点。但通常,除非你真的关心延迟,否则差别不会太大。它不是一个数量级的差异。但通常在同一个插槽中,与另一个核心通信仍然相当快。
但是,与另一个插槽通信,如果你有一个双插槽系统或四插槽系统,与另一个插槽通信要慢得多得多,因为你遇到了光速问题。它是通过主板上的连接进行通信,那需要长得多得多的时间。
另一件事是,当然,如果你有一个更大的系统,可能有多跳。但这相对罕见。只有在大型系统上你才有多跳,而不是直接连接。
另一件在现代英特尔 CPU 上发生的事情是,它们有一个叫做 home snoop 的算法。这里发生的是,延迟实际上取决于缓存行位于哪里。这意味着,如果内存位于本地插槽,而另一个家伙,比如在另一个插槽,正在其核心之间弹跳,它仍然会更慢。因为算法或协议的工作方式是,它实际上必须请求所属的插槽,即主插槽(home socket),来做一些事情,而这需要更长的时间。
你可以看到,实际上我用英特尔延迟检查器做了一些测量。这是一个专门测量这些延迟的工具。你看到在 remote one 和 remote two 之间有很大的差异。local home 之间也有差异。这取决于它是否在主地址上。顺便说一下,我应该补充一点,我不建议自己写工具来测量这些延迟。这真的非常非常难做。最好使用专家写好的工具。英特尔延迟检查器就相当不错,因为自己做很容易出错,特别是当你不考虑预取器和各种奇怪的事情时。所以测量这些延迟真的很难。再说一次,我其实不建议太担心确切的延迟。重要的是数量级,因为确切的延迟无论如何都会变的。而且它并不是恒定的,因为它取决于系统上的负载水平。但再说一次,真正重要的是你不要搞错核心到核心、同插槽与远程插槽之间的差异,因为那是数量级的差异。这是需要记住的事情。
另一件非常非常重要的事情是,缓存是一个网络,所以你在做消息传递。这意味着你有排队效应 (queuing effects)。事情会排队。如果你做过网络优化,你可能见过网络效应,每个人都在敲打那个队列,它被填满了,然后有背压,有各种各样的问题。缓存也是一样,并且由于内存一致性(memory coherency)的工作方式,存在顺序要求。所以你可能会陷入所谓的冲突,然后事情会变得非常慢,我指的是非常慢。
例如,我在这里做了一个简单的实验。我只是在一个共享的缓存行上做增量操作,你看到这是一个双插槽系统。开始时,在大约 8 个核心之前相对较快,甚至 16 个核心也不是那么糟,但随后随着队列被填满和发生抖动,事情变得非常慢。所以你真的必须考虑,因为有趣的是,通常你做性能调优时,你并不真的关心无负载的情况,因为无负载的情况很简单。事情相对较快。但你关心的是负载下会发生什么,因为当你在负载下,在高压下,那才是你需要性能的时候。所以为这种情况优化比为无负载的情况优化要重要得多。所以,把弹跳的缓存行想象成一个消息,一个排队问题。它就像一个网络。
通信优化¶
你可以做一些优化,比如避免不必要的往返 (round trips)。这是最基本的。这和你做网络优化一样,对吧?做 RPC 时真正重要的一点是避免不必要的 RPC。这是第一法则。
另一件很基本的事情是避免伪共享 (false sharing)。当你把多个独立的东西放在同一个 64 字节的缓存行上,它们就会弹跳。通常解决这个问题的方法很简单。你用 x_snoop.hitm
事件找到它,然后你只需添加一些填充(padding)。这实际上很容易修复。
当然,另一件事是为局部性优化 (optimize for locality)。你真的想要,如果可以的话,停留在同一个插槽,甚至可能在同一个核心上,只有在需要时才去远程插槽。
另一件事是我提到的,为负载设计 (design for load)。一种重要的方法,与人们在网络优化中使用的非常相似,就是添加退避 (backoffs)。所以当你在等待某样东西时,退避一下。不要一直不停地敲打它,因为那对队列非常糟糕。它真的会把它们搞垮,并且需要很长时间。
另一个问题,这再次是标准技术,我想 Dave 昨天在讲 epoll 时谈到了,你可以避免惊群效应 (thundering herds)。惊群效应是当多个家伙在等待某样东西,然后它们被唤醒,然后它们都一起敲打它,试图一起得到它,就像一群受惊的牛,试图得到它,但只有一个家伙能得到。那就是惊群效应问题。所以你应该真的避免它,因为它也会导致抖动和各种问题。
这些是你可以做的非常基础的优化,以获得更好的缓存消息传递。让我们看一个例子。
你看到这两种写东西的不同方式。一种方式是,我们有一个全局标志,我们只是把它设为 true。某事发生了。
// 方式一
global_flag = true;
另一种方式是,我们首先检查全局标志,只有当它不为 true 时,我们才把它设为 true。
// 方式二
if (global_flag != true) {
global_flag = true;
}
你可能会凭直觉认为,第一种方式更快,对吧?因为它代码更少,没有 if,所以它必须更快,对吧?但如果你从缓存的角度来思考它,对吧?在缓存上发生的是,如果标志之前已经是 true 并且保持为 true,那么在第二种方式下,缓存行变成共享状态,这非常快,因为它被复制了。每个人在他们的本地缓存中都有它。他们不需要问另一个人发生了什么。但是如果每个人都在不停地写那个全局标志(方式一),那么你就开始在不同核心间弹跳那个缓存行,因为 CPU 不知道它已经被设为 true 了。它必须再次重写它,以防它改变了。所以,如果你在不需要写的时候避免写,你可以获得好得多的性能,因为你基本上把它从“弹跳”变成了“共享”,这要高效得多得多。所以,对于这个,性能上可能有数量级的差异,特别是当它在不同插槽之间时。然后特别是当多个家伙同时这样做时,这是一个非常有用的优化。再说一次,这真的很好,因为它很简单。你可以用 hitm
事件找到这样的问题,它能找到弹跳的缓存行。然后你在你的代码中找到这个模式,你说,好吧,我只要加上那个 if,事情就会好得多。
锁的性能问题¶
让我多谈谈锁。我做了很多关于锁的工作。把这些原则应用到锁上真的很有趣。因为锁很常见,并且有很多关于锁的性能问题。
通常,当人们开始做某事时,他们有一些代码,他们想在越来越大的系统上扩展它,或者让它多线程。通常他们做的是从一个大锁开始,比如代码锁或者你开始用的任何大锁。然后随着时间的推移,你把锁向下推,你有了越来越多对单个对象的细粒度锁定,遵循基本规则:锁数据,不锁代码。
但你会想,如果把这个推到极致,最终目标是什么?你会认为最终目标是在每个缓存行上放一个锁。实际上,有很多人似乎认为这是正确的做法。但实际上,那是错的。不要这样做。有很多问题。让我多谈谈。
第一个问题是,如果你在每个缓存行上都有一个锁,你最终会得到大量的锁。问题是,即使是一个无竞争的锁,也就是只在本地缓存中的锁,它也有一些开销。如果你添加越来越多的锁,你就在花费越来越多的时间来承担那个开销。而且它有点不可预测,因为至少在英特尔的实现中,大多数时候,锁可能比一个简单的指令慢一个数量级。但有时它会慢得多。这与内存排序有关。因为锁与内存排序有关。有时当发生强制内存排序的事情时,锁必须等待这件事完成。这可能会慢得多。所以首先,当你有大量的锁时,你有时会得到慢得多的锁。你通常无法解释为什么,因为理解起来可能非常棘手。
然后另一个问题是,即使它比一个基本指令慢一个数量级,如果你有太多,它们会累加起来。我想象的方式是,我认为锁是繁文缛节 (red tape)。因为锁本身并不能完成任何事情。它只是,就像一个官僚阻止你做某事,你必须填一张表格才能做某事。你想要一些繁文缛节,因为否则就会有混乱,但没人想要太多的繁文缛节。所以如果你在每个缓存行上,每个对象上都有一个锁,你基本上在花费大量的时间只是在做繁文缛节,这真的非常非常糟糕。所以你不想这样。
小锁的另一个问题是,这实际上是来自 Linux 内核的真实注释。我想 Hannes 会知道它。这是内存管理子系统一个关键部分的锁顺序。你可以看到,这里有非常细粒度的锁定。所以有很多不同的东西,各自有独立的锁。你可能知道,你需要考虑锁的顺序。否则你会得到 ABBA 问题,如果你有时以错误的顺序锁定,就会死锁。所以基本上问题是,如果你有那么多的锁,你最终会有大量的关于如何获取锁的规则。如果你改了什么东西,你必须确保你遵守了规则。这实际上有时使得改动某些东西变得非常困难。所以,在你的大脑“交换”之前,要把它记在你的心智模型里真的很难。所以那是小锁的另一个问题。基本上它会让你不堪重负。太难思考了。
然后是小锁最关键的问题。假设你真的有非常细粒度的锁,并且没有竞争,那么你有来自繁文缛节的开销,但也许不是太糟。它有些糟糕,但不是太糟。但问题是,一旦你有了竞争,那么每次你获取锁时,你都必须和别人通信,因为你必须从别人那里获取锁的缓存行。
这使得它非常慢。基本上你必须做更多的工作来分摊锁的成本。所以这要求在锁内做足够的工作。我在这里做了一个简单的实验。基本上只是在一个锁内做分离操作,并测量了你在锁内做了多少工作,与获取缓存行相比。基本上,你让临界区变得越长,你完成的工作就越多。所以你真的必须分摊获取锁的成本。这是它的重要部分。锁的获取是通信,你必须处理它。
最后一个问题是锁的稳定性 (lock stability)。这有点微妙,但基本上问题是,当你有非常小的锁时,它们倾向于自己重新获取自己。所以当你释放锁,然后同一个家伙再次获取同一个锁。所以它们停留在他们的本地缓存上。但有时这工作得很好,但有时当你改变了时机,要么你得到一个新的硬件平台,它有不同的时机,要么你在你的代码中改了什么东西,这就不再有效了,然后你突然就崩溃了。所以,你不是得到一个非常快的锁,而是突然得到一个非常慢的锁。那种不稳定的行为,真的非常非常糟糕。那是你真的想要避免的事情。
我们实际上就此做了一篇论文和一些研究。所以有一篇关于这个的英特尔白皮书。我们发现的一件事是,你需要做大约,每个临界区需要大约 200 微秒,来避免锁的不稳定性。但问题是,那是大量的指令。你真的需要做很多工作。
一种方法是,你可以为锁批处理 (lock batching) 设计你的库。所以,不是做一件事,然后获取所有的锁,而是你批量处理多件事情,你获取一个锁,然后做多件事情。这是获得更大临界区和避免很多这些问题的基本方法。这也改善了时间局部性。所以你更好地使用了你的缓存。
微基准测试与新工具¶
我没有时间再谈论的一件事是,你可以做锁省略 (lock elision)。这是使用硬件事务性内存,你使用一个大锁,然后它自动地…
在我结束之前,我想提一件事,那就是微基准测试 (micro-benchmarks) 真的很难。有很多方法,你必须记住要固定频率。准确的计时很棘手。编译器可能会把它们优化掉。但最关键的问题是,如果你做一个微基准测试来理解发生了什么,如果你总是用相同的值来调用你的库,那么你就命中了缓存,因为一切都是一样的,你的表是相同的。但这不现实,因为真实的应用程序很可能不这样做。或者你可以用不同的值来调用库,但那也不现实,因为你没有任何局部性。所以写一个能真正模拟你真实工作负载的微基准测试真的很难。这使得写一个现实的微基准测试变得非常棘手。
我们一直在使用的一种新技术,这是在 Skylake CPU 中增加的一个新功能,有一种方法可以让 CPU 采样,然后取最后两个分支,测量并给你这两个分支之间的周期计数。通过这种方式,你实际上可以看像基本块这样的东西,并直接看到它们花了多长时间。我称之为自动微基准测试 (automatic micro-benchmarking)。所以你基本上可以在你真实的生产工作负载中做微基准测试,而不需要写一个不现实的微基准测试。
另一种你可以使用的方法是做追踪。我想我可能时间不够了,所以我不能真的谈这个,但在 Broadwell 和 Skylake 中增加了一个新功能,你可以为每条指令做精确的追踪,并看每个函数花了多长时间。这是我真的经常用来校准我的心智模型的一个工具,某件事花了多长时间,我实际上可以看看它。但再说一次,你不能追踪太多。你只能追踪一个有限的区域,因为否则数据太多了。但是,这也行得通。
总结¶
好的,这是我的总结。
专注于关键瓶颈。这真的是一个重要部分。
记住数量级。所以不要记延迟数字,而是记数量级。这非常有用。
如果你做缓存通信,那就是消息传递。所以你真的要把它想象成一个网络。
锁的代价很高。不要做细粒度的锁定。它问题很多。
正确地测量。
好的,谢谢大家。
(主持人:我们没有时间提问了,抱歉。)