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?
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
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
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?
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).
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;
}
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):
result = a - b
)jo
et la prévision de branche devraient très bien fonctionner ici)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.