只传递一次 if 语句

Only pass if-statement once

本文关键字:一次 if 语句      更新时间:2023-10-16

我目前正在构建一个内核,并且有一个可以(最坏的情况)运行几百万次的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) );
}

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

现在来问问题:

在调用一次 if 语句后,我怎么能忽略它,只使用最后一次的输出?

编译器生成的cmp/jccacpi_version == 0和一般情况下一样便宜。 它应该可以很好地预测,因为分支每次总是以相同的方式进行,并且分支本身的成本非常低,即使每次都采用。 (获取分支的成本略高于未采用分支,因为它们对前端获取/解码阶段有影响,并且因为它将代码的已使用部分分解到更多的 I-cache 行中。

CPU 无法以任何特殊方式存储比较结果,这比测试非零整数更快。 (即零/非零整数已经存储比较结果的方式!1

if两面非常相似的特定情况下,可能会有节省的空间,但易于预测的比较+分支非常便宜。 程序充满了比较+分支,因此现代CPU必须非常擅长运行它们。 在正常程序中,大约 10% 到 25% 的指令计数是 compare&branch,包括无条件跳转(不要引用我的确切数字,我听说过这个,但无法通过快速搜索找到可靠的来源)。 更多的分支占用了稍微多一点的分支预测资源,或者平均而言,其他分支的预测会恶化,但这也是一个小的影响。

编译器已经在内联后提升了循环的签出。 (到目前为止,您可以在这里做的最重要的事情是确保像sdt_wrapper::get_table这样的小型访问器函数可以内联,无论是将它们放在.h中还是使用链接时间优化) 内联asm只会使它变得更糟(http://gcc.gnu.org/wiki/DontUseInlineAsm),除非你做一些超级黑客,比如在asm代码中放一个标签,这样你就可以修改它或其他东西。

如果你比较了这么多,以至于你认为值得将acpi_version保存在一个只用于此的固定全局寄存器中(一个全局寄存器变量,GNU C++支持它,但即使你认为它实际上可能不是好2),那么你可以将条件作为所有代码的模板参数(或static constexpr或 CPP 宏), 并构建 2 个版本的代码:一个用于 true,一个用于 false。 当您在启动时发现条件的值时,取消映射并回收包含永远不会运行的版本的页面,然后跳转到将运行的版本。 (或者对于非内核,即在操作系统下的用户空间中运行的普通程序,只映射干净的页面通常不是性能问题,尤其是在未触及的情况下(包括运行时重定位))。if(acpi_version == 0) { rest_of_kernel<0>(...); } else { rest_of_kernel<1>(...); }(省略取消映射/自由部分)。

如果rsdt_ptrxsdt_ptr是不变的,如果两个PointerToOtherSDT数组(简称PTOS)都在静态存储中,则至少可以消除额外的间接级别。


启动时修补一次代码

您没有标记架构,但是您的(以太多方式损坏而无法提及)asm似乎是x86,所以我将讨论这一点。 (所有现代 x86 CPU 都具有无序执行和非常好的分支预测,因此可能没有太多收获。

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

事实上,Linux 甚至在jmpnop之间使用asm goto和补丁来处理像您这样的不变条件,这些条件在启动时确定一次,在 arch/x86/include/asm/cpufeature.h 中bool _static_cpu_has(u16 bit)。 首先,有一个对执行正常运行时检查的块的jmp,并进行一些测试。 但它使用.section .altinstructions,"a"/.previous来记录每个jmp的位置以及补丁长度/位置。 它看起来构造巧妙,可以处理 2 字节短与 5 字节长jmp rel8/jmp rel32跳跃。 因此,内核可以修补此代码结束的所有位置,将jmp替换为正确位置的jmp或落入t_yes: return true标签的nop。 当你写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 6fn"
"2:n"
".skip -(((5f-4f) - (2b-1b)) > 0) * "
"((5f-4f) - (2b-1b)),0x90n"
"3:n"
".section .altinstructions,"a"n"
" .long 1b - .n"      /* src offset */
" .long 4f - .n"      /* repl offset */
" .word %P[always]n"      /* always replace */
" .byte 3b - 1bn"     /* src len */
" .byte 5f - 4fn"     /* repl len */
" .byte 3b - 2bn"     /* pad len */
".previousn"
".section .altinstr_replacement,"ax"n"
"4: jmp %l[t_no]n"
"5:n"
".previousn"
".section .altinstructions,"a"n"
" .long 1b - .n"      /* src offset */
" .long 0n"           /* no replacement */
" .word %P[feature]n"     /* feature bit */
" .byte 3b - 1bn"     /* src len */
" .byte 0n"           /* repl len */
" .byte 0n"           /* pad len */
".previousn"
".section .altinstr_aux,"ax"n"
"6:n"
" testb %[bitnum],%[cap_byte]n"
" jnz %l[t_yes]n"
" jmp %l[t_no]n"
".previousn"
: : [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*)很容易,但以不同的方式索引是棘手的。 在您的情况下,它是一个uint32_tuint64_t数组,作为结构末尾的灵活数组成员。 (实际上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 (http://agner.org/optimize/) 上,可变计数移位的成本为 3 uops(由于传统的 CISC 语义;count=0 使 EFLAGS 保持不变,因此 EFLAGS 是可变计数移位的输入。 除非您让编译器使用 BMI2 进行shlx(无标志移位)。index += foo ? 0 : index会有条件地将index加倍(一个班次计数的差异),但是对于预测良好的条件,在 x86 上无分支地这样做是不值得的。

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

uint64_tvs. 没有运行时修补uint32_t是另一个问题;一个版本需要执行零扩展 32 位加载,另一个版本需要执行 64 位加载(除非高字节碰巧对您来说总是为零? 我们总是可以执行 64 位加载,然后掩码以保留或丢弃较高的 32 位,但这需要另一个常量。 如果负载越过缓存行(或更糟的是页面)边界,它可能会遭受性能损失。 例如,如果 32 位值是页面中的最后一个值,正常的 32 位加载将刚刚加载它,但 64 位加载 + 掩码需要从下一页加载数据。

但把这两件事加在一起,真的不值得。 只是为了好玩,你可以这样做:源 + asm 输出在 Godbolt 编译器资源管理器上

// 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);
}

来自 Godbolt 的 Asm 输出(具有更简单的类型,因此它实际上可以编译)

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

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

顺便说一句,不要static变量成为函数内的局部变量;这将迫使编译器每次检查它是否是函数第一次执行。static local的快速路径(一旦从 init 代码尘埃落定后运行的所有路径)非常便宜,但与您试图避免的成本大致相同:整数上的分支不为零!

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

静态函数指针(在类或文件范围,而不是函数)值得考虑,但用无条件间接调用替换条件分支不太可能是成功的。 然后你有函数调用开销(破坏寄存器,arg传递)。编译器通常会尝试将虚拟化解耦回条件分支作为优化


脚注1:如果你的条件是acpi_version == 4,那么MIPS可以保存一条存储0/1结果的指令。 它没有比较到标志,而是具有比较到寄存器,以及与零或寄存器进行比较的分支指令,以及已经读取为零的寄存器。 即使在 x86 上,如果值已经在寄存器中,则比较零/非零可以节省一个字节的代码大小(test eax,eaxvs.cmp eax,4)。 如果它是 ALU 指令的结果(因此已经设置 ZF),它会节省更多,但事实并非如此。

但大多数其他体系结构都比较为标志,您无法从内存直接加载到标志中。因此,如果acpi_version比较成本很高,则只需存储静态bool结果,例如比寄存器宽的整数(如__int128或 32 位计算机上的int64_t)。

脚注2:不要对acpi_version使用全局寄存器变量;那太愚蠢了。 如果它无处不在,那么希望链接时间优化可以很好地将比较从事物中提升出来。

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


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

您的特定函数不是任何优化的好候选者if(),如果由于 Peter 在他的答案中提到的原因,特别是 (1) 计算通常非常便宜和 (2) 根据定义,它不在关键路径上,因为它只是一个控制依赖项,因此不会出现为任何依赖项链的延续部分。

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

出于说明目的,在您的情况下将是这样的。首先,为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_impl版本还是nonzero_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并硬编码了一个案例,我认为您不会更快地找到它) - 但这种技术对于更复杂的情况很有用。可以想象一种"自我修改代码轻量级":你只修改一个函数指针,而不是实际的基础代码。

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

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


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

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

相关文章: