通过const引用传递基本值确实会损害性能吗?

时间:2019-01-11 17:23:09

标签: c++ performance templates

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

在SO / google上,有很多关于按价值传递和按引用传递的文章。 “经验法则”之一似乎是:

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

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

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

vs。

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

对于boost::multiprecision,您几乎可以肯定要通过const引用传递。问题是,将double传递给const &是否比按值传递更糟。许多SO答案都说const &“没有更好,也许还更糟”……但是我找不到任何好的硬参考。

我做了以下benchmark

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

有可能做类似的事情:

#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;
}

但是,如果它没有带来任何好处,则会增加复杂性,并且您会丢失类型推断(Any way to fix this type deduction?

我可以认为通过const引用传递的速度较慢的一个原因是,如果它确实在使用引用,则可能存在缓存局部性问题。但是,如果编译器只是优化价值……那就没关系了。

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

5 个答案:

答案 0 :(得分:3)

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

您的最终用户可能会在以下情况下使用错误的编译器或平台: double不适合寄存器。我不明白为什么会激励您为这些特定用户进行微优化,但是也许您会这么做。

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

如果您不相信编译器,那么答案是“概要介绍相关的和具有代表性的用例”。


编辑(已删除宏解决方案):如Jarod42所建议,C ++方式将使用别名模板。这也避免了缺少推论者与他们的original approach碰到的推论:

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

https://godbolt.org/z/mopZ6B

cppreference says

  

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

答案 1 :(得分:3)

在至少一种情况下,通过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 and up)都支持__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中,这似乎不会更改生成的代码。)

答案 2 :(得分:1)

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

答案 3 :(得分:0)

如果自变量是微不足道的构造且未修改,则按值传递。调用约定将自动通过引用传递大型结构。

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引用传递以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(to_int(t)) {return to_int(t);};

答案 4 :(得分:0)

这是一个复杂的问题,取决于体系结构,编译器优化和许多其他细节,如答案所示。由于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 value\n";
}

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.