Le temps d'un pipeline de données se répartit en trois endroits : la lecture et le décodage des données, le travail effectué sur chaque ligne, et l'attente (du stockage, d'un verrou, ou d'un thread occupé). Le décodage est généralement déjà optimisé dans les bibliothèques. Le travail sur chaque ligne est ce que vous écrivez, et c'est là qu'une boucle négligente coûte 2 à 3 fois plus.
La majeure partie de ce fichier concerne le code qui consomme les lots d'un scan. Pour le lecteur, le rédacteur ou le codec sous-jacent, et pour obtenir un nombre fiable, voir les deux dernières sections et leurs références.
D'abord, mesurer où va le temps
Vous avez rarement une seconde implémentation (Ray, DuckDB, pyarrow) à laquelle vous comparer. Utilisez le scan lui-même comme étalon à la place :
- Chronométrez le scan seul. Lancez le même scan, avec les mêmes colonnes, filtre
et
ScanOptions, et jetez les lots. C'est la référence de décodage seul : aucun pipeline ne peut être plus rapide que cela. - Chronométrez votre travail seul. Encapsulez l'appel par lot (
fold,add, ou tout ce qui consomme le lot) dansperf_counter_ns()et additionnez les temps. - Comparez-les. Sur un seul thread, un temps total inférieur à environ 1,5 fois le temps de décodage seul signifie que le travail est bon marché ; au-dessus, le travail est le goulot d'étranglement et mérite d'être optimisé. Sur plusieurs threads, vérifiez également si le travail s'exécute sur un seul thread pendant que le décodage s'exécute sur plusieurs.
L'exemple mesuré : un group-by sur 77 millions de trajets a pris 4,6 s sur un thread, dont 2,1 s pour le travail par ligne (45 %). Sur 8 threads, il a pris 2,6 s, dont les mêmes 2,1 s pour le travail (82 %), car un seul thread consumait chaque lot. references/profiling.md contient la recette et ses pièges.
Règles pour la boucle par ligne
Chaque règle est accompagnée de ce qu'elle a économisé dans le benchmark de brassage dista (77 millions de lignes, table locale, un thread). Le code se trouve dans references/hot-loops.md.
| Règle | Effet mesuré |
|---|---|
Prenez le pointeur brut de chaque colonne une fois par lot, puis indexez le pointeur. N'indexez pas List ou Tuple[List, List] à l'intérieur de la boucle. | 1,40 → 0,78 s pour l'ensemble du travail par ligne |
| Gardez tout ce qu'une clé accumule côte à côte dans un seul emplacement (tableau de structures), et non dans des tableaux parallèles, afin qu'une mise à jour touche une seule ligne de cache. | 2,09 → 1,40 s, avec la règle suivante |
Filtrez sans branche : calculez chaque ligne, écrivez-la à un index kept, et avancez kept de Int(keep). Ajoutez ensuite les lignes conservées dans une seconde boucle serrée. | mesuré avec la règle ci-dessus |
Lorsque les clés sont de petits entiers (zone × zone × heure est 1,7 M), indexez un tableau dense par clé au lieu d'utiliser un Dict. | non mesuré seul ; les deux versions utilisaient un tableau dense |
| Ne modifiez pas l'ordre des additions en virgule flottante pendant l'optimisation. Ainsi, la réponse doit correspondre bit pour bit à l'ancienne, ce qui constitue une vérification de correction gratuite. | chaque étape ci-dessus correspondait exactement |
Règles pour l'ensemble du pipeline
| Règle | Effet mesuré |
|---|---|
| Ne laissez pas un seul thread consommer ce que plusieurs threads décodent. Donnez à chaque worker son propre accumulateur et fusionnez-les à la fin, comme le font les tâches map de dista. | 16 threads n'ont fait passer un group-by mono-processus que de 202 s à 126 s sur AWS |
Gardez plusieurs lectures en vol, même avec un seul thread de décodage : ScanOptions.files_at_once (et overlap_io) dans iceberg.mojo. | 6,6 à 6,9 fois sur un thread lisant depuis S3 |
| Chargez une bibliothèque C partagée une fois par processus et conservez le handle, jamais une fois par appel. | avec zstd ouvert à chaque appel, un scan passait 52 % de son temps dans dyld ; corrigé, 2 687 → 262 ms |
| Mettez en cache tout ce que vous analysez et qui se répète octet par octet, comme les schémas Avro dans les manifestes. | le coût fixe de la planification des manifestes a chuté de 3,6 fois (113,7 → 31,9 µs) |
| N'allouez pas par enregistrement : décodez dans des emplacements réutilisés. | le lecteur Avro allouait environ 60 fois par enregistrement avant son curseur |
| Connaissez le plafond : au-delà de quelques cœurs, un scan est limité par la bande passante mémoire, et davantage de threads n'aideront pas. Lisez plutôt moins d'octets. | un scan Iceberg a pris 35,8 ms sur un cœur et 12,1 ms sur quatre, soit environ 3 fois |
references/case-studies.md raconte chacune de ces histoires, avec ce qui a révélé le problème.
Lecteurs, rédacteurs et codecs
Lorsque le décodage lui-même est la partie lente, les règles concernent principalement le fait de ne pas effectuer un travail qui sera de toute façon jeté. Les détails et leurs nombres sont dans references/readers-and-codecs.md.
- Vérifiez que la porte de chaque chemin rapide peut être franchie par les appelants qui l'atteignent, et prouvez par instrumentation qu'il s'exécute. Un chemin vectorisé dont la porte aucune colonne de liste ne pouvait franchir a laissé une colonne de 9 Kio à 43 % d'une lecture.
- Ne calculez pas ce qu'une décision ultérieure jettera généralement. Construire un dictionnaire uniquement pour choisir un encodage simple représentait 67 % d'une écriture de colonne int64 ; sa suppression a rendu l'écriture 3,3 fois plus rapide avec une sortie identique octet par octet.
- Empruntez au lieu de copier, et décodez directement dans la disposition finale, comme les niveaux de définition dans le bitmap de validité.
- Travaillez par séquences là où les données en ont : à 1 % de valeurs nulles, environ 250
memmoveremplacent environ 25 000 branches par emplacement. - Sachez quand une étape est déjà à la bande passante mémoire : une étape qui déplaçait 18,3 Mo en entrée et sortie en 1,2 ms, soit environ 30 Go/s, ne pouvait qu'être ignorée.
- Trouvez l'élément le plus lent avant d'ajouter des threads. La division par bloc de colonnes a plafonné une lecture à 1,39 fois, car une colonne volumineuse s'exécutait tout le temps.
Testez l'optimisation ainsi que la réponse : cassez-la intentionnellement et vérifiez
qu'un test échoue (references/testing-optimizations.md).
Les pièges propres à Mojo, tels que les valeurs qui meurent à leur dernière utilisation et
MutUntrackedOrigin, sont dans references/mojo-hazards.md.
Obtenir un nombre fiable
references/measurement.md contient les règles complètes. Celles qui ont changé les conclusions chez magmalake :
- Rapportez la médiane (p50) avec le p90 à côté, jamais la moyenne seule. Un premier appel lent a déplacé la moyenne de 30 % et la p50 pas du tout.
- Construisez le benchmark à l'avance ; ne le chronométrez jamais sous
mojo run. Le JIT a gonflé un benchmark de 1,9 fois. - Vérifiez que la machine est restée stable. Le même benchmark a pris 31 ms au repos et 89 ms à côté de 16 threads occupés.
- Nommez l'API de référence et le nombre de threads.
pq.read_tableest le scanneur de datasets de pyarrow, pas le chemin de lecture de parquet-cpp, et le réglage des threads de pyarrow a déplacé la lecture d'un fichier de 2,9 fois. - Lorsque deux nombres diffèrent, vérifiez qu'ils ont été produits de la même manière avant d'expliquer la différence.
Avant de revendiquer une accélération
- Comparez les réponses avant et après, exactement là où vous le pouvez (voir la dernière règle sur les boucles). Une réponse fausse plus rapide est pire qu'une réponse lente correcte.
- Rapportez les deux nombres et la configuration : lignes, threads, disque local ou S3, et si le cache était chaud.
- Dites ce qu'il reste. Après la correction du brassage, 325 ms des 777 ms servent encore à copier des colonnes, et sur plusieurs threads, le travail s'effectue encore sur un seul thread.