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 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”,更多是在测“分配+拷贝”的耗时,而不是单纯的反转算法效率。

控制变量:双方纯原地反转对决

为了得到准确的结论,我重新制定了测试规则:

  1. 纯原地操作:全部使用 std::reverse / Array.Reverse 或手写循环,绝不分配新数组。
  2. 防状态污染:每轮 Benchmark 前恢复原始数据(连续递增的 int 数组)。
  3. 防死代码消除 (DCE):对反转后的结果进行消费(计算 checksum)。
  4. 测试规模: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

N=1,000 时 C++ 与 C# 的正面对位对比

N=1,000,000 时 C++ 与 C# 的正面对位对比

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

为什么会出现这种两级反转?

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

posted @ 2026-03-23 10:18  .NET骚操作  阅读(303)  评论(4)    收藏  举报