通过常量引用传递基本值真的会损害性能吗?

Does passing fundamental values by const reference really hurt performance?

本文关键字:真的 性能 常量 引用      更新时间:2023-10-16

我正在编写一个进行数值计算的库。我正在使用模板,以便最终用户可以选择他们想要的精度。我希望它适用于基本类型(doublefloat)和高精度类类型(例如boost::multiprecision)。我想知道参数类型应该是T还是const & T.

在SO/google上,有许多关于按值传递与按引用传递的帖子。 "经验法则"之一似乎是:

  • 按值传递基本类型
  • 通过常量引用传递其他所有内容

但是,如果您有模板,这会变得混乱:

template<typename T>
T doSomething(T x, T y)
{
return x + y;
}

与。

template<typename T>
T doSomething(const T & x, const T & y)
{
return x + y;
}

对于boost::multiprecision,您几乎肯定希望通过常量引用传递。问题是传递const &double是否比按值传递更糟糕。许多SO答案都说const &"没有更好,也许更糟"......但我找不到任何好的硬参考。

我做了以下基准测试

这似乎表明没有区别,尽管这可能取决于函数和内联行为的简单性。

有可能执行以下操作:

#include <type_traits>
template<typename T>
using choose_arg_type =
typename std::conditional<std::is_fundamental<T>::value,
T,
const T &>::type;
template <typename T>
T someFunc(choose_arg_type<T> arg)
{
return arg + arg;
}
int main()
{
auto result = someFunc<double>(0.0);
return 0;
}

但是,如果它没有带来任何好处,它会增加复杂性,并且您将失去类型推导(有什么方法可以解决此类型推导吗?

我认为通过 const 引用传递速度较慢的一个原因是,如果它确实使用引用,则可能存在缓存局部性问题。但是,如果编译器只是优化到值...这不重要。

处理这个问题的最佳方法是什么?

至少有一种情况,传递const引用可能会禁用优化。 但是,最流行的编译器提供了一种重新启用它们的方法。

让我们看一下这个函数:

int cryptographicHash( int& salt, const int& plaintext )
{
salt = 4; // Chosen by fair dice roll
// guaranteed to be random
return plaintext; // If we tell them there's a salt,
// this is the last hash function they'll
// ever suspect!
}

看起来很安全,对吧? 但是,既然我们用C++写作,它是否尽可能快?(绝对是我们想要的加密哈希。

不,因为如果您用以下方式调用它:

int x = 0xFEED;
const int y = cryptographicHash( x, x );

现在通过引用传递的参数别名是同一个对象,所以函数应该像写的那样返回4,而不是0xFEED。 这意味着,灾难性的是,编译器无法再优化其const int&参数中的&

但是,最流行的编译器(包括GCC,clang,Intel C++和Visual C++ 2015及更高版本)都支持__restrict扩展。 因此,将函数签名更改为int cryptographicHash( int& salt, const int& __restrict plaintext ),它将永远解决所有问题。

由于此扩展不是C++标准的一部分,因此您可以使用如下所示的内容来提高可移植性:

#if ( __GNUC__ || __clang__ || __INTEL_COMPILER || __ICL || _MSC_VER >= 1900 )
#  define RESTRICT __restrict
#else
#  define RESTRICT /**/
#endif
int cryptographicHash( int& salt, const int& RESTRICT plaintext );

(在 GCC 和 clang 中,这似乎不会更改生成的代码。

在所讨论的基本类型适合寄存器的平台上,一个体面的编译器应该从参数中删除 const 引用,如果它可以看到调用的双方。对于通常是给定的模板(除非它们在某处显式实例化)。由于您的库可能必须一直向下模板化,因此这将适用于您的情况。

您的最终用户可能会有糟糕的编译器或平台,例如double不适合寄存器。我不明白为什么你会被激励为这些特定用户进行微优化,但也许你会这样做。

也可能希望显式实例化库中某些类型集的所有模板,并提供无实现的头文件。在这种情况下,用户的编译器必须遵守该平台上存在的任何调用约定,并且可能会通过引用传递基本类型。

最终,答案是"分析相关和有代表性的用例",如果你对编译器没有信心。


编辑(删除宏解决方案):正如 Jarod42 所建议的,C++方法是使用别名模板。这也避免了提问者在原始方法中遇到的缺乏演绎

template<class T>
using CONSTREF = const T&; // Or just T for benchmarking.

https://godbolt.org/z/mopZ6B

正如cpppreferences所说:

在推导模板模板参数

时,从不通过模板参数推导来推导别名模板。

通过引用(基本上是一个指针)传递类似int的东西显然是次优的,因为通过指针的额外间接寻址可能会导致缓存未命中,并且还可能阻止编译器优化,因为编译器不能总是知道指向变量不能被其他实体更改,因此在某些情况下可能会强制从内存执行额外的加载。按值传递会删除间接寻址,并让编译器假定没有其他人在更改该值。

这是一个复杂的问题,取决于架构、编译器优化和许多其他细节,如答案的变化所示。由于 OP 是关于编写模板函数的,因此还可以选择使用 SFINAE 控制调用哪个函数。

#include <iostream>
template <typename T, typename = typename std::enable_if_t<std::is_fundamental_v<T>> >
void f(T t) {
std::cout << "Pass by valuen";
}
template <typename T, typename = typename std::enable_if_t<not std::is_fundamental_v<T>> >
void f(T const &t) {
std::cout << "Pass by const ref.n";
}
class myclass {};
int main() {
float x;
int i;
myclass c;
std::cout << "float: ";
f(x);
std::cout << "int: ";
f(i);
std::cout << "myclass: ";
f(c);
return 0;
}

输出:

float: Pass by value
int: Pass by value
myclass: Pass by const ref.

如果参数是可构造的且未被修改,请按值传递。调用约定将通过引用自动传递大型结构。

struct alignas(4096) page {unsigned char bytes[4096];};
[[nodiscard]] constexpr page operator^(page l, page r) noexcept {
for (int i = 0; i < 4096; ++i)
l.bytes[i] = l.bytes[i] ^ r.bytes[i];
return l;
}

由非常量引用修改和/或返回的参数必须由非常量引用传递。

constexpr page& operator^=(page& l, page r) noexcept {return l = l ^ r;}

通过常量引用传递使用 const 引用语义返回的任何参数。

using buffer = std::vector<unsigned char>;
[[nodiscard]] std::string_view to_string_view(const buffer& b) noexcept {
return {reinterpret_cast<const char*>(b.data()), b.size()};
}

通过 const 引用将任何深度复制到不同类型的参数传递。

[[nodiscard]] std::string to_string(const buffer& b) {
return std::string{to_string_view(b)};
}

通过 const 引用传递任何非平凡可构造、未修改和非深度复制的参数。

std::ostream& operator<<(std::ostream& os, const buffer& b) {
os << std::hex;
for (const unsigned short u8 : b)
os << u8 << ',';
return os << std::dec;
}

按值将任何深度复制的参数传递到相同类型的值。无论如何,通过引用传递复制的参数是没有意义的,并且返回副本的构造函数被优化掉了。见 https://en.cppreference.com/w/cpp/language/copy_elision

[[nodiscard]] buffer operator^(buffer l, const buffer& r) {
const auto lsize = l.size();
const auto rsize = r.size();
const auto minsize = std::min(lsize, rsize);
for (buffer::size_type i = 0; i < minsize; ++i)
l[i] = l[i] ^ r[i];
if (lsize < rsize)
l.insert(l.end(), r.begin() + minsize, r.end());
return l;
}

这也包括模板函数。

template<typename T>
[[nodiscard]]
constexpr T clone(T t) noexcept(std::is_nothrow_constructible_v<T, T>) {
return t;
}

否则通过转发引用 (&&) 获取模板参数类型的参数。注意:&&仅在模板参数类型的参数中具有转发(通用)引用语义,和/或用于auto&&decltype(auto)&&

template<typename T>
constexpr bool nt = noexcept(std::is_nothrow_constructible_v<int, T&&>);
template<typename T>
[[nodiscard]]
constexpr int to_int(T&& t) noexcept(nt<T>) {return static_cast<int>(t);}
const auto to_int_lambda = [](auto&& t) noexcept(noexcept(to_int(t))) {
return to_int(t);
};