复制与参考基准:何时对象未在寄存器中传递?

Copy vs ref benchmark: When are objects NOT passed in registers?

提问人:glades 提问时间:8/27/2022 更新时间:8/28/2022 访问量:98

问:

我听说小对象在函数调用时在 CPU 寄存器中传递。当情况不再如此时,我试图搜索最大值。我知道这是在寄存器中传递的。但极限是什么?我知道这与架构有关,但似乎即使是非常大的对象也可以通过寄存器传递。比较以下内容:string_view

(在 Quickbench 上查看)

struct big_object
{
    int a_;
    double b_;
    char c_;
    long long d_;
    bool e_;
    int f_;
    double g_;
    char h_;
    long long i_;
    bool j_;
    int k_;
    double l_;
    char m_;
    long long n_;
    bool o_;
};

big_object obj = {
    .a_ = 2, 
    .b_ = 2.5,
    .c_ = 'A',
    .d_ = 1203912045891732283,
    .e_ = false,
    .f_ = 10, 
    .g_ = 15.5,
    .h_ = 'D',
    .i_ = 123123123,
    .j_ = true,
    .k_ = 10, 
    .l_ = 15.5,
    .m_ = 'D',
    .n_ = 123123123,
    .o_ = true,
};

volatile int a;
volatile double b;
volatile char c;
volatile long long d;
volatile bool e;
volatile int f;
volatile double g;
volatile char h;
volatile long long i;
volatile bool j; 
volatile int k;
volatile double l;
volatile char m;
volatile long long n;
volatile bool o; 

int foo(big_object obj)
{
    a = obj.a_;
    b = obj.b_;
    c = obj.c_;
    d = obj.d_;
    e = obj.e_;
    f = obj.f_;
    g = obj.g_;
    h = obj.h_;
    i = obj.i_;
    j = obj.j_;
    k = obj.k_;
    l = obj.l_;
    m = obj.m_;
    n = obj.n_;
    o = obj.o_;
    return 1;
}

int foo_ref(big_object& obj)
{
    a = obj.a_;
    b = obj.b_;
    c = obj.c_;
    d = obj.d_;
    e = obj.e_;
    f = obj.f_;
    g = obj.g_;
    h = obj.h_;
    i = obj.i_;
    j = obj.j_;
    k = obj.k_;
    l = obj.l_;
    m = obj.m_;
    n = obj.n_;
    o = obj.o_;
    return 1;
}

static void Foo(benchmark::State& state) {
  // Code inside this loop is measured repeatedly
  for (auto _ : state) {
    foo(obj);
  }
}
// Register the function as a benchmark
BENCHMARK(Foo);

static void FooRef(benchmark::State& state) {
  // Code before the loop is not measured
  for (auto _ : state) {
    foo_ref(obj);
  }
}
BENCHMARK(FooRef);

这将编译为完全相同的代码,从而提供相同的性能。我不是汇编专家,但我想我可以看到很多寄存器用于将对象传递给函数。我知道物理 CPU 寄存器比逻辑寄存器还要多,但这是否意味着在任何实际情况下,通过引用传递实际上是多余的?

C++ 按引用 C++20 基准测试 值传递

评论

1赞 Daniel McLaury 8/27/2022
您的方法会复制整个结构,因此,与传递副本相比,通过引用传递不会为您节省任何内容也就不足为奇了。
2赞 user17732522 8/27/2022
foo并已内联在程序集的链接中。根本没有发生函数调用。foo_ref
1赞 Peter Cordes 8/27/2022
首先,这取决于您要编译的 ISA 和调用约定。Quickbench 针对 x86-64 System V(用于所有非 Windows x86-64 系统)进行编译,因此可以在一对寄存器中传递最大 16 字节宽的 POD 类型。(拥有构造函数或析构函数可以强制它始终具有地址,并通过引用传递。大于 16 字节意味着它按堆栈上的传递。正如其他评论者所指出的,您的代码没有测试任何这些内容,只是在内联后循环对全局变量的赋值,因此来自 regs 的一堆存储。volatile
0赞 glades 8/27/2022
@DanielMcLaury 这就是我所说的“我不是组装专家”的意思......如何将基准测试改进为不内联?我无法传递编译器标志...
1赞 freakish 8/27/2022
你为什么要这样做?是否要在禁用内联的情况下编写生产代码?当然不是。因此,现实情况是,您应该始终衡量性能。这样的问题太宽泛了,取决于架构、编译器和许多其他东西。答案是没有用的。

答:

0赞 yotsugi 8/28/2022 #1

当函数调用被优化时,当函数被内联时,当你使用指针而不是对象时,编译器决定它不值得复制,仅举几例。除了 C++ 标准之外,编译器程序员没有必须遵循的明确规则集。

但这是否意味着在任何实际情况下,通过引用实际上是多余的?

不,它不是,引用是一种语义结构,而不是优化“技巧”,所以这样对待它们。

引用只是变量的别名。其他一切都取决于编译器。可变引用的一个有用的正确之处是,一旦函数返回,变异对象实际上也会在调用方端发生变化。

当然,您也可以使用指针来实现相同的目的,但更有用的引用属性是保证您将访问的对象是有效的,而指针是可为空的。

说到这一点,现在甚至指针的处理方式或多或少都是一样的,只要看看 GCC,以类似的方式,编译器可以优化出来,简单地直接复制。我不记得这个优化的名称,但我知道一个事实,GCC 至少曾经拥有它。如果你有一个像这样的函数,它在大多数情况下都会被内联,所以你如何编写它并不重要。这只是选择最清晰版本的问题。-findirect-inliningfloat*floatfloat add(float* a, float* b)

与其担心你不理解的东西,甚至不需要担心,不如把重点放在代码的语义上。说实话,你使用像C++这样的高级语言,而不是自己手动优化所有的ISA,正是因为你想让别人为你做艰苦的工作,所以当这是他们唯一的工作时,你为什么不信任他们呢?

例如,仅仅为了复制对象而作为参数是没有意义的。如果你想在函数中有一个对象的副本,那就这样写。这不仅对程序员来说更干净,更明显地表明您将要复制,而且甚至对编译器来说也更清晰,这也是由程序员制作的。您从某些优化中受益的事实是这里唯一多余的东西。你只是让任何阅读声明的人都清楚,这个对象将被复制,这才是最重要的。不管有多慢,你都需要复制,所以你复制。const&

编写可读和可理解的代码,当您有一个工作程序时,您可能会担心性能。如果你能想到一些“巧妙”的东西,事实是编译器程序员早就想到了。

顺便说一句,你的基准测试不会对任何东西进行基准测试。您实际上是在比较相同的代码。

所以就像我之前已经写过的,你想复制结构吗?或者只是引用其中的某些内容?这是唯一重要的事情。