只传递一次if语句

时间:2018-05-03 17:40:13

标签: c++ assembly optimization x86 micro-optimization

我目前正在构建一个内核,并且有一个if语句可能(最坏的情况下)运行几百万次。然而,在第一次运行后结果很清楚。 知道cmp的结果存储在寄存器中,有没有办法记住上述语句的结果,以便不经常运行它? acpi_version 保证永不改变。

SDT::generic_sdt* sdt_wrapper::get_table (size_t index) { //function is run many times with varying index
    if (index >= number_tables) { //no such index
        return NULL;
    }

    if (acpi_version == 0) [[unlikely]] {
        return (SDT::generic_sdt*) (rsdt_ptr -> PointerToOtherSDT [index]);
    } else [[likely]] {
        return (SDT::generic_sdt*) (xsdt_ptr -> PointerToOtherSDT [index]);
    }
}

尽可能地,看到(至少对我来说)没有任何明显的方式可以不必做出这样的陈述。

我尝试过尝试使用以下ASM - " HACK":

static inline uint32_t store_cmp_result (uint32_t value1, uint32_t value2) {
    uint32_t zf;
    asm volatile ( "cmp %0, %1" :: "a" (value1), "a" (value2) );
    asm volatile ( "mov %ZF, [WORD]%0" :: "a" (zf) );   
    return zf;
} 

static inline void prestored_condition (uint32_t pres, void (*true_func)(), void (*false_func) ()) {
    asm volatile ( "mov %0, %1" :: "a" (pres)  "Nd=" () );
    asm volatile ( "je %0" :: "a" (&true_func) );
    asm volatile ( "jne %0" :: "a" (&false_func) );
}

然而,它只是一个hackish解决方案(它没有真正起作用,所以我放弃了它。)

现在问题:

我怎样才能忽略if之后的if语句被调用一次而只使用最后一次的输出?

2 个答案:

答案 0 :(得分:4)

cmp/jcc上编译器生成的acpi_version == 0与您在一般情况下的价格一样便宜。它应该很好地预测,因为每次分支总是以相同的方式,并且分支本身的成本非常低,即使它每次都被采用。 (由于它们对前端提取/解码阶段的影响,并且因为它在更多的I-cache行中分解了代码的使用部分,所以分支的成本比未采用的分支略高一些。)

CPU不能以任何特殊的方式存储比较结果,这比测试非零的整数要快。 (即已经的零/非零整数您将如何存储比较结果! 1

if的两边非常相似的特定情况下,可能存在节省的空间,但是容易预测的比较+分支非常便宜。程序充满了compare + branch,因此现代CPU必须非常擅长运行它们。正常程序中指令数量的10%到25%之类的东西是比较和分支,包括无条件跳转(不要引用我的确切数字,我已经听过这个,但是找不到快速搜索的可靠来源)。更多的分支占用更多的分支预测资源,或平均恶化其他分支的预测,但这也是一个小的影响。

编译器已经在内联后提升了循环检查。 (到目前为止,您可以做的最重要的事情是确保sdt_wrapper::get_table之类的小型访问者功能可以内联,方法是将它们放入.h或使用链接时优化 )内联asm只会使它变得更糟(http://gcc.gnu.org/wiki/DontUseInlineAsm),除非你做一些超级黑客,比如在asm代码中添加一个标签,这样你就可以对它进行修改。

如果你比较那么多,你认为值得将acpi_version保存在一个专用于此的固定全局寄存器中,(一个全局寄存器变量,GNU C ++支持但可能不< / em>实际上是好的 2 ,即使你认为它可能是),那么你可以改为使条件成为你所有代码的模板参数(或{{1或者构建代码的两个版本:一个用于true,一个用于false 。当您在启动时找到条件的值时,取消映射并回收包含永远不会运行的版本的页面,并跳转到将运行的版本。 (或者对于非内核,即在OS下的用户空间中运行的正常程序,通常不会将完整的页面留下来,特别是如果不受影响(包括通过运行时重定位))。 static constexpr(省略unmap / free部分)。

如果if(acpi_version == 0) { rest_of_kernel<0>(...); } else { rest_of_kernel<1>(...); }rsdt_ptr是不变的,如果两个xsdt_ptr数组(简称PointerToOtherSDT)都在静态存储中,则至少可以消除这种额外的间接级别

在启动时修补代码

你没有标记一个架构,但是你的(以太多方式提及)PTOS似乎是x86所以我会谈论这个。 (所有现代的x86 CPU都有无序执行和非常好的分支预测,所以可能并没有那么多。)

Linux内核可以做到这一点,但它很复杂:例如像asm这样的东西,用于构建一个指针数组(作为一个特殊的链接器部分)到需要修补的地方,asm语句内联。使用此技巧的一个地方是在运行使用SMP支持编译的内核的单处理器计算机上将.pushsection list_of_addresses_to_patch; .quad .Lthis_instance%= ; .popsection前缀修补到lock。这个修补在启动时发生一次。 (它甚至可以在热添加CPU之前修复nop前缀,因为互斥计数器仍在维护。)

事实上, Linux甚至在lockasm goto之间使用jmpnop之间的补丁,以便在启动时确定的一次性不变条件,{ {3}}。首先,jmp到一个块进行正常的运行时检查,测试一下。但它使用.section .altinstructions,"a" / .previous来记录每个jmp的位置以及补丁长度/位置。它似乎巧妙地构建为使用2字节短和5字节长jmp rel8 / jmp rel32跳转。因此,内核可以修补此代码结束的所有位置,将jmp替换为jmp到正确的位置,或将nop替换为t_yes: return true标签。当你写if(_static_cpu_has(constant)) { ... }时,gcc会很好地编译它。在执行CPU功能检测后的某个时刻进行修补后,最终只需要一个NOP,然后进入循环体。 (或者可能有多个简短的NOP指令,我没有检查,但希望不会!)

这非常酷,所以我只是要复制代码,因为看到如此创造性地使用内联asm很有趣。我还没有找到修补代码,但显然+链接器脚本是其他关键部分。我是试图为这种情况提供一个可行的版本,只是表明该技术可能,以及在哪里找到它的GPLv2实现你可以复制。

// from Linux 4.16 arch/x86/include/asm/cpufeature.h

/*
 * Static testing of CPU features.  Used the same as boot_cpu_has().
 * These will statically patch the target code for additional
 * performance.
 */
static __always_inline __pure bool _static_cpu_has(u16 bit)
{
    asm_volatile_goto("1: jmp 6f\n"
         "2:\n"
         ".skip -(((5f-4f) - (2b-1b)) > 0) * "
             "((5f-4f) - (2b-1b)),0x90\n"
         "3:\n"
         ".section .altinstructions,\"a\"\n"
         " .long 1b - .\n"      /* src offset */
         " .long 4f - .\n"      /* repl offset */
         " .word %P[always]\n"      /* always replace */
         " .byte 3b - 1b\n"     /* src len */
         " .byte 5f - 4f\n"     /* repl len */
         " .byte 3b - 2b\n"     /* pad len */
         ".previous\n"
         ".section .altinstr_replacement,\"ax\"\n"
         "4: jmp %l[t_no]\n"
         "5:\n"
         ".previous\n"
         ".section .altinstructions,\"a\"\n"
         " .long 1b - .\n"      /* src offset */
         " .long 0\n"           /* no replacement */
         " .word %P[feature]\n"     /* feature bit */
         " .byte 3b - 1b\n"     /* src len */
         " .byte 0\n"           /* repl len */
         " .byte 0\n"           /* pad len */
         ".previous\n"
         ".section .altinstr_aux,\"ax\"\n"
         "6:\n"
         " testb %[bitnum],%[cap_byte]\n"
         " jnz %l[t_yes]\n"
         " jmp %l[t_no]\n"
         ".previous\n"
         : : [feature]  "i" (bit),
             [always]   "i" (X86_FEATURE_ALWAYS),
             [bitnum]   "i" (1 << (bit & 7)),
             [cap_byte] "m" (((const char *)boot_cpu_data.x86_capability)[bit >> 3])
         : : t_yes, t_no);
t_yes:
    return true;
t_no:
    return false;
}

针对您的特定情况的运行时二进制修补

在您的具体情况下,您的两个版本之间的区别在于您要解除引用的全局(?)指针以及PTOS 的类型。使用纯C ++,将指针存储到右侧数组的基础(作为void*char*)很容易,但索引不同是很棘手的。 bool _static_cpu_has(u16 bit) in arch/x86/include/asm/cpufeature.h,作为结构末尾的灵活数组成员。 (实际上uint32_t PTOS[1]因为ISO C ++不支持灵活的数组成员,但是如果您要使用GNU内联asm语法,那么像uint32_t PTOS[]这样的适当灵活的数组成员可能是好主意)。

在x86-64上,将索引寻址模式中的比例因子从4更改为8可以达到目的,因为64位负载与零扩展32位负载使用相同的操作码,只需REX。对于操作数大小,W = 0(或无REX前缀)与REX.W = 1。 .byte 0x40; mov eax, [rdx + rdi*4]的长度与mov rax, [rdx + rdi*8]相同。 (第一个中的0x40字节是REX前缀,其所有位都清零。第二个版本需要REX.W = 1表示64位操作数;第一个零通过写EAX扩展到RAX。如果第一个版本已经需要一个像r10这样的寄存器的REX前缀,它已经有一个REX前缀。)无论如何,如果你知道所有相关指令所在的位置,那么将它们修改为另一个就很容易。

如果您有用于记录要修补的位置的基础结构,则可以使用它来修补mov指令,该指令在寄存器中获取表指针和index,并返回64位值(来自32或64位负载)。(并且不要忘记一个虚拟输入,告诉编译器你实际读取了表指针指向的内存,否则编译器是允许进行可能破坏代码的优化,例如在asm语句中移动商店。但是你必须要小心;内联asm可以通过禁用常量传播(例如index)来损害优化。至少如果省略volatile,编译器就可以将其视为输入和CSE的纯函数。

用纯C ++破解它

在x86上,寻址中的比例因子必须编码到指令中。即使使用运行时不变量,您(或编译器)仍然需要一个变量计数移位或乘法,以便在没有自修改代码的情况下将其拉出(编译器不会发出这些代码)。

在英特尔Sandybridge系列CPU(In your case, it's an array of uint32_t or uint64_t)上,可变计数移位成本为3 uops(因为传统的CISC语义; http://agner.org/optimize/。)除非您让编译器使用BMI2作为{{1 (无标志的转变)。 shlx会有条件地加倍index += foo ? 0 : index(一个班次计数的差异),但是在x86上无法实现这一点并不值得预测那么好。

使用可变计数移位而不是缩放索引寻址模式可能比预测良好的条件分支更昂贵。

没有运行时修补的

indexuint64_t是另一个问题;一个版本需要进行零扩展32位加载,另一个版本需要进行64位加载(除非高位字节碰巧总是为零?)我们可以总是做64位加载然后屏蔽以保持或丢弃高32位,但这需要另一个常量。如果负载越过缓存行(或更糟的页面)边界,它可能会遭受性能损失。例如如果32位值是页面中的最后一个,那么正常的32位加载就会加载它,但是64位加载+掩码需要从下一页加载数据。

但是将这两件事情放在一起,实在是不值得。只是为了好玩,这里有你能做的:count=0 leaves EFLAGS unmodified so EFLAGS is an input to variable-count shifts

uint32_t

Godbolt的Asm输出(类型更简单,因此实际编译)

// I'm assuming rsdt_ptr and xsdt_ptr are invariants, for simplicity
static const char *selected_PTOS;
static uint64_t opsize_mask;   // 0x00000000FFFFFFFF or all-ones
static unsigned idx_scale;     // 2 or 3
// set the above when the value for acpi_version is found

void init_acpi_ver(int acpi_version) {
    ... set the static vars;
}

// branchless but slower than branching on a very-predictable condition!
SDT::generic_sdt* sdt_wrapper::get_table (size_t index)
{
    const char *addr = selected_PTOS + (index << idx_scale);
    uint64_t entry = *reinterpret_cast<const uint64_t*>(addr);
    entry &= opsize_mask;      // zero-extend if needed
    return reinterpret_cast<SDT::generic_sdt*>(entry);
}

使用内联和CSE,编译器可以在寄存器中保留一些掩码和移位计数值,但这仍然是额外的工作(并且绑定寄存器)。

顺便说一句,在函数内部创建get_table(unsigned long): mov ecx, DWORD PTR idx_scale[rip] mov rax, QWORD PTR selected_PTOS[rip] # the table sal rdi, cl mov rax, QWORD PTR [rax+rdi] # load the actual data we want and rax, QWORD PTR opsize_mask[rip] ret vars本地人;这会强制编译器每次都检查它是否是第一次执行该函数。 static的快速路径(一旦尘埃落定于初始代码后就会运行)非常便宜,但与您尝试避免的成本大致相同< / em> :整数上的分支非零!

static local

静态函数指针(在类或文件范围,而不是函数)值得考虑,但用无条件间接调用替换条件分支不太可能是胜利。然后你有函数调用开销(破坏寄存器,arg传递)。 编译器通常会尝试将 devirtualize 重新作为优化重新添加到条件分支中!

脚注1 :如果您的条件是int static_local_example() { static int x = ext(); return x; } # gcc7.3 movzx eax, BYTE PTR guard variable for static_local_example()::x[rip] test al, al je .L11 # x86 loads are always acquire-loads, other ISAs would need a barrier after loading the guard mov eax, DWORD PTR static_local_example()::x[rip] ret ,那么MIPS可以保存一条指令以存储0/1结果。而不是比较标志,source + asm output on the Godbolt compiler explorer比较寄存器,以及与零或寄存器进行比较的分支指令,以及已经读为零的寄存器。即使在x86上,如果值已经在寄存器(it has)中,则比较零/非零会保存一个字节的代码大小。如果它是ALU指令的结果(因此已经设置了ZF),它会节省更多,但它不是。

但是大多数其他架构都会与标志进行比较,并且您无法直接从内存加载到标志中。因此,如果acpi_version == 4比较昂贵,您只想存储静态bool结果,例如比acpi_version__int128等寄存器更宽的整数在32位机器上。

脚注2 :不要为int64_t使用全局注册变量;那会很傻。如果它在任何地方使用,那么希望链接时优化可以很好地提升比较性。

分支预测+推测执行意味着CPU在分支时实际上不必等待加载结果,并且如果你一直读它,它仍会在L1d缓存中保持热点。 (推测执行意味着控制依赖关系不是关键路径的一部分,假设正确的分支预测)

PS:如果你做到这一点并且理解了所有内容,那么你应该考虑像Linux一样使用二进制补丁来处理一些经常检查的条件。如果没有,你可能不应该!

答案 1 :(得分:1)

您的特定功能不适合任何优化,如果if()Peter mentions in his answer原因而移除,尤其是(1)它通常计算成本低廉(2)根据定义它是关键路径,因为它只是一个控制依赖,因此不会作为任何依赖链的连续部分出现。

那就是说,一种一般的模式来做这种&#34;一次性&#34;在检查实际上有些昂贵的情况下,运行时行为选择是将函数指针与类似蹦床的函数结合使用,该函数在第一次调用时选择两个(或多个实现)中的一个,并用函数指针覆盖函数指针。选择实施。

为了便于说明,在您的情况下会是这样的。首先,为get_table函数声明一个typedef,并定义一个名为get_table的函数指针,而不是函数 1

typedef generic_sdt* (*get_table_fn)(size_t index);

// here's the pointer that you'll actually "call"
get_table_fn get_table = get_table_trampoline;

请注意,get_table初始化为get_table_trampoline,这是我们的选择器函数,只调用一次,以选择运行时实现。它看起来像:

generic_sdt* get_table_trampoline(size_t index) {
    // choose the implementation
    get_table_fn impl = (acpi_version == 0) ? zero_impl : nonzero_impl;
    // update the function pointer to redirect future callers
    get_table = impl;
    // call the selected function 
    return impl(index);
}

它只是根据acpi_version选择是否使用该函数的zero_implnonzero_impl版本,这些版本只是您已经解析的if语句的实现,像这样:

generic_sdt* zero_impl(size_t index) {
    if (index >= number_tables) { //no such index
        return NULL;
    }

    return (SDT::generic_sdt*) (rsdt_ptr -> PointerToOtherSDT [index]);
}

generic_sdt* nonzero_impl(size_t index) {
    if (index >= number_tables) { //no such index
        return NULL;
    }

    return (SDT::generic_sdt*) (xsdt_ptr -> PointerToOtherSDT [index]);
}

现在所有后续调用者都使用if直接跳转到基础简化实现。

如果原始代码实际在底层程序集中调用get_table(即,它没有内联,可能是因为它没有在头文件中声明),当正确预测间接调用时,从函数调用到函数指针调用的转换可能只有很小到零的性能影响 - 并且由于目标在第一次调用后被修复,因此可以很好地预测除非您的间接BTB处于压力之下,否则在几次通话后。

如果呼叫者能够内联原始的get_table呼叫,则这种方法更不可取,因为它会抑制内联。但是,您对自修改代码的原始建议并不适用于内联。

如上所述,对于删除单个预测 if 的特定情况,这不会做太多:我希望它是关于洗涤的(即使你只是删除了if和hardcoded的一个案例,我认为你不会发现它更快) - 但这种技术对于更复杂的案例非常有用。可以把它想象成一种自我修改的代码灯&#34;:你只修改一个函数指针,而不是实际的底层代码。

在多线程程序中,这将在理论上调用未定义的行为,尽管在实践中很少 2 。为了使其符合要求,您需要将get_table指针包装在std::atomic中并使用适当的方法加载和存储它(使用memory_order_relaxed应该足够,有效地使用racy-single-check成语)。

或者,如果您的代码中有合适的位置,则可以通过在首次使用之前初始化函数指针来完全避免蹦床和内存排序问题。

1 我在这里删除了名称空间以使事情更加简洁,并使我的未经验证的代码更有可能实际上近似编译。

2 这里我很少使用 非常有限的意义:我不是说这会编译成有问题的多线程代码但是你很少在运行时遇到问题(那会非常糟糕)。相反,我说这将编译为纠正大多数编译器和平台上的多线程代码。因此,基础生成的代码很少是不安全的(事实上这样的模式在C ++ 11之前的原子中被大量使用和支持)。