Comment lire une valeur double Ă partir d'une chaĂźne en code C ++?
std::stringstream in(mystring);
while(in >> x) {
sum += x;
}
Sur Intel Skylake avec le compilateur GCC 8.3, ce code analyse 50 Mo / s. Les disques durs peuvent facilement fournir des lectures séquentielles à une vitesse de plusieurs Go / s, il ne fait donc aucun doute que ce n'est pas la vitesse de lecture à partir du disque qui nous limite, mais la vitesse d'analyse. Comment puis-je l'accélérer?
La premiÚre chose qui se suggÚre est d'abandonner la commodité fournie par les flux en C ++ et d'appeler directement strtod (3):
do {
number = strtod(s, &end);
if(end == s) break;
sum += number;
s = end;
} while (s < theend);
La vitesse augmente jusqu'Ă 90 Mo / s; le profilage montre que lors de la lecture Ă partir du flux, ~ 1600 instructions sont exĂ©cutĂ©es pour chaque numĂ©ro lu, lors de l'utilisation de strtod - ~ 1100 instructions par numĂ©ro. Les bibliothĂšques standard C et C ++ peuvent ĂȘtre justifiĂ©es par les exigences d'universalitĂ© et de portabilitĂ©; mais si nous nous limitons Ă l'analyse uniquement en double et uniquement sur x64, alors nous pouvons Ă©crire du code beaucoup plus efficace: 280 instructions par nombre suffisent.
Analyse des entiers
Commençons par une sous-tùche: étant donné un nombre décimal à huit chiffres, nous devons le convertir en int. à l'école, on nous a tous appris à faire cela dans un cycle:
int sum = 0;
for (int i = 0; i <= 7; i++)
{
sum = (sum * 10) + (num[i] - '0');
}
return sum;
CompilĂ© GCC 9.3.1 -O3, pour moi ce code gĂšre 3 Go / s. La maniĂšre Ă©vidente de l'accĂ©lĂ©rer est d'inverser la boucle; mais le compilateur le fait lui-mĂȘme.
Notez que la notation dĂ©cimale d'un nombre Ă huit chiffres s'inscrit dans la variable int64_t: par exemple, la chaĂźne «87654321» est stockĂ©e de la mĂȘme maniĂšre que la valeur int64_t 0x3132333435363738 (le premier octet contient les 8 bits les moins significatifs, le dernier - les plus significatifs). Cela signifie qu'au lieu de huit accĂšs mĂ©moire, nous pouvons allouer la valeur de chaque chiffre avec un dĂ©calage:
int64_t sum = *(int64_t*)num;
return (sum & 15) * 10000000 +
((sum >> 8) & 15) * 1000000 +
((sum >> 16) & 15) * 100000 +
((sum >> 24) & 15) * 10000 +
((sum >> 32) & 15) * 1000 +
((sum >> 40) & 15) * 100 +
((sum >> 48) & 15) * 10 +
((sum >> 56) & 15);
Il n'y a pas encore d'accélération; au contraire, la vitesse tombe à 1,7 Go / s! Allons plus loin: ET avec le masque 0x000F000F000F000F nous donnera 0x0002000400060008 - chiffres décimaux dans des positions paires. Combinons chacun d'eux avec ce qui suit:
sum = ((sum & 0x000F000F000F000F) * 10) +
((sum & 0x0F000F000F000F00) >> 8);
AprÚs cela, 0x3132333435363738 se transforme en 0x00 (12) 00 (34) 00 (56) 00 (78) - les octets aux positions paires sont mis à zéro, aux impairs - ils contiennent des représentations binaires de nombres décimaux à deux chiffres.
return (sum & 255) * 1000000 +
((sum >> 16) & 255) * 10000 +
((sum >> 32) & 255) * 100 +
((sum >> 48) & 255);
- termine la conversion d'un nombre Ă huit chiffres.
La mĂȘme astuce peut ĂȘtre rĂ©pĂ©tĂ©e avec des paires de nombres Ă deux chiffres:
sum = ((sum & 0x0000007F0000007F) * 100) +
((sum >> 16) & 0x0000007F0000007F);
- 0x00 (12) 00 (34) 00 (56) 00 (78) devient 0x0000 (1234) 0000 (5678);
et avec la paire résultante de ceux à quatre chiffres:
return ((sum & 0x3FFF) * 10000) + ((sum >> 32) & 0x3FFF);
- 0x00 (12) 00 (34) 00 (56) 00 (78) devient 0x00000000 (12345678). La moitié inférieure de l'int64_t résultant est le résultat souhaité. Malgré la simplification notable du code (trois multiplications au lieu de sept), la vitesse d'analyse (2,6 Go / s) reste pire que celle de l'implémentation naïve.
Mais la combinaison de paires de nombres peut ĂȘtre simplifiĂ©e mĂȘme si vous remarquez que l'action suivante appliquera le masque 0x007F007F007F007F, ce qui signifie que tout garbage peut ĂȘtre laissĂ© dans les octets Nullable:
sum = ((sum & 0x0?0F0?0F0?0F0?0F) * 10) + ((sum & 0x0F??0F??0F??0F??) >> 8) =
= (((sum & 0x0F0F0F0F0F0F0F0F) * 2560)+ (sum & 0x0F0F0F0F0F0F0F0F)) >> 8 =
= ((sum & 0x0F0F0F0F0F0F0F0F) * 2561) >> 8;
En termes simplifiĂ©s, un masque au lieu de deux, et il n'y a pas d'ajout. Les deux expressions restantes sont simplifiĂ©es de la mĂȘme maniĂšre:
sum = ((sum & 0x00FF00FF00FF00FF) * 6553601) >> 16;
return((sum & 0x0000FFFF0000FFFF) * 42949672960001) >> 32;
Cette astuce s'appelle SIMD dans un registre (SWAR): en l'utilisant, la vitesse d'analyse passe Ă 3,6 Go / s.
Une astuce SWAR similaire peut ĂȘtre utilisĂ©e pour vĂ©rifier si une chaĂźne de huit caractĂšres est un nombre dĂ©cimal Ă huit chiffres:
return ((val & 0xF0F0F0F0F0F0F0F0) |
(((val + 0x0606060606060606) & 0xF0F0F0F0F0F0F0F0) >> 4))
== 0x3333333333333333;
Double appareil
La norme IEEE attribuait 52 bits Ă la mantisse et 11 Ă l'exposant dans ces nombres; cela signifie que le numĂ©ro est stockĂ© sous , oĂč et . En particulier, cela signifie que dans la notation dĂ©cimale de double, seuls les 17 chiffres les plus significatifs sont significatifs; les bits les moins significatifs ne peuvent en aucun cas affecter la valeur double. MĂȘme 17 chiffres significatifs sont bien plus que ce qui pourrait ĂȘtre nĂ©cessaire pour toute application pratique: les nombres dĂ©normalisĂ©s compliquent un peu le travail (de
Ă avec un nombre proportionnellement plus petit de bits significatifs dans la mantisse) et des exigences d'arrondi (tout nombre dĂ©cimal doit ĂȘtre reprĂ©sentĂ© par le binaire le plus proche, et si le nombre est exactement au milieu entre le binaire le plus proche, alors la mantisse paire est prĂ©fĂ©rĂ©e ). Tout cela garantit que si l'ordinateur A convertit le nombre X en une chaĂźne dĂ©cimale avec 17 chiffres significatifs, alors tout ordinateur B, lisant cette chaĂźne, recevra le mĂȘme nombre X, bit pour bit - indĂ©pendamment du fait que A et B soient identiques. modĂšles de processeur, systĂšmes d'exploitation et langages de programmation. (Imaginez Ă quel point il serait amusant de dĂ©boguer votre code si les erreurs d'arrondi Ă©taient diffĂ©rentes sur diffĂ©rents ordinateurs.) En raison des exigences d'arrondi, les malentendusrĂ©cemment mentionnĂ©ssurviennent. sur HabrĂ©: la fraction dĂ©cimale 0,1 est reprĂ©sentĂ©e comme la fraction binaire la plus proche , qui est exactement 0,1000000000000000055511151231257827021181583404541015625; 0,2 - sous la forme , qui est exactement 0,200000000000000011102230246251565404236316680908203125; mais leur somme n'est pas une fraction binaire la plus proche de 0,3: approximation par le bas = 0. 2999999999999988897769753748434595763683319091796875 se rĂ©vĂšle ĂȘtre plus prĂ©cis. Par consĂ©quent, la norme IEEE nĂ©cessite d'ajouter 0,1 + 0,2 pour produire un rĂ©sultat autre que 0,3.
Analyse double
Une gĂ©nĂ©ralisation simple de l'algorithme des nombres entiers consiste en des multiplications et des divisions itĂ©ratives par 10,0 - contrairement Ă l'analyse des entiers, ici il est nĂ©cessaire de traiter les chiffres de bas en haut pour que les erreurs d'arrondi dans les chiffres Ă©levĂ©s «masquent» les erreurs d'arrondi dans les chiffres faibles. En mĂȘme temps, l'analyse de la mantisse se rĂ©duit facilement Ă l'analyse d'entiers: il suffit de changer la normalisation pour que le point binaire imaginaire ne soit pas au dĂ©but de la mantisse, mais Ă la fin; l'entier de 53 bits rĂ©sultant se multiplie ou se divise par la puissance dĂ©sirĂ©e de dix; et enfin, soustrayez 52 de l'exposant, i.e. dĂ©placez le point de 52 bits vers la gauche - oĂč il devrait ĂȘtre selon la norme IEEE. De plus, il y a trois faits importants Ă noter:
- Il suffit d'apprendre à multiplier ou à diviser par 5, et la multiplication ou la division par 2 n'est qu'un incrément ou un décrément d'un exposant;
- uint64_t 5 0xcccccccccccccccd 66 , , 64 ( );
- â ; , 309 , 324 0xcccccccccccccccd, . ( 53 ; 128- , 53- 53- .) 633 double ( , â , 7205759403792794 â 0xcccccccccccccccd, 53 ), double ; 53 , . , , 64 64 , , 128- . .
La complexitĂ© de l'arrondi standard est que pour dĂ©couvrir que le rĂ©sultat est exactement au milieu entre les fractions binaires les plus proches, nous n'avons pas seulement besoin de 54 bits significatifs du rĂ©sultat (le bit zĂ©ro le moins significatif signifie "tout est en ordre", l'un signifie "frapper le milieu"), mais et vous devez regarder s'il y a eu une continuation non nulle aprĂšs le bit le moins significatif. En particulier, cela signifie que ne considĂ©rer que les 17 chiffres les plus significatifs dans la notation dĂ©cimale du nombre ne suffit pas: ils ne dĂ©finissent la mantisse binaire qu'avec une prĂ©cision de ± 1, et pour sĂ©lectionner le sens d'arrondi souhaitĂ©, vous devrez considĂ©rer les chiffres infĂ©rieurs. Par exemple, 10000000000000003 et 10000000000000005 sont la mĂȘme valeur double (Ă©gale Ă 10000000000000004) et 10000000000000005.00 (plusieurs zĂ©ros) 001 est une valeur diffĂ©rente (Ă©gale Ă 10000000000000006).
Le professeur Daniel Lemire de l'UniversitĂ© par correspondance du QuĂ©bec (TĂLUQ), qui a inventĂ© cet analyseur, l'a publiĂ© sur github . Cette bibliothĂšque fournit deux fonctions
from_chars
: l'une analyse une chaĂźne pour doubler, l'autre pour flotter. Ses expĂ©riences ont montrĂ© que dans 99,8% des cas, il suffit de considĂ©rer 19 chiffres dĂ©cimaux significatifs (64 bits): si pour deux valeurs consĂ©cutives d'une mantisse de 64 bits, la mĂȘme valeur double finale est obtenue, alors c'est la valeur correcte . Seulement dans 0,2% des cas, lorsque ces deux valeurs ne coĂŻncident pas, un code plus complexe et plus universel est exĂ©cutĂ© qui implĂ©mente toujours un arrondi correct.