【问题标题】:Lookup table vs switch in C embedded softwareC嵌入式软件中的查找表与开关
【发布时间】:2016-06-20 17:08:58
【问题描述】:

在另一个帖子中,有人告诉我switch 在速度和紧凑性方面可能比查找表更好。

所以我想了解这之间的区别:

查找表

static void func1(){}
static void func2(){}

typedef enum
{
    FUNC1,
    FUNC2,
    FUNC_COUNT
} state_e;

typedef void (*func_t)(void);

const func_t lookUpTable[FUNC_COUNT] =
{
    [FUNC1] = &func1,
    [FUNC2] = &func2
};

void fsm(state_e state)
{
    if (state < FUNC_COUNT) 
        lookUpTable[state]();
    else
        ;// Error handling
}

还有这个:

开关

static void func1(){}
static void func2(){}

void fsm(int state)
{
    switch(state)
    {
        case FUNC1: func1(); break;
        case FUNC2: func2(); break;
        default:    ;// Error handling
    }
}

我认为查找表更快,因为编译器会尽可能将 switch 语句转换为跳转表。 由于这可能是错误的,我想知道为什么!

感谢您的帮助!

【问题讨论】:

  • 我们不能告诉你答案,因为它取决于太多的东西,但主要是你使用的编译器。相反,您应该指示编译器在两种情况下都输出程序集,同时使用优化标志,然后自己进行比较。
  • 你应该看看下面关于 switch 语句的帖子:lazarenko.me/switch
  • 一些编译器将简单的开关语句转换为查找表。一个普遍的答案确实是不可能的。
  • @Olaf:不知道你为什么要教我这是 20 多年以来的实践,特别是因为它并不是普遍正确的,并且不会发生在每次切换和所有优化标志的情况下。它还取决于成本/收益启发式。不是每个 LUT 都是优化,同样,也不是每个硬编码的 if-else 结构都是。
  • 许多编译器不能内联函数指针调用(或者可能需要多个特定于实现的选项),因此会错过任何与内联相关的优化......只是需要记住的一点。

标签: c performance switch-statement embedded lookup-tables


【解决方案1】:

使用函数指针的 LUT 会强制编译器使用该策略。它可以在理论上将 switch 版本编译为与 LUT 版本基本相同的代码(现在您已经向两者添加了越界检查)。实际上,这不是 gcc 或 clang 选择做的事情,因此值得查看 asm 输出以了解发生了什么。

(更新:gcc -fpie(在大多数现代 Linux 发行版上默认启用)喜欢制作相对偏移量表,而不是绝对函数指针,因此rodata 也是与位置无关的。GCC Jump Table initialization code generating movsxd and add?。这可以是一个错过的优化,请参阅我的答案以获取 gcc 错误报告的链接。手动创建函数指针数组可以解决这个问题。)


我将代码 on the Godbolt compiler explorer 与这两个函数放在一个编译单元(带有 gcc 和 clang 输出)中,以查看它是如何编译的。我稍微扩展了功能,所以不只是两种情况。

void fsm_switch(int state) {
    switch(state) {
        case FUNC0: func0(); break;
        case FUNC1: func1(); break;
        case FUNC2: func2(); break;
        case FUNC3: func3(); break;
        default:    ;// Error handling
    }
    //prevent_tailcall();
}

void fsm_lut(state_e state) {
    if (likely(state < FUNC_COUNT))  // without likely(), gcc puts the LUT on the taken side of this branch
        lookUpTable[state]();
    else
        ;// Error handling
    //prevent_tailcall();
}

另请参阅 How do the likely() and unlikely() macros in the Linux kernel work and what is their benefit?


x86

在 x86 上,clang 为开关制作自己的 LUT,但条目是指向函数内部的指针,而不是最终的函数指针。所以对于 clang-3.7,switch 恰好编译成比手动实现的 LUT 更差的代码。无论哪种方式,x86 CPU 都倾向于具有可以处理间接调用/跳转的分支预测,至少在它们易于预测的情况下是这样。

GCC 使用一系列条件分支but unfortunately doesn't tail-call directly with conditional branches, which AFAICT is safe on x86。它会依次检查 1、

他们为 LUT 编写了基本相同的代码:边界检查,使用 mov 将 arg 寄存器的高 32 位归零,然后使用索引寻址模式进行内存间接跳转。


手臂:

gcc 4.8.2 和 -mcpu=cortex-m4 -O2 生成有趣的代码。

正如 Olaf 所说,它制作了一个包含 1B 条目的内联表。它不会直接跳转到目标函数,而是跳转到正常的跳转指令(如b func3)。这是一个正常的无条件跳转,因为它是一个尾调用。

如果fsm_switch 在调用后执行任何操作(如在本例中,如果声明了void prevent_tailcall(void); 但未定义),则每个表目标条目都需要significantly more code (Godbolt),或者如果这被内联到更大的功能。

@@ With   void prevent_tailcall(void){} defined so it can inline:
@@ Unlike in the godbolt link, this is doing tailcalls.
fsm_switch:
        cmp     r0, #3    @ state,
        bhi     .L5       @
        tbb     [pc, r0]  @ state
       @@ There's no section .rodata directive here: the table is in-line with the code, so there's no need for base pointer to be loaded into a reg.  And apparently it's even loaded from I-cache, not D-cache
        .byte   (.L7-.L8)/2
        .byte   (.L9-.L8)/2
        .byte   (.L10-.L8)/2
        .byte   (.L11-.L8)/2
.L11:
        b       func3     @ optimized tail-call
.L10:
        b       func2
.L9:
        b       func1
.L7:
        b       func0
.L5:
        bx      lr         @ This is ARM's equivalent of an x86 ret insn

IDK 如果在轻量级 ARM 内核上,tbb 与完全间接跳转或调用 (blx) 的分支预测效果有很大差异。加载表的数据访问可能比通过switch 获得的两步跳转到分支指令更重要。

我了解到间接分支在 ARM 上的预测很差。如果间接分支每次都有相同的目标,我希望这还不错。但如果不是,我会假设大多数 ARM 内核都不会像大型 x86 内核那样找到短模式。

指令获取/解码在 x86 上需要更长的时间,因此避免指令流中的气泡更为重要。这就是 x86 CPU 具有如此出色的分支预测的原因之一。现代分支预测器甚至可以很好地处理间接分支的模式,基于该分支和/或其他分支的历史。

LUT 函数必须花费几条指令将 LUT 的基地址加载到寄存器中,但除此之外很像 x86:

fsm_lut:
        cmp     r0, #3    @ state,
        bhi     .L13      @,
        movw    r3, #:lower16:.LANCHOR0 @ tmp112,
        movt    r3, #:upper16:.LANCHOR0 @ tmp112,
        ldr     r3, [r3, r0, lsl #2]      @ tmp113, lookUpTable
        bx      r3  @ indirect register sibling call    @ tmp113
.L13:
        bx      lr  @

@ in the .rodata section
lookUpTable:
        .word   func0
        .word   func1
        .word   func2
        .word   func3

有关 Microchip dsPIC 的类似分析,请参阅 Mike of SST's answer

【讨论】:

  • 这是另一个很好的答案,非常感谢!我想知道是否可以通过程序集看到所有内容。现在我知道您必须知道您的硬件将如何处理您提供给它的组件!所以你回答我的另一个问题:这个问题的答案不仅取决于你使用的编译器,还取决于硬件。因此,唯一完全有效的解决方案是基准测试而不是代码分析(除非您完全了解所有硬件机制)。再次感谢!
  • 我想再次为你 +1 发现可能()和高调用效果!
  • @Plouff:实际上,查看 asm 并了解一系列硬件系列的缓慢之处可以替代基准测试。大多数人不会在基准农场中拥有每个 x86 uarch 中的一个。虽然对于嵌入式,您当然可以在目标平台上进行基准测试。
  • 是的,这就是我想说的:)。就我而言,除非我花大量时间研究目标的架构,否则基准测试将是唯一的解决方案!
  • 在许多基于慢速闪存代码存储的平台上,手动使用表将允许您控制该表是存储在快速 RAM 还是慢速闪存中。我认为我从未见过具有以这种方式控制 switch 语句代码生成的选项的编译器。
【解决方案2】:

为了获得更多的编译器输出,这里是 TI C28x 编译器使用@PeterCordes 示例代码生成的:

_fsm_switch:
        CMPB      AL,#0                 ; [CPU_] |62| 
        BF        $C$L3,EQ              ; [CPU_] |62| 
        ; branchcc occurs ; [] |62| 
        CMPB      AL,#1                 ; [CPU_] |62| 
        BF        $C$L2,EQ              ; [CPU_] |62| 
        ; branchcc occurs ; [] |62| 
        CMPB      AL,#2                 ; [CPU_] |62| 
        BF        $C$L1,EQ              ; [CPU_] |62| 
        ; branchcc occurs ; [] |62| 
        CMPB      AL,#3                 ; [CPU_] |62| 
        BF        $C$L4,NEQ             ; [CPU_] |62| 
        ; branchcc occurs ; [] |62| 
        LCR       #_func3               ; [CPU_] |66| 
        ; call occurs [#_func3] ; [] |66| 
        B         $C$L4,UNC             ; [CPU_] |66| 
        ; branch occurs ; [] |66| 
$C$L1:    
        LCR       #_func2               ; [CPU_] |65| 
        ; call occurs [#_func2] ; [] |65| 
        B         $C$L4,UNC             ; [CPU_] |65| 
        ; branch occurs ; [] |65| 
$C$L2:    
        LCR       #_func1               ; [CPU_] |64| 
        ; call occurs [#_func1] ; [] |64| 
        B         $C$L4,UNC             ; [CPU_] |64| 
        ; branch occurs ; [] |64| 
$C$L3:    
        LCR       #_func0               ; [CPU_] |63| 
        ; call occurs [#_func0] ; [] |63| 
$C$L4:    
        LCR       #_prevent_tailcall    ; [CPU_] |69| 
        ; call occurs [#_prevent_tailcall] ; [] |69| 
        LRETR     ; [CPU_] 
        ; return occurs ; [] 



_fsm_lut:
;* AL    assigned to _state
        CMPB      AL,#4                 ; [CPU_] |84| 
        BF        $C$L5,HIS             ; [CPU_] |84| 
        ; branchcc occurs ; [] |84| 
        CLRC      SXM                   ; [CPU_] 
        MOVL      XAR4,#_lookUpTable    ; [CPU_U] |85| 
        MOV       ACC,AL << 1           ; [CPU_] |85| 
        ADDL      XAR4,ACC              ; [CPU_] |85| 
        MOVL      XAR7,*+XAR4[0]        ; [CPU_] |85| 
        LCR       *XAR7                 ; [CPU_] |85| 
        ; call occurs [XAR7] ; [] |85| 
$C$L5:    
        LCR       #_prevent_tailcall    ; [CPU_] |88| 
        ; call occurs [#_prevent_tailcall] ; [] |88| 
        LRETR     ; [CPU_] 
        ; return occurs ; [] 

我还使用了 -O2 优化。 我们可以看到,即使编译器有能力,开关也没有转换成跳转表。

【讨论】:

    【解决方案3】:

    由于我是该评论的原作者,因此我必须添加一个您在问题中未提及的非常重要的问题。也就是说,原版是关于嵌入式系统的。假设这是一个带有集成闪存的典型裸机系统,与我将重点介绍的 PC 有非常重要的区别。

    此类嵌入式系统通常具有以下限制。

    • 没有 CPU 缓存。
    • Flash 需要等待状态才能获得更高(即 >ca. 32MHz)的 CPU 时钟。实际比例取决于模具设计、低功耗/高速工艺、工作电压等。
    • 为了隐藏等待状态,Flash 具有比 CPU 总线更宽的读取线。
    • 这只适用于带有指令预取的线性代码。
    • 数据访问会干扰指令预取或停止直到完成。
    • Flash 可能有一个非常小的内部指令缓存。
    • 如果有的话,还有更小的数据缓存。
    • 小缓存会导致更频繁地丢弃(替换之前已被再次使用的先前条目)。

    例如STM32F4xx 读取需要 6 个时钟,频率为 150MHz/3.3V,128 位(4 个字)。因此,如果需要数据访问,很可能会为所有要获取的数据增加超过 12 个时钟的延迟(涉及额外的周期)。

    假设紧凑的状态代码,对于实际问题,这对该架构(Cortex-M4)有以下影响:

    • 查找表:读取函数地址是一种数据访问。具有上述所有含义。
    • switch otoh 使用特殊的“查表”指令,该指令使用指令后面的代码空间数据。所以第一个条目可能已经预取。其他条目不会破坏预取。访问也是代码访问,因此数据进入 Flash 的指令缓存。

    还要注意switch 不需要函数,因此编译器可以完全优化代码。这对于查找表是不可能的。至少不需要函数进入/退出的代码。


    由于上述和其他因素,很难估计。这在很大程度上取决于您的平台和代码结构。但是假设上面给出的系统,切换很可能更快(顺便说一句,更清晰)。

    【讨论】:

    • 您的回答确实更相关,因为我在谈论嵌入式软件。我的目标不是 STM32,但这是一个 MCU。我需要 8 个周期来实现对 FLASH 的读取。不幸的是,我更喜欢使用函数,即使我会将代码改写为switch。所以我还必须考虑解决方案的可读性。除了有效性的角度,我倾向于更喜欢查找表的可读性。但这是一个品味问题!感谢您非常详细的回答(我给了您答案标记:)!
    • 补充问题:这种行为与装配没有直接关系吧?我的意思是,检测代码(如切换 GPIO)是查看这些硬件机制效果的唯一解决方案吗?谢谢!
    • @Plouff:我不确定你最后的评论是什么意思。这当然是一个组装/实施细节,就像在性能方面一样。我希望我说清楚这涉及到很多因素。关于使用函数:如果你使用现代编译器(例如 gcc),声明函数static,它可以很好地将它们内联到switch(也取决于优化设置)。添加inline可以给编译器一个更强的提示(但不一定)。不确定像 IAR 这样更“保守”的编译器表现如何(它们有时倾向于更糟糕地优化此类结构)。
    • 我的意思是我不明白我怎么能看到程序集中闪存的等待状态的影响。这是一个正确的假设吗?我过去读过有关inline 函数的内容。但我记得这并不意味着编译器实际上会内联该函数。此外,inline 是 C99 功能。由于所有这些原因,我不怎么使用它……也许是时候尝试一下了。感谢您的提示! (@name 功能坏了?!)
    • inline 是自 C99 以来的标准 C 功能,当前版本为 C11。 C 标准只有一个,所以当谈到 C 时,它是 C11。 C99向下兼容;差异在这里并不重要。您不应该使用至少不支持 C99 的过时编译器——它现在被 C11 取代 5 年,自发布以来已经 17 年!请仔细阅读我的评论。我写的是现代编译器,而不是一些垃圾但昂贵的专有工具链。如果您说明您使用的是哪个 MCU,我可能会提供更多提示。
    【解决方案4】:

    在 Microchip dsPIC 系列器件上,查找表作为一组指令地址存储在闪存本身中。执行查找涉及从 Flash 读取地址,然后调用例程。进行调用会增加另外几个周期来推送指令指针和其他位和 bobs(例如设置堆栈帧)的内务处理。

    例如,在 dsPIC33E512MU810 上,使用 XC16 (v1.24) 查找代码:

    lookUpTable[state]();
    

    编译为(从 MPLAB-X 的反汇编窗口):

    !        lookUpTable[state]();
    0x2D20: MOV [W14], W4    ; get state from stack-frame (not counted)
    0x2D22: ADD W4, W4, W5   ; 1 cycle (addresses are 16 bit aligned)
    0x2D24: MOV #0xA238, W4  ; 1 cycle (get base address of look-up table)
    0x2D26: ADD W5, W4, W4   ; 1 cycle (get address of entry in table)
    0x2D28: MOV [W4], W4     ; 1 cycle (get address of the function)
    0x2D2A: CALL W4          ; 2 cycles (push PC+2 set PC=W4)
    

    ...并且每个(空的,什么都不做)函数编译为:

    !static void func1()
    !{}
    0x2D0A: LNK #0x0         ; 1 cycle (set up stack frame)
    ! Function body goes here
    0x2D0C: ULNK             ; 1 cycle (un-link frame pointer)
    0x2D0E: RETURN           ; 3 cycles
    

    对于任何一种情况,这总共有 11 个指令周期的开销,而且它们都采用相同的方法。 (注:如果表格或其中包含的函数不在同一个 32K 程序字 Flash 页中,则由于必须让地址生成单元从正确的页读取,或设置PC 拨打长途电话。)

    另一方面,如果整个 switch 语句符合特定大小,编译器将生成执行测试和相关分支的代码,每个案例需要三个(或可能四个)循环,直到这是真的。

    例如switch语句:

    switch(state)
    {
    case FUNC1: state++; break;
    case FUNC2: state--; break;
    default: break;
    }
    

    编译为:

    !    switch(state)
    0x2D2C: MOV [W14], W4       ; get state from stack-frame (not counted)
    0x2D2E: SUB W4, #0x0, [W15] ; 1 cycle (compare with first case)
    0x2D30: BRA Z, 0x2D38       ; 1 cycle (if branch not taken, or 2 if it is)
    0x2D32: SUB W4, #0x1, [W15] ; 1 cycle (compare with second case)
    0x2D34: BRA Z, 0x2D3C       ; 1 cycle (if branch not taken, or 2 if it is)
    !    {
    !    case FUNC1: state++; break;
    0x2D38: INC [W14], [W14]    ; To stop the switch being optimised out
    0x2D3A: BRA 0x2D40          ; 2 cycles (go to end of switch)
    !    case FUNC2: state--; break;
    0x2D3C: DEC [W14], [W14]    ; To stop the switch being optimised out
    0x2D3E: NOP                 ; compiler did a fall-through (for some reason)
    !    default: break;
    0x2D36: BRA 0x2D40          ; 2 cycles (go to end of switch)
    !    }
    

    如果采用第一种情况,这是 5 个周期的开销,如果采用第二种情况,则需要 7 个周期,依此类推,这意味着它们在第四种情况下收支平衡。

    这意味着在设计时了解您的数据将对长期速度产生重大影响。如果您有大量(超过大约 4 个案例)并且它们都以相似的频率出现,那么从长远来看,查找表会更快。如果案例的频率显着不同(例如案例 1 比案例 2 更可能,这比案例 3 更有可能等)那么,如果您首先订购最有可能案例的开关,那么开关将是从长远来看更快。对于只有少数情况的边缘情况,对于大多数执行而言,切换(可能)无论如何都会更快,并且更具可读性且不易出错。

    如果交换机中只有少数情况,或者某些情况会比其他情况更频繁地发生,那么执行交换机的测试和分支可能会比使用查找表花费更少的周期。另一方面,如果您有多个以相似频率发生的案例,那么平均而言查找可能最终会更快。

    提示:除非您知道查找肯定会更快并且运行时间很重要,否则请使用开关。

    编辑:我的 switch 示例有点不公平,因为我忽略了最初的问题并内联了案例的“正文”,以突出使用 switch 的真正优势抬头。如果交换机也必须进行调用,那么它仅对第一种情况具有优势!

    【讨论】:

    • 感谢您的案例研究。目前我有大约 20 个州(即案例)。将来可能会更多。我在您的回答中看到的唯一问题是编译器没有将开关转换为跳转表。那样就好了。更重要的是,就像你说的那样,switch 中没有子例程调用开销,但无论如何这些示例给出了一个好主意。再次感谢!
    • @Plouff 你是对的,Microchip 编译器不会将 switch 语句转换为跳转表,仅仅是因为双指令测试和分支序列更有效。这使开发人员可以根据他们的要求选择解决方案。
    • @Plouff 我选择忽略案例中的调用,因为您的示例代码暗示用例是状态机。对于这些,我通常会尝试在 switch 语句案例中保持状态转换处理内联(以便封装状态管理),并根据需要将特定的状态转换处理委托给其他函数。这也允许代码在可能的情况下利用跳转表切换的(苗条)性能增益。不过,这都是非常主观的,我毫不怀疑您的项目与我的项目有不同的要求。 :-)
    • 感谢您提供详细信息。你会说大约 20 个州是很多州吗?
    • 一般来说是的。但是,如果一个或两个状态比其他状态更频繁地出现,我希望切换效率更高一些。但是,跳转表和切换之间的选择很大程度上取决于编译器和您的目标处理器。您通常可以让编译器输出包含已编译的汇编语言指令的“中间”文件。值得一看并比较两者。但除非您确定它需要优化,否则您最好将时间花在其他事情上,因为两者之间的差异非常小。
    【解决方案5】:

    首先,在一些处理器上,间接调用(例如通过指针)——就像你的查找表示例中的那些——代价高昂(管道中断、TLB、缓存效应)。间接跳转也可能如此......

    然后,一个好的优化编译器可能在您的 Switch 示例中内联对func1() 的调用;那么您将不会为内联函数运行任何序言或结尾。

    您需要确定基准,因为许多其他因素对性能很重要。另请参阅this(以及那里的参考资料)。

    【讨论】:

    • 非常感谢我正在寻找的答案!我不知道间接调用会导致管道损坏、TLB 和缓存效应等问题。但是现在,我需要弄清楚这意味着什么……我一开始有一个问题,现在我还有 3 个问题!谢谢;)!
    • @BarryTheHatchet:我说的是这个:stackoverflow.com/q/35797254/882697。我不认为你在这里发表评论。你说的是哪个线程?这对我来说可能很有趣:)。
    • @Plouff 好吧,那肯定是另一个帖子。巧合!
    【解决方案6】:

    msc 的回答和 cmets 为您提供了很好的提示,说明为什么性能可能不是您所期望的。基准测试是规则,但结果会因架构而异,并且可能会随着编译器的其他版本以及所选的配置和选项而改变。

    但请注意,您的 2 段代码不会对 state 执行相同的验证:

    • 如果state 不是定义的值之一,则该开关不会正常执行任何操作,
    • 跳转表版本将对除FUNC1FUNC2 这两个值之外的所有值调用未定义的行为。

    如果不对FUNC_COUNT 做任何假设,没有通用的方法可以用虚拟函数指针初始化跳转表。得到相同的行为,跳转表版本应该是这样的:

    void fsm(int state) {
        if (state >= 0 && state < FUNC_COUNT && lookUpTable[state] != NULL)
            lookUpTable[state]();
    }
    

    尝试对此进行基准测试并检查汇编代码。这是一个方便的在线编译器:http://gcc.godbolt.org/#

    【讨论】:

    • 在这个问题中,我想关注查找表与开关。但你是对的:状态验证也是有代价的!我添加了一些细节,以更好地反映我使用的实现。到目前为止,有趣的是,在我得到的 3 个答案中,总是有相同的建议:基准测试!感谢在线编译器,这将是一个非常有用的链接!
    • 在适当的实现中(即包括捕获无效状态和紧凑开关),两者都会产生非常相似的结构。更多细节在硬件架构、如何从 Flash 中读取数据、访问类型等方面。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2011-01-17
    • 1970-01-01
    • 2010-11-01
    • 2010-11-06
    相关资源
    最近更新 更多