“有没有办法在不通过循环枚举的情况下执行 data1-data2 = data3 ?”不,这在技术上是不可能的。
最好,或者更糟的是,您可以调用将为您进行枚举的函数。但它会很慢。在 LINQ 的情况下,速度太慢了。
对于我目前正在处理的机器,其他答案的结果如下 4KB 表(1024 个整数)。
- 23560 滴答声 - Giannis Paraskevopoulos。 Array-Enumerable-Array 转换不是很快,通过 ToList().ToArray() 链复制数组比 Array.Copy() 慢大约 25 倍。
- 10198 个滴答声 - Selman22。快了 2 倍,但仍然很慢。 Lambda 让创建事件更漂亮,而不是更快。你最终会得到一些匿名方法,这可能比它的操作花费更多的 CPU 时间来返回调用(请记住,我们在这里做的数学运算 CPU 可以在几个周期内完成)。
- 566 个滴答声 - Tim Schmelter GetDifference() 函数(这里的罪魁祸首是 JIT,在本机代码中和/或更常见的用法差异可以忽略不计)
- 27 个滴答声 - 只是一个循环。比 Zip 快 400 倍,比将数组转换为列表再返回快 800 多倍。
循环代码:
for (int i = 0; i < data3.Length; i++)
{
data3[i] = Math.Abs(data1[i] - data2[i]);
}
这些基本的内存操作可以直接转换为机器代码,而不需要 LINQ 的可怕性能和巨大的内存占用。
故事的寓意是:LINQ 是为了可读性(在这种情况下是有争议的)而不是为了性能(在这种情况下是显而易见的)。
优化时间!让我们稍微滥用一下我们的 CPU。
-
Unroll loop。或者不要。您的体验可能会有所不同。即使在
汇编程序本身循环展开性能增益或损失变化
在同一系列处理器中非常重要。新的 CPU 和编译器是
了解旧技巧并简单地自行实施它们。为了
i3-3220 我在循环展开到 4 行时测试代码导致更快
在 32 位代码上执行,但在 64 位上它有点慢,而展开到 8 则相反。
- 为 x64 编译。因为我们在这里处理 32 位数据,所以我们不会制作
使用 64 位寄存器……还是我们会?在 x86 上不到一半
寄存器真正可用于生成的代码(在汇编中
手动您总是可以挤出更多),在 x64 上,您可以获得八个免费使用的奖励寄存器。您可以在不访问内存的情况下做的越多,您的代码就越快。在这种情况下,速度增益约为 20%。
- 关闭 Visual Studio。不要在 32 位 IDE 中对 64 位代码进行速度测试
(目前还没有 64 位版本,probably wont be for
long time)。它会使 x64 代码大约慢两倍,因为
架构不匹配。 (嗯......无论如何,你永远不应该在调试器下对代码进行速度测试......)
- 不要过多使用内置函数。在这种情况下 Math.Abs 有
overhead hidden inside。由于某些原因(需要分析 IL 来找出),使用 ?: 检查负值比使用 If-Else 更快。这样的检查节省了很多时间。
更新: ?: 比 If-Else 更快,因为生成的机器代码存在差异……至少对于比较两个值而言。它的机器代码远没有 If-Else 奇怪(它看起来不像你会“手工”编写的代码)。显然,这不仅仅是编写 If-Else 语句的不同形式,而是针对简单条件赋值优化的完全独立的命令。
生成的代码比使用 Math.Abs() 的简单循环快大约 8 倍;请记住,您只能将循环展开到数据集大小的除数。你写道你的数据集大小是 25920,所以 8 就可以了。 (最大值为 64,但我怀疑它是否有任何意义)。我建议将此代码隐藏在某些函数中,因为它很丑。
int[] data3 = new int[data1.Length];
for (int i = 0; i < data1.Length; i += 8)
{
int b;
b = (data1[i + 0] - data2[i + 0]);
data3[i + 0] = b < 0 ? -b : b;
b = (data1[i + 1] - data2[i + 1]);
data3[i + 1] = b < 0 ? -b : b;
b = (data1[i + 2] - data2[i + 2]);
data3[i + 2] = b < 0 ? -b : b;
b = (data1[i + 3] - data2[i + 3]);
data3[i + 3] = b < 0 ? -b : b;
b = (data1[i + 3] - data2[i + 4]);
data3[i + 4] = b < 0 ? -b : b;
b = (data1[i + 5] - data2[i + 5]);
data3[i + 5] = b < 0 ? -b : b;
b = (data1[i + 6] - data2[i + 6]);
data3[i + 6] = b < 0 ? -b : b;
b = (data1[i + 7] - data2[i + 7]);
data3[i + 7] = b < 0 ? -b : b;
}
这甚至不是它的最终形式。我会尝试在它上面做一些异端的把戏。
BitHack 的低级秘籍!
正如我所提到的,仍有改进的地方。
删除 LINQ 后,munchkin 的主要刻度是 Abs()。当它从代码中删除时,我们留下了 IF-ELSE 和速记 ?: 运算符之间的竞争。
两者都是分支运算符,过去曾被广泛认为比线性代码慢。目前,易于使用/编写往往比性能更重要(有时正确,有时不正确)。
所以让我们的分支条件线性化。滥用这个代码中的分支包含仅对单个变量进行的数学运算这一事实是可能的。所以让我们让代码等效于this。
现在你还记得如何取反二的补数吗?取反所有位并加一。那就无条件一行搞定吧!
现在是位运算符大放异彩的时候了。 OR 和 AND 很无聊,真正的男人使用 XOR。 XOR 有什么了不起的?除了通常的行为,您还可以将其转换为 NOT(否定)和 NOP(无操作)。
1 XOR 1 = 0
0 XOR 1 = 1
所以按仅填充 1 的值进行异或运算会得到 NOT 运算。
1 XOR 0 = 1
0 XOR 0 = 0
所以按只填充 0 的值进行异或运算根本没有任何作用。
我们可以从我们的号码中获得符号。对于 32 位整数,它就像 x>>31 一样简单。它将位符号移动到最低位。甚至 wiki 都会告诉您,从左侧插入的位将为零,因此您的 x>>31 的结果将为负数 (x=0) 为 0,对吧?
不。对于有符号值,Arithmetic shift 用于普通位移。所以我们将根据符号得到 -1 或 0.... 这意味着 'x>>31' 将给出 111...111 为负数, 000...000 为非负数。如果您将通过这种移位的结果对原始 x 进行异或运算,您将根据值符号执行 NOT 或 NOP。另一个有用的事情是,0 将导致 NOP 进行加法/否定,因此我们可以根据值符号加/减 -1。
所以 'x^(x>>31)' 将翻转负数位,同时不对非负数进行任何更改,而 'x-(x>>31)' 将加 1(负数为正数)负 x 且不更改非负值。
结合起来你会得到'(x ^ (x >> 31)) - (x >> 31)'... 可以翻译成:
IF X<0
X=!X+1
只是
IF X<0
X=-X
它如何影响性能?
我们的 XorAbs() 只需要四次基本整数运算,一次加载一次存储。分支运算符本身占用的 CPU 滴答数差不多。虽然现代 CPU 擅长进行分支预测,但它们仍然更快,因为在提供顺序代码时根本不这样做。
分数是多少?
- 比内置 Abs() 快大约四倍;
- 大约是以前代码的两倍(没有展开的版本)
- 根据 CPU 的不同,它可以在不展开循环的情况下获得更好的结果。
由于消除了代码分支,CPU 可以在其上“展开”循环
自己的。 (Haswells 在展开时很奇怪)
结果代码:
for (int i = 0; i < data1.Length; i++)
{
int x = data1[i] - data2[i];
data3[i] = (x ^ (x >> 31)) - (x >> 31);
}
并行度和缓存使用
CPU 有超快的缓存内存,当顺序处理一个数组时,它会将整个数据块复制到缓存中。
但是,如果您编写蹩脚的代码,您将获得缓存未命中。 screwing up order of nested loops你很容易掉入这个陷阱。
并行性(多线程,相同数据)必须在顺序块上工作才能充分利用 cpu 缓存。
手动编写线程将允许您手动为线程挑选块,但这是一种麻烦的方式。
由于 4.0 .NET 附带了帮助程序,但是默认的 Parallel.For 使缓存变得一团糟。
所以由于cache-miss,这段代码实际上比它的单线程版本慢。
Parallel.For(0, data1.Length,
fn =>
{
int x = data1[fn] - data2[fn];
data3[fn] = (x ^ (x >> 31)) - (x >> 31);
}
可以通过在其中执行顺序操作来手动使用缓存数据。例如,您可以展开循环,但其肮脏的破解和展开有其自身的性能问题(取决于 CPU 型号)。
Parallel.For(0, data1.Length >> 3,
i =>
{
int b;
b = (data1[i + 0] - data2[i + 0]);
data3[i + 0] = b < 0 ? (b ^ -1) + b : b;
b = (data1[i + 1] - data2[i + 1]);
data3[i + 1] = b < 0 ? (b ^ -1) + b : b;
b = (data1[i + 2] - data2[i + 2]);
data3[i + 2] = b < 0 ? (b ^ -1) + b : b;
b = (data1[i + 3] - data2[i + 3]);
data3[i + 3] = b < 0 ? (b ^ -1) + b : b;
b = (data1[i + 3] - data2[i + 4]);
data3[i + 4] = b < 0 ? (b ^ -1) + b : b;
b = (data1[i + 5] - data2[i + 5]);
data3[i + 5] = b < 0 ? (b ^ -1) + b : b;
b = (data1[i + 6] - data2[i + 6]);
data3[i + 6] = b < 0 ? (b ^ -1) + b : b;
b = (data1[i + 7] - data2[i + 7]);
data3[i + 7] = b < 0 ? (b ^ -1) + b : b;
}
但是 .NET 也有 Parrarel.ForEach 和 Load Balancing Partitioners。
通过同时使用它们,您将获得最好的结果:
- 数据集大小无关代码
- 简洁的代码
- 多线程
- 良好的缓存使用率
所以最终的代码是:
var rangePartitioner = Partitioner.Create(0, data1.Length);
Parallel.ForEach(rangePartitioner, (range, loopState)
=>
{
for (int i = range.Item1; i < range.Item2; i++)
{
int x = data1[i] - data2[i];
data3[i] = (x ^ (x >> 31)) - (x >> 31);
}
});
它远未达到最大 CPU 使用率(这比最大化其时钟更复杂,有多个缓存级别,多个管道等等)但它可读、快速且独立于平台(整数大小除外,但 C# int是 System.Int32 的别名,所以我们在这里是安全的)。
我想我们将停止优化。
它是作为一篇文章而不是答案出来的,我希望没有人会因此而清洗我。