C vs .NET 数组原地反转实测小数组 C 碾压大数组 .NET 反杀前几天在知乎看到一篇文章《将一个序列反序在C与C#下性能比较》链接大家可以自行搜索。作者对比了 C# 的“托管/非托管”实现和 C 的std::reverse_copy最后得出的结论是在小数组1000 个元素下 C 远超 .NET而在大数据量下 .NET 非托管优于托管。文章的切入点挺有意思但作为老 .NET 开发者我看完代码后发现这个对比其实没有控制好变量C# 版本测试的是原地反转In-place reverse只做指针/索引交换不分配新内存而 C 版本用的是std::reverse_copy到一个新vector中包含了内存分配和数据拷贝。这俩的语义完全不对等底层的成本结构也完全不一样。拿“纯计算”去和“内存分配拷贝”比性能得出的结论很容易误导人。好奇之下我决定自己动手做个控制变量的公平测试双方都只测纯粹的原地反转并使用专业工具BenchmarkDotNet 和自写的 C 高精度基准跑一下。结果非常有意思小数组下 C 确实碾压但数据量一上来.NET 的Array.Reverse确实能反杀下面是完整的复现过程、代码和数据分析。为什么原文章的对比不够公平我们先快速回顾一下原文章里的代码逻辑。C# 原地反转Span Slice 写法static void ReverseT(SpanT span) { while(span.Length 1) { T firstElement span[0]; T lastElement span[^1]; span[0] lastElement; span[^1] firstElement; span span[1..^1]; // Slicing in each iteration introduces noticeable overhead } }C 非原地反转分配拷贝// C11 std::vectorint test1() { std::vectorint rev(NumSize); // New allocation! std::reverse_copy(vec.cbegin(), vec.cend(), rev.begin()); return rev; }发现问题了吗C 每次都在 new 内存。在小数组测试中内存分配的开销成了主导在大数组测试中带宽和缓存的影响又掩盖了纯粹的反转逻辑。原作者得出的“C .NET”更多是在测“分配拷贝”的耗时而不是单纯的反转算法效率。控制变量双方纯原地反转对决为了得到准确的结论我重新制定了测试规则1.纯原地操作全部使用std::reverse/Array.Reverse或手写循环绝不分配新数组。2.防状态污染每轮 Benchmark 前恢复原始数据连续递增的 int 数组。3.防死代码消除 (DCE)对反转后的结果进行消费计算 checksum。4.测试规模N1,000测小规模调用的固定开销N1,000,000测大规模吞吐量。.NET 端测试代码BenchmarkDotNet为了探究极限我写了四种实现原生Array.Reverse、Span 切片、常规下标以及 Unsafe 指针。using System; using System.Linq; using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Columns; using BenchmarkDotNet.Configs; using BenchmarkDotNet.Exporters; using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Order; using BenchmarkDotNet.Running; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; BenchmarkRunner.RunReverseBench(new Config()); public sealed class Config : ManualConfig { public Config() { AddJob(Job.ShortRun .WithWarmupCount(3) .WithIterationCount(8)); AddColumnProvider(DefaultColumnProviders.Instance); AddExporter(MarkdownExporter.GitHub); WithOrderer(new DefaultOrderer(SummaryOrderPolicy.FastestToSlowest)); } } [MemoryDiagnoser] [RankColumn] public class ReverseBench { private int[] _original Array.Emptyint(); private int[] _work Array.Emptyint(); [Params(1_000, 1_000_000)] public int N; [GlobalSetup] public void Setup() { _original Enumerable.Range(0, N).ToArray(); _work new int[N]; } [IterationSetup] public void Reset() { Array.Copy(_original, _work, N); } [Benchmark(Baseline true)] public void ArrayReverse() Array.Reverse(_work); [Benchmark] public void ManagedSpanSliceReverse() ManagedSpanSlice(_work); [Benchmark] public void ManagedIndexReverse() ManagedIndex(_work); [Benchmark] public void UnsafeSpanReverse() UnsafeSpan(_work); private static void ManagedSpanSlice(Spanint span) { while (span.Length 1) { int first span[0]; int last span[^1]; span[0] last; span[^1] first; span span[1..^1]; } } private static void ManagedIndex(Spanint span) { int i 0; int j span.Length - 1; while (i j) { int tmp span[i]; span[i] span[j]; span[j] tmp; i; j--; } } private static void UnsafeSpan(Spanint span) { if (span.Length 1) return; ref int left ref MemoryMarshal.GetReference(span); ref int right ref Unsafe.Add(ref left, span.Length - 1); do { int a left; int b right; left b; right a; left ref Unsafe.Add(ref left, 1); right ref Unsafe.Subtract(ref right, 1); } while (Unsafe.IsAddressLessThan(ref left, ref right)); } }(环境配置: Ubuntu 24.04, AMD EPYC 7763, .NET 10.0.3 RyuJIT AVX2)C 端测试代码C 这边同样准备了三种实现std::reverse标准库、手写下标和手写指针。为了对标 BenchmarkDotNet我自己写了个高精度计时器。#include algorithm #include chrono #include cstdint #include functional #include iomanip #include iostream #include numeric #include string #include utility #include vector using Clock std::chrono::steady_clock; // Prevent compiler from optimizing away the results static inline void do_not_optimize(const void* p){ asm volatile( : : g(p) : memory); } static void reverse_std(std::vectorint v){ std::reverse(v.begin(), v.end()); } static void reverse_index(std::vectorint v){ if (v.size() 1) return; size_t i 0; size_t j v.size() - 1; while (i j) { int t v[i]; v[i] v[j]; v[j] t; i; --j; } } static void reverse_pointer(std::vectorint v){ if (v.size() 1) return; int* left v.data(); int* right v.data() v.size() - 1; while (left right) { int t *left; *left *right; *right t; left; --right; } } struct Result { std::string name; int n; int iterations; double mean_ns; double ns_per_element; }; static Result bench(const std::string name, int n, int iterations, const std::functionvoid(std::vectorint) fn){ std::vectorint original(n); std::iota(original.begin(), original.end(), 0); std::vectorint work(n); // Warmup for (int i 0; i 3; i) { work original; fn(work); do_not_optimize(work.data()); } auto start Clock::now(); std::uint64_t checksum 0; for (int i 0; i iterations; i) { work original; fn(work); checksum static_caststd::uint64_t(work[n / 2]); do_not_optimize(work.data()); } auto end Clock::now(); do_not_optimize(checksum); double total_ns std::chrono::durationdouble, std::nano(end - start).count(); double mean_ns total_ns / iterations; return {name, n, iterations, mean_ns, mean_ns / n}; } int main(){ std::vectorint sizes {1000, 1000000}; std::vectorstd::pairstd::string, std::functionvoid(std::vectorint) fns { {std::reverse, reverse_std}, {manual_index, reverse_index}, {manual_pointer, reverse_pointer}, }; std::cout impl,n,iterations,mean_ns,ns_per_element\n; for (int n : sizes) { int iterations n 1000 ? 200000 : 600; for (auto [name, fn] : fns) { auto r bench(name, n, iterations, fn); std::cout r.name , r.n , r.iterations , std::fixed std::setprecision(2) r.mean_ns , std::fixed std::setprecision(6) r.ns_per_element \n; } } return 0; }(环境配置: g 13.3.0, -O3 -marchnative -stdc20)核心对决谁才是真正的性能怪兽直接来看两边跑出来的最快成绩对比数组规模 (N)C 最快实现耗时 (Mean).NET 最快实现耗时 (Mean)谁赢了1,000manual_pointer150.83 nsArray.Reverse445.60 nsC 快 2.95x1,000,000manual_pointer162,917 nsArray.Reverse88,716 ns.NET 快 1.84xN1,000 时 C 与 C# 的正面对位对比N1,000,000 时 C 与 C# 的正面对位对比C 原始结果CSVimplniterationsmean_nsns_per_elementstd::reverse1000200000152.290.152291manual_index1000200000394.250.394251manual_pointer1000200000150.830.150829std::reverse1000000600199966.210.199966manual_index1000000600426880.660.426881manual_pointer1000000600162917.170.162917.NET BenchmarkDotNet 完整结果测试环境如下BenchmarkDotNet v0.14.0, Ubuntu 24.04.4 LTS (Noble Numbat) (container) AMD EPYC 7763, 1 CPU, 4 logical and 2 physical cores .NET SDK 10.0.103 [Host] : .NET 10.0.3 (10.0.326.7603), X64 RyuJIT AVX2 ShortRun : .NET 10.0.3 (10.0.326.7603), X64 RyuJIT AVX2 JobShortRun InvocationCount1 IterationCount8 LaunchCount1 UnrollFactor1 WarmupCount3MethodNMeanErrorStdDevRatioRatioSDRankArrayReverse1000445.6 ns52.67 ns27.55 ns1.000.081ManagedIndexReverse10003,035.8 ns53.50 ns27.98 ns6.830.402UnsafeSpanReverse10004,873.0 ns436.03 ns193.60 ns10.970.753ManagedSpanSliceReverse10009,961.2 ns120.77 ns43.07 ns22.431.304ArrayReverse100000088,716.0 ns761.39 ns398.22 ns1.000.011UnsafeSpanReverse1000000349,287.4 ns22,874.65 ns11,963.88 ns3.940.132ManagedIndexReverse1000000412,116.6 ns18,274.90 ns9,558.12 ns4.650.102ManagedSpanSliceReverse1000000501,977.0 ns25,291.77 ns13,228.09 ns5.660.142为什么会出现这种两级反转1.小数组场景C 的主场在处理 1000 个元素时C 的指针版开销极小。没有边界检查极致紧凑的循环编译器直接将其优化到了硬件指令的极限。而 .NET 虽然Array.Reverse很快但在小数组下托管环境的方法调用开销、类型检查等固定成本占比就凸显出来了导致略逊一筹。2.大数组场景.NET 的反杀当数据量来到 100 万时.NET 的Array.Reverse(int[])展现出了恐怖的吞吐量直接拉开了近一倍的差距。为什么因为 .NET 运行时的Array.Reverse针对基元类型Primitive types做了深度优化底层大概率走的是专属的 JIT 路径或高度优化的 SIMD/向量化指令。反观我们自己手写的 Unsafe 代码或者原生 C 循环如果没有显式进行向量化优化在大吞吐量面前反而打不过官方的基础库。3.永远不要盲目自信手写算法测试数据证实了原知乎文章里的一个现象用 Span Slice 的写法确实是最慢的切片开销大。但同时我们也发现在 .NET 中哪怕你用上了Unsafe指针操作依旧跑不过原生的Array.Reverse。这告诉我们永远优先相信标准库。那帮写 Runtime 的微软大佬底层的骚操作远比我们手写的while循环要多得多。结语抛开场景谈性能就是耍流氓。通过控制好“原地反转”这个核心变量我们看到了 C 在微操作上的极致低开销也看到了当代 .NET 在大数据吞吐和标准库优化上的强悍实力。如果你觉得这种深扒底层细节的硬核性能分析有意思或者也在做 C# / .NET 的高性能开发欢迎在评论区聊聊你的看法。也欢迎大家加入我的.NET骚操作 QQ群495782587一起探讨更多硬核技术玩法