65.9K
CodeProject 正在变化。 阅读更多。
Home

Apex memmove - x86/x64 上最快的 memcpy/memmove ... 史上最快,C 语言编写

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (27投票s)

2016 年 7 月 3 日

CPOL

36分钟阅读

viewsIcon

126099

downloadIcon

3671

2 年前,我开始对 memcpy/memmove 产生了强迫症;我写了超过 140 个变种(80,000 行代码)的 memmove;在多台机器上进行测试、反汇编、优化和基准测试。我从未发布过这篇文章或代码;直到现在!所以我必须在自己疯掉之前发布它!

引言

所以,我当然想要一个非常有争议的标题,我们见过多少次“史上最快的算法”?但我需要引起你们的注意,而且我成功了!然而,我的标题并非没有依据!

“最快”的头衔并非属于我在任何大小的拷贝上。因为为任何一个尺寸优化都是一种权衡。我认为我唯一在汇编器中总是被超越的大小是 16 字节的拷贝(以及可能其他一些小的字节组合,也许还有 8 字节和 32 字节,如果你专门优化以击败这些算法)。这段代码在拷贝大小 > 16 字节时更快。对于小于这个值的,我只有 1 或 2 个时钟周期的延迟!但对于所有其他尺寸,这个头衔属于我!这些函数还将非常快速地达到你的最大内存带宽限制!

问:为什么它比我内置的 memcpy/memmove 更快?

答:(我提前回答这个问题,因为它一定在你脑海中!)

1) 这不是一个单一的 memcpy/memmove 函数,实际上它包含三个具有不同特性、算法和优化的独立函数;代码将在运行时为 CPU 选择最佳函数,每个函数都专门为 3 种不同的 CPU 架构构建。支持 SSE4.2 的 64 位处理器(Core i 代,无未对齐内存的惩罚)、较旧的 64 位处理器(Core、Core 2 和 AMD 等效处理器)以及支持 SSE2 的 32 位处理器。如果找不到这些功能,函数将回退到使用内置的 memcpy/memmove!

2) 第一次调用 memcpy/memmove 时,它们会进行 CPU 功能检测(CPUID)并选择最适合你 CPU 的函数在运行时运行。这是第一次调用时的“一次性”延迟。在那之后,就是顺风顺水了!所以这里实际上有 3 个独立的函数,它们将自动选择并使用最适合的函数!

3) 大多数内置的 memcpy/memmove 函数(包括 MSVC 和 GCC)使用一个经过高度优化的 QWORD(64 位)拷贝循环。apex 函数使用 SSE2 load/loadu/store/storeu 和 SSE 流式传输,根据情况使用/不使用数据预取。换句话说,一切都适应情况,无论拷贝大小如何!

4) 如果你没有 SSE2,它们将默认使用内置函数,它们使用 QWORD。基本上,PC 需要比 15 年前更新才能发生这种情况,因为 2001 年的 P4 已经有了 SSE2!我们检测 SSE4.2 的存在;支持 SSE4.2 的 CPU 在读取/写入未对齐 SSE 数据(loadu/storeu)时没有惩罚。这基本上是 Core i 代!

5) 在大型数据拷贝循环中,它们使用 SSE2 流式内省指令,这是最快的数据拷贝方法;我包含了一个高性能的 4K 数据预取(CPU 提示)。当函数正在拷贝时,它会持续发出 4K 预取的命令。这种设计以前从未这样做过!`tiberium`(其中一个函数)将预对齐内存(到 16 字节边界)以避免未对齐惩罚,然后对齐的字节执行 SSE 流式传输!流式内省指令由 Intel(和 AMD)设计用于高性能!你将以你的机器的最大内存带宽吞吐量进行拷贝!

6) 这些函数使用了所有的技巧!它们来自一个长久的函数家族。我使用的一些技术和算法以前从未被公开过。它们是在 2013/2014 年的几个月内开发的。每个拷贝大小都使用不同的技术。最短的代码路径是为 `size <= 112` 字节优化,然后是 `size >= 16`,最后是其余的。所以小字节拷贝有最短的代码路径,跳转最少!

7) 尽管这些是 C/C++ 函数,但它们是“通过反汇编设计的”……这意味着我密切关注编译器输出。这通常是不推荐的(我们不希望编译器决定我们如何设计函数),但是,当你为最大性能进行设计时,关注代码的编译方式很重要!函数的奇怪布局证明了我对编译器输出的密切观察。指令的排列方式还能防止编译器做出一些“假设”!它们主要在 Visual Studio 2010 编译器上设计,但是,GCC 的代码从未差过,所以它们在 GCC 上编译效果同样好(通常更好),甚至可能在 LLCV/Clang 上也是如此!

8) 优化过的汇编版本这些算法将更快(我知道,因为我构建过汇编版本),但它们有时更难实现/添加到现有库中。我想要提供一个可以随处使用的代码的“复制粘贴”版本。另外,这些函数使用的算法是它们之所以更快的原因,而不是微优化,尽管我尽了一切努力来帮助编译器构建最优化的代码!

9) 有几个代码路径,每个路径都为不同的尺寸进行了优化。

  • 小于 16 字节
  • 小于等于 112 字节
  • 小于 256KB
  • 大于等于 256KB(SSE 流式传输 + 4KB “预取前瞻”)我的 4KB 预取前瞻是独一无二的!为大拷贝优化!让代码达到最大内存带宽吞吐量!

背景

请注意,我写/发布这篇文章比我写这些函数晚了 2 年多!

2013 年底,我的强迫症爆发了,我完全沉迷于编写世界上最快的 memcpy/memmove 函数;这占据了我工作和生活。我变得如此痴迷,以至于写了 80,000 行代码,超过 140 个 memmove 变种,大部分是微小变动和调整的拷贝;在 P4、Core 笔记本、Core 2 E6600、第三代 i5 3550 和 i7 上进行基准测试,与我能找到的最好的算法(包括 Agner Fog 的优秀 A_memmove() from asmlib)进行比较。

最初,我从反汇编和研究 Visual Studio 的 memcpy() 开始,然后是 GCC 等。我写了几个 QWORD 拷贝实现,但只用 C 代码就难以超越内置函数。最终,我开始研究 Agner Fog 的 A_memmove,我使用了好几年。其中一个代码路径是 AVX(256 位)版本。最终,我将他的代码重新工程化为 C,以便我能够分析算法(我的 C 版本他的算法在 `string.zip` 文件中称为 avx_memcpy0)。在我能够超越 Agner 的代码和内置版本之前,至少需要 20 个函数,主要是通过我自己的无知。最终,在大约 100 个函数组合后,我能够稳定地击败它们。

因此,为了防止自己有重温这场黑暗疯狂的冲动,我必须发布我所拥有的,并希望有人能理解我的疯狂!

基准测试

这些只是从原始文章中提取的估计值,其中不包括我最快的实现,那些实现即将到来;所以这些估计值来自较旧的较慢的变种。

大拷贝(>= 128 字节)

32 位 = 快 40%

64 位 = 快 30%

小拷贝(< 128 字节)

快 15%~40%

这些是非常旧的数字了!这里包含的函数更快!当然,这取决于硬件!

代码

为了尽可能简洁,代码包含 3 个文件,一个头文件 (.h),用于 C 的 .c 文件,以及用于 C++ 的 .cpp 文件(使用 `apex` 命名空间)!选择你想要的 C 或 C++ 版本……性能上没有区别!

你不需要担心这个;但代码使用了一个基于你的 CPU 功能的 memcpy/memmove 分派函数(受 Agner Fog 启发)。这只是第一次调用函数时的一次性延迟,用于检测你的 CPU 功能,如 SSE4.2(Core i)和 SSE2……然后将函数指针路由到运行时最合适的/最优的函数。我已经包含了 3 个用于不同场景的函数。但是正如我所说的,你不需要担心这个!只需在 C++ 中调用 `apex::memmove()` 或在 C 中调用 `apex_memmove()`。它们都可以安全地调用重叠数据。对于重叠数据,它们以相反的方向读/写!

注意:CPU 功能检测不是针对 SSE 4.2 指令集,而是针对具有该指令集的计算机的架构。即,支持 SSE4.2 的计算机具有快速的 `UNaligned` 内存读取。这意味着它们在使用 `loadu`(加载未对齐内存)时没有时钟周期惩罚。所以我们不必先读取 1-15 字节来“对齐”内存。`kryptonite` 在没有 `UNaligned` 读取惩罚的机器上执行拷贝(例如 Core i),而 `tiberium` 用于需要对齐以获得最佳效率的机器。`mithril` 用于 32 位 + SSE2 机器,否则它们将默认使用正常的/内置的 memmove()/memcpy(),因此无论它们在哪种硬件上运行,拷贝始终是安全的!

我给我的最快函数起了代号,因此有了 `kryptonite`、`tiberium` 和 `mithril` 这些名字。我想呈现这些通用函数,因为我相信它们可以为世界做出重大贡献!

`apex` 是我的通用函数库的名称,其中包含许多其他比 stdlib、GCC、MSVC 等都更快的功能。我还有更快的字符串操作、lcase、ucase、strlen、strcpy 等函数。但我除了这两个函数之外,没有发布其他任何函数。总之,享受这场疯狂吧!

 

 

下载 apex_memmove.zip

 

 

memmove-OLD-archive.zip

我上传此文件仅用于**研究目的**,供任何对该主题进行调查和研究的人员使用!它包含了我的大部分/许多原始函数,并附有大量注释。前 100 个左右的函数被命名为 `sse2_memcpy##`,然后我将命名约定改为 `memmove##`,因为 `move` 测试只有 1 或 2 个时钟周期!它还应该包含许多 DWORD/QWORD 变种,尽管我甚至没有查看过文件,已经 2 年了,如果我看那个文件,它会困扰我。请只拿你能拿到的,并心存感激!除非你万不得已,否则不要问我关于它的问题。我不想再被卷入那个黑暗的世界了!实际上,下面的 `string.zip` 文件应该包含我函数更完整的记录,因为它包含了我的原始 AVX 实验等。

下载 memmove-OLD-archive.zip

string.zip

请阅读下面的更新 5!此文件包含原始的 80,000 行代码!注意:这是为**研究目的**!除非你想发疯,否则不要看这个!它包含了 Agner Fog 的 `A_memmove` AVX(256 位)函数,我用汇编器编写但转换为 C 源代码(avx_memcpy0)的原始版本!我逐行注释了转换!

下载 string.zip

benchmarks.zip

一些旧的基准测试,仅供留存。不要问这些数字意味着什么,我曾经知道。**请阅读下面的更新 3** 获取更多信息。

下载 benchmarks-OLD.zip

memmove64-asm.zip

该文件包含 `mithril` 的“优化”汇编版本。`mithril` 不是我最快的版本。这是从反汇编代码进行的转换。**请阅读下面的更新 2!**

下载 memmove64-OLDER-asm.zip

 


问:汇编函数不是更快吗?

是的,但是,我的这些函数将让你达到 99% 的速度!我在下面复制我 2 年前的原始文章的部分中提供了更多细节,但我认为我应该在这里回答这个问题。

我在更新 2 中包含了我的 64 位 `mithril`(memmove13/mm13)函数的汇编版本,你可以仔细分析我对代码所做的更改。请记住,这已经是接近 3 年前我写那段汇编代码的时间了,但我很有信心这仍然可能是写过的最快的 memmove 函数之一(因为其中包含一些我从未公开过的高效拷贝算法!)!

汇编版本确实会更快,但是,有几点需要注意。当我编写每个 C 函数时,我非常仔细地查看了反汇编(这就是为什么你会看到一些高度不规则的东西,比如 `size = -size`,以及其他奇怪的测试。我基于我看到的编译器(Visual Studio 2010)的输出做了许多算法更改。当你看到类似 (size <=112) 而不是 128 的情况时,你就可以看到查看反汇编的意义,因为 Visual Studio 没有使用所有可用的寄存器。同样的事情也适用于 XMM0-XMM5 ……我无法使用 2 个 XMM 寄存器,一旦我使用了 7 或 8 个 XMM 寄存器,Visual Studio 就会搞砸一切,开始将数据写入临时堆栈变量等(我相信 Visual Studio 保留了 2 个 XMM 寄存器,我不记得为什么了)。GCC 会更好,但我想要目标是它们之间的“最小”公分母!GCC 可以从我写的其他一些算法中获益更多,它们可以在 zip 文件中找到,但它们更具体,我想要一个通用、复制粘贴的版本,可以在大多数人的机器上快速编译!

所以,这些 C 算法是“通过反汇编编写”的,或者至少是“通过反汇编优化”的……所以我相信它们会编译成非常好的、足够好的机器代码!优化每个函数大约需要 2~3 小时,而且在大多数情况下,我只能节省几个时钟周期。我确信像 Agner Fog(我与他有过联系)这样更好的汇编编写者可以进一步改进它们,但那样就会失去复制粘贴 C 代码的便利性。对我来说,拥有一个可以添加到任何人的库中的 C 复制粘贴版本具有更高的重要性。大多数人可能不会尝试实现/用汇编代码替换他们的 memmove,但 C 应该有更广泛的受众!

所以是的,优化过的汇编版本更快,但这些比你以前拥有的任何东西都快!只需复制粘贴并编译(并解决你构建中可能缺失的任何东西)!

size = -size

我实在记不清为什么这样做。我知道它是一个无符号值,而我却使用了负值,我就是记不清为什么。在我代码的某个地方,我看到一个注释说“它节省了 2 个时钟周期”,但这已经是 3 年后了,我对算法很有信心,因为我测试并验证了我复制/移动的所有内存,以确保我的算法正常工作。这个陈述与 Visual Studio 如何处理替代方案有关。我当时使用的一个技巧/窍门是为了减少指令。

我知道我不应该这样做,但我不在乎。要么使用代码,要么缓慢使用,要么不使用……我真的不在乎。只需保留这条指令。它很重要!

自我更新:我认为这应该是 `size = ~size`……但我不再确定了!我到底为什么要这样做?说真的,不应该是 `size = ~size` 吗?当时为什么我没有使用那个指令??

结论

我必须在这里结束这篇文章,并直接把代码呈现给你们,否则我永远也写不完。我希望发布这段代码能对其他人/任何其他人有所帮助。也许 MicroSoft 或 GCC/LLVM/Clang/stdlib 的人能从中获得一些想法,即使我能帮助他们改进他们的版本 5%、10% 或 20%,我也觉得这场疯狂是值得的。

我知道这篇文章比我想要的要短得多,但是,我觉得如果我过于详细,我会迷失方向,沮丧于解释,并且只是绕圈子;所以,即使我觉得这篇文章不太专业(我不是作家),我也必须尽快发布它!

愿这段代码能为人类带来进步!


更新 1

“Mithril”

我决定向你们展示 `mithril`。它在 zip 文件中名为 memmove13 (mm13)。啊,我刚想起来,我还有一个优化过的汇编版本,我将在发布之后放出!

问:`mithril` 是什么?

答:`mithril` 是我最快的通用、跨平台(32 位和 64 位)实现之一。它是所有编译器中所有内置 memmove/memcpy 实现的通用替代品!只要你有 P4(约 2001 年)或更新的 CPU,这个函数将超越 Visual Studio (2010) 和 GCC 的 memmove/memcpy!由于我所有的 PC 都不到 15 年新,并且所有 64 位处理器都支持 SSE2,所以使用起来相当安全!如果你真的想,你也可以在前面加上 `#if is64bit` (`#if _WIN64`) 测试?或者如果你真的认真,你可以像上面那样进行 CPU 功能检测。然而,这个函数也为 32 位进行了优化,因为它们有较少的通用寄存器(函数使用较少的变量,占用较少的寄存器,因此仍然适合 32 位机器,但保持了内循环的最佳和高效率,尤其是对于更大的数据)。由于算法的原因,这个函数将显著超越 MSVC 和 GCC 的内置函数(在 99.9% 的情况下)!上面的 `tiberium` 和 `kryptonite` 更快,因为它们使用了更多的寄存器,对 64 位进行了更多优化。

`mithril` 是我证明了可以用 C 语言编写一个通用的 memmove/memcpy 函数,它能超越内置函数!你只需要花几个月的时间编写、测试、基准测试和优化它!或者你只需要复制粘贴并编译我的代码!

原始的 mm13 代码包含几个备选代码路径,但我为了展示的目的将其删除。如果你想研究原始的 mm13,请在 zip 文件中搜索 `mithril`!

你可以将此代码复制到你拥有的任何库/命名空间中,或者将其保留为全局函数!这个函数将比你拥有的任何其他东西都更快地执行 memmove 和 memcpy!尽情享受!

关于 `mithril` 代码的更新

有人要求我缩短文章长度。所以我已经将 `mithril` 包含在上面 `apex_memmove.zip` 文件中!`mithril` 用于检测到 SSE2 时 32 位代码!

 


更新 2

上面的 `mithril`(优化后的)64 位汇编版本 = `Atomic Edition`

`mithril` 是我最后一个转换为汇编的函数。在编译和反汇编后优化每个函数大约需要 3 个小时。所以我在 mithril 之后就停止了。我也把这个函数给了 Agner Fog,所以我想在这里发布供学术研究。我还会上传我的完整/最终的 64 位汇编文件。请注意,这个文件已经 3 年了,它不包含上面的 `tiberium`/`kryptonite`,只包含我反汇编和优化过的一些版本。你可以将下面的代码视为我(反汇编和清理后)制作过的最快的 64 位汇编版本!

附注:用 C 语言编写/测试/调试/基准测试这些函数要比用汇编语言快得多,也容易得多!因为我可以复制粘贴整个代码段/块来测试不同大小的各种组合,而不是一开始就用纯汇编语言编写!这就是为什么我能生产 140 个不同的变种,如果用汇编语言编写和测试所有这些组合,将花费我数月时间(即使是复制粘贴,你也需要确保每个块/部分寄存器仍然相同,我对此有噩梦!)!

仅供学术研究!

下载 memmove64-OLDER-asm.zip

更新

我删除了这里原有的 `` 部分。汇编列表太长了。请在 `memmove64-OLDER-asm.zip` 中查找 `asm_memmove13` 函数!

 


更新 3

旧基准测试文件

我现在上传我能找到的所有文件。

此文件包含原始文章模板,该模板未完成且未发布。

以及 4 个 `benchmarks.xls` 文件。我记不清那些基准测试是关于什么的了。我认为使用的值是我 `Bpc` 值 `bytes-per-counter`(每计数器字节数)……所以越多越好,我猜。我只记得我可能花了大约 20~40 个函数才开始超越其他实现。

如果你翻阅“benchmarks”文件中的一些选项卡,尤其是在文件 2 和 3 中,你应该能看到我绘制的一些漂亮的图表来分析每个函数的特性。函数在我的 Core i5 和 Core 2 E6600 上测试最多。所以你应该能看到 i5 和 C2,但我还使用了 Core i7、Core(单核)等进行测试。我在我所有可用的机器上都进行了测试!

另外,我记得测试了从 1~128 字节的所有尺寸;以及许多各种大尺寸;对齐、未对齐、缓存和未缓存!我甚至记不清 MISaligned 和 UNaligned 之间的区别了。我认为 MISaligned 意味着源和目标都位于未对齐的地址!?!?

我有一个非常高级的测试/基准测试套件,但现在找不到了!:( 如果我找到了,我会在这里上传!它非常令人印象深刻!我很抱歉现在找不到它了!

下载 benchmarks-OLD.zip

 


更新 4

新的/改进的 `memmove_dispatcher()`

这是对 memmove_dispatcher() 函数的完全重写,它会检测编译器、CPU 架构和 CPU 功能。

我重写了这个函数,因为我确信会有很多 GCC 用户想测试/基准测试我的函数,而 __get_cpuid() 实现起来有点麻烦。所以这应该是一个很好的复制粘贴版本!

请注意,你需要添加上面 `mithril`、`tiberium` 和 `kryptonite` 的代码!你需要将一个函数从 `memmove` 重命名为 `mithril` 来添加它。`mithril` 用于 32 位代码,当 CPU 具有 SSE2 指令时(即 32 位 + SSE2 = `mithril`)。如果 CPU 非常老旧,那么我们将默认使用标准的内置 memmove/memcpy 函数,它们通常只使用 QWORDS 进行拷贝!

基本上,一旦你实现了这个函数,并添加了 `mithril`、`tiberium` 和 `kryptonite` 到列表中,你就拥有了所有可能的组合,无论你的 CPU 架构是什么,都拥有一个“完全”更快的 memmove/memcpy 实现!你将满足所有情况!这些函数将最大化你的内存带宽,尤其是在大拷贝时,这可能是世界上最快的循环之一!

快乐拷贝/移动!祝你好运,尝试击败这些函数的速度!

 

关于改进的 memmove_dispatcher() 的更新

我已经将此代码包含在 `apex_memmove.zip` 文件中!

 


更新 5

最后的 80,000 行代码(string.zip)

在与 Agner Fog 通信时,我找到了这段代码的最终实现。它保存在一个名为 `string.hpp` 的文件中……难怪我之前找不到!

这个文件包含了我将 Agner Fog 的 A_memcpy() 函数转换为 C 代码的原始版本,该函数名为 avx_memcpy0……这是一个零!这是一个非常有趣的函数来研究!它是 AVX(256 位)版本的 Agner Fog 代码的转换,他有几个基于 CPU 功能的代码路径(就像我一样),这是我写函数时发现的最先进的一个。我最初的目标是更好地理解他的代码路径/结构,以便我可以进一步研究设计!我写了大约 23 个 AVX 基础函数之后,就完全放弃了 AVX。理论上,AVX 应该更快,但是,在实际的设计和测试中,使用 AVX 的好处很小甚至没有。正如我告诉 Agner Fog 的,我在第三代 Core i 上进行了测试,所以完全有可能在第四代中有显著的好处,但我已经达到了内存带宽限制,所以对我来说没有好处!

我发布的所有代码均**无保修**!**`string.zip` 是为教育/研究目的发布的**。特别是如果你想比较/理解 Agner Fog 的 C 代码函数。通常阅读和理解 C 代码比汇编更容易!

下载 string.zip

 


原始未完成和未发布的文章(供留存)- 2 年前!

这是我第二次尝试写这篇文章。我的初稿是在我生活中的那段黑暗时期写成的,它很长而且很详细,但从未发表(直到今天);因为在我写了 22 个函数之后,我开始写它,但不断有新的想法出现,文档也跟不上细节。

我将在此复制原始文章作为参考。它记录了我当时的一些想法,但请记住,它是在写了 22 个函数之后才写成的,所以我离疯狂只有 20% 的路程!我写的最快的函数是用于“size <= 112”的 memmove09;以及用于“size > 112”的 memmove40 / memmove41。

另外,请注意,我不想被问到我当时在想什么,这已经是 2 年多以前了,很多细节我都记不清了。我只包含这篇文章是为那些真正研究这个主题的人准备的!如果你只是一个想使用这些函数的普通开发人员,甚至不要看原始文章!它非常令人困惑!

 

……原始未完成/未发布文章开始……


引言

这是我致力于编写更快的 memcpy() 实现的探索之旅的故事,我希望它能以这篇文章达到高潮并结束!我唯一的愿望是,无论是在直接还是间接方面,有人能从我花费的无数天编写和分析各种算法、从这篇文章以及我提供的代码中受益!

我将展示一个基于 C/C++ 的 SSE2 内省 memcpy() 实现,对于大拷贝尺寸,它比 Visual Studio 2010 的 32 位 memcpy() 函数快 40% 以上,比 64 位编译的 memcpy() 快 30%。对于小拷贝尺寸,速度将在 128 字节以下各种尺寸上快 15% 到 40% 不等。这仅仅是我编写的至少 22 个 SSE2 memcpy() 函数之一,每个函数都有不同的特性,例如改进了对齐/未对齐内存、各种缓存预取方案以及对小于 16、32、64 或 128 字节等各种拷贝尺寸的改进。这不是我写过的最快的版本,也不是最紧凑的版本,因为每个大小的拷贝,无论大小,都有不同的优化方法,但这只是一个不错的通用实现,可以直接复制粘贴,并有一些有趣的特性!

背景

最初是作为一个实现更快的 strlen() 函数在 C/C++ 中(我做到了),结果发展成了一周的痴迷,要编写一个更快的 memcpy()。我分析了 memcpy() 过程的每一个方面,其中每一个“if”或“switch”语句的添加都是一种权衡,每一个循环或额外的变量都会影响性能。事实上,我从数天的分析各种函数中获得了如此多的数据,以至于我不知道从哪里开始或如何呈现所有数据,更不用说我编写的 22 个 SSE2 memcpy() 版本以及它们代表的特性,或者我编写的 26+ 种其他 memcpy() 函数来测试其他方法、想法或方面。总共我编写了 50 多个 memcpy() 或相关函数,有些被移除是因为它们只是特定的实验,有些则成为进一步研究的基础。

在多个开发者相关论坛和社区网站上搜索更快的 memcpy() 实现时,我发现一个最常见的(也是最令人厌恶的)回答是:“只需使用你的编译器提供的标准 memcpy(),它已经经过高度优化。”嗯,我不认为任何说这种胡话的人真正分析过 memcpy(),我怀疑他们是否真的尝试编写过更好的实现,如果他们说了这样的话,并且实际上尝试编写了更好的实现但失败了,那么我对他们的技能或在这方面的意见毫无尊重!现在,我称之为“令人厌恶”的原因并非 memcpy() 事实上比自定义编写的 SSE2 实现慢(在 MSVC 中),而是因为被问到的问题是关于解决他们代码的“时间关键”部分,而 memcpy() 实际上是某种“瓶颈”,任何速度提升都会对项目有益。

汇编 vs. C/C++

在您继续阅读之前,我必须说的一点是,我所有的函数都是纯粹的 C/C++ 实现。为什么?主要原因是无法在 Visual Studio 的 64 位编译中使用内联汇编。我正在开发的项目是一个 Windows 桌面/客户端应用程序,我通常使用 Visual Studio 进行构建,而服务器应用程序我则在 Centos 上使用 GCC 进行构建。此外,我的汇编时代随着 32 位 CPU、FPU 和 MMX 指令而结束,大约有 200~250 条指令,但我停止了,因为 Visual Studio 不支持 64 位内联汇编。我使用 MASM,我知道我可以将其作为一个单独的库在 MASM 中构建并链接,但我只是想先研究各种算法,并且在 C 函数的中间进行快速的更改或复制粘贴代码比在汇编中快得多,也容易得多!

不幸的是,有些假设和事情在汇编中可以做到,而我却无法在 C/C++ 中很好地模拟,比如跳转表。我知道 GCC 为 C 有一个相当不错的跳转表结构,您可以使用“@@label”将标签放在数组中,但 Visual Studio 没有这个。所以在 Visual Studio 中,我能做到最接近的是尝试让 switch 语句尽可能接近我想要的跳转表,希望编译器能看到使用跳转表的益处,但我知道有些 switch 语句或 case 是“手动”评估的,这在某种程度上取决于您的编译器设置。

Intel vs. AMD

我知道存在一些架构差异,但我没有 AMD 可以测试。我只有 3 个较新的 Intel 处理器,而且我只对更新的处理器感兴趣。此外,我查阅了 Agner Fog 的“Instruction tables”中各种 AMD 架构的各种 AMD 指令时序和延迟,它们在新架构上看起来与 Intel 非常相似。我必须指出的一点是,我将在此展示的 SSE2 实现使用了 `loadu`(未对齐加载)指令来加载少于 128 字节的数据。在旧 CPU 上,此指令速度较慢,但是,主要问题是拷贝 17~32 字节之间,它`可能`会慢几个周期,因为它需要 2 个 16 字节的 loadu/storeu 指令,不足以补偿 3 周期的 loadu 指令。例如,AMD K10 (2007) 上的 MOVDQU(loadu/storeu)指令加载和存储未对齐内存,`loadu` 需要 1 个周期,`storeu` 需要 3 个周期,MOVNTDQ(流式传输)需要 2 个周期。但是,从 Bulldozer (2011) 开始,所有 3 条指令的周期都与 Nehalem (2008)、Sandy Bridge (2009) 和 Ivy Bridge (2011) 的 Intel 处理器一样,为 1 个时钟周期。我尝试使用“预取”尽可能早地处理,但我没有旧的 Intel 或 AMD 可以测试,我希望早期预取能够弥补一些旧 CPU 的不足。但预取语句即使在最新的 Intel CPU 上也能带来可衡量的 2% 的改进!

Intel Core 架构

从 Agner Fog 的指令表中可以看出,我认为最糟糕的情况将是 Pentium M、Core Solo、Core Duo、Merom 和 Wolfdale 架构。在 Pentium M、Core Solo 和 Core Duo 上,`loadu` 是 4 个周期,`storeu` 是 8 个周期,而 `streaming` 指令是 4 个周期。当拷贝大于 128 字节时,我使用 `streaming` 指令来绕过 CPU 缓存,但 `storeu` 用于小于 128 字节。在这些 CPU 上,您可以使用对齐的 MOVDQA(加载/存储)指令,这些指令需要 2 个周期进行 `load`,2 个周期进行 `store`,但这意味着您需要添加更多检查来对齐小于等于 128 字节的数据。我已经在大于 128 字节的拷贝中处理了对齐,但对于小于等于 128 字节的尺寸则没有!我确实有早期 `prefetching`,这可以弥补这些情况。或者,您可以完全删除“if size <= 128”语句,让主代码(也进行对齐)来处理小于 128 字节的情况。我之所以保留这个,是因为它在较新的 CPU 上更快,并且演示了一些有趣的情况!这只是一个有趣的代码来学习和分析!

在 Merom (2006~2009) 和 Wolfdale (2007~2011) 上,我不知道 Intel 对 `storeu`(保存未对齐内存)MOVDQU 指令做了什么,但它从 8 个周期增加到 9 个周期(除非 Agner 的文档是错的?)。然而,对齐的 `load` 和 `store` 指令 MOVDQA 以及 `streaming` MOVNTDQ 指令都只需要 1 个周期。

性能分析方法

我使用了 Visual Studio 2010 的 Release 版本,通常是“全优化”和“偏好快速代码”。所有函数都通过 QueryPerformanceCounter() 进行计时,调用数百万次,持续时间通常为几分钟或几小时,取决于测试性质。过去一周我一直在进行夜间测试,白天进行较短的测试,然后在睡前创建一些较长的测试,并在睡眠期间运行它们。许多测试被安排为运行很长时间,可能需要几个月才能完成。在 64 位模式下分配了 2 个 1GB 的缓冲区,在 32 位编译中分配了 2 个 512MB 的缓冲区。我的主要开发机器是 core i5,但也 G 进行了测试。我感兴趣的不是各种架构上的特定时序和发现,而是每个函数相对于彼此的时序。我在其他机器上的测试趋于相同。我没有 AMD 可以用于测试。我尝试了“AMD64 处理器软件优化指南”中的一个建议……我知道这是一篇非常旧的文章,但关于优化 memcpy() 的文章不多。Anyway,我尝试了,但考虑到更近的架构,我认为有更好的优化方法。

所以,请读者注意,这最初不是一个“科学”研究,记录我的进展也不在议程上,所以我实际上丢失了许多早期笔记。但我收集了如此多的数据,花费了如此多的时间研究 memcpy(),以至于我认为如果没有人从我的发现和观察中受益,那将是耻辱,我当时完全不知道会花费一个多星期。我开始时只是用一些我在网上找到的以及我自己编写或修改的简单函数来分析 memcpy()。我编写的所有函数都具有与标准库中的 memcpy() 完全相同的输入和输出。我没有分析 GCC 的 memcpy() 实现,因为最初的目的是开发一个 Windows 桌面/客户端应用程序。

我应该指出的一个重要方面是,我的性能分析方法是将所有函数放入一个函数指针数组中。这完全消除了这些函数之间可能存在的“内联”优势,但也使它们处于公平竞争的环境。测试数组中的所有函数都在相同的数据范围内进行测试,并且源和目标地址是分配缓冲区内的随机地址。在进行该函数的每次测试之前,都会调用 srand(0) 来重置 PRNG,以便每个函数使用与所有其他函数完全相同的随机数作为 src 和 dst 地址。

测试和结果

我进行了所有你能想象的测试。对齐/未对齐、小/大、带/不带预取。我收集的数据量如此之大,以至于结果文件超过 10,000 行。我认为,与其解释所有结果,不如专注于我呈现的实现与 64 位 memcpy() 的对比。

内存对齐;源、目标和大小对齐

因此,当我们谈论“对齐”内存时,我们可能是在谈论源或目标地址在 16 字节边界上的`对齐`,或者`对齐`的拷贝大小,例如 16、32 或 48 字节拷贝。对齐在用 SSE2 拷贝内存时起着重要作用,因为某些指令只为对齐内存设计,并且它们具有显著的性能优势。这通常适用于旧技术,但我注意到一些较新的 Intel `低功耗` CPU 在未对齐内存时也有 2 个时钟周期的惩罚。此外,我注意到即使在我的 i5 上,对齐内存也只有非常小的性能提升。这有几个原因,比如未对齐内存通常需要 CPU 读取 2 个缓存行,但我不会在这里进行过多技术细节的讨论。许多 SSE2 实现根本不处理未对齐内存,但我认为这会极大地限制你,所以我的实现将接受未对齐内存,在最坏的情况下,将对齐目标地址,但让源保持未对齐。由于源是“读取”地址(目标是“写入”),所以每个 16 字节读取的最坏情况是 4 个时钟周期,但由于我们稍微分散了数据并使用了预取,这种惩罚在旧 CPU 上应该可以得到最小化。无论你发送的是对齐/未对齐内存,代码都会对齐目标地址以进行大于 128 字节的拷贝。

Bpc

那么 Bpc 是什么?嗯,这是一个有趣的故事。当我开始分析时,我必须查看一个巨大的 64 位数字(这是 QueryPerformanceCounter() 调用结果),并试图找出哪些函数最快。这变得非常烦人,所以我最终改为“每计数器字节数”比例。基本上是总拷贝字节数除以 QueryPerformanceCounter() 起始和结束调用之间的差值。可以将其视为“每周期字节数”或“每调用字节数”或“每拷贝字节数”或“每计数器字节数”等等……我将称之为“Bpc”。一般来说,拷贝的字节数是天文数字,例如 2015000000000,这是几分钟内的数万亿字节。这是一种非常不切实际的数字,周期计数就更差了,所以 Bpc 只是给我一个像 3515.685421 Bpc 这样的数字,这是我能达到的峰值吞吐量!

函数类别

因此,为了测试各种拷贝范围,我编写了一些特定的函数,以及一些更通用的函数。有些函数是为一个目标而编写的,有些是为了测试一系列目标,或者看看如何将不同的方法链接起来形成最终函数。我认为一个快速的 memcpy() 实现应该在所有数字范围内都很快,而且在许多情况下,最好的方法是编写特定于该范围的代码。所以数字范围是 0-16 字节,17-128,然后大于 128。在这 3 个大小类别中的每一个中,都有子范围。例如,0-16 包含 1-3、4、5-7、8、9-15 和 16 字节。每一个都有“最佳方法”,但其他方法都会受影响。这总是一种权衡!例如,要拷贝 8 字节,最快的方法是使用一个 8 字节(64 位)的 `long long`/__int64/int64_t 拷贝,但如何处理剩余部分呢?更多的测试(if)或一个循环意味着拷贝速度更慢。你是用 1x SSE2 拷贝 16 字节,还是 2x 8 字节拷贝,或者 4x 4 字节拷贝?一个 4 字节拷贝循环可以处理 4、8、12 和 16 字节,但它比 2x 8 字节拷贝慢。Anyway,拷贝数据的方式有很多种,这令人难以置信。每次你认为你已经开发出“万能药”时,总会有一个让你失败的案例!

loword

这个函数类别是为了测试小尺寸拷贝,通常是 16 字节或更少。有些使用 for 循环每次拷贝 1、2 或 4 字节,而有些则使用按位“&”。总的来说,for 循环的开销会减慢函数的速度,所以按位操作更有利。但是你不能对所有 64 位使用按位操作,我们只是测试非常小的尺寸,一次只拷贝一两个。关于这个类别其实有很多话要说,但我不太确定它对其他人有多大兴趣,而且总的来说,我应该花更少的时间来优化我的 16 字节拷贝 :p

dword

这是常见的`朴素` 32 位拷贝。Visual Studio 中 32 位 memcpy() 函数绝对、肯定地使用这种方法进行拷贝,因为我的实现性能与之完全相同。主要区别可能在于它们如何拷贝最后 3 个字节,以及它们如何“寻址”源/目标,是同时增加指针,还是像我一样使用一个共同的偏移量?在现代处理器上,无论哪种方式,结果速度几乎都相同,因为处理器每周期可以执行多个操作。

qword

我还编写了几种 8 字节(64 位)(long long)拷贝函数。这些函数在性能上比 Visual Studio 的 64 位 memcpy() 函数好 10%。但这个幅度不足以说服我相信它们在内部使用了 8 字节拷贝。有可能是它们展开了循环,或者它们可能使用了一些汇编技巧来获得额外的 10% 加速,我不是很确定。

SSE2 拷贝

这是主要的が研究领域。我保留了 22 个函数。它们代表了改进拷贝不同方面的各种尝试。其中许多函数看起来很相似,只有几行不同,而这些就是我正在测试的行。我实际上丢失了一些函数,有时我写了一个函数,然后测试它,当它未能证明其观点时,我就删除了它。

发现

内省 vs. 非内省 memcpy()

我分析了 32 位和 64 位 memcpy() 的内省和函数调用版本。我使用了“#pragma intrinsic(memcpy)”或“#pragma function(memcpy)”语句,也许我错了,但我可以诚实地说,“内省”和非内省版本之间几乎没有可感知的差异。也许别人能找到更好的方法来强制编译器使用这 2 个“理论上”不同的版本。在某些条件下,通过调整编译器设置,如“最小化大小”和其他设置,如禁用内联展开等,我能够强制产生不同的结果。在一些测试中,违背常识,非内省版本总是更快。在我测试过的所有机器上,没有哪个测试中内省版本比非内省(函数调用)版本更快!所以我实际上主张禁用 memcpy() 内省(使用上述 pragma),如果你有/使用其他内省!在任何我测试过的机器上,使用 memcpy() 内省都没有带来任何好处!我很乐意被证明是错的,告诉我该怎么做,启用/禁用什么编译器设置,以及测试/运行什么代码来清晰地展示差异!

32 位 vs. 64 位 memcpy()

在 256MB 的数据范围内,复制数百万次,使用随机地址,64 位 memcpy() 比 32 位版本快 12.5%(2700 vs. 2400 Bpc)。作为参考,以下是一些实际结果,经过数百万次运行和数小时的性能分析

32 位编译

memcpy() = 2407.409763 Bpc
memcpy8() = 2426.479289 Bpc **
dword_memcpy1() = 2199.207560 Bpc ***
dword_memcpy2() = 2400.391856 Bpc
dword_memcpy3() = 2387.596476 Bpc
dword_memcpy4() = 2406.398597 Bpc

64 位编译

memcpy() = 2703.055754 Bpc
memcpy8() = 2460.156299 Bpc
dword_memcpy1() = 2341.839341
dword_memcpy2() = 2340.425519
dword_memcpy3() = 2343.732592
dword_memcpy4() = 2342.167511
** memcpy8() 是我编写的“朴素”简单的 memcpy() 实现,它只在 for 循环中拷贝一个“long long”(64 位值),以及尾部的字节。*** 仅就 dword 实现提供一些注释,以便您理解它们。dword_memcpy1() 使用 while 循环,但主要问题是它递减了一个“已拷贝字节数”计数器。其他实现只是各种内存寻址方法,我想看看不同的内存调用如何影响性能。尽管有一些不同的数字,但我会说其他函数基本上具有相同的性能。但是从 2400 Bpc 到 2200 Bpc 的 200 Bpc 的差异是值得注意的!

 

现在,这让我对上述方法有了最重要的观察。????????????


……未完成的原始文章结束……

 

 

© . All rights reserved.