web-dev-qa-db-fra.com

Fonction de comparaison d'entiers efficace

La fonction compare est une fonction qui prend deux arguments a et b et renvoie un entier décrivant leur ordre. Si a est inférieur à b, le résultat est un entier négatif. Si a est plus grand que b, le résultat est un entier positif. Sinon, a et b sont égaux et le résultat est zéro.

Cette fonction est souvent utilisée pour paramétrer des algorithmes de tri et de recherche à partir de bibliothèques standard.

Implémenter la fonction compare pour les caractères est assez facile; vous soustrayez simplement les arguments:

int compare_char(char a, char b)
{
    return a - b;
}

Cela fonctionne parce que la différence entre deux caractères est généralement supposée tenir dans un entier. (Notez que cette hypothèse n'est pas valable pour les systèmes où sizeof(char) == sizeof(int).)

Cette astuce ne peut pas fonctionner pour comparer des nombres entiers, car la différence entre deux nombres entiers ne correspond généralement pas à un nombre entier. Par exemple, INT_MAX - (-1) = INT_MIN suggère que INT_MAX est inférieur à -1 (techniquement, le débordement entraîne un comportement indéfini, mais supposons une arithmétique modulo).

Alors, comment pouvons-nous implémenter efficacement la fonction de comparaison pour les entiers? Voilà ma première tentative:

int compare_int(int a, int b)
{
    int temp;
    int result;
    __asm__ __volatile__ (
        "cmp %3, %2 \n\t"
        "mov $0, %1 \n\t"

        "mov $1, %0 \n\t"
        "cmovg %0, %1 \n\t"

        "mov $-1, %0 \n\t"
        "cmovl %0, %1 \n\t"
    : "=r"(temp), "=r"(result)
    : "r"(a), "r"(b)
    : "cc");
    return result;
}

Peut-il être fait en moins de 6 instructions? Existe-t-il un moyen moins simple, plus efficace?

57
fredoverflow

Ce qui suit s’est toujours révélé assez efficace pour moi:

return (a < b) ? -1 : (a > b);

Avec gcc -O2 -S, cela compile les cinq instructions suivantes:

xorl    %edx, %edx
cmpl    %esi, %edi
movl    $-1, %eax
setg    %dl
cmovge  %edx, %eax

Pour faire suite à l'excellente réponse d'accompagnement d'Ambroz Bizjak , je n'étais pas convaincu que son programme testait le même code d'assemblage que ce qui a été posté ci-dessus. Et, en étudiant de plus près la sortie du compilateur, j'ai remarqué que celui-ci ne générait pas les mêmes instructions que celles publiées dans nos réponses. J'ai donc suivi son programme de test, modifié à la main le résultat de l'assemblage afin qu'il corresponde à ce que nous avons publié et comparé les temps qui en résultent. Il semble que les deux versions se comparent à peu près à l'identique.

./opt_cmp_branchless: 0m1.070s
./opt_cmp_branch:     0m1.037s

Je poste l’Assemblée de chaque programme dans son intégralité afin que d’autres puissent tenter la même expérience et confirmer ou contredire mon observation.

Voici la version avec l'instruction cmovge ((a < b) ? -1 : (a > b)):

        .file   "cmp.c"
        .text
        .section        .rodata.str1.1,"aMS",@progbits,1
.LC0:
        .string "%d=0\n"
        .text
        .p2align 4,,15
.globl main
        .type   main, @function
main:
.LFB20:
        .cfi_startproc
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        pushq   %rbx
        .cfi_def_cfa_offset 24
        .cfi_offset 3, -24
        movl    $arr.2789, %ebx
        subq    $8, %rsp
        .cfi_def_cfa_offset 32
.L9:
        leaq    4(%rbx), %rbp
.L10:
        call    Rand
        movb    %al, (%rbx)
        addq    $1, %rbx
        cmpq    %rbx, %rbp
        jne     .L10
        cmpq    $arr.2789+4096, %rbp
        jne     .L9
        xorl    %r8d, %r8d
        xorl    %esi, %esi
        orl     $-1, %edi
.L12:
        xorl    %ebp, %ebp
        .p2align 4,,10
        .p2align 3
.L18:
        movl    arr.2789(%rbp), %ecx
        xorl    %eax, %eax
        .p2align 4,,10
        .p2align 3
.L15:
        movl    arr.2789(%rax), %edx
        xorl    %ebx, %ebx
        cmpl    %ecx, %edx
        movl    $-1, %edx
        setg    %bl
        cmovge  %ebx, %edx
        addq    $4, %rax
        addl    %edx, %esi
        cmpq    $4096, %rax
        jne     .L15
        addq    $4, %rbp
        cmpq    $4096, %rbp
        jne     .L18
        addl    $1, %r8d
        cmpl    $500, %r8d
        jne     .L12
        movl    $.LC0, %edi
        xorl    %eax, %eax
        call    printf
        addq    $8, %rsp
        .cfi_def_cfa_offset 24
        xorl    %eax, %eax
        popq    %rbx
        .cfi_def_cfa_offset 16
        popq    %rbp
        .cfi_def_cfa_offset 8
        ret
        .cfi_endproc
.LFE20:
        .size   main, .-main
        .local  arr.2789
        .comm   arr.2789,4096,32
        .section        .note.GNU-stack,"",@progbits

La version ci-dessous utilise la méthode sans branche ((a > b) - (a < b)):

        .file   "cmp.c"
        .text
        .section        .rodata.str1.1,"aMS",@progbits,1
.LC0:
        .string "%d=0\n"
        .text
        .p2align 4,,15
.globl main
        .type   main, @function
main:
.LFB20:
        .cfi_startproc
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        pushq   %rbx
        .cfi_def_cfa_offset 24
        .cfi_offset 3, -24
        movl    $arr.2789, %ebx
        subq    $8, %rsp
        .cfi_def_cfa_offset 32
.L9:
        leaq    4(%rbx), %rbp
.L10:
        call    Rand
        movb    %al, (%rbx)
        addq    $1, %rbx
        cmpq    %rbx, %rbp
        jne     .L10
        cmpq    $arr.2789+4096, %rbp
        jne     .L9
        xorl    %r8d, %r8d
        xorl    %esi, %esi
.L19:
        movl    %ebp, %ebx
        xorl    %edi, %edi
        .p2align 4,,10
        .p2align 3
.L24:
        movl    %ebp, %ecx
        xorl    %eax, %eax
        jmp     .L22
        .p2align 4,,10
        .p2align 3
.L20:
        movl    arr.2789(%rax), %ecx
.L22:
        xorl    %edx, %edx
        cmpl    %ebx, %ecx
        setg    %cl
        setl    %dl
        movzbl  %cl, %ecx
        subl    %ecx, %edx
        addl    %edx, %esi
        addq    $4, %rax
        cmpq    $4096, %rax
        jne     .L20
        addq    $4, %rdi
        cmpq    $4096, %rdi
        je      .L21
        movl    arr.2789(%rdi), %ebx
        jmp     .L24
.L21:
        addl    $1, %r8d
        cmpl    $500, %r8d
        jne     .L19
        movl    $.LC0, %edi
        xorl    %eax, %eax
        call    printf
        addq    $8, %rsp
        .cfi_def_cfa_offset 24
        xorl    %eax, %eax
        popq    %rbx
        .cfi_def_cfa_offset 16
        popq    %rbp
        .cfi_def_cfa_offset 8
        ret
        .cfi_endproc
.LFE20:
        .size   main, .-main
        .local  arr.2789
        .comm   arr.2789,4096,32
        .section        .note.GNU-stack,"",@progbits
53
jxh

Celui-ci n'a pas de branches et ne souffre ni de débordement ni de débordement:

return (a > b) - (a < b);

Avec gcc -O2 -S, cela compile les six instructions suivantes:

xorl    %eax, %eax
cmpl    %esi, %edi
setl    %dl
setg    %al
movzbl  %dl, %edx
subl    %edx, %eax

Voici un code pour comparer différentes implémentations de comparaison:

#include <stdio.h>
#include <stdlib.h>

#define COUNT 1024
#define LOOPS 500
#define COMPARE compare2
#define USE_Rand 1

int arr[COUNT];

int compare1 (int a, int b)
{
    if (a < b) return -1;
    if (a > b) return 1;
    return 0;
}

int compare2 (int a, int b)
{
    return (a > b) - (a < b);
}

int compare3 (int a, int b)
{
    return (a < b) ? -1 : (a > b);
}

int compare4 (int a, int b)
{
    __asm__ __volatile__ (
        "sub %1, %0 \n\t"
        "jno 1f \n\t"
        "cmc \n\t"
        "rcr %0 \n\t"
        "1: "
    : "+r"(a)
    : "r"(b)
    : "cc");
    return a;
}

int main ()
{
    for (int i = 0; i < COUNT; i++) {
#if USE_Rand
        arr[i] = Rand();
#else
        for (int b = 0; b < sizeof(arr[i]); b++) {
            *((unsigned char *)&arr[i] + b) = Rand();
        }
#endif
    }

    int sum = 0;

    for (int l = 0; l < LOOPS; l++) {
        for (int i = 0; i < COUNT; i++) {
            for (int j = 0; j < COUNT; j++) {
                sum += COMPARE(arr[i], arr[j]);
            }
        }
    }

    printf("%d=0\n", sum);

    return 0;
}

Les résultats sur mon système 64 bits, compilé avec gcc -std=c99 -O2, pour les entiers positifs (USE_Rand=1):

compare1: 0m1.118s
compare2: 0m0.756s
compare3: 0m1.101s
compare4: 0m0.561s

Parmi les solutions en C uniquement, celle que j'ai proposée était la plus rapide. La solution de user315052 était plus lente malgré la compilation de 5 instructions seulement. Le ralentissement est probable car, malgré le fait qu'il y ait une instruction de moins, il existe une instruction conditionnelle (cmovge).

Dans l’ensemble, l’implémentation de 4 instructions de FredOverflow était la plus rapide avec des entiers positifs. Toutefois, ce code ne fait que comparer la plage d’entiers Rand_MAX. Le test à 4 instances est donc biaisé, car il traite les dépassements de capacité séparément, ce qui n’a pas lieu dans le test. la vitesse peut être due à une prédiction de branche réussie.

Avec une gamme complète d’entiers (USE_Rand=0), la solution à 4 instructions est en réalité très lente (les autres sont identiques):

compare4: 0m1.897s
94
Ambroz Bizjak

D'accord, j'ai réussi à le réduire à quatre instructions :) L'idée de base est la suivante:

La moitié du temps, la différence est assez petite pour tenir dans un entier. Dans ce cas, il suffit de renvoyer la différence. Sinon, déplacez le numéro un vers la droite. La question cruciale est de savoir quel bit passer ensuite dans le MSB.

Regardons deux exemples extrêmes, utilisant 8 bits au lieu de 32 bits pour des raisons de simplicité:

 10000000 INT_MIN
 01111111 INT_MAX
---------
000000001 difference
 00000000 shifted

 01111111 INT_MAX
 10000000 INT_MIN
---------
111111111 difference
 11111111 shifted

Décaler le bit de report dedans donnerait 0 pour le premier cas (bien que INT_MIN ne soit pas égal à INT_MAX) et un nombre négatif pour le second cas (bien que INT_MAX ne soit pas inférieur à INT_MIN).

Mais si nous retournons le report avant de passer au poste de travail, nous obtenons des nombres raisonnables:

 10000000 INT_MIN
 01111111 INT_MAX
---------
000000001 difference
100000001 carry flipped
 10000000 shifted

 01111111 INT_MAX
 10000000 INT_MIN
---------
111111111 difference
011111111 carry flipped
 01111111 shifted

Je suis sûr qu'il y a une raison mathématique profonde pour laquelle il est logique de retourner le support, mais je ne le vois pas encore.

int compare_int(int a, int b)
{
    __asm__ __volatile__ (
        "sub %1, %0 \n\t"
        "jno 1f \n\t"
        "cmc \n\t"
        "rcr %0 \n\t"
        "1: "
    : "+r"(a)
    : "r"(b)
    : "cc");
    return a;
}

J'ai testé le code avec un million d'entrées aléatoires, plus chaque combinaison de INT_MIN, -INT_MAX, INT_MIN/2, -1, 0, 1, INT_MAX/2, INT_MAX/2 + 1, INT_MAX. Tous les tests ont réussi. Pouvez-vous me prouver le contraire?

15
fredoverflow

Pour ce que cela vaut, je construis une implémentation SSE2. vec_compare1 utilise la même approche que compare2 mais nécessite seulement trois instructions arithmétiques SSE2:

#include <stdio.h>
#include <stdlib.h>
#include <emmintrin.h>

#define COUNT 1024
#define LOOPS 500
#define COMPARE vec_compare1
#define USE_Rand 1

int arr[COUNT] __attribute__ ((aligned(16)));

typedef __m128i vSInt32;

vSInt32 vec_compare1 (vSInt32 va, vSInt32 vb)
{
    vSInt32 vcmp1 = _mm_cmpgt_epi32(va, vb);
    vSInt32 vcmp2 = _mm_cmpgt_epi32(vb, va);
    return _mm_sub_epi32(vcmp2, vcmp1);
}

int main ()
{
    for (int i = 0; i < COUNT; i++) {
#if USE_Rand
        arr[i] = Rand();
#else
        for (int b = 0; b < sizeof(arr[i]); b++) {
            *((unsigned char *)&arr[i] + b) = Rand();
        }
#endif
    }

    vSInt32 vsum = _mm_set1_epi32(0);

    for (int l = 0; l < LOOPS; l++) {
        for (int i = 0; i < COUNT; i++) {
            for (int j = 0; j < COUNT; j+=4) {
                vSInt32 v1 = _mm_loadu_si128(&arr[i]);
                vSInt32 v2 = _mm_load_si128(&arr[j]);
                vSInt32 v = COMPARE(v1, v2);
                vsum = _mm_add_epi32(vsum, v);
            }
        }
    }

    printf("vsum = %vd\n", vsum);

    return 0;
}

Le temps pour cela est de 0,137s.

Le temps pour compare2 avec le même processeur et le même compilateur est de 0,674s.

Ainsi, la mise en œuvre de SSE2 est environ 4 fois plus rapide, comme on pouvait s'y attendre (puisqu'il s'agit d'un SIMD à 4 bandes).

10
Paul R

Ce code n'a pas de branche et utilise 5 instructions. Il peut être plus performant que les autres solutions sans branche sur les processeurs Intel récents, où les instructions cmov * sont assez coûteuses. L’inconvénient est une valeur de retour non symétrique (INT_MIN + 1, 0, 1).

int compare_int (int a, int b)
{
    int res;

    __asm__ __volatile__ (
        "xor %0, %0 \n\t"
        "cmpl %2, %1 \n\t"
        "setl %b0 \n\t"
        "rorl $1, %0 \n\t"
        "setnz %b0 \n\t"
    : "=q"(res)
    : "r"(a)
    , "r"(b)
    : "cc"
    );

    return res;
}

Cette variante n'a pas besoin d'initialisation, elle utilise donc seulement 4 instructions:

int compare_int (int a, int b)
{
    __asm__ __volatile__ (
        "subl %1, %0 \n\t"
        "setl %b0 \n\t"
        "rorl $1, %0 \n\t"
        "setnz %b0 \n\t"
    : "+q"(a)
    : "r"(b)
    : "cc"
    );

    return a;
}
3
Evgeny Kluev

Peut-être pouvez-vous utiliser l’idée suivante (en pseudo-code; n’a pas écrit asm-code car je ne suis pas à l'aise avec la syntaxe):

  1. Soustrayez les nombres (result = a - b)
  2. Si aucun dépassement de capacité, fait (l'instruction jo et la prévision de branche devraient très bien fonctionner ici)
  3. En cas de débordement, utilisez une méthode robuste (return (a < b) ? -1 : (a > b))

Modifier: pour plus de simplicité: en cas de débordement, retournez le signe du résultat au lieu de l'étape 3.

0
anatolyg