基准测试:.NET Core SIMD 性能与 Intel ISPC 对比





5.00/5 (9投票s)
使用 Vector 类型的 .NET SIMD 程序表现出与 Intel ISPC 和开源 C++ SIMD 库相当的性能,同时在高级语言中实现了 SIMD 开发人员生产力的相同目标。
引言
(注意:您可以在项目发布页面找到 jembench 项目的最新版本)
在本文中,我将介绍并解释使用来自 jemalloc.NET 项目的 CLI 基准测试工具 jembench 对 .NET Core 中 CPU 单指令多数据(SIMD)性能进行基准测试的结果。结果显示,使用 Vector<T> SIMD 启用类型进行 .NET Core SIMD 程序加速的性能与使用 Intel 单程序编译器(ISPC)编写的程序相当,在 AVX2 处理器上,单线程矢量化 .NET 程序的速度提高了 9 倍。多线程 .NET 程序在 8 核 i7 Skylake 处理器上显示出 40-50 倍的加速。使用 RyuJIT 和 .NET 任务并行库(TPL)的 .NET Core 中的单线程和多线程 SIMD 程序性能比 AVX2 处理器上的 ISPC 编译本地代码慢约 3 倍。与 ISPC 相比,.NET Core Vector<T>
的相对和绝对性能范围与 C++ SIMD 库与 ISPC 相比观察到的性能范围完全一致。
使用 Vector<T>
等 SIMD 启用的 C# 类型,结合 RyuJIT x64 编译器和 .NET 新功能,如引用值类型、Span<T>
和数据结构的零拷贝重解释,使 .NET 开发人员能够充分利用 CPU 的 SIMD 功能,实现与 C++ SIMD 库相当的性能,同时保持使用 C# 和 .NET 框架的高生产力。通过使用 Vector<T>
等显式矢量化类型,并允许 RyuJIT 编译器在运行时生成利用 CPU 向量单元所需的指令,减少了在非托管 C 库中使用低级内在函数的需要。C# 可用于开发数据并行工作负载的计算内核,无需与外部 C 或 C++ 库接口即可实现高性能。通过 TPL 使用并发任务可以与单 CPU 核心上的数据并行性相结合,以最大限度地利用多核 CPU。随着 RyuJIT 中 SIMD 支持的持续改进,.NET 开发人员可以预期 .NET Core 程序的绝对性能数字将更接近本地代码的性能。Vector<T>
与用于管理非常大的内存数组的 jemalloc.NET 本地内存管理器相结合,使 .NET 成为开发内存中数字、科学和其他类型 HPC 应用程序的可行平台。
背景
SIMD
几位作者已经观察到,尽管 SIMD 向量单元已经在现代 CPU 中存在了数十年,但 SIMD 编程模型仍然非常耗费精力且不灵活,导致绝大多数应用程序从未利用 CPU 的 SIMD 功能,因此也从未利用最大化现代 CPU 数据处理性能的最有效和重要方式。必须在低级别访问 CPU 硬件,同时能够将现有算法和例程移植到“矢量化”实现,同时还要确保代码在不同的 SIMD 指令集和可能不同的芯片架构之间具有可移植性和有效性,这三重限制使得为开发人员创建有效的 SIMD 编程模型成为一项艰巨的任务。
互联网和大规模分布式以及客户端-服务器应用程序的普及意味着,近年来,应用程序性能普遍被视为 I/O 密集型,而 CPU 硬件级别的优化仅对游戏和其他专用应用程序类型很重要。对于通用业务应用程序,开发人员利用 CPU 上的 SIMD 功能所需的额外努力与使用 C# 和 Java 等高级编程语言所带来的生产力提升相比,并未被视为合理,因为这些语言旨在使程序员免受此类低级编程的复杂性影响。例如,SIMD 支持直到 2014 年才首次出现在 .NET RyuJit JIT 编译器中,这比 .NET 的推出以及 Intel 和 AMD 推出 SSE2 指令集晚了 10 年。(值得称赞的是,Mono 早在 2008 年就开始支持 SIMD。)
云计算和大数据、机器学习以及许多其他计算进步的出现,使应用程序性能的焦点重新回到实现低级硬件性能。Java 和 C# 等语言的应用程序越来越多地被要求在大规模处理大量数据,并在特定时间范围内处理具有大量数据并行性的工作负载。使用具有并行单程序多数据 (SPMD) 功能的 GPGPU 被视为这些工作负载的下一代硬件解决方案,但通用 CPU 在处理不同程序和数据集合方面仍然更有效。至少有一篇论文对 GPU 在高度并行工作负载上相对于多核 CPU 的性能提升承诺提出了质疑。但撇开 GPU 性能不谈,从多个角度来看,例如能效,软件能够最大限度地发挥底层 CPU 硬件的能力在今天仍然极其重要。
用汇编语言编写计算内核或在 C 等语言中使用 SIMD 内在函数仍然是保证 CPU 最大 SIMD 性能的唯一方法。但原始性能并非全部。SIMD 编程始终是程序员投入、生产力与性能之间的权衡。即使在当今的数据中心环境中,性能确实可以用金钱来衡量,但如果代码的开发、维护和集成到现有应用程序中所需的精力超过 3 倍且成本更高,那么即使性能快 3 倍也可能不那么理想。作为 SPMD 编程的新手,我承认我花了一周时间才将这里基准测试的串行程序转换为 C# 中有效的矢量化实现。编写矢量化代码需要您以更通用的逻辑术语而不是简单的操作术语来思考循环等熟悉的概念。它还需要您思考内存访问和编译器将生成的指令,并迭代地优化您的算法。这种不熟悉以及矢量化现有代码所需的额外难度和复杂性无疑导致了 SIMD 编程的采用不足。
代码矢量化
编译器有几种方法可以帮助您编写利用 SIMD 功能的代码或对现有代码进行矢量化。最简单的方法可能是使用 Intel MKL 等库,这些库包含已经针对 SIMD 优化的代码。除了可能产生的大量成本外,这种方法的一个主要缺点是这些库必须已经存在于您正在工作的领域。基于或扩展这些库以针对特定问题定制代码,您仍然面临如何为 SIMD 优化代码的相同问题。
下一个最简单的方法是简单地告诉编译器对现有代码进行自动矢量化,编译器会分析您的代码,寻找可以从矢量化中受益的地方,并生成最佳的矢量代码。像 LLVM、GCC 和 MSVC 这样的 C++ 编译器有开关,可以为循环矢量化和使用超字级别并行性等自动并行化算法发出 SIMD 指令。然而,自动矢量化显然只有在代码相对简单,编译器可以通过静态分析推导出正确的矢量化版本时才可能实现。尽管自动矢量化是编译器高度期望的功能,并且可以在不改变核心语言的情况下实现,但现代编译器自动矢量化实现的有效性尚不清楚。
另一种利用 SIMD 的方法是由编译器或工具级别的隐式矢量化提供。通过隐式矢量化,您可以使用 `#pragma` 或其他指令、属性和关键字来告诉编译器或预处理器哪些代码块应该被矢量化,并提供如何生成正确矢量化代码的提示。隐式矢量化相对于自动矢量化的优点是,编译器有更多关于如何矢量化特定代码块的信息。缺点是隐式矢量化必须作为特定于编译器的指令或扩展来实现,这些指令或扩展在不同编译器之间不可移植,并且可能在语言级别没有支持。应用程序逻辑未明确矢量化,并且编译器可能无法针对特定实现优化 SIMD 寄存器和指令的使用。
显式矢量化库要求程序员通过低级内在函数或通过 SIMD 向量寄存器和操作的抽象表示的接口和类型来显式矢量化其串行算法。C 中的内在函数库以及大多数 C++ SIMD 库,如 UME::SIMD、Vc、Boost.Simd 等都属于这一类。
还存在其他解决方案,例如用于 SIMD 矢量化的嵌入式 DSL,或在程序执行期间进行 JIT 编译以生成 SIMD 指令,以及被认为是这些矢量化解决方案的混合方法。ISPC 和 Vector<T>
都可以被视为混合矢量化解决方案。
Vector<T>
.NET Vector<T>
类型抽象了一个 SIMD 寄存器以及可以在 SIMD 寄存器中的数据上并行执行的算术、按位和逻辑操作。在运行时,RyuJIT 编译器会生成执行所开发操作所需的 CPU 指令。因此,Vector<T>
是显式矢量化模型和 JIT 编译到 SIMD 指令的混合体。
Intel 单程序编译器
ISPC 编译器提供 C 语言扩展,允许开发人员以 C 语言的一种方言表达单程序多数据算法。使用 ISPC,您必须将应用程序逻辑中将并行化的部分分离出来,并使用 ISPC 语言和并行编程模型实现并行算法,然后将其编译为可在 CPU 上运行并可链接到其他 C 或 C++ 代码的本地代码。与 Vector<T>
不同,ISPC 没有用于抽象 SIMD 寄存器或操作的显式类型;相反,您使用 C 和标量变量以串行方式表达您的逻辑,ISPC 编译器会推断程序应如何并行化。因此,ISPC 是隐式矢量化模型和用于矢量化代码的 DSL 或语言扩展的混合体。
ISPC 以其生成的本地代码所能实现的高性能而著称。在 Pohl 等人的“C++ 当前 SIMD 编程模型评估”中,ISPC 在 C 和 C++ SIMD 库的 Mandelbrot 基准测试结果中名列前茅。
适用于 Windows 的 ISPC 源代码分发是一个 VS 2015 解决方案,其中包含大量 MSVC 示例,这些示例使用 CPU rdtsc 计时器进行内置计时。这使得可以直接比较 ISPC 中实现的矢量化算法并编译成本地代码的性能,与 .NET 托管代码中由 RyuJIT 编译器在运行时 JIT 编译的实现。这可以让我们很好地了解 .NET Vector<T>
与 C 和 C++ 中 SIMD 矢量化解决方案的性能。
基准测试
我最初开始编写这些基准测试的代码是为了比较使用托管堆上的数组与由 jemalloc.NET 本地内存管理器分配的非托管内存支持的数组的 Vector<T>
性能。FixedBuffer<T>
是 jemalloc.NET 的高级 API 提供的一种数据结构类型,它是一种由非托管内存支持的基本类型数组,该内存未分配在 .NET 托管堆上,并满足某些约束,如零分配或用于与非托管代码互操作的封送处理,避免“结构撕裂”,并为 SIMD 操作正确对齐。然后我开始对 .NET Core 显式矢量化类型与 C 或 C++ 编写的 SIMD 库的性能比较感兴趣。如果性能相当,则意味着 C# 可以成为实现真实 HPC 应用程序的可行选择,而无需链接到本地 C 或 C++ 库。
近期,微软和 .NET Core 开发团队一直致力于 .NET 的底层性能优化。jemalloc.NET 的工作受到了 .NET Core 团队最近围绕 Span<T>
、Memory<T>
和类似类型的工作的推动。这些 .NET Core 框架的最新补充主要集中在改进底层性能和内存利用率,目标是使 .NET 在现代硬件和操作系统环境中的数据操作更高效、更具可伸缩性。以下来自 Span<T>
设计文档的段落解释了这些更改的动机
引用
Span<T>
是一个虽小但关键的构建块,旨在为更宏大的工作提供 .NET API,以实现高可扩展性服务器应用程序的开发。.NET 框架的设计理念几乎完全专注于为编写应用程序软件的开发人员提供生产力。此外,许多框架的设计决策都是基于大约 1999 年的 Windows 客户端-服务器应用程序而做出的。这种设计理念是 .NET 成功的重要组成部分,因为 .NET 被普遍认为是一个高生产力平台。
但自我们平台诞生近 20 年以来,格局已经发生了变化。我们现在针对非 Windows 操作系统,我们的开发人员编写云托管服务,需要与客户端-服务器应用程序不同的权衡,最先进的模式已经摆脱了曾经流行的技术,如 XML、UTF16、SOAP(仅举几例),并且运行当今软件的硬件与 20 年前可用的硬件截然不同。
当我们分析今天存在的差距和当今高规模服务器的需求时,我们意识到我们需要提供现代的无拷贝、低分配和 UTF8 数据转换 API,这些 API 应该高效、可靠且易于使用。此类 API 的原型可在 corefxlab 存储库中获得,而
Span<T>
是这些 API 的主要基本构建块之一。
像 .NET 这样的框架必须在高性能应用程序与保持生产力提升,尤其是 .NET 开发者所钟爱的某些类别的错误安全之间取得平衡。像 Span<T>
这样的类型旨在允许您通过使用指针访问支持 .NET 对象和值的内存,同时仍保留一定程度的安全性,以防止诸如无效内存引用之类的错误。
此外,`System.Runtime.CompilerServices.Unsafe` 库通过为直接寻址和操作内存添加更多框架支持,允许 .NET 开发人员为了性能而承担更多风险。例如,在下面的调试器截图中,请注意 `Unsafe.Read` 调用和 `cmp` 测试之间有多少条指令。
使用 Unsafe.Read
允许我们对数据结构执行直接指针算术,然后将标量数组数据结构的元素跨度重新解释为向量数据类型,而无需分配内存。另请注意,矢量化比较操作 Vector<T>.LessThan()
如何直接映射到 CPU 指令 vpcmpgtd,这是 AVX2 指令,用于“比较 a 和 dst 中。”
jembench
使用 jembench CLI 程序,我能够快速实现并迭代比较矢量化算法的不同实现与基准串行算法实现。jembench 在学习过程中非常宝贵,因为早期矢量化尝试常常惨遭失败。启动 Visual Studio 调试器并查看反汇编代码很快就让我发现了错误。jembench 建立在出色的 BenchmarkDotNet 库之上,该库为此类用例提供准确且可定制的基准。jembench CLI 运行器和 Visual Studio 调试器的组合被证明是迭代开发矢量化算法并立即测试其相对于基准串行算法的性能,同时比较 RyuJIT 生成的机器代码的非常有效的方法。
基准代码
ISPC
将用作基准测试的主要操作或内核是 Mandelbrot 集算法,它是一种高度并行的算法,其中集合中的每个成员都可以独立于其他成员进行计算,并且通常用作并行操作的基准。以下是 ISPC 发布附带的示例文件夹中矢量化 Mandelbrot 算法实现的 ISPC 代码:
export void mandelbrot_ispc(uniform float x0, uniform float y0,
uniform float x1, uniform float y1,
uniform int width, uniform int height,
uniform int maxIterations,
uniform int output[])
{
float dx = (x1 - x0) / width;
float dy = (y1 - y0) / height;
for (uniform int j = 0; j < height; j++) {
// Note that we'll be doing programCount computations in parallel,
// so increment i by that much. This assumes that width evenly
// divides programCount.
foreach (i = 0 ... width) {
// Figure out the position on the complex plane to compute the
// number of iterations at. Note that the x values are
// different across different program instances, since its
// initializer incorporates the value of the programIndex
// variable.
float x = x0 + i * dx;
float y = y0 + j * dy;
int index = j * width + i;
output[index] = mandel(x, y, maxIterations);
}
}
}
static inline int mandel(float c_re, float c_im, int count) {
float z_re = c_re, z_im = c_im;
int i;
for (i = 0; i < count; ++i) {
if (z_re * z_re + z_im * z_im > 4.)
break;
float new_re = z_re*z_re - z_im*z_im;
float new_im = 2.f * z_re * z_im;
unmasked {
z_re = c_re + new_re;
z_im = c_im + new_im;
}
}
return i;
}
该代码与 C 类似,但增加了 `uniform` 等新关键字和 `foreach` 等新循环结构。这里的关键是程序员不必显式表达如何在 CPU 的向量寄存器上并行化操作。通过使用 `foreach` 关键字,程序员向 ISPC 编译器发出信号,表明下一个语句块需要被矢量化。ISPC 编译器将根据程序员在代码中提供的信息处理实现细节。关键字 `uniform` 告诉编译器该变量将以统一的方式变化,并且可以在所有并行执行的计算中共享为单个值。此代码使用 ISPC 编译器编译成本地代码。`export` 关键字表示 `mandelbrot_ispc` 函数将作为库函数导出,可以从常规 C 代码中调用。
.NET
我们来看一个 Mandelbrot 集绘图的 C# 实现
private unsafe void _MandelbrotUnmanagedv5(ref FixedBuffer<int> output)
{
Vector2 C0 = new Vector2(-2, -1);
Vector2 C1 = new Vector2(1, 1);
Vector2 B = new Vector2(Mandelbrot_Width, Mandelbrot_Height);
Vector2 D = (C1 - C0) / B;
FixedBuffer<float> scanLine = new FixedBuffer<float>(Mandelbrot_Width);
for (int j = 0; j < Mandelbrot_Height; j++)
{
for (int x = 0; x < Mandelbrot_Width; x++)
{
float px = C0.X + (D.X * (x));
scanLine.Write(x, ref px);
}
Vector<float> Vim = new Vector<float>(C0.Y + (D.Y * j));
for (int h = 0; h < Mandelbrot_Width; h += VectorWidth)
{
int index = j * Mandelbrot_Width + h;
Vector<float> Vre = scanLine.Read<Vector<float>>(h);
Vector<int> outputVector = GetByte(ref Vre, ref Vim, 256);
output.Write(index, ref outputVector);
}
}
scanLine.Free();
return;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
Vector<int> GetByte(ref Vector<float> Cre, ref Vector<float> Cim, int max_iterations)
{
Vector<float> Zre = Cre; //make a copy
Vector<float> Zim = Cim; //make a copy
Vector<int> MaxIterations = new Vector<int>(max_iterations);
Vector<int> Increment = One;
Vector<int> I;
for (I = Zero; Increment != Zero; I += Vector.Abs(Increment))
{
Vector<float> S = SquareAbs(Zre, Zim);
Increment = Vector.LessThanOrEqual(S, Limit) & Vector.LessThan(I, MaxIterations);
if (Increment.Equals(Zero))
{
break;
}
else
{
Vector<float> Tre = Zre;
Zre = Cre + (Zre * Zre - Zim * Zim);
Zim = Cim + 2f * Tre * Zim;
}
}
return I;
}
GetByte
相当于 ISPC 代码中的 mandel 函数。在此实现中,我们使用 FixedBuffer<float>
数组来表示每个扫描线,并将整个 Mandelbrot 绘图表示为 float
数组。我们可以使用 Read<T>()
和 Write<T>()
方法将 float
片段重新解释为 Vector<float>
,以加载 SIMD 向量寄存器并并行执行操作。我们使用执行掩码来屏蔽掉当前向量中已处理的元素。C# 代码比 ISPC 代码冗长得多,因为需要显式声明变量为向量类型,将数据在标量数组和向量寄存器之间封送,并设置执行掩码等等。
基准设置
.NET
Mandelbrot 操作将通过两种方式实现:一种是作为基准基线的串行版本,另一种是矢量化/并行版本。矢量化操作由比例因子参数化,该因子决定 Mandelbrot 集绘图区域的大小。不同的并行实现使用单线程或多线程操作,这些操作利用 .NET 任务并行库提供并发支持。操作还在两种内存类型之间进行比较:.NET 托管数组和 jemalloc.NET FixedBuffer<T>
数组。
基准测试运行时使用默认的 `BenchmarkDotNet` 计时器以及 CPU TSC。TSC 值从 Win32 API 函数 `QueryThreadCycleTime()` 获取。这使我们能够直接比较 .NET 基准测试结果与 ISPC 基准测试结果。ISPC 基准测试结果单独运行,并作为一列插入 `BenchmarkDotNet` 结果中。
每个基准测试都有一个验证步骤,用于验证算法是否正确运行并生成了正确的结果。所有不同 Mandelbrot 算法版本的输出都与基线串行版本进行比较,如果数组不相等则抛出异常。每个 Mandelbrot 实现还会生成一个 .PPM 格式的位图,可用于目视验证 Mandelbrot 操作是否成功完成。
这里描述的每个 .NET 基准测试都可以从随附的 jembench CLI 程序运行。例如:`jembench vector --mandel 3` 将以 3 倍的比例因子运行 Mandelbrot 基准测试。
ISPC
ISPC Mandelbrot 基准测试也具有串行和矢量化版本,并且也通过比例参数化。基准测试使用 `__rdtsc` MSVC 内在函数计时。该程序还会生成位图作为输出,因此您可以直观地验证算法是否成功运行并与 .NET 程序输出相同。您可以从命令行启动基准测试运行:`mandelbrot --scale=1 25 25` ISPC 示例还包括一个多线程矢量化版本,该版本通过 `mandelbrot_tasks` 命令从命令行调用。
测试硬件
我使用了两台机器来运行基准测试。由于这些基准测试仅测量一小部分内存中 x-y 坐标的 CPU 计算,因此此基准测试唯一相关的硬件规格是 CPU 类型。
- Intel Core i7-6700HQ CPU 2.60GHz (Skylake)。这是一款相对较新的 Intel 处理器,支持 AVX2 指令。
- 2 x Intel Xeon CPU X5650 2.67GHz。 Xeon X5650 是一款较旧的 CPU,仅支持 SSE4.2,没有 AVX 扩展。然而,它通过拥有 6 个物理核心弥补了这一点,在双 CPU 系统中提供了 12 个物理核心,与 Skylake 处理器的 4 个物理核心形成对比。
我们预计 Xeon 芯片在多线程测试中表现会更好,而 Skylake 芯片在单线程测试中表现会更好。
结果
- 1 M = 100 万 CPU 周期,由 CPU TSC 计时。
- 1 G = 10 亿 CPU 周期,由 CPU TSC 计时。
- 比例 x1 位图大小 = 768 * 512 = 0.4 M 像素
- 比例 x3 位图大小 = 768 * 512 * 32 = 3.5M 像素
- 比例 x3 位图大小 = 768 * 512 * 33 = 14M 像素
测试机器 1 (AVX2)
ISPC @ 比例 x1,x3,x6
单线程串行 | 单线程矢量化 | 多线程矢量化 | 单线程向量加速 | 多线程向量加速 | |
---|---|---|---|---|---|
比例 x1 | 328 M | 26.8 M | 5.6 M | 12倍 | 59倍 |
比例 x3 | 2971 M | 236 M | 48.2 M | 12倍 | 62倍 |
比例 x6 | 11.9 G | 940 M | 196.26 | 12.6倍 | 60倍 |
.NET @ 比例 x1,x3,x6
BenchmarkDotNet=v0.10.11, OS=Windows 10 Redstone 2 [1703, Creators Update] (10.0.15063.726)
Processor=Intel Core i7-6700HQ CPU 2.60GHz (Skylake), ProcessorCount=8
Frequency=2531251 Hz, Resolution=395.0616 ns, Timer=TSC
.NET Core SDK=2.1.2
[Host] : .NET Core 2.0.3 (Framework 4.6.25815.02), 64bit RyuJIT
Job=JemBenchmark Jit=RyuJit Platform=X64
Runtime=Core AllowVeryLargeObjects=True Toolchain=InProcessToolchain
RunStrategy=Throughput
方法 | 参数 | 平均 | Error(错误) | 标准差 | 线程周期 | ISPC结果 | ISPC结果2 | 缩放 | Gen 0 | Gen 1 | Gen 2 | 已分配 |
---|---|---|---|---|---|---|---|---|---|---|---|---|
“使用托管内存 v1 创建 Mandelbrot 绘图位图单线程串行。” | 1 | 301.775 毫秒 | 1.2143 毫秒 | 1.1358 毫秒 | 746.7 M | 26.8 M | 94 M | 1.00 | - | - | - | 776 B |
“使用非托管内存 v1 创建 Mandelbrot 绘图位图单线程串行。” | 1 | 311.592 毫秒 | 1.9438 毫秒 | 1.8182 毫秒 | 763.5 M | 26.8 M | 94 M | 1.03 | - | - | - | 1200 B |
“使用托管内存 v7 创建 Mandelbrot 绘图位图单线程。” | 1 | 33.404 毫秒 | 0.3979 毫秒 | 0.3527 毫秒 | 89.6 M | 26.8 M | 94 M | 0.11 | - | - | - | 944 B |
“使用托管内存 v8 创建 Mandelbrot 绘图位图多线程。” | 1 | 7.363 毫秒 | 0.1263 毫秒 | 0.1119 毫秒 | 18.2 M | 5.6 M | 14.4 M | 0.02 | 759.1912 | 153.9522 | 111.2132 | 1894306 B |
“使用非托管内存 v5 创建 Mandelbrot 绘图位图单线程。” | 1 | 32.471 毫秒 | 0.2151 毫秒 | 0.1796 毫秒 | 82.5 M | 26.8 M | 94 M | 0.11 | - | - | - | 1096 B |
“使用非托管内存 v4 创建 Mandelbrot 绘图位图多线程。” | 1 | 10.411 毫秒 | 0.1028 毫秒 | 0.0912 毫秒 | 25.8 M | 5.6 M | 14.4 M | 0.03 | 46.8750 | - | - | 26974 B |
方法 | 参数 | 平均 | Error(错误) | 标准差 | 中位数 | 线程周期 | ISPC结果 | ISPC结果2 | 缩放 | ScaledSD | Gen 0 | 已分配 |
---|---|---|---|---|---|---|---|---|---|---|---|---|
“使用托管内存 v1 创建 Mandelbrot 绘图位图单线程串行。” | 3 | 2,737.59 毫秒 | 40.764 毫秒 | 38.130 毫秒 | 2,728.30 毫秒 | 6.5 G | 236 M | 819.5 M | 1.00 | 0.00 | - | 768 B |
“使用非托管内存 v1 创建 Mandelbrot 绘图位图单线程串行。” | 3 | 2,780.78 毫秒 | 33.610 毫秒 | 31.439 毫秒 | 2,768.50 毫秒 | 6.7 G | 236 M | 819.5 M | 1.02 | 0.02 | - | 1195 B |
“使用托管内存 v7 创建 Mandelbrot 绘图位图单线程。” | 3 | 293.73 毫秒 | 5.656 毫秒 | 8.465 毫秒 | 293.95 毫秒 | 689.4 M | 236 M | 819.5 M | 0.11 | 0.00 | - | 944 B |
“使用托管内存 v8 创建 Mandelbrot 绘图位图多线程。” | 3 | 57.84 毫秒 | 1.133 毫秒 | 2.209 毫秒 | 57.58 毫秒 | 144.3 M | 48.2M | 61.6 M | 0.02 | 0.00 | 4000.0000 | 16967131 B |
“使用非托管内存 v5 创建 Mandelbrot 绘图位图单线程。” | 3 | 273.98 毫秒 | 5.428 毫秒 | 9.363 毫秒 | 277.00 毫秒 | 679.0 M | 236 M | 819.5 M | 0.10 | 0.00 | - | 1099 B |
“使用非托管内存 v4 创建 Mandelbrot 绘图位图多线程。” | 3 | 72.32 毫秒 | 1.415 毫秒 | 1.453 毫秒 | 72.13 毫秒 | 169.6 M | 48.2M | 61.6 M | 0.03 | 0.00 | - | 82613 B |
方法 | 参数 | 平均 | Error(错误) | 标准差 | 线程周期 | ISPC结果 | ISPC结果2 | 缩放 | Gen 0 | Gen 1 | 已分配 |
---|---|---|---|---|---|---|---|---|---|---|---|
“使用托管内存 v1 创建 Mandelbrot 绘图位图单线程串行。” | 6 | 10,846.5 毫秒 | 53.641 毫秒 | 50.176 毫秒 | 26.1 G | 940 M | 3.3 G | 1.00 | - | - | 776 B |
“使用非托管内存 v1 创建 Mandelbrot 绘图位图单线程串行。” | 6 | 11,116.5 毫秒 | 66.343 毫秒 | 62.057 毫秒 | 26.9 G | 940 M | 3.3 G | 1.02 | - | - | 1203 B |
“使用托管内存 v7 创建 Mandelbrot 绘图位图单线程。” | 6 | 1,112.2 毫秒 | 21.838 毫秒 | 29.153 毫秒 | 2.7 G | 940 M | 3.3 G | 0.10 | - | - | 936 B |
“使用托管内存 v8 创建 Mandelbrot 绘图位图多线程。” | 6 | 230.4 毫秒 | 4.511 毫秒 | 7.022 毫秒 | 546.4 M | 196.2M | 244.6 M | 0.02 | 17957.4468 | 148.9362 | 66783734 B |
“使用非托管内存 v5 创建 Mandelbrot 绘图位图单线程。” | 6 | 1,094.5 毫秒 | 21.365 毫秒 | 39.067 毫秒 | 2.6 G | 940 M | 3.3 G | 0.10 | - | - | 1090 B |
“使用非托管内存 v4 创建 Mandelbrot 绘图位图多线程。” | 6 | 274.8 毫秒 | 1.516 毫秒 | 1.418 毫秒 | 601.8 M | 196.2M | 244.6 M | 0.03 | - | - | 138660 B |
测试机器 2 (SSE4.2)
ISPC @ 比例 x1,x3,x6
单线程串行 | 单线程矢量化 | 多线程矢量化 | 单线程向量加速 | 多线程向量加速 | |
---|---|---|---|---|---|
比例 x1 | 295 M | 95 M | 7.3 M | 3倍 | 42倍 |
比例 x3 | 2663.3 M | 819.5 M | 61.6 M | 3倍 | 43倍 |
比例 x6 | 10.8 G | 3.3 G | 244.6 M | 3倍 | 44倍 |
.NET @ 比例 x1,x3,x6
BenchmarkDotNet=v0.10.11, OS=Windows 10 Redstone 1 [1607, Anniversary Update] (10.0.14393.1944)
Processor=Intel Xeon CPU X5650 2.67GHz, ProcessorCount=24
Frequency=2597656 Hz, Resolution=384.9624 ns, Timer=TSC
.NET Core SDK=2.1.2
[Host] : .NET Core 2.0.3 (Framework 4.6.25815.02), 64bit RyuJIT
Job=JemBenchmark Jit=RyuJit Platform=X64
Runtime=Core AllowVeryLargeObjects=True Toolchain=InProcessToolchain
RunStrategy=ColdStart
方法 | 参数 | 平均 | Error(错误) | 标准差 | 中位数 | 线程周期 | ISPC结果 | ISPC结果2 | 缩放 | ScaledSD | Gen 0 | 已分配 |
---|---|---|---|---|---|---|---|---|---|---|---|---|
“使用托管内存 v1 创建 Mandelbrot 绘图位图单线程串行。” | 1 | 362.379 毫秒 | 6.9952 毫秒 | 7.485 毫秒 | 361.012 毫秒 | 898.4 M | 26.8 M | 95 M | 1.00 | 0.00 | - | 776 B |
“使用非托管内存 v1 创建 Mandelbrot 绘图位图单线程串行。” | 1 | 373.978 毫秒 | 5.1484 毫秒 | 4.816 毫秒 | 372.067 毫秒 | 904.1 M | 26.8 M | 95 M | 1.03 | 0.02 | - | 1203 B |
“使用托管内存 v7 创建 Mandelbrot 绘图位图单线程。” | 1 | 59.493 毫秒 | 1.1786 毫秒 | 1.835 毫秒 | 59.320 毫秒 | 149.6 M | 26.8 M | 95 M | 0.16 | 0.01 | - | 912 B |
“使用托管内存 v8 创建 Mandelbrot 绘图位图多线程。” | 1 | 6.324 毫秒 | 0.5008 毫秒 | 1.477 毫秒 | 5.965 毫秒 | 9.1 M | 5.6 M | 7.3 M | 0.02 | 0.00 | - | 1732035 B |
“使用非托管内存 v5 创建 Mandelbrot 绘图位图单线程。” | 1 | 58.324 毫秒 | 1.1369 毫秒 | 2.021 毫秒 | 58.082 毫秒 | 141.3 M | 26.8 M | 95 M | 0.16 | 0.01 | - | 1098 B |
“使用非托管内存 v4 创建 Mandelbrot 绘图位图多线程。” | 1 | 8.376 毫秒 | 0.5052 毫秒 | 1.490 毫秒 | 7.840 毫秒 | 16.1 M | 5.6 M | 7.3 M | 0.02 | 0.00 | - | 13147 B |
方法 | 参数 | 平均 | Error(错误) | 标准差 | 线程周期 | ISPC结果 | ISPC结果2 | 缩放 | ScaledSD | Gen 0 | Gen 1 | 已分配 |
---|---|---|---|---|---|---|---|---|---|---|---|---|
“使用托管内存 v1 创建 Mandelbrot 绘图位图单线程串行。” | 3 | 3,229.10 毫秒 | 47.5660 毫秒 | 44.493 毫秒 | 7.9 G | 236 M | 819.5 M | 1.00 | 0.00 | - | - | 768 B |
“使用非托管内存 v1 创建 Mandelbrot 绘图位图单线程串行。” | 3 | 3,241.58 毫秒 | 32.4737 毫秒 | 30.376 毫秒 | 8.2 G | 236 M | 819.5 M | 1.00 | 0.02 | - | - | 1195 B |
“使用托管内存 v7 创建 Mandelbrot 绘图位图单线程。” | 3 | 518.36 毫秒 | 10.3181 毫秒 | 13.049 毫秒 | 1.2 G | 236 M | 819.5 M | 0.16 | 0.00 | - | - | 904 B |
“使用托管内存 v8 创建 Mandelbrot 绘图位图多线程。” | 3 | 43.42 毫秒 | 0.8537 毫秒 | 1.801 毫秒 | 88.3 M | 48.2M | 61.6 M | 0.01 | 0.00 | 2000.0000 | - | 15217779 B |
“使用非托管内存 v5 创建 Mandelbrot 绘图位图单线程。” | 3 | 511.90 毫秒 | 2.6393 毫秒 | 2.469 毫秒 | 1.3 G | 236 M | 819.5 M | 0.16 | 0.00 | - | - | 1091 B |
“使用非托管内存 v4 创建 Mandelbrot 绘图位图多线程。” | 3 | 55.52 毫秒 | 1.0988 毫秒 | 2.009 毫秒 | 137.9 M | 48.2M | 61.6 M | 0.02 | 0.00 | - | - | 32816 B |
方法 | 参数 | 平均 | Error(错误) | 标准差 | 线程周期 | ISPC结果 | ISPC结果2 | 缩放 | Gen 0 | Gen 1 | 已分配 |
---|---|---|---|---|---|---|---|---|---|---|---|
“使用托管内存 v1 创建 Mandelbrot 绘图位图单线程串行。” | 6 | 12,802.5 毫秒 | 111.763 毫秒 | 104.543 毫秒 | 31.6 G | 940 M | 3.3 G | 1.00 | - | - | 776 B |
“使用非托管内存 v1 创建 Mandelbrot 绘图位图单线程串行。” | 6 | 12,976.0 毫秒 | 119.839 毫秒 | 112.098 毫秒 | 32.0 G | 940 M | 3.3 G | 1.01 | - | - | 1203 B |
“使用托管内存 v7 创建 Mandelbrot 绘图位图单线程。” | 6 | 2,018.6 毫秒 | 13.527 毫秒 | 12.653 毫秒 | 5.3 G | 940 M | 3.3 G | 0.16 | - | - | 904 B |
“使用托管内存 v8 创建 Mandelbrot 绘图位图多线程。” | 6 | 168.7 毫秒 | 3.318 毫秒 | 3.104 毫秒 | 371.3 M | 196.2M | 244.6 M | 0.01 | 9000.0000 | 3000.0000 | 60010046 B |
“使用非托管内存 v5 创建 Mandelbrot 绘图位图单线程。” | 6 | 1,985.7 毫秒 | 17.230 毫秒 | 16.117 毫秒 | 5.1 G | 940 M | 3.3 G | 0.16 | - | - | 1091 B |
“使用非托管内存 v4 创建 Mandelbrot 绘图位图多线程。” | 6 | 209.2 毫秒 | 4.043 毫秒 | 3.781 毫秒 | 535.0 M | 196.2M | 244.6 M | 0.02 | - | - | 71691 B |
分析与讨论
我们观察到的第一件事是 ThreadCycles
TSC 值与 BenchmarkDotNet
时间匹配:例如,对于测试机器 1,在第一个 .NET 基准测试中,2.5 Ghz * 0.3 秒 ~= 750 M CPU 周期,因此 ISPC 结果和 .NET 结果之间的直接比较应该是准确的。这些基准数字是基于高度数据并行的 Mandelbrot 内核的理论值,但它们确实很好地说明了软件在理论上可能达到的性能以及在优化尝试中要达到的目标。
通常,在 Skylake 处理器上,AVX2 支持为单线程 ISPC 编译程序提供了约 12 倍的加速,为使用 Vector<T>
的单线程 .NET Core 程序提供了约 9 倍的加速。多线程 ISPC 程序能够利用 Skylake 处理器 8 个逻辑核心的更多部分,从而实现约 60 倍的加速。利用 AVX2 的多线程矢量化 .NET Core 程序能够比串行实现提高 50 倍。双 SSE CPU 系统上的多线程加速表现相当出色,在某些情况下比串行实现加速了近 60 倍。
正如预期,SSE4.2 处理器上的单线程性能比 AVX2 处理器慢得多。然而,在多线程测试中,SSE 系统能够利用 24 个可用的逻辑核心来缩小两个处理器之间的差距。
在这些基准测试中,非托管 FixedBuffer<T>
数组通常比托管数组慢。非托管数组必须承担在基准测试运行期间重复释放已分配内存的开销,而分配的 .NET 托管数组只需等待下一个 GC 周期。FixedBuffer<T>
和高级 API 中其他数据结构的优势在数百万和数千万元素规模时才真正发挥作用,此时 LOH 的碎片化会降低托管堆对象的性能,或者必须与本机代码库进行互操作时。
在 Pohl 等人的基准测试中,使用 Mandelbrot 内核在支持 AVX2 的 Intel i7-4770 处理器上对本地代码 SIMD 库进行了测试。本文中展示的 .NET Core 性能将使其排在 gSIMD 和 Sierra 等性能较低的 C++ SIMD 库之列。矢量化 .NET Core 程序比 ISPC 编译的本地代码程序慢约 3 倍。然而,矢量化 .NET Core 代码仍然比串行本地代码快得多。例如,在 x3 规模下,使用 AVX2 的单线程 .NET Core 矢量化代码比等效的串行本地代码快 4 倍。这意味着,如果使用高级框架和语言的生产力收益超过了开发本地代码程序的成本和精力,就可以用 .NET 编写 HPC 程序。将 .NET 任务并行库与 Vector<T>
结合使用,在 x3 规模下比单线程串行本地代码性能提高了 17 倍以上。在 x6 规模下,位图大小约为 14M 像素,适用于 AVX2 的多线程 .NET Core 矢量化代码比 ISPC 单线程矢量化代码快 0.5 倍。SSE 系统上多线程矢量化托管代码与单线程串行本地代码之间的性能差距更大。
利用高级类型和编程模型实现数据并行和并发性,可以使 .NET 成为开发高性能应用程序的诱人选择。为了性能而将本机代码库集成到 .NET 代码中,并不能保证获得本机代码优化的好处,除非自定义代码本身也经过优化。借助 .NET 中针对 SIMD、并发 I/O、数据库访问、网络编程和许多其他类型操作的高级编程模型,开发人员可以选择在 .NET 中开发所有 HPC 代码,从而避免承担将低级本机代码集成到其应用程序中的成本和精力。
结论
.NET Core 新增的用于高效内存访问的低级功能,结合 TPL 和 Vector<T>
等高级编程模型,使 .NET Core 成为开发需要高性能的应用程序或组件的可行选择。