C数组VS.NET数组,小数组反转胜,大数组反转赢?

摘要:前几天在知乎看到一篇文章:《将一个序列反序,在C++与C#下性能比较》(链接大家可以自行搜索)。作者对比了 C# 的“托管非托管”实现和 C++ 的 std::rev
前几天在知乎看到一篇文章:《将一个序列反序,在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 Reverse<T>(Span<T> 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++ 非原地反转(分配+拷贝): // C++11 std::vector<int> test1() { std::vector<int> rev(NumSize); // New allocation! std::reverse_copy(vec.cbegin(), vec.cend(), rev.begin()); return rev; } 发现问题了吗?C++ 每次都在 new 内存。在小数组测试中,内存分配的开销成了主导;在大数组测试中,带宽和缓存的影响又掩盖了纯粹的反转逻辑。原作者得出的“C++ > .NET”,更多是在测“分配+拷贝”的耗时,而不是单纯的反转算法效率。 控制变量:双方纯原地反转对决 为了得到准确的结论,我重新制定了测试规则: 纯原地操作:全部使用 std::reverse / Array.Reverse 或手写循环,绝不分配新数组。 防状态污染:每轮 Benchmark 前恢复原始数据(连续递增的 int 数组)。 防死代码消除 (DCE):对反转后的结果进行消费(计算 checksum)。 测试规模:N=1,000(测小规模调用的固定开销),N=1,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.Run<ReverseBench>(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.Empty<int>(); private int[] _work = Array.Empty<int>(); [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(Span<int> 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(Span<int> 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(Span<int> 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::vector<int>& v) { std::reverse(v.begin(), v.end()); } static void reverse_index(std::vector<int>& 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::vector<int>& 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::function<void(std::vector<int>&)>& fn) { std::vector<int> original(n); std::iota(original.begin(), original.end(), 0); std::vector<int> 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_cast<std::uint64_t>(work[n / 2]); do_not_optimize(work.data()); } auto end = Clock::now(); do_not_optimize(&checksum); double total_ns = std::chrono::duration<double, std::nano>(end - start).count(); double mean_ns = total_ns / iterations; return {name, n, iterations, mean_ns, mean_ns / n}; } int main() { std::vector<int> sizes = {1000, 1000000}; std::vector<std::pair<std::string, std::function<void(std::vector<int>&)>>> 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 -march=native -std=c++20) 核心对决:谁才是真正的性能怪兽? 直接来看两边跑出来的最快成绩对比: 数组规模 (N) C++ 最快实现 耗时 (Mean) .NET 最快实现 耗时 (Mean) 谁赢了? 1,000 manual_pointer 150.83 ns Array.Reverse 445.60 ns C++ 快 2.95x 1,000,000 manual_pointer 162,917 ns Array.Reverse 88,716 ns .NET 快 1.84x C++ 原始结果(CSV) impl n iterations mean_ns ns_per_element std::reverse 1000 200000 152.29 0.152291 manual_index 1000 200000 394.25 0.394251 manual_pointer 1000 200000 150.83 0.150829 std::reverse 1000000 600 199966.21 0.199966 manual_index 1000000 600 426880.66 0.426881 manual_pointer 1000000 600 162917.17 0.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 Job=ShortRun InvocationCount=1 IterationCount=8 LaunchCount=1 UnrollFactor=1 WarmupCount=3 Method N Mean Error StdDev Ratio RatioSD Rank ArrayReverse 1000 445.6 ns 52.67 ns 27.55 ns 1.00 0.08 1 ManagedIndexReverse 1000 3,035.8 ns 53.50 ns 27.98 ns 6.83 0.40 2 UnsafeSpanReverse 1000 4,873.0 ns 436.03 ns 193.60 ns 10.97 0.75 3 ManagedSpanSliceReverse 1000 9,961.2 ns 120.77 ns 43.07 ns 22.43 1.30 4 ArrayReverse 1000000 88,716.0 ns 761.39 ns 398.22 ns 1.00 0.01 1 UnsafeSpanReverse 1000000 349,287.4 ns 22,874.65 ns 11,963.88 ns 3.94 0.13 2 ManagedIndexReverse 1000000 412,116.6 ns 18,274.90 ns 9,558.12 ns 4.65 0.10 2 ManagedSpanSliceReverse 1000000 501,977.0 ns 25,291.77 ns 13,228.09 ns 5.66 0.14 2 为什么会出现这种两级反转? 小数组场景(C++ 的主场): 在处理 1000 个元素时,C++ 的指针版开销极小。没有边界检查,极致紧凑的循环,编译器直接将其优化到了硬件指令的极限。而 .NET 虽然 Array.Reverse 很快,但在小数组下,托管环境的方法调用开销、类型检查等固定成本占比就凸显出来了,导致略逊一筹。 大数组场景(.NET 的反杀): 当数据量来到 100 万时,.NET 的 Array.Reverse(int[]) 展现出了恐怖的吞吐量,直接拉开了近一倍的差距。为什么?因为 .NET 运行时的 Array.Reverse 针对基元类型(Primitive types)做了深度优化,底层大概率走的是专属的 JIT 路径或高度优化的 SIMD/向量化指令。 反观我们自己手写的 Unsafe 代码或者原生 C++ 循环,如果没有显式进行向量化优化,在大吞吐量面前反而打不过官方的基础库。 永远不要盲目自信手写算法: 测试数据证实了原知乎文章里的一个现象:用 Span Slice 的写法确实是最慢的(切片开销大)。但同时我们也发现,在 .NET 中,哪怕你用上了 Unsafe 指针操作,依旧跑不过原生的 Array.Reverse。这告诉我们:永远优先相信标准库。那帮写 Runtime 的微软大佬,底层的骚操作远比我们手写的 while 循环要多得多。 结语 抛开场景谈性能就是耍流氓。通过控制好“原地反转”这个核心变量,我们看到了 C++ 在微操作上的极致低开销,也看到了当代 .NET 在大数据吞吐和标准库优化上的强悍实力。 如果你觉得这种深扒底层细节的硬核性能分析有意思,或者也在做 C# / .NET 的高性能开发,欢迎在评论区聊聊你的看法。 也欢迎大家加入我的 .NET骚操作 QQ群:495782587,一起探讨更多硬核技术玩法!