【问题标题】:when should we care about cache missing?我们什么时候应该关心缓存丢失?
【发布时间】:2017-07-05 21:48:34
【问题描述】:

我想通过我在项目中遇到的一个实际问题来解释我的问题。

我正在编写一个c库(其行为类似于可编程vi editor),我计划提供一系列API(总共超过20个):

void vi_dw(struct vi *vi);
void vi_de(struct vi *vi);
void vi_d0(struct vi *vi);
void vi_d$(struct vi *vi);
...
void vi_df(struct vi *, char target);
void vi_dd(struct vi *vi);

这些 API 不执行核心操作,它们只是包装器。例如,我可以这样实现vi_de()

void vi_de(struct vi *vi){
    vi_v(vi);  //enter visual mode
    vi_e(vi);  //press key 'e'
    vi_d(vi);  //press key 'd'
}

但是,如果包装器这么简单,我必须编写 20 多个类似的包装器函数。
因此,我考虑实施更复杂的包装器来减少数量:

void vi_d_move(struct vi *vi, vi_move_func_t move){
   vi_v(vi);
   move(vi);
   vi_d(vi);
}
static inline void vi_dw(struct vi *vi){
    vi_d_move(vi, vi_w);
}
static inline void vi_de(struct vi *vi){
    vi_d_move(vi, vi_e);
}
...

函数vi_d_move()是一个更好的包装函数,他可以将类似移动操作的一部分转换为API,但不是全部,比如vi_f(),这需要另一个带有第三个参数char target的包装器。

我完成了从我的项目中挑选的示例的解释。
上面的伪代码比实际情况简单,但足以说明:
包装器越复杂,我们需要的包装器就越少,它们的运行速度也会越慢。(它们会变得更加间接或需要考虑更多的条件)。

有两个极端:

  1. 只使用一个包装器,但足够复杂,可以采用所有移动操作并将它们转换为相应的 API。

  2. 使用二十多个小而简单的包装器。一个包装器就是一个 API。

对于案例 1,包装器本身很慢,但它有更多机会驻留在缓存中,因为它经常被执行(所有 API 共享它)。这是一条缓慢但炙手可热的道路。

对于情况 2,这些包装器简单快速,但驻留在缓存中的机会较小。至少,对于任何第一次调用的 API,都会发生缓存未命中。(CPU 需要从内存中获取指令,而不是 L1、L2)。

目前,我实现了五个包装器,每个都比较简单和快速。这似乎是一种平衡,但似乎只是。我之所以选择五,是因为我觉得移动操作自然可以分为五组。我不知道如何评估它,我不是说分析器,我的意思是,理论上,在这种情况下应该考虑哪些主要因素?

在后期,我想为这些 API 添加更多细节:

  1. 这些 API 需要快速。因为这个库被设计成一个高性能的虚拟编辑器。删除/复制/粘贴操作旨在接近裸 C 代码。

  2. 基于此库的用户程序很少调用所有这些 API,仅调用其中的一部分,而且通常每个调用不超过 10 次。

  3. 在实际情况下,这些简单包装器的大小约为 80 字节,即使合并成一个复杂的包装器也不超过 160 字节。 (但会引入更多 if-else 分支)。

4、以库的使用情况为例,我以lua-shell为例(有点跑题,但有些朋友想知道我为什么这么在意它的性能):

lua-shell 是一个 *nix shell,它使用 lua 作为其脚本。它的命令执行单元(执行 forks()、execute()..)只是一个注册到 lua 状态机的 C 模块。

Lua-shell 将所有内容视为lua

所以,当用户输入时:

local files = `ls -la`

然后按Enter。输入的字符串首先发送到 lua-shell 的预处理器——将混合语法转换为纯 lua 代码:

local file = run_command("ls -la")

run_command()是lua-shell命令执行单元的入口,我之前说过,是一个C模块。

我们现在可以谈谈libvi。 lua-shell 的预处理器是我正在编写的库的第一个用户。这是它的相关代码(伪):

#include"vi.h"
vi_loadstr("local files = `ls -la`");
vi_f(vi, '`');
vi_x(vi);
vi_i(vi, "run_command(\"");
vi_f(vi, '`');
vi_x(vi);
vi_a(" \") ");

上面的代码是 luashell 的预处理器实现的一部分。 生成纯 lua 代码后,将其提供给 Lua 状态机并运行。

shell 用户对Enter 和新提示之间的时间间隔很敏感,在大多数情况下 lua-shell 需要更大尺寸和更复杂的混合语法的预处理脚本。

这是使用libvi 的典型情况。

【问题讨论】:

  • 你是在编写一个真正的source code editor(那么性能就没有那么重要了)还是你的库真正在做什么?为什么它对性能如此重要?您是否对其进行基准测试(在编译时启用优化)并对其进行分析?请编辑您的问题以解释更多真正的动机以及您想到的应用程序。
  • 以最易于理解和易于维护的方式组织您的代码。除非您的代码非常频繁地执行,例如每秒 60 次,因为它必须为每个图形帧完成,通过优化指令缓存在程序的整个生命周期中节省的时间可能总计少于您的时间花费在优化和后续额外的维护时间上。例如,如果您通过优化一个小时来缩短一微秒,那么您的代码必须执行 36 亿次才能恢复该小时。
  • @JeremyP 所说的,实际上比这更糟。为了避免优化尝试在未来产生负面影响,您可能必须重新测试每个新版本的编译器、操作系统、硬件环境等,因此 Jeremy 的设计/测试/调试/测量示例“标准时间”有不断重复:(
  • 为什么标记为 C++? void vi_dw(struct vi *vi); 显然是 C,在 C++ 中应该是 void vi::dw( )
  • 不要垃圾标签! C++ 不是 C。

标签: c caching vim


【解决方案1】:

我不会太在意缓存未命中(尤其是在您的情况下),除非您的 基准(启用编译器优化,即如果使用 GCC 则使用 gcc -O2 -mtune=native 编译... .) 表明它们很重要。

如果性能如此重要,请启用更多优化(也许使用链接时优化的gcc -flto -O2 -mtune=native 编译和链接整个应用程序或库),并仅手动优化关键部分.你应该信任你的optimizing compiler

如果您处于设计阶段,请考虑让您的应用程序多线程或以某种方式并发和并行。小心,这可以比缓存优化更快。

目前尚不清楚您的库是关于什么的以及您的设计目标是什么。增加灵活性的一种可能是在您的应用程序中嵌入一些解​​释器(如luaguilepython 等...),从而通过脚本对其进行配置。在许多情况下,这样的嵌入可能足够快(尤其是当应用程序特定的原语具有足够高的级别时)。另一种(更复杂的)可能性是通过一些JIT compiling 库(如libjitlibgccjit)提供metaprogramming 功能(因此您可以将用户脚本“编译”成动态生成的机器代码)。

顺便说一句,您的问题似乎集中在指令缓存未命中上。我相信数据缓存未命中更重要(编译器的优化程度更低),这就是为什么你更喜欢例如向量到链表(更普遍地关心低级数据结构,专注于使用顺序或缓存友好的访问)

(你可以找到 Herb Sutter 的一个很好的视频,它解释了最后一点;我忘记了参考)

在一些非常具体的情况下,最近的GCCClang,添加一些__builtin_prefetch 可能略微提高性能(通过减少缓存未命中),但它也可能显着损害它,所以我一般不建议使用它,但请参阅this

【讨论】:

  • 谢谢。也许我想知道的不是如何优化它,而是其他人将如何处理这种情况。你的“我不在乎”对我来说也是一个很好的回答。 PS:我在帖子结束的item 4中更新了这个库的应用情况。
猜你喜欢
  • 1970-01-01
  • 2019-09-17
  • 1970-01-01
  • 2011-03-21
  • 2012-01-23
  • 2021-09-12
  • 1970-01-01
  • 1970-01-01
  • 2021-09-07
相关资源
最近更新 更多