web-dev-qa-db-ja.com

ルックアップテーブルとC組み込みソフトウェアのスイッチ

別のスレッドで、速度とコンパクトさの点で、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ステートメントをジャンプテーブルに変換しようとするため、ルックアップテーブルの方が高速だと思いました。これは間違っている可能性があるため、理由を知りたいと思います!

ご協力いただきありがとうございます!

35
Plouff

私はコメントの元の著者であったため、質問で言及しなかった非常に重要な問題を追加する必要があります。つまり、オリジナルは組み込みシステムに関するものでした。これが統合されたフラッシュを備えた典型的なベアメタルシステムであると仮定すると、私が集中するPCとは非常に重要な違いがあります。

このような組み込みシステムには通常、次の制約があります。

  • cPUキャッシュなし。
  • フラッシュには、より高い(つまり、約32MHz)CPUクロックの待機状態が必要です。実際の比率は、ダイ設計、低電力/高速プロセス、動作電圧などに依存します。
  • 待機状態を隠すために、FlashにはCPUバスよりも広い読み取りラインがあります。
  • これは、命令プリフェッチを使用した線形コードでのみ有効です。
  • データアクセスは、命令のプリフェッチを妨害するか、完了するまで停止します。
  • フラッシュには、内部に非常に小さな命令キャッシュがある場合があります。
  • あるとしても、さらに小さなデータキャッシュがあります。
  • キャッシュが小さいと、トラッシュが頻繁に発生します(以前に使用されたエントリが別の時間に使用される前に置き換えられます)。

たとえばSTM32F4xxでは、128ビット(4ワード)で150MHz/3.3Vで6クロックかかります。したがって、データアクセスが必要な場合、すべてのデータをフェッチするために12クロック以上の遅延が追加される可能性があります(追加のサイクルが含まれます)。

コンパクトな状態コードを想定して、実際の問題では、これはこのアーキテクチャ(Cortex-M4)に次の影響を及ぼします。

  • ルックアップテーブル:関数アドレスの読み取りはデータアクセスです。上記のすべての意味を持ちます。
  • スイッチotohは、命令の直後にコード空間データを使用する特別な「テーブル検索」命令を使用します。したがって、最初のエントリはすでにプリフェッチされている可能性があります。他のエントリはプリフェッチを中断しません。また、アクセスはコードアクセスなので、データはFlashの命令キャッシュに入ります。

また、switchは関数を必要としないため、コンパイラはコードを完全に最適化できます。これは、ルックアップテーブルでは不可能です。少なくとも関数の出入りのコードは必要ありません。


前述の要因およびその他の要因により、推定値を判断するのは困難です。プラットフォームとコード構造に大きく依存します。しかし、上記のシステムを想定すると、切り替えは非常に高速です(そして、より明確になります)。

まず、someプロセッサでは、間接呼び出し(たとえば、ポインターを使用)-Lookup Tableの例のように-高価です(パイプラインの破損、TLB、キャッシュ効果)。間接ジャンプにも当てはまるかもしれません...

次に、適切な最適化コンパイラmightは、Switchの例でfunc1()の呼び出しをインライン化します。インライン関数のプロローグまたはエピローグを実行しません。

他の多くの要因がパフォーマンスに重要であるため、確実にベンチマークする必要がありますthis (およびそこの参照)も参照してください。

17

mscの回答とコメントは、パフォーマンスが期待したものと異なる理由について良いヒントを与えてくれます。ベンチマークがルールですが、結果はアーキテクチャごとに異なり、コンパイラの他のバージョン、そしてもちろんその構成と選択されたオプションによって変わる可能性があります。

ただし、2つのコードはstateに対して同じ検証を実行しないことに注意してください。

  • スイッチはstateが定義された値の1つではないため、正常に何もしません。
  • ジャンプテーブルバージョンは、2つの値FUNC1およびFUNC2を除くすべての未定義の動作を呼び出します。

FUNC_COUNTを仮定せずに、ダミー関数ポインタでジャンプテーブルを初期化する一般的な方法はありません。同じ動作をするか、ジャンプテーブルバージョンは次のようになります。

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

これをベンチマークしてみて、アセンブリコードを調べてください。以下は、このための便利なオンラインコンパイラです。 http://gcc.godbolt.org/#

3
chqrlie

Microchip社のdsPICファミリのデバイスでは、ルックアップテーブルが命令アドレスのセットとしてフラッシュ自体に保存されます。ルックアップを実行するには、フラッシュからアドレスを読み取り、ルーチンを呼び出します。呼び出しを行うと、ハウスキーピングの命令ポインターと他のビットとボブ(スタックフレームの設定など)をプッシュするために、さらに少数のサイクルが追加されます。

たとえば、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プログラムのWord Flashページにない場合、正しいページから読み取るためにアドレス生成ユニットを取得するか、セットアップする必要があるため、さらに大きなオーバーヘッドが発生しますPCで長い電話をかけます。)

一方、switchステートメント全体が特定のサイズに収まる場合、コンパイラーは、ケースごとに2つの命令としてテストおよび相対分岐を実行するコードを生成します。 。

たとえば、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サイクルのオーバーヘッドであり、2番目のケースが取得された場合は7サイクルなどです。

これは、設計時にデータを知ることが長期的な速度に大きな影響を与えることを意味します。かなりの数(約4件以上)があり、それらがすべて同じ頻度で発生する場合、ルックアップテーブルは長期的にはより高速になります。ケースの頻度が大幅に異なる場合(たとえば、ケース1はケース2よりも可能性が高く、ケース2はケース3よりも可能性が高いなど)、最初に最も可能性の高いケースでスイッチを注文すると、スイッチは長期的にはより高速です。少数のケースしかないEdgeケースの場合、ほとんどの場合、スイッチは(おそらく)より高速になり、読みやすくなり、エラーが発生しにくくなります。

スイッチに少数のケースしかない場合、またはいくつかのケースが他よりも頻繁に発生する場合、スイッチのテストとブランチの実行は、おそらくルックアップテーブルを使用するよりも少ないサイクルで済みます。一方、同じような頻度で発生するケースが少数を超える場合は、おそらく平均して検索が高速になります。

ヒント:ルックアップが確実に高速になり、実行にかかる時間が重要であることがわかっていない限り、スイッチを使用してください。

編集:私のスイッチの例は少し不公平です。元の質問を無視し、ケースの「ボディ」をインライン化して、ルックアップに対してスイッチを使用する本当の利点を強調しています。スイッチが同様に呼び出しを行う必要がある場合、最初の場合にのみ利点があります!

3
Evil Dog Pie

さらに多くのコンパイラ出力を得るために、ここでは@PeterCordesサンプルコードを使用してTI C28xコンパイラによって生成されたものを示します。

_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最適化も使用しました。コンパイラーが機能を備えていても、スイッチはジャンプテーブルに変換されないことがわかります。

2
Plouff