关于 vectorization
.NET SIMD vectorization 面向使用 SIMD 优化 .NET 代码的开发者,提供编写可移植且内存安全实现的规则,要求保持标量行为,并明确测试与基准测量要求。
主要功能
- 01优先使用现有加速框架 API
- 02遵循可移植 SIMD 分派规则
- 03安全处理尾部与缓冲区重叠
- 04测试标量和向量路径
- 05与标量实现进行基准比较
适用场景
- 01检查框架 API 后优化数值循环
- 02验证 SIMD 边界情况和重叠处理
- 03衡量更宽向量路径是否值得采用
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dotnet/ skills --skill vectorization安装后在 Claude Code 中输入/vectorization确认已加载。
详细介绍
.NET SIMD 向量化
实现可移植的优化,在保持标量契约的同时,确保所有长度下的内存安全,并通过实测结果证明其复杂度值得。请先阅读官方的 SIMD 和硬件内置函数指南, 并遵循其中全面的实现模板。尤其要采用其中按位宽自包含的分派方式、专门的小输入处理、循环和余数处理结构,而不是将其简化为一连串位宽检查。本技能提供了在修改实际代码时应采用的决策规则和验证检查。
输入和前提条件
在询问用户之前,先从代码库中确认以下内容:
| 输入 | 必需 | 需要确认的内容 |
|---|---|---|
| 标量实现和测试 | 是 | 现有契约、具有代表性的调用位置,以及支持的重叠情况 |
| 目标框架和平台 | 是 | 可用的 SIMD API,以及必须保持一致行为的架构 |
| 构建和测试工作流 | 是 | 代码库的常规命令,以及如何启动独立的测试进程 |
| 具有代表性的工作负载或基准测试 | 优化时需要 | 典型输入大小和要超越的基线 |
不要仅仅因为某个 API 存在于某个包中就添加该包。应先检查目标框架和项目现有的依赖项及版本管理策略。
核心规则
- 使用与契约匹配的最高层级 API,然后止步。
Span<T>和string操作、TensorPrimitives以及张量类型已经能加速许多操作。如果源对象公开了底层 span,LINQ 归约操作(如Sum、Min、Max和Average)也可以加速。请验证空输入和浮点行为,不要假定名称相似的操作可以互换。现有 API 能保留契约时,应使用它,而不是继续手写 SIMD。在编写显式循环之前,请列出考虑过的框架 API,并说明为何都不适用。固定形状的System.Numerics类型仍适用于图形和类似领域。 - 编写新的显式 SIMD 循环时,从
Vector128<T>开始。 它在最广泛的硬件上都能加速。只有实测证明有必要时,才添加更宽的固定宽度路径。 - 保持平台间行为一致。 优先使用固定宽度向量类型上的跨平台操作;这些操作会被转换为相应的目标指令。例如,
(vector & mask) == Vector128<byte>.Zero在 x86/x64 上会转换为ptest。只有在实测发现性能差距时才使用架构专用内置函数,并用IsSupported进行保护,同时保留等效的可移植或标量行为。 - 直接读取
IsHardwareAccelerated、IsSupported和Count。 JIT 会将它们视为常量,因此缓存它们毫无价值,还会掩盖哪些分支会被消除。 - 表达清晰时优先使用运算符。 混用位运算符和比较运算符时,请用括号明确优先级。
如果任务仅限于审查,请勿重写代码。应先报告正确性和内存安全缺陷,再讨论性能优化机会。
编写检查清单
- **契约:**在更改实现之前,先明确空输入和短输入、重叠、溢出、NaN、有符号零、顺序和异常方面的行为。
- **框架检查:**检查目标框架和现有的包引用,然后使用所需的边界情况编译或测试最高级别的候选 API。小型契约适配器(例如保留空输入的特殊行为)并不足以成为重新实现该操作的理由。如果 API 符合契约,就使用它并停止;未经检查,不要声称该 API 不可用。
- **结构:**对于新的显式 SIMD,先实现
Vector128<T>和标量路径。只有测量结果证明需要更宽的路径后,才依次检查Vector512<T>、Vector256<T>、可选的Vector<T>、Vector128<T>,最后是标量。省略实现不需要的路径。每个外层固定宽度保护条件只检查其IsHardwareAccelerated属性,以及泛型元素类型的IsSupported。在该代码块内,如果输入至少包含Count个元素,就运行特定宽度的辅助方法;否则运行专用的小输入辅助方法,然后返回。不要把长度检查放在外层保护条件中,再继续到较窄宽度重复分派。将每个受支持宽度的代码块保持为独立整体,可让 JIT 移除不受支持的代码块,并避免常见短输入上的冗余工作。 - **加载和存储:**优先使用基于 span 的
Vector128.Create(span)和CopyTo;JIT 会高效处理它们,而且无需固定内存或引用运算。通常不需要不安全的加载和存储。如果某条路径确实必须通过托管引用遍历缓冲区,请使用按元素偏移量操作的LoadUnsafe(ref T, nuint)和StoreUnsafe重载,而不是指针或手动递增引用。 - **空输入:**在基于引用的路径中,使用
MemoryMarshal.GetReference(span)或MemoryMarshal.GetArrayDataReference(array)获取起始引用,不要通过索引元素0来获取。 - **不受支持的元素类型:**固定宽度向量支持基本数值元素类型,不支持
char或bool。使用MemoryMarshal.Cast或As<TFrom, TTo>重新解释;重新解释只会改变类型,不会改变位。将布尔数据保留为0或1,将字符保留为有效的 UTF-16,并在必要时于存储前规范化结果。 - **偏移量:**在减去
Count或将索引转换为nuint之前,先确认输入包含完整向量;否则,负值会变成很大的无符号偏移量。 - **托管引用:**不要构造指向 span 起始位置之前或末尾之后的引用,包括指向末尾后一个位置的引用。运行时允许非解引用的托管指针恰好指向对象或数组之后一个位置,但本指南有意禁止这种模式,因为它很脆弱且容易误用。保持基引用位于有效范围内,并使用元素偏移量表示遍历位置。
- **余数:**覆盖所有长度,包括
0、Count - 1、Count、Count + 1,以及不能被各宽度整除的长度。输入包含完整向量后,继续以向量方式处理尾部:重新处理最后一个完整向量。幂等操作可以直接合并这段重叠处理。非幂等操作必须使用ConditionalSelect,将重复的通道替换为该操作的单位元后再合并。这是 JIT 可识别的通用模式;它可以将零单位元的选择优化为位掩码,同时保留更广泛的优化机会。对于原地转换,在重叠存储之前保留原始尾部值,并且只写入有效结果。 - **缓冲区重叠:**选择遍历方向或暂存策略,以防存储操作破坏尚未加载的值。
- **数值行为:**考虑浮点数重新结合、NaN 和有符号零语义、有符号或无符号整数溢出,以及算法依赖字节序时的端序。
Native和Estimate操作可能会有意放宽精度或 IEEE 边界情况行为;只有在契约允许且测量结果证明合理时才使用它们。 官方指南中包含完整的分派、小输入、展开和余数处理模板;完整实现时请使用这些模板。以下摘录仅展示原地逐元素变换中安全的Vector128<T>内层循环,前提是独立的分派代码块已确认至少有一个完整向量。Transform表示待实现的操作:
Span<int> tail = data.Slice(data.Length - Vector128<int>.Count);
Vector128<int> end = Vector128.Create<int>(tail);
Span<int> remaining = data;
while (remaining.Length >= Vector128<int>.Count)
{
Vector128<int> values = Vector128.Create<int>(remaining);
Transform(values).CopyTo(remaining);
remaining = remaining.Slice(Vector128<int>.Count);
}
if (!remaining.IsEmpty)
{
Transform(end).CopyTo(tail);
}
提前加载 end 可在存在重叠写入时保留原始值。对于只读归约,在主循环之后加载相同的最终范围,并使用 ConditionalSelect 将已处理的通道替换为该操作的单位元。不要仅为避免跨度边界检查而改用 LoadUnsafe/StoreUnsafe 或标量收尾代码。
测试清单
- 针对边界长度、随机值、空输入、支持的重叠情况和数值边界情况,将优化实现与标量契约进行比较。覆盖所有已实现的宽度以及标量路径,并使用足够大和过小、无法从中获益的输入。
- 在独立进程中测试每种已实现的宽度和标量回退路径。在 x86/x64 CoreCLR 上,
DOTNET_EnableAVX2=0可禁用 AVX2,DOTNET_EnableHWIntrinsic=0可禁用硬件内在函数。使用仓库的常规测试命令,不要在单元测试中更改这些进程级设置。这些设置不会更改已编译为 ReadyToRun 或提前编译的代码,因此用它们强制选择某条路径时,请确认目标代码由 JIT 编译。 - 对于不安全加载和存储,在条件允许时使用保护页或等效的边界测试。正向迭代时,将不可访问页放在缓冲区之后;反向迭代时,将其放在缓冲区之前,并包含非整数倍长度。普通数组分配无法可靠地暴露越界读取。
基准测试
使用 BenchmarkDotNet 测量有代表性的小型和大型输入,再决定是否保留新增的复杂性。在同一次运行中比较标量、Vector128<T> 和每种已实现的更宽路径。小型输入可能更慢,因为开销主要来自准备工作;受内存吞吐量、对齐和延迟影响,实际加速通常达不到理论上的向量宽度倍数。报告吞吐量或耗时,并说明噪声背景;相关时,还应报告生成代码大小或指令数。为获得稳定测量,需控制分配对齐;也可随机化对齐,以观察其分布。更宽的向量并不一定更快。
如果项目无法面向所需框架、运行相关架构或执行回退配置,请准确说明哪些路径仍未验证。不要仅凭默认硬件上的测试就宣称成功。
完成约定
- 编写: 确保测试覆盖标量契约;说明所选框架或 SIMD 层;报告有代表性工作负载的测量结果;指出任何未能测试的架构或回退路径。
- 审查: 仅报告具体问题,并依正确性、内存安全、可移植性、测试、性能证据的顺序排列。若没有问题,请直接说明。
- 如果优化仅能构建、仅在当前机器上通过,或没有与标量基线进行比较,就不要称其已完成。
审查清单
按以下顺序审查:
- 是否等同于标量契约,包括有符号零、NaN、溢出和相关的字节序
- 是否复用了现有的加速框架 API
- 幂等与非幂等操作的尾部处理是否正确
- 内存安全、无符号偏移运算、空输入和缓冲区重叠
- 可移植的分派以及行为等效的回退路径
- 能否通过测试强制执行每种宽度和标量路径
- 基准测试是否证明显式 SIMD 和增加更多宽度的合理性