我了解到,在使用内部时,System.Runtime.Intrinsics.X86不必使用指针来寻址数据,但您可以简单地使用 转换一个数据数组,它的工作速度与在代码System.Runtime.InteropServices.MemoryMarshal中使用指针一样快。unsafe我很惊讶并使用Benchmark.NET测试了性能。
我写了 4 个基准,一个标量来检查结果,用它来检查System.Numerics.Vector<T>性能(这很有趣),实际上是 2 个基于Vector256<int>托管和非托管代码的测试。
我做了最简单的任务——1000 万个元素的数组元素的总和。我意识到该实现有一个限制,数组的长度必须是 8 的倍数 - 256 位向量的长度(8 x 32),否则输出中的结果将是不可预测的。
public class SumTest
{
private static readonly int[] _numbers = Enumerable.Repeat(2, 100000000).ToArray();
public IEnumerable<object> Params
{
get
{
yield return _numbers;
}
}
[Benchmark]
[ArgumentsSource(nameof(Params))]
public int SumScalar(int[] numbers)
{
int result = 0;
for (int i = 0; i < numbers.Length; i++)
{
result += numbers[i];
}
return result;
}
[Benchmark]
[ArgumentsSource(nameof(Params))]
public int SumNumerics(int[] numbers)
{
Vector<int> acc = Vector<int>.Zero;
for (int i = 0; i < numbers.Length; i += Vector<int>.Count)
{
Vector<int> v = new Vector<int>(numbers, i);
acc += v;
}
return Vector.Dot(acc, Vector<int>.One);
}
[Benchmark]
[ArgumentsSource(nameof(Params))]
public int SumIntrinsics(int[] numbers)
{
ReadOnlySpan<Vector256<int>> vectors = MemoryMarshal.Cast<int, Vector256<int>>(numbers);
Vector256<int> acc = Vector256<int>.Zero;
for (int i = 0; i < vectors.Length; i++)
{
acc = Avx2.Add(acc, vectors[i]);
}
Vector128<int> r = Ssse3.HorizontalAdd(acc.GetUpper(), acc.GetLower());
r = Ssse3.HorizontalAdd(r, r);
r = Ssse3.HorizontalAdd(r, r);
return r.GetElement(0);
}
[Benchmark]
[ArgumentsSource(nameof(Params))]
public unsafe int SumIntrinsicsUnsafe(int[] numbers)
{
Vector256<int> acc = Vector256<int>.Zero;
fixed (int* numPtr = numbers)
{
int* endPtr = numPtr + numbers.Length;
for (int* numPos = numPtr; numPos < endPtr; numPos += 8)
{
Vector256<int> v = Avx.LoadVector256(numPos);
acc = Avx2.Add(acc, v);
}
Vector128<int> r = Ssse3.HorizontalAdd(acc.GetUpper(), acc.GetLower());
r = Ssse3.HorizontalAdd(r, r);
r = Ssse3.HorizontalAdd(r, r);
return r.GetElement(0);
}
}
}
检查了输出
int[] numbers = Enumerable.Repeat(2, 100000000).ToArray();
SumTest sum = new SumTest();
Console.WriteLine(sum.SumScalar(numbers));
Console.WriteLine(sum.SumNumerics(numbers));
Console.WriteLine(sum.SumIntrinsics(numbers));
Console.WriteLine(sum.SumIntrinsicsUnsafe(numbers));
200000000
200000000
200000000
200000000
也就是说,一切正常。
我收集并启动了基准测试。
var summary = BenchmarkRunner.Run<SumTest>();
而他又吃了一惊。
BenchmarkDotNet=v0.12.1, OS=Windows 10.0.19042
Intel Core i7-4700HQ CPU 2.40GHz (Haswell), 1 CPU, 8 logical and 4 physical cores
.NET Core SDK=5.0.102
[Host] : .NET Core 5.0.2 (CoreCLR 5.0.220.61120, CoreFX 5.0.220.61120), X64 RyuJIT
DefaultJob : .NET Core 5.0.2 (CoreCLR 5.0.220.61120, CoreFX 5.0.220.61120), X64 RyuJIT
| Method | numbers | Mean | Error | StdDev |
|-------------------- |----------------- |---------:|---------:|---------:|
| SumScalar | Int32[100000000] | 83.69 ms | 0.466 ms | 0.436 ms |
| SumNumerics | Int32[100000000] | 31.30 ms | 0.303 ms | 0.268 ms |
| SumIntrinsics | Int32[100000000] | 28.98 ms | 0.282 ms | 0.236 ms |
| SumIntrinsicsUnsafe | Int32[100000000] | 28.80 ms | 0.191 ms | 0.169 ms |
也就是说,统计误差之间SumIntrinsics和SumIntrinsicsUnsafe内部的差异(StdDev)。
问题:这是什么动物MemoryMarshal,现在在使用内在函数时使用它是否有意义unsafe,实际上是使用向量?
如果问题是是否可以将计算结果以安全代码写入数组 - 是的,有可能,就像数组被强制转换并且写入向量的所有信息都将在数组中一样,即也就是说,工作与使用常规结构数组完全相同。换句话说,不安全代码的好处并不是立即可见的。好吧,仅当源数据最初以指针的形式出现,而不是以托管数组的形式出现时,但那里可能存在细微差别,我对此主题并不深入。
顺便说一句,我很惊喜Vector<T>。我认为在代码对性能不是超级敏感的情况下,可以使用它来Numerics支持跨处理器。
添加
我尝试再次重写该方法SumNumerics或添加另一个版本的实现SumIntrinsicsHybrid。
[Benchmark]
[ArgumentsSource(nameof(Params))]
public int SumNumerics(int[] numbers)
{
ReadOnlySpan<Vector<int>> vectors = MemoryMarshal.Cast<int, Vector<int>>(numbers);
Vector<int> acc = Vector<int>.Zero;
for (int i = 0; i < vectors.Length; i ++)
{
acc += vectors[i];
}
return Vector.Dot(acc, Vector<int>.One);
}
[Benchmark]
[ArgumentsSource(nameof(Params))]
public unsafe int SumIntrinsicsHybrid(int[] numbers)
{
ReadOnlySpan<Vector256<int>> vectors = MemoryMarshal.Cast<int, Vector256<int>>(numbers);
Vector256<int> acc = Vector256<int>.Zero;
fixed (Vector256<int>* numPtr = vectors)
{
Vector256<int>* endPtr = numPtr + vectors.Length;
for (Vector256<int>* numPos = numPtr; numPos < endPtr; numPos++)
{
acc = Avx2.Add(acc, *numPos);
}
Vector128<int> r = Ssse3.HorizontalAdd(acc.GetUpper(), acc.GetLower());
r = Ssse3.HorizontalAdd(r, r);
r = Ssse3.HorizontalAdd(r, r);
return r.GetElement(0);
}
}
基准再次表明,在帮助下进行铸造MemoryMarshal,如果不是免费的,那么完全可以得到回报。
| Method | numbers | Mean | Error | StdDev |
|-------------------- |----------------- |---------:|---------:|---------:|
| SumScalar | Int32[100000000] | 83.30 ms | 0.214 ms | 0.189 ms |
| SumNumerics | Int32[100000000] | 28.85 ms | 0.222 ms | 0.207 ms |
| SumIntrinsics | Int32[100000000] | 28.74 ms | 0.145 ms | 0.136 ms |
| SumIntrinsicsUnsafe | Int32[100000000] | 28.14 ms | 0.234 ms | 0.195 ms |
| SumIntrinsicsHybrid | Int32[100000000] | 28.09 ms | 0.174 ms | 0.163 ms |
小阵列测试
| Method | numbers | Mean | Error | StdDev |
|-------------------- |------------ |----------:|---------:|---------:|
| SumScalar | Int32[1000] | 712.65 ns | 2.889 ns | 2.702 ns |
| SumNumerics | Int32[1000] | 81.22 ns | 0.466 ns | 0.436 ns |
| SumIntrinsics | Int32[1000] | 82.63 ns | 0.311 ns | 0.291 ns |
| SumIntrinsicsUnsafe | Int32[1000] | 60.66 ns | 0.347 ns | 0.308 ns |
| SumIntrinsicsHybrid | Int32[1000] | 61.01 ns | 0.418 ns | 0.370 ns |
也许您的问题可以转化为
Span<T>/是什么ReadOnlySpan<T>。虽然很肤浅,但是一篇概述文章给出了一个思路\u200b\u200bthis:简而言之,您感兴趣的是定义类型:
Span 是包含此技巧的仅堆栈结构:
没有上下文,不清楚这是否更接近 C++ 术语中的链接或指针,所以我将使用指针这个词。现在,这个新的内部类型是一个跟踪指针。而且,与 operator 不同的
fixed是,现在堆上没有任何东西是固定的,GC 本身会在压缩阶段之后更改此指针的地址。根据文档,跟踪此类指针在性能方面的成本很高,因此 Span 被制成一个ref 结构,即使作为对象的一部分也不能移动到堆中。
实际上,span 有高级的亲戚
Memory<T>,ReadOnlyMemory<T>不仅可以包装数组。但是这篇文章对它们的揭示很差:-(这是我想到的其他事情。如果 GC 在压缩阶段之后动态更改此类链接的地址。但是,正如我们所知,大对象最终会在LOH中它们已经变得未重定位,那么如果您更改测试以使其适用于小数组但在垃圾收集器的背景下,那么它可能会显示更多还是不太显着的性能下降?