为何使用Span<T>比普通数组的性能开销更高?
我有一个使用多年的复杂数学算法,近期性能测试发现它占用大量CPU资源。该算法编写时间较早,多次使用double[] array = new double[6]并传递给子函数。原本以为改用Span<double>和stackalloc double能通过减少GC负载、减少范围检查来提升性能,结果却相反——整体测试时间从23秒增至28秒。
我编写了简化测试用例复现该现象(实际代码复杂得多):
// 循环次数要足够大才能看到简单测试用例的耗时差异 private const int MeasuremenLoopCount = 1000000; private const int ArraySize = 10; [Fact] public void UseArray() { for (int i = 0; i < MeasuremenLoopCount; i++) { double[] array = new double[ArraySize]; array[0] = 2; CalcOnArray(array); } } private void CalcOnArray(double[] array) { for (int i = 1; i < array.Length; i++) { array[i] = array[i - 1] * 2; } } [Fact] public void UseSpanOnArray() { for (int i = 0; i < MeasuremenLoopCount; i++) { Span<double> array = new double[ArraySize]; array[0] = 2; CalcOnSpan(array); } } private void CalcOnSpan(Span<double> array) { for (int i = 1; i < array.Length; i++) { array[i] = array[i - 1] * 2; } } [Fact] public void UseSpanOnStack() { for (int i = 0; i < MeasuremenLoopCount; i++) { StackAllocArray(); } } // 这个方法仅用于规避“循环中使用stackalloc”的错误提示 private void StackAllocArray() { Span<double> array = stackalloc double[ArraySize]; array[0] = 2; CalcOnSpan(array); }
在我的系统上,各测试用例耗时如下:
UseArray:46msUseSpanOnArray:57msUseSpanOnStack:55ms
后两者的微小差异可能源于栈内存相对堆内存的优势,但为什么UseArray比UseSpanOnArray性能高出不少?
分析器运行结果显示测试用例间性能关系一致,但未提供更多有效信息。
JIT优化差异:数组的范围检查消除更彻底
CLR的JIT编译器对double[]这类具体数组类型有更成熟的优化支持。它能轻松识别循环边界与数组长度的匹配关系,完全消除数组访问的范围检查。而Span<T>是泛型结构体,JIT对其范围检查的消除逻辑更复杂,当Span指向堆数组时,部分场景下无法完全消除边界检查,额外的检查逻辑会累积出可观的性能开销。小数组的GC负载远低于预期
测试中使用的ArraySize=10属于极小数组,CLR会将这类数组分配到小对象堆(SOH),且由于分配频率极高,CLR的内存分配器会复用已释放的内存块,实际产生的GC负载极低。反而Span<double>包装堆数组时,额外的结构体实例化、指针和长度信息的存储会带来微小但累积的开销。数组转Span的隐式转换开销
将double[]赋值给Span<double>时会触发隐式转换,这个过程需要创建包含指针、长度的Span<T>结构体实例。单次转换开销可以忽略,但在百万次循环的累积下,这部分开销会被放大,最终体现为整体耗时的增加。栈分配Span的优势被抵消
栈分配的Span虽然避免了堆分配,但现代CPU的缓存机制对小对象堆数组的局部性优化已经非常成熟,堆数组同样能被高效缓存。同时,栈分配的Span受限于栈空间大小,在复杂算法中可能无法充分发挥作用,反而因结构体的额外逻辑拖慢性能。
- 复用数组对象:如果核心瓶颈是内存分配,可创建数组池缓存,避免每次循环新建数组,这比切换到Span更能有效降低GC负载。
- 强制方法内联:将
CalcOnSpan标记为[MethodImpl(MethodImplOptions.AggressiveInlining)],强制JIT内联方法,减少结构体传递的开销。 - 按需使用Span:极小数组场景下,数组的JIT优化已经足够优秀,无需强行切换到Span;只有当数组尺寸较大、或需要处理非托管内存/栈内存时,Span才会体现出明显优势。
内容的提问来源于stack exchange,提问作者PMF

