web-dev-qa-db-fra.com

Table de correspondance vs commutateur dans le logiciel intégré C

Dans un autre fil, on m'a dit qu'un switch peut être meilleur qu'un table de recherche en termes de vitesse et de compacité.

Je voudrais donc comprendre les différences entre cela:

Table de recherche

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
}

et ça:

Commutateur

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

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

Je pensais qu'une table de recherche était plus rapide car les compilateurs essayaient de transformer les instructions de commutateur en tables de saut lorsque cela était possible. Comme cela peut être faux, j'aimerais savoir pourquoi!

Merci de votre aide!

35
Plouff

Comme j'étais l'auteur original du commentaire, je dois ajouter une question très importante que vous n'avez pas mentionnée dans votre question. Autrement dit, l'original concernait un système embarqué. En supposant qu'il s'agit d'un système nu-métal typique avec Flash intégré, il existe des différences très importantes par rapport à un PC sur lequel je vais me concentrer.

Ces systèmes embarqués présentent généralement les contraintes suivantes.

  • pas de cache CPU.
  • Flash nécessite des temps d'attente pour les horloges CPU supérieures (c'est-à-dire> environ 32 MHz). Le rapport réel dépend de la conception de la matrice, du processus à faible puissance/haute vitesse, de la tension de fonctionnement, etc.
  • Pour masquer les temps d'attente, Flash a des lignes de lecture plus larges que le bus CPU.
  • Cela ne fonctionne bien que pour le code linéaire avec l'instruction prefetch.
  • Les accès aux données perturbent la prélecture des instructions ou sont bloqués jusqu'à la fin.
  • Flash peut avoir un très petit cache d'instructions interne.
  • Le cas échéant, le cache de données est encore plus petit.
  • Les petits caches entraînent une corbeille plus fréquente (remplacement d'une entrée précédente avant qu'elle ait été utilisée une autre fois).

Par exemple le STM32F4xx une lecture prend 6 horloges à 150 MHz/3,3 V pour 128 bits (4 mots). Donc, si un accès aux données est requis, les chances sont bonnes, cela ajoute plus de 12 horloges de retard pour toutes les données à extraire (il y a des cycles supplémentaires impliqués).

En supposant des codes d'état compacts, pour le problème réel, cela a les effets suivants sur cette architecture (Cortex-M4):

  • Lookup-table: La lecture de l'adresse de la fonction est un accès aux données. Avec toutes les implications mentionnées ci-dessus.
  • Un commutateur otoh utilise une instruction spéciale de "recherche de table" qui utilise des données d'espace de code juste derrière l'instruction. Ainsi, les premières entrées sont peut-être déjà prélues. Les autres entrées ne cassent pas la prélecture. L'accès est également un accès au code, ainsi les données vont dans le cache d'instructions de Flash.

Notez également que le switch n'a pas besoin de fonctions, donc le compilateur peut optimiser pleinement le code. Ce n'est pas possible pour une table de recherche. Au moins, le code d'entrée/sortie de fonction n'est pas requis.


En raison des facteurs susmentionnés et d'autres, une estimation est difficile à dire. Cela dépend fortement de votre plateforme et de la structure du code. Mais en supposant le système donné ci-dessus, le changement est très probablement plus rapide (et plus clair, en passant).

Tout d'abord, sur les processeurs certains, les appels indirects (par exemple via un pointeur) - comme ceux de votre Table de consultation - sont coûteux (rupture de pipeline, TLB, cache effets). Cela pourrait aussi être vrai pour les sauts indirects ...

Ensuite, un bon compilateur d'optimisation pourrait incorporer l'appel à func1() dans votre Switch exemple; alors vous n'exécuterez aucun prologue ou épilogue pour des fonctions intégrées.

Vous devez comparer pour être sûr, car de nombreux autres facteurs sont importants pour les performances. Voir aussi this (et la référence là-bas).

17

L'utilisation d'une LUT de pointeurs de fonction force le compilateur à utiliser cette stratégie. Il pourrait en théorie, compiler la version du commutateur vers essentiellement le même code que la version LUT (maintenant que vous avez ajouté des contrôles hors limites aux deux). En pratique, ce n'est pas ce que gcc ou clang choisissent de faire, donc cela vaut la peine de regarder la sortie asm pour voir ce qui s'est passé.

(mise à jour: gcc -fpie (activé par défaut sur la plupart des distributions Linux modernes) aime créer des tableaux de décalages relatifs, au lieu de pointeurs de fonctions absolus, de sorte que les rodata sont également indépendantes de la position. GCC Jump Code d'initialisation de table générant movsxd et ajouter? . Cela pourrait être une optimisation manquée, voir ma réponse ici pour les liens vers les rapports de bogues gcc. La création manuelle d'un tableau de pointeurs de fonction pourrait contourner cela.)


J'ai mis le code sur l'explorateur du compilateur Godbolt avec les deux fonctions dans une seule unité de compilation (avec sortie gcc et clang), pour voir comment il a été compilé. J'ai un peu élargi les fonctions, donc ce n'était pas seulement deux cas.

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

Voir aussi Comment fonctionnent les macros probable () et improbable () dans le noyau Linux et quel est leur avantage?


x86

Sur x86, clang crée sa propre LUT pour le commutateur, mais les entrées sont des pointeurs vers la fonction, pas les pointeurs de la fonction finale . Donc, pour clang-3.7, le commutateur arrive à compiler en code strictement pire que le LUT implémenté manuellement. Dans les deux cas, les processeurs x86 ont tendance à avoir une prédiction de branche qui peut gérer les appels/sauts indirects, du moins s'ils sont faciles à prévoir.

GCC utilise une séquence de branches conditionnelles ( mais malheureusement ne fait pas d'appel direct directement avec les branches conditionnelles, ce que AFAICT est sûr sur x86 . Il vérifie 1, <1, 2, 3, dans cet ordre, avec principalement des branches non prises jusqu'à ce qu'il trouve une correspondance.

Ils font un code essentiellement identique pour la LUT: vérification des limites, zéro les 32 bits supérieurs du registre arg avec un mov, puis un saut indirect en mémoire avec un mode d'adressage indexé.


BRAS:

gcc 4.8.2 avec -mcpu=cortex-m4 -O2 crée un code intéressant.

Comme l'a dit Olaf, , il crée un tableau en ligne d'entrées 1B . Il ne saute pas directement à la fonction cible, mais à la place à une instruction de saut normale (comme b func3). Il s'agit d'un saut inconditionnel normal, car il s'agit d'un appel de queue.

Chaque entrée de destination de table a besoin de beaucoup plus de code (Godbolt) si fsm_switch Fait quelque chose après l'appel (comme dans ce cas un appel de fonction non en ligne, si void prevent_tailcall(void); est déclaré mais non défini), ou si cela est inséré dans une fonction plus grande.

@@ 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 s'il y a beaucoup de différence entre le bon fonctionnement de la prédiction de branche pour tbb par rapport à un saut ou un appel indirect complet (blx), sur un poids léger ARM Un accès aux données pour charger la table peut être plus important que le saut en deux étapes vers une instruction de branche que vous obtenez avec un switch.

J'ai lu que les branches indirectes sont mal prédites sur ARM. J'espère que ce n'est pas mal si la branche indirecte a la même cible à chaque fois. Sinon, je suppose que la plupart des cœurs ARM ne trouveront même pas de modèles courts comme les grands cœurs x86.

La récupération/décodage des instructions prend plus de temps sur x86, il est donc plus important d'éviter les bulles dans le flux d'instructions. C'est une des raisons pour lesquelles les processeurs x86 ont une si bonne prédiction de branche. Les prédicteurs de branche modernes font même du bon travail avec les modèles de branches indirectes, basés sur l'historique de cette branche et/ou d'autres branches qui y mènent.

La fonction LUT doit passer quelques instructions à charger l'adresse de base de la LUT dans un registre, mais sinon, c'est à peu près comme 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

Voir réponse de Mike de SST pour une analyse similaire sur un microprocesseur dsPIC.

3
Peter Cordes

la réponse de msc et les commentaires vous donnent de bonnes indications pour expliquer pourquoi les performances peuvent ne pas être celles que vous attendez. Le benchmarking est la règle, mais les résultats varieront d'une architecture à l'autre et peuvent changer avec d'autres versions du compilateur et bien sûr sa configuration et ses options sélectionnées.

Notez cependant que vos 2 morceaux de code n'effectuent pas la même validation sur state:

  • Le commutateur ne fera rien gracieusement. state n'est pas l'une des valeurs définies,
  • La version de la table de sauts invoquera un comportement indéfini pour tous sauf les 2 valeurs FUNC1 et FUNC2.

Il n'y a aucun moyen générique d'initialiser la table de saut avec des pointeurs de fonction factices sans faire d'hypothèses sur FUNC_COUNT. Obtenez le même comportement, la version de la table de saut devrait ressembler à ceci:

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

Essayez de comparer cela et inspectez le code d'assembly. Voici un compilateur en ligne pratique pour cela: http://gcc.godbolt.org/#

3
chqrlie

Sur la famille de périphériques Microchip dsPIC, une table de recherche est stockée sous la forme d'un ensemble d'adresses d'instructions dans le Flash lui-même. Pour effectuer la recherche, il faut lire l'adresse à partir du Flash, puis appeler la routine. Faire l'appel ajoute une autre poignée de cycles pour pousser le pointeur d'instruction et d'autres bits et bobs (par exemple, définir le cadre de pile) de l'entretien ménager.

Par exemple, sur le dsPIC33E512MU810, en utilisant XC16 (v1.24) le code de recherche:

lookUpTable[state]();

Compile vers (à partir de la fenêtre de démontage dans 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)

... et chaque fonction (vide, ne rien faire) se compile pour:

!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

Il s'agit d'un total de 11 cycles d'instruction de surcharge pour tous les cas, et ils prennent tous la même chose. (Remarque: Si le tableau ou les fonctions qu'il contient ne se trouvent pas dans la même page Word Flash du programme 32K, il y aura un surcoût encore plus important en raison de la nécessité de faire lire l'unité de génération d'adresse à partir de la page correcte ou de configurer le PC pour passer un long appel.)

D'un autre côté, à condition que l'instruction switch entière tienne dans une certaine taille, le compilateur générera du code qui effectue un test et une branche relative sous la forme de deux instructions par cas prenant trois (ou peut-être quatre) cycles par cas jusqu'à celui qui est vrai .

Par exemple, l'instruction switch:

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

Compile vers:

!    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)
!    }

Il s'agit d'une surcharge de 5 cycles si le premier cas est pris, 7 si le deuxième cas est pris, etc., ce qui signifie qu'ils atteignent le seuil de rentabilité sur le quatrième cas.

Cela signifie que connaître vos données au moment de la conception aura une influence significative sur la vitesse à long terme. Si vous avez un nombre significatif (plus de 4 cas environ) et qu'ils se produisent tous avec une fréquence similaire, une table de recherche sera plus rapide à long terme. Si la fréquence des cas est significativement différente (par exemple, le cas 1 est plus probable que le cas 2, ce qui est plus probable que le cas 3, etc.), si vous commandez d'abord le commutateur avec le cas le plus probable, alors le commutateur sera plus rapide à long terme. Pour le cas Edge lorsque vous n'avez que quelques cas, le commutateur sera (probablement) plus rapide de toute façon pour la plupart des exécutions et est plus lisible et moins sujet aux erreurs.

S'il n'y a que quelques cas dans le commutateur, ou si certains cas se produisent plus souvent que d'autres, l'exécution du test et de la branche du commutateur prendra probablement moins de cycles que l'utilisation d'une table de recherche. D'un autre côté, si vous avez plus d'une poignée de cas qui se produisent avec une fréquence similaire, la recherche finira probablement par être plus rapide en moyenne.

Astuce: utilisez le commutateur, sauf si vous savez que la recherche sera certainement plus rapide et que le temps nécessaire à l'exécution est important.

Edit: Mon exemple de commutateur est un peu injuste, car j'ai ignoré la question d'origine et j'ai aligné le `` corps '' des cas pour mettre en évidence le réel avantage d'utiliser un commutateur sur une recherche . Si le commutateur doit également effectuer l'appel, il n'a l'avantage que pour le premier cas!

3
Evil Dog Pie

Pour avoir encore plus de sorties de compilateur, voici ce qui est produit par le compilateur TI C28x en utilisant l'exemple de code @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 ; [] 

J'ai également utilisé des optimisations -O2. Nous pouvons voir que le commutateur n'est pas converti en table de saut même si le compilateur en a la capacité.

2
Plouff