De nombreuses méthodes utilisées dans les algorithmes à haute performance pourraient être (et sont) simplifiées si elles étaient autorisées à lire une petite quantité au-delà de la fin des tampons d'entrée. Ici, "petite quantité" signifie généralement jusqu'à W - 1
octets après la fin, où W
est la taille du mot en octets de l'algorithme (par exemple, jusqu'à 7 octets pour un algorithme traitant l'entrée en morceaux de 64 bits).
Il est clair que écrire après la fin d'un tampon d'entrée n'est jamais sûr, en général, puisque vous pouvez bloquer les données au-delà du tampon. 1 . Il est également clair que la lecture au-delà de la fin d'un tampon vers une autre page peut déclencher une erreur de segmentation/une violation d'accès, puisque la page suivante peut ne pas être lisible.
Dans le cas particulier de la lecture de valeurs alignées, cependant, un défaut de page semble impossible, du moins sur x86. Sur cette plate-forme, les pages (et donc les drapeaux de protection de la mémoire) ont une granularité de 4K (des pages plus grandes, par exemple 2MiB ou 1GiB, sont possibles, mais ce sont des multiples de 4K) et donc les lectures alignées n'accèdent qu'aux octets de la même page que la partie valide du tampon.
Voici un exemple canonique d'une boucle qui aligne son entrée et lit jusqu'à 7 octets après la fin du tampon :
int processBytes(uint8_t *input, size_t size) {
uint64_t *input64 = (uint64_t *)input, end64 = (uint64_t *)(input + size);
int res;
if (size < 8) {
// special case for short inputs that we aren't concerned with here
return shortMethod();
}
// check the first 8 bytes
if ((res = match(*input)) >= 0) {
return input + res;
}
// align pointer to the next 8-byte boundary
input64 = (ptrdiff_t)(input64 + 1) & ~0x7;
for (; input64 < end64; input64++) {
if ((res = match(*input64)) > 0) {
return input + res < input + size ? input + res : -1;
}
}
return -1;
}
La fonction interne int match(uint64_t bytes)
n'est pas montré, mais c'est quelque chose qui cherche un octet correspondant à un certain modèle, et retourne la position la plus basse (0-7) si elle est trouvée ou -1 sinon.
Tout d'abord, les cas de taille < 8 sont confiés à une autre fonction pour simplifier l'exposition. Ensuite, une seule vérification est effectuée pour les 8 premiers (octets non alignés). Puis une boucle est faite pour les autres floor((size - 7) / 8)
des morceaux de 8 octets 2 . Cette boucle peut lire jusqu'à 7 octets au-delà de la fin du tampon (le cas de 7 octets se produit quand input & 0xF == 1
). Cependant, l'appel de retour comporte une vérification qui exclut toute correspondances erronées qui se produisent au-delà de la fin du tampon.
En pratique, une telle fonction est-elle sûre sur x86 et x86-64 ?
Ces types de dépasse les limites de sont courantes dans les codes de haute performance. Un code de queue spécial pour éviter de tels dépasse les limites de est également courant. Parfois, on voit ce dernier type remplacer le premier pour faire taire des outils comme valgrind. Parfois, vous voyez un proposition pour effectuer un tel remplacement, qui est rejeté au motif que l'idiome est sûr et que l'outil est en erreur (ou simplement trop conservateur). 3 .
Une note à l'intention des juristes linguistes :
La lecture d'un pointeur au-delà de sa taille allouée n'est absolument pas autorisée dans la norme. J'apprécie les réponses des avocats du langage, et je les écris même occasionnellement les écrire moi-même, et je serai même heureux quand quelqu'un déterrera le chapitre et les versets qui montrent que le code ci-dessus est comportement indéfini et donc pas sûr au sens strict (et je copierai les détails ici). Mais en fin de compte, ce n'est pas ce que ce que je recherche. En pratique, beaucoup d'idiomes communs impliquant la conversion de pointeurs, l'accès à la structure par ces pointeurs, etc. conversion de pointeur, l'accès aux structures à travers de tels pointeurs et donc sont techniquement non définis, mais sont très répandus dans le code de haute qualité et de haute performance. Souvent, il n'y a pas d'alternative, ou l'alternative fonctionne à la moitié de la vitesse ou moins.
Si vous le souhaitez, vous pouvez envisager une version modifiée de cette question, à savoir :
Après que le code ci-dessus a été compilé en assembleur x86/x86-64, et que l'utilisateur a vérifié qu'il est compilé de la manière attendue (c'est-à-dire que le compilateur n'a pas utilisé d'accès partiellement hors limites prouvable), il est possible de vérifier que le code a bien été compilé, le compilateur n'a pas utilisé un accès partiellement hors limites prouvable pour faire quelque chose vraiment intelligent , l'exécution du programme compilé est-elle sûre ?
À cet égard, cette question est à la fois une question sur le langage C et une question sur l'assemblage x86. La plupart du code utilisant cette astuce que j'ai vu est écrit en C, et le C est toujours le langage dominant pour les bibliothèques de haute performance, éclipsant facilement les choses de plus bas niveau comme asm, et les choses de plus haut niveau comme <tout le reste>. Du moins en dehors de la niche numérique hardcore où FORTRAN joue encore le jeu. Je suis donc intéressé par le Compilateur C et inférieur C'est pourquoi je ne l'ai pas formulée comme une question portant uniquement sur l'assemblage x86.
Tout ceci étant dit, alors que je ne suis que modérément intéressé par un lien vers le norme montrant qu'il s'agit d'UD, je suis très intéressé par tous les détails des des implémentations réelles qui peuvent utiliser cette UD particulière pour produire code inattendu. Maintenant, je ne pensez à cela peut arriver sans une profonde assez profonde analyse inter-procédures, mais le débordement de gcc a surpris beaucoup de gens aussi...
1 Même dans des cas apparemment inoffensifs, par exemple lorsque la même valeur est réécrite, on peut briser le code concurrent .
2 Notez que pour que ce chevauchement fonctionne, il faut que cette fonction et la match()
pour qu'elle se comporte d'une manière idempotente spécifique - en particulier que la valeur de retour supporte des vérifications superposées. Ainsi, un "trouver le premier octet correspondant au motif" fonctionne puisque tous les octets de la fonction match()
sont toujours d'actualité. Une méthode consistant à "compter les octets correspondant au motif" ne fonctionnerait cependant pas, car certains octets pourraient être comptés deux fois. Par ailleurs, certaines fonctions telles que l'appel "retourner l'octet minimum" fonctionneraient même sans la restriction de l'ordre, mais elles doivent examiner tous les octets.
3 Il est intéressant de noter ici que pour le Memcheck de valgrind il y a un drapeau , --partial-loads-ok
qui contrôle si de telles lectures sont en fait rapportées comme une erreur. La valeur par défaut est oui signifie qu'en général, ces chargements ne sont pas traités comme des erreurs immédiates, mais qu'un effort est fait pour suivre l'utilisation ultérieure des octets chargés, dont certains sont valides et d'autres non, une erreur étant signalée si les octets hors limites sont utilisé . Dans des cas comme celui de l'exemple ci-dessus, où l'on accède à l'intégralité du mot en match()
Une telle analyse conclura que les octets ont été consultés, même si les résultats sont finalement rejetés. Valgrind ne peut pas en général déterminer si les octets invalides d'un chargement partiel sont effectivement utilisés (et la détection en général est probablement très dur).
1 votes
En théorie, un compilateur C pourrait mettre en œuvre ses propres contrôles, plus restrictifs que ceux du matériel sous-jacent.
0 votes
Si votre utilisateur a vérifié qu'il est compilé de "la manière attendue", où la manière attendue est que l'accès est sûr, alors il est sûr. Malheureusement, si votre utilisateur ne lit pas le code intermédiaire de l'assembleur, il n'aura pas de telles garanties. Ne le faites pas. (Vous pouvez le rendre sûr en implémentant votre propre gestion de la mémoire).
0 votes
Cela ressemble plus à une réponse qu'à une question :) En ce qui concerne le code de queue spécial, il n'est normalement utilisé que si l'algorithme se déroule par morceaux et ne s'aligne pas en premier.
0 votes
Pourquoi ne pas simplement traiter tous les morceaux de 8 octets en utilisant la boucle, et ensuite appeler
shortMethod()
pour le dernier morceau ?0 votes
@Jester - vous avez peut-être détecté mon parti pris dans le fait que je pensez à il est sûr. Je cherche quand même des réponses qui ont de bons contre-exemples montrant que ce n'est pas sûr, ou des raisons plausibles pour lesquelles ça pourrait ne pas être sûr à l'avenir, ou des raisons encore plus fortes pour lesquelles c'est sûr. Au minimum, cela peut être un bon lien à indiquer aux gens car cette question revient tout le temps dans la mise en œuvre, la révision et la discussion de code à haute performance, mais des informations solides sur la pratique sont largement répandues et difficiles à trouver.
0 votes
@Jester à quelle couverture de queue faites-vous référence ? Je n'ai pas de code de queue pour traiter la partie finale non alignée du tampon, c'est pourquoi cette approche est rapide (du moins sous réserve de réserves comme le matériel sous-jacent ayant un accès non aligné rapide).
0 votes
@Barmar car dans de nombreux cas réels, le code shortMethod(), qui procède généralement octet par octet, peut être 8 fois plus lent par octet, que la boucle ci-dessus. Donc si vous avez, en moyenne, des morceaux de ~40 octets, vous pouvez facilement passer autant de temps à traiter les ~4 octets de queue par rapport aux autres ~36 octets "principaux". De plus, le fait d'avoir deux boucles (principale et de queue) au lieu d'une seule entraînera souvent deux fois plus d'erreurs d'anticipation - une pour chaque boucle, et parfois pire (puisque la boucle principale quantifie effectivement le nombre de boucles en seaux de 8).
0 votes
@Barmar ... et dans le cas d'un code SIMD, cela peut être 16 ou 32 ou ... fois pire, et la loi d'Amdahl ne fera que s'accentuer avec le temps au fur et à mesure que la longueur des vecteurs augmente.
1 votes
Eh bien, il y a toujours
asm()
. :)0 votes
@BadZen - en effet, j'ai dit "de la manière attendue", et non "de la manière sûre" car il y a deux aspects à cette question. Le premier est la probabilité qu'il compile de la manière attendue. Jusqu'à présent, il semble que ce soit le cas, mais je suis très intéressé par les cas où cela pourrait ne pas être vrai. Beaucoup de code se compilait de la manière attendue dans gcc aussi, jusqu'à ce que les optimisations de débordement signées soient implémentées. Je suis donc intéressé par les façons raisonnables dont ce genre de chose pourrait se produire ici.
0 votes
Laissez-moi vous dire ceci : n'attendez jamais d'un compilateur qu'il fasse quelque chose d'une certaine manière parce qu'il l'a déjà fait auparavant dans des conditions qui semblent similaires au programmeur. C'est le chemin de la folie et du code non portable (hey, que fait VC+ ? un LLVM bidouillé ? etc.) Vous donnez un excellent exemple de pourquoi il ne faut pas faire ce genre de choses ci-dessus...
0 votes
@BadZen : Deuxièmement, je suis intéressé par les façons dont même l'assemblage prévu peut ne pas être sûr. Par exemple, quelqu'un pourrait dire "la protection de la mémoire à granularité de ligne de cache est à venir/existe déjà dans x86". Ou ils pourraient trouver une autre façon dont il n'est pas sûr - voir par exemple mon exemple "écrit après la fin du tampon" pour un idiome qui a été considéré comme sûr, mais a été rendu sûr par les architectures multi-CPU.
0 votes
@Barmar - voir ma réponse "Deuxièmement..." à BadZen ci-dessus pour savoir pourquoi cela s'applique même au code que vous avez écrit à la main en asm. BadZen - ne vous inquiétez pas, je ne m'attends pas à cela. En particulier, je cherche de bonnes raisons pour lesquelles ce modèle pourrait échouer en raison des améliorations apportées au compilateur à l'avenir. Je connais assez bien la technologie des compilateurs, alors ne vous retenez pas et soyez précis !
1 votes
En ce qui concerne votre première question, C ne garantit pas que le modèle de mémoire avec lequel vous travaillez corresponde à quoi que ce soit dans le matériel sous-jacent pour ce genre de "cas limite" (avec quelques exceptions pour des choses comme la taille des mots, et même dans ce cas, c'est difficile). C'est donc un échec sur ce plan. Le "langage juridique" dit "indéfini" pour une bonne raison. En ce qui concerne la deuxième question, il faudrait que vous posiez des ASM spécifiques pour que la question ait un sens.
0 votes
Il est également courant d'utiliser un code de queue spécial pour éviter ces surlectures. - Je faisais référence à ça.
0 votes
@Jester - Je ne comprends pas. Si l'algorithme procède par chunks, qu'il s'aligne ou non, du tail code est normalement nécessaire. S'il procède par morceaux de W-octets et qu'il s'aligne sur une frontière de W-octets, du code de queue est nécessaire chaque fois que la fin du tampon ne tombe pas sur une frontière de W-octets. S'il procède par morceaux de W octets sans s'aligner, le code de queue est nécessaire chaque fois que la taille de l'entrée n'est pas un multiple de W. Donc, en l'absence d'overread, le code de queue est nécessaire en général, et aussi "habituellement" si les tailles sont uniformément distribuées.
0 votes
@Barmar - c'est vrai, mais je parle de C sur x86. Je suis intéressé par tout exemple réel de compilateurs C x86 qui compilent ceci d'une manière qui rend l'idiome non sûr. Je ne suis pas d'accord sur le fait que l'asm explicite doit être affiché - il suffit de supposer l'ASM "évident" impliqué par le code C. L'asm exact n'a pas vraiment d'importance, il suffit de le supposer. dépasse les limites de le dernier octet exactement comme dans l'exemple de code.