什么时候我们应该关心缓存缺失?

时间:2017-07-05 09:48:32

标签: c caching vim

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

我正在编写一个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(),它需要另一个带有第三个参数的包装器{{1 }。。

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

有两个极端:

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

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

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

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

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

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

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

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

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

    4. 4,与使用库的情况一样,我将以char target为例(稍微偏离主题,但有些朋友想知道我为什么如此关心其性能):

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

      lua将所有内容视为Lua-shell

      所以,当用户输入时:

      lua

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

      Enter

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

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

      libvi

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

      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(" \") "); 和新提示之间的时间间隔很敏感,在大多数情况下,lua-shell需要具有更大尺寸和更复杂混合语法的预处理脚本。

      这是使用Enter的典型情况。

1 个答案:

答案 0 :(得分:12)

我不会那么关心缓存未命中(特别是在你的情况下),除非你的基准(启用了编译器优化,即使用gcc -O2 -mtune=native进行编译如果使用{{3} } ....)表明它们很重要。

如果性能非常重要,请启用更多优化(可能使用链接时优化的gcc -flto -O2 -mtune=native编译和链接整个应用程序或库),并仅手动优化至关重要。您应该信任您的GCC

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

目前还不清楚您的图书馆是什么以及您的设计目标是什么。增加灵活性的可能性可能是在应用程序中嵌入了一些解释器(如optimizing compilerluaguile等),因此可以通过脚本进行配置。在许多情况下,这种嵌入可能足够快(特别是当应用程序特定的基元具有足够高的水平时)。另一种(更复杂的)可能性是通过某些python库提供metaprogramming能力,例如JIT compilinglibjit(因此您可以将用户脚本“编译”成动态制作机器代码)。

顺便说一句,你的问题似乎集中在指令缓存未命中。我认为数据缓存未命中更重要(并且编译器不太可以优化),这就是为什么你更喜欢例如链接列表的向量(更一般地关注低级数据结构,侧重于使用顺序 - 或缓存友好 - 访问)

(您可以通过Herb Sutter找到一个很好的视频来解释最后一点;我忘记了参考资料)

在某些非常具体的情况下,使用最近的libgccjitGCC,添加一些Clang可能稍微提高性能(通过减少缓存未命中),但它也可能会对它造成很大的伤害,所以我一般不推荐使用它,但请参阅__builtin_prefetch