编译器对编译时分支做了什么?

时间:2014-05-31 13:19:09

标签: c++ templates if-statement c++11 typetraits

编辑:我拿了&#34; if / else&#34; case作为一个例子,有时可以在编译时解决(例如,当涉及静态值时,参见<type_traits>)。将以下答案调整为其他类型的静态分支(例如,多个分支或多标准分支)应该是直截了当的。请注意,使用模板元编程的编译时分支不是此处的主题。


在像这样的典型代码中

#include <type_traits>

template <class T>
T numeric_procedure( const T& x )
{
    if ( std::is_integral<T>::value )
    {
        // Integral types
    }
    else
    {
        // Floating point numeric types
    }
}
当我在代码中稍后定义特定模板类型时,编译器会优化if / else语句吗?

一个简单的替代方案是写下这样的东西:

#include <type_traits>

template <class T>
inline T numeric_procedure( const T& x )
{
    return numeric_procedure_impl( x, std::is_integral<T>() );
}

// ------------------------------------------------------------------------

template <class T>
T numeric_procedure_impl( const T& x, std::true_type const )
{
    // Integral types
}

template <class T>
T numeric_procedure_impl( const T& x, std::false_type const )
{
    // Floating point numeric types
}

这些解决方案的性能有何不同?是否有任何非主观理由说一个人比另一个人好?还有其他(可能更好的)解决方案来处理编译时分支吗?

5 个答案:

答案 0 :(得分:50)

TL; DR

有几种方法可以根据模板参数获得不同的运行时行为。 性能不应该是您的主要关注点,但灵活性和可维护性应该是。在所有情况下,各种精简包装器和常量条件表达式都将在发布版本的任何合适的编译器上进行优化。下面是一个带有各种权衡的小摘要(受@AndyProwl的this answer启发)。

运行时如果

您的第一个解决方案是简单的运行时if

template<class T>
T numeric_procedure(const T& x)
{
    if (std::is_integral<T>::value) {
        // valid code for integral types
    } else {
        // valid code for non-integral types,
        // must ALSO compile for integral types
    }
}

它简单有效:任何体面的编译器都会优化掉死分支。

有几个缺点:

  • 在某些平台(MSVC)上,一个常量条件表达式会产生一个虚假的编译器警告,然后您需要忽略或静默。
  • 但更糟糕的是,在所有符合要求的平台上, if/else语句的两个分支都需要为所有类型T 进行实际编译,即使其中一个分支未知要采取。如果T根据其性质包含不同的成员类型,那么一旦您尝试访问它们就会出现编译错误。

标签调度

您的第二种方法称为标签调度:

template<class T>
T numeric_procedure_impl(const T& x, std::false_type)
{
    // valid code for non-integral types,
    // CAN contain code that is invalid for integral types
}    

template<class T>
T numeric_procedure_impl(const T& x, std::true_type)
{
    // valid code for integral types
}

template<class T>
T numeric_procedure(const T& x)
{
    return numeric_procedure_impl(x, std::is_integral<T>());
}

它工作正常,没有运行时开销:临时std::is_integral<T>()和对单行帮助函数的调用都将在任何体面的平台上进行优化。

主要(次要的IMO)缺点是你有一些样板,而不是1个功能。

SFINAE

与标签调度密切相关的是SFINAE(替换失败不是错误)

template<class T, class = typename std::enable_if<!std::is_integral<T>::value>::type>
T numeric_procedure(const T& x)
{
    // valid code for non-integral types,
    // CAN contain code that is invalid for integral types
}    

template<class T, class = typename std::enable_if<std::is_integral<T>::value>::type>
T numeric_procedure(const T& x)
{
    // valid code for integral types
}

这与tag-dispatching具有相同的效果,但工作方式略有不同。它不是使用参数推导来选择正确的帮助器重载,而是直接操作主函数的重载集。

缺点是,如果你不确切知道整个重载集是什么,它可能是一个脆弱而棘手的方法(例如,使用模板繁重的代码, ADL可能会从关联的命名空间中引入更多重载没想到)。与标签调度相比,基于二进制决策以外的任何选择都需要更多参与。

部分专业化

另一种方法是使用带有函数应用程序运算符的类模板助手,并对其进行部分专门化

template<class T, bool> 
struct numeric_functor;

template<class T>
struct numeric_functor<T, false>
{
    T operator()(T const& x) const
    {
        // valid code for non-integral types,
        // CAN contain code that is invalid for integral types
    }
};

template<class T>
struct numeric_functor<T, true>
{
    T operator()(T const& x) const
    {
        // valid code for integral types
    }
};

template<class T>
T numeric_procedure(T const& x)
{
    return numeric_functor<T, std::is_integral<T>::value>()(x);
}

如果您希望获得细粒度控制和最少的代码重复(例如,如果您还想专注于大小和/或对齐,但仅限于浮点类型),这可能是最灵活的方法。部分模板专业化给出的模式匹配非常适合这种高级问题。与标记调度一样,帮助函子被任何体面的编译器优化掉。

如果您只想专注于单一的二元条件,主要的缺点是稍大的锅炉板。

如果是constexpr(C ++ 1z提案)

static if template<class T> T numeric_procedure(const T& x) { if constexpr (std::is_integral<T>::value) { // valid code for integral types } else { // valid code for non-integral types, // CAN contain code that is invalid for integral types } } 的早期提案失败reboot(在D编程语言中使用)

if

与运行时else一样,一切都在一个地方,但这里的主要优点是编译器在知道不被采用时将完全删除z==7分支。一个很大的优点是您可以将所有代码保持在本地,而不必像标记调度或部分模板专业化那样使用小辅助函数。

Concepts-Lite(C ++ 1z提案)

Concepts-Lite是一个upcoming Technical Specification,计划成为下一个主要C ++版本的一部分(C ++ 1z,其中template<Non_integral T> T numeric_procedure(const T& x) { // valid code for non-integral types, // CAN contain code that is invalid for integral types } template<Integral T> T numeric_procedure(const T& x) { // valid code for integral types } 为最佳猜测)。

class

此方法使用描述代码应该适用的类型系列的概念名称替换typename括号内的template< >或{{1}}关键字。它可以看作是标签调度和SFINAE技术的概括。一些编译器(gcc,Clang)对此功能有实验支持。 Lite形容词指的是失败的Concepts C ++ 11提案。

答案 1 :(得分:12)

请注意,虽然优化器可能能够从生成的代码中删除静态已知的测试和无法访问的分支,但编译器仍然需要能够编译每个分支。

那是:

int foo() {
  #if 0
    return std::cout << "this isn't going to work\n";
  #else
    return 1;
  #endif
}

可以正常工作,因为预处理器会在编译器看到它之前去除死分支,但是:

int foo() {
  if (std::is_integral<double>::value) {
    return std::cout << "this isn't going to work\n";
  } else {
    return 1;
  }
}

不会。即使优化器可以丢弃第一个分支,它仍然无法编译。这是使用enable_if和SFINAE帮助的地方,因为您可以选择有效(可编译)代码,而无效(不可编译)代码的编译失败不是错误。

答案 2 :(得分:3)

要回答有关编译器如何处理if(false)的标题问题:

它们优化了恒定的分支条件(以及无效代码)

语言标准当然不会 require 编译器并不可怕,但是人们实际使用的C ++实现并不可怕。 (大多数C实现也是,除了tinycc之类的非常简单的非优化实现之外。)

C ++围绕if(something)设计而不是C预处理器的#ifdef SOMETHING的主要原因之一是它们同样高效。许多C ++功能(例如constexpr)仅在编译器已实现必要的优化(内联+常量传播)之后才添加。 (我们忍受C和C ++的所有不确定行为陷阱和陷阱的原因是性能,特别是对于现代编译器,它们在没有UB的假设下进行了积极的优化。语言设计通常不会造成不必要的性能成本。)< / p>


但是,如果您关心调试模式的性能,则选择取决于您的编译器。(例如,对于具有实时要求的游戏或其他程序,甚至可能需要调试版本)可测试的。)

例如clang++ -O0(“调试模式”)在编译时仍会评估if(constexpr_function()),并将其视为if(false)if(true)。其他一些编译器仅在被迫(通过模板匹配)时才在编译时进行评估。


启用优化的if(false)不会降低性能。(排除未优化的错误,这可能取决于在编译过程的早期可以将条件解析为false和死代码消除功能可以在编译器“考虑”为其变量保留堆栈空间,或者该函数可能是非叶函数之类的东西之前将其删除。)

任何不可怕的编译器都可以在编译时常数条件(Wikipedia: Dead Code Elimination)之后优化死代码。这是人们对C ++实现在现实世界中可用的基本期望的一部分;这是最基本的优化之一,所有实际使用的编译器都可以在constexpr这样的简单情况下使用它。

通常常量传播(尤其是在内联之后)会使条件编译时常量,即使它们在源代码中不是很明显。一种最明显的情况是优化for (int i=0 ; i<n ; i++)的第一个迭代的比较,因此,如果{{1,则它可以变成一个普通的asm循环,其条件分支在底部(like a do{}while loop in C++)。 }}是常数或证明是n。 (是的,真正的编译器会进行值范围优化,而不仅仅是恒定传播。)


某些编译器,例如gcc和clang,即使在“调试”模式下,也可以通过其内部架构以最低级别optimization that's required for them to transform the program logic删除> 0内部的无效代码,中立的表示形式,最终散发出asm。 (但是对于未在源中声明为if(false)const的变量,调试模式将禁用任何类型的常量传播。)

某些编译器仅在启用优化后才这样做;例如,MSVC非常喜欢在调试模式下将C ++转换为asm的字面意思,实际上会在寄存器中创建一个零,然后分支为constexpr设置为零。

对于gcc调试模式(if(false)),如果不必插入-O0函数,则不会对其进行内联。 (在某些地方,语言需要一个常量,例如结构体中的数组大小。GNUC ++支持C99 VLA,但确实选择内联constexpr函数,而不是实际在调试模式下创建VLA。)

但是非功能constexpr确实会在编译时进行评估,而不是存储在内存中并经过测试。

但要重申,在任何优化级别上,constexpr函数都会被完全内联和优化,然后是constexpr


示例(from the Godbolt compiler explorer

if()

所有启用了#include <type_traits> void baz() { if (std::is_integral<float>::value) f1(); // optimizes for gcc else f2(); } 优化的编译器(针对x86-64):

-O2

调试模式代码质量,通常不相关

具有优化功能已禁用的GCC仍会评估该表达式并消除死代码:

baz():
        jmp     f2()    # optimized tailcall

要查看gcc不能在禁用优化的情况下内联

baz():
        push    rbp
        mov     rbp, rsp          # -fno-omit-frame-pointer is the default at -O0
        call    f2()              # still an unconditional call, no runtime branching
        nop
        pop     rbp
        ret
static constexpr bool always_false() { return sizeof(char)==2*sizeof(int); }
void baz() {
    if (always_false()) f1();
    else f2();
}
static constexpr bool always_false() { return sizeof(char)==2*sizeof(int); }
void baz() {
    if (always_false()) f1();
    else f2();
}

已禁用优化的MSVC死脑筋的文字代码生成:

;; gcc9.1 with no optimization chooses not to inline the constexpr function
baz():
        push    rbp
        mov     rbp, rsp
        call    always_false()
        test    al, al              # the bool return value
        je      .L9
        call    f1()
        jmp     .L11
.L9:
        call    f2()
.L11:
        nop
        pop     rbp
        ret
void foo() {
    if (false) f1();
    else f2();
}

禁用优化的基准测试没有用

您应该始终启用对真实代码的优化;只有 时间调试模式的性能才是可调试性的先决条件。 不是一个有用的代理,可以避免基准测试被优化。调试代码的不同,或多或少地取决于调试代码的编写方式。

除非这对您的项目来说确实很重要,并且您找不到足够的有关本地变量或诸如;; MSVC 19.20 x86-64 no optimization void foo(void) PROC ; foo sub rsp, 40 ; 00000028H xor eax, eax ; EAX=0 test eax, eax ; set flags from EAX (which were already set by xor) je SHORT $LN2@foo ; jump if ZF is set, i.e. if EAX==0 call void f1(void) ; f1 jmp SHORT $LN3@foo $LN2@foo: call void f2(void) ; f2 $LN3@foo: add rsp, 40 ; 00000028H ret 0 之类的最小优化的信息,否则此答案的标题就是完整答案。忽略调试模式,只考虑优化版本中的asm质量。 (最好是启用LTO,如果您的项目可以启用LTO以允许跨文件内联。)

答案 3 :(得分:2)

编译器可能足够聪明,可以看到它可以用两个不同的函数实现替换if语句体,只需选择正确的函数。但截至2014年,我怀疑是否有任何编译器足够聪明地做到这一点。我可能错了。第二个想法,std::is_integral很简单,我认为 会被优化掉。

您对std::is_integral的结果重载的想法是一种可能的解决方案。

另一个和IMHO清洁解决方案是使用std::enable_if(与std::is_integral一起使用)。

答案 4 :(得分:1)

归功于@MooingDuck and @Casey

template<class FN1, class FN2, class ...Args>
decltype(auto) if_else_impl(std::true_type, FN1 &&fn1, FN2 &&, Args&&... args)
{
    return fn1(std::forward<Args>(args)...);
}

template<class FN1, class FN2, class ...Args>
decltype(auto) if_else_impl(std::false_type, FN1 &&, FN2 &&fn2, Args&&... args)
{
    return fn2(std::forward<Args>(args)...);
}

#define static_if(...) if_else_impl(__VA_ARGS__, *this)

和ussage一样简单:

static_if(do_it,
    [&](auto& self){ return 1; },
    [&](auto& self){ return self.sum(2); }
);

作为静态if - 编译器仅用于&#34; true&#34;分支。

P.S。由于gcc bug,您需要self = *this并从中拨打成员电话。如果您已经嵌套了lambda调用,那么就不能use this-> instead of self.