只传递一次 if 语句

Only pass if-statement once

我目前正在构建一个内核,并且有一个 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 语句,只使用上次的输出?

acpi_version == 0 上由编译器生成的 cmp/jcc 与一般情况下的成本差不多。它应该可以很好地预测,因为分支每次总是走相同的路,并且分支本身的成本非常低,即使每次都被采用也是如此。 (采用的分支比不采用的分支成本略高,因为它们对前端 fetch/decode 阶段有影响,并且因为它将代码的使用部分分解到更多的 I-cache 行。)

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

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

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

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

如果 rsdt_ptrxsdt_ptr 是不变的,如果两个 PointerToOtherSDT 数组(简称 PTOS)都在静态存储。


在启动时修补一次代码

您没有标记架构,但您的(有太多问题无法提及)asm 似乎是 x86,所以我将讨论它。 (所有现代 x86 CPUs 都有乱序执行和非常好的分支预测,所以可能没有太多收获,不过。)

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

事实上,Linux 甚至使用 asm gotojmpnop 之间的补丁,用于像您这样的不变条件启动,在bool _static_cpu_has(u16 bit) in arch/x86/include/asm/cpufeature.h。首先,有一个 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 很有趣。我没有寻找进行修补的代码,但显然 linker 脚本是其中的其他关键部分。我 不是 试图为这种情况提供一个可行的版本,只是表明该技术是 可能的 ,以及在哪里可以找到 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*),但不同的索引很棘手。 ,作为结构末尾的灵活数组成员。 (实际上 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 指令,该指令得到一个 table 指针和 index在寄存器中,returns 一个 64 位值(来自 32 位或 64 位加载)。(并且不要忘记一个虚拟输入来告诉编译器你实际读取了指向的内存通过 table 指针,否则允许编译器进行优化,这可能会破坏您的代码,例如在 asm 语句中移动存储)。但是你必须要小心;内联 asm 可以通过禁用常量传播来损害优化(例如 index)。至少如果您省略 volatile,编译器可以将其视为输入的纯函数并对其进行 CSE。


用纯 C++ 解决这个问题

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

英特尔 Sandybridge 系列 CPU(http://agner.org/optimize/) (because of legacy CISC semantics; )上的可变计数移位成本为 3 微指令,除非您让编译器将 BMI2 用于 shlx(无标志移位)。 index += foo ? 0 : index 将有条件地加倍 index(一个班次计数的差异),但在 x86 上这样做是不值得的,因为条件可以很好地预测。

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

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

但是这两件事加在一起,真的不值得。仅供娱乐,您可以执行以下操作:source + asm output on the Godbolt compiler explorer

// 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 的快速路径(所有 运行s 一旦尘埃落定从 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

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


脚注 1:如果您的条件是 acpi_version == 4,那么 MIPS 可以从存储 0 / 1 结果中节省一条指令。而不是比较标志,it has compare-into-register, and branch instructions that compare against zero or a register, and a register that already reads as zero. Even on x86, comparing for zero / non-zero saves a byte of code-size if the value is already in a register ()。如果它是 ALU 指令的结果(因此 ZF 已经设置),它会节省更多,但事实并非如此。

但是大多数其他体系结构比较为标志,您不能从内存直接加载到标志中。因此,如果 acpi_version 比较昂贵,您只想存储静态 bool 结果,例如比 __int128int64_t 这样的寄存器宽的整数32位机器。

脚注 2:不要为 acpi_version 使用全局寄存器变量;那将是愚蠢的。如果它无处不在,那么希望 link-time 优化可以很好地提升比较。

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


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

由于 Peter mentions in his answer 的原因,如果 if(),您的特定函数不适合任何优化,特别是 (1) 通常 非常 计算成本低,并且 (2) 根据定义,它在关键路径之外,因为它只是一个控制依赖项,因此不会作为任何依赖链的连续部分出现。

就是说,在检查实际上有些昂贵的情况下,进行这种 "one-off" 运行时行为选择的一种通用模式是将函数指针与 trampoline-like 函数结合使用第一次调用时选择两个(或更多实现)之一,并用所选实现覆盖函数指针。

出于说明目的,在您的情况下可能是这样的。首先,为您的 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(即它没有内联,可能是因为它没有在 header 文件),当间接调用被正确预测时,从函数调用到通过函数指针调用的转换可能只会对性能产生很小甚至为零的影响——并且由于目标在第一次调用后是固定的,除非您的间接 BTB 处于压力之下,否则在前几次通话后就会很好地预测。

如果调用者能够内联原始 get_table 调用,则这种方法不太可取,因为它会抑制内联。但是,您对 self-modifying 代码的最初建议根本不适用于内联。

如上所述,对于删除单个 well-predicted if 的特定情况,这不会有太大作用:我希望它是关于清洗(即使您只是删除了 if 并硬编码了一个案例,我认为您也不会更快地找到它)-但是这种技术对于更复杂的案例很有用。将其视为一种 "self-modifying code light":您只修改函数指针,而不修改实际的底层代码。

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

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


1 我删除了此处的命名空间以使内容更加简洁,并使我未经验证的代码更有可能实际编译。

2 在这里,我在非常有限的意义上使用 rarely:我并不是说这会编译成有问题的 multi-threaded 代码,但你很少会在运行时遇到问题(那将是非常糟糕的)。相反,我是说这将在大多数编译器和平台上编译为正确的 multi-threaded 代码。因此,底层生成的代码完全不安全的情况很少见(事实上,像这样的模式在 pre-C++11 原子中被大量使用和支持)。