使用.NET Hardware Intrinsics API加速机器学习场景

ML.NET 0.6版本刚刚发布不久,咱们知道ML.NET代码已经依赖于使用本机代码库的性能矢量化。这是一个从新实现托管代码中现有代码库的机会,使用.NET Hardware Intrinsics进行矢量化,并比较结果。html

什么是矢量化,什么是SIMD,SSE和AVX?

矢量化是用于同时将相同操做应用于阵列的多个元素的名称。在x86 / x64平台上,能够经过使用单指令多数据(SIMD)CPU指令在相似阵列的对象上操做来实现矢量化。git

SSE(Streaming SIMD Extensions)和AVX(Advanced Vector Extensions)是x86架构的SIMD指令集扩展的名称。SSE已经存在很长时间了:CoreCLR底层.NET Core要求x86平台至少支持SSE2指令集。AVX是SSE的扩展,如今能够普遍使用。它的关键优点在于它能够在一条指令中处理内存中8个连续的32位元素,是SSE的两倍。github

.NET Core 3.0将SIMD指令公开为可直接用于托管代码的API,从而无需使用本机代码来访问它们。api

基于ARM的CPU确实提供了相似的内在函数,但.NET Core尚不支持它们(尽管工做正在进行中)。所以,当AVX和SSE都不可用时,必须使用软件回退代码路径。JIT使得以很是有效的方式执行此回退成为可能。当.NET Core确实公开ARM内在函数时,代码能够利用它们,此时软件回退不多须要。 数组

项目目标

  1.  经过使用软件回退建立单个托管程序集,增长ML.NET平台范围(x86,x64,ARM32,ARM64等)
  2.  经过在可用的状况下使用AVX指令来提升ML.NET性能
  3. 验证.NET硬件内在函数API 并演示性能与本机代码至关

本来能够经过简单地更新本机代码来使用AVX指令来实现第二个目标,可是同时转移到托管代码我能够消除为每一个目标架构构建和发布单独二进制文件的须要 - 它一般也更容易维护托管代码。架构

挑战

首先要熟悉C#和.NET,而后个人工做包括:机器学习

  • 用于C#中CPU数学运算的基层实现。若是你不熟悉,请参阅这篇伟大的MSDN杂志文章C# - All About Span:探索新的.NET Mainstay以及文档 
  • 根据可用性,启用AVX,SSE和软件实现之间的切换。
  • 正确处理托管代码中的指针,并删除某些现有代码所作的对齐假设
  • 使用multitargeting容许ML.NET继续在没有.NET Hardware Intrinsics API的平台上运行。

多目标

.NET Hardware Intrinsics将在.NET Core 3.0中提供,目前正在开发中。ML.NET还须要在.NET Standard 2.0兼容平台上运行 - 例如.NET Framework 4.7.2和.NET Core 2.1。为了支持这二者,我选择使用多目标建立一个同时针对.NET Standard 2.0和.NET Core 3.0的.csproj 文件。   函数

  1. .NET Standard 2.0上,系统将使用具备SSE硬件内在函数的原始本机实现 
  2. .NET Core 3.0上,系统将使用带有AVX硬件内在函数的新托管实现。 

代码之初

在原始代码中,机器学习中使用的每一个培训师,学习者和转换最终都称为包装器方法,对输入数组执行CPU数学运算,例如 SseUtils 性能

  • MatMulDense,它将两个密集数组的矩阵乘法解释为矩阵,和
  • SdcaL1UpdateSparse,执行稀疏数组的随机双坐标上升的更新步骤。

这些包装器方法假定优先选择SSE指令,并在另外一个类中调用相应的方法,该方法用做托管代码和本机代码之间的接口,并包含直接调用其本机等效项的方法。文件中的这些本机方法依次使用包含SSE硬件内在函数的循环实现CPU数学运算。学习

打破托管代码路径

对于这段代码,我为在.NET Core 3.0上变为活动的CPU数学运算添加了一个新的独立代码路径,并保持原始代码路径在.NET Standard 2.0上运行。之前全部方法的调用站点如今都称为相同名称的方法,保持CPU数学运算的API签名相同。 SseUtils CpuMathUtils 

CpuMathUtils 是一个新的分部类,它包含表示CPU数学运算的每一个公共API的两个定义,其中一个仅在.NET Standard 2.0上编译,而另外一个仅在.NET Core 3.0上编译。此条件编译功能为方法建立两个独立的代码路径在.NET Standard 2.0上编译的那些函数定义直接调用它们的对应物,它们基本上遵循原始的本机代码路径。 

software fallback编写代码

另外一方面,在.NET Core 3.0上编译的其余函数定义根据运行时的可用性切换到同一CPU数学运算的三个实现之一:

  1. 一个用包含AVX硬件内在函数的循环实现操做方法, AvxIntrinsics 
  2. 一个用包含SSE硬件内在函数的循环实现操做方法 SseIntrinsics 
  3. 软件后备,以防AVX和SSE都不受支持。

每当代码使用.NET硬件内在函数时,您一般会看到此模式 - 例如,这是向向量添加标量的代码:

若是支持AVX,则首选,不然使用SSE(若是可用),不然使用软件回退路径。在运行时,JIT实际上只为这三个块中的一个生成代码,适用于它本身发现的平台。

为了给你一个想法,这里的AVX实现看起来像上面的方法调用:

您将注意到它使用AVX以8为一组进行操做,而后使用SSE对任何4组进行操做,最后为任何剩余的进行软件循环。

因为托管代码中方法直接实现相似于最初在文件中的本机方法的CPU数学运算,所以代码更改不只消除了本机依赖性,还简化了公共API和基础层硬件内在函数之间的抽象级别。

在进行此替换后,我可以使用ML.NET执行任务,例如具备随机双坐标上升的列车模型,进行超参数调整,以及在Raspberry Pi上执行交叉验证,此时ML.NET须要x86 CPU。 

这就是如今架构的样子(图1):

性能改进

那么这对性能有何不一样?

我使用Benchmark.NET编写测试来收集测量数据。  

首先,我禁用了AVX代码路径,以便在使用相同的SSE指令时公平地比较本机和托管实现。如图2所示,性能具备可比性:在测试运行的大型向量上,托管代码添加的开销并不显着。 

图2

其次,我启用了AVX支持。图3显示微基准测试的平均性能增益比单独的SSE高20%  

图3

将二者结合起来 - 从本机代码中的SSE实现升级到托管代码中的AVX实现 - 我测量了微基准测试18%的改进。有些操做的速度提升了42%,而其余一些涉及稀疏输入的操做则有进一步优化的潜力。  

最重要的固然是真实场景的表现。在.NET Core 3.0上,K-means聚类和逻辑回归的训练模型加快了约14%(图4)。  

 图4

我但愿这已经证实了.NET硬件内在函数的强大功能,而且我鼓励您在.NET Core 3.0预览可用时考虑在本身的项目中使用它们的机会。

相关文章
相关标签/搜索