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,一起探讨更多硬核技术玩法!
