Comment bc-count compte 86 000 fichiers en 566 ms

Comment bc-count compte 86 000 fichiers en 566 ms

wc est un outil vieux de 50 ans. Il fait le travail. Mais quand tu le lances sur 86 000 fichiers – un arbre source du noyau Linux, 1,6 Go – il met 3,5 secondes rien que pour compter les mots. bc-count fait la même chose en 566 millisecondes. 6,2 fois plus vite. Et sur le comptage de caractères UTF-8, c’est 18 fois plus vite.

Pas de magie. Juste trois décisions techniques qui changent tout.

Le problème avec wc

wc traite un fichier à la fois, dans un seul fil d’exécution, avec des lectures read() séquentielles. Pour chaque fichier, le noyau copie les données du cache de pages vers l’espace utilisateur. Sur 86 000 fichiers, ça représente des millions d’appels système et autant de copies mémoire.

Pire : find -exec wc {} + regroupe les fichiers en lots, mais wc lui-même reste monocœur. Ton processeur 8 cœurs tourne à 12,5 % de sa capacité.

Décision 1 : mmap pour les gros fichiers, read pour les petits

bc-count utilise deux stratégies de lecture selon la taille du fichier. Le seuil est à 128 Ko.

Pour les fichiers en dessous de 128 Ko (la majorité dans un arbre source), read() est plus efficace. L’appel système est simple, le noyau copie quelques kilo-octets, c’est terminé.

Pour les fichiers au-dessus de 128 Ko, mmap prend le relais. Au lieu de copier les données dans un tampon utilisateur, mmap projette directement le cache de pages du noyau dans l’espace d’adressage du processus. Le processeur lit la mémoire sans copie intermédiaire, ce qui élimine le coût de la copie noyau vers espace utilisateur sur les gros fichiers.

Ce seuil n’est pas arbitraire. En dessous de 128 Ko, le coût fixe de mmap (création du VMA, mise à jour des tables de pages, munmap au nettoyage) dépasse le gain. Au-dessus, le zéro-copie l’emporte largement.

Décision 2 : SIMD pour le comptage

Compter les lignes, c’est compter les octets 0x0A dans un flux. Un octet à la fois, c’est lent. Les instructions SIMD (Single Instruction, Multiple Data) permettent de traiter 32 ou 64 octets en un seul cycle d’horloge.

bc-count utilise un mécanisme de sélection à l’exécution. Au démarrage, il détecte les capacités du processeur (AVX2 ou AVX-512) et sélectionne le chemin de code adapté. Sur un Ryzen 7 5700G, c’est AVX2 – 32 octets par comparaison, soit 32 caractères testés en parallèle.

Pour le comptage de lignes, l’instruction _mm256_cmpeq_epi8 compare 32 octets au caractère \n simultanément. Le masque résultant est ensuite réduit par popcnt (comptage de bits à 1). En pratique, ça transforme une boucle qui traitait 1 octet par cycle en une boucle qui en traite 32.

Pour le comptage de caractères UTF-8, la technique est différente. Un caractère UTF-8 peut faire 1 à 4 octets, mais seul le premier octet d’une séquence ne commence pas par 10xxxxxx. Donc compter les caractères revient à compter les octets qui ne sont pas des octets de continuation. bc-count utilise une table de correspondance basée sur les quartets (nibble lookup) via _mm256_shuffle_epi8 pour classifier 32 octets en une instruction.

Les résultats parlent :

Mode bc-count find + wc Gain
--lines 195 ms 891 ms 4,6x
--chars (UTF-8) 197 ms 3 540 ms 18x
--words 566 ms 3 530 ms 6,2x
--bytes 154 ms 648 ms 4,2x

Le comptage de mots est plus lent que le comptage de lignes parce que détecter les frontières de mots (transitions espace/non-espace) est intrinsèquement plus complexe qu’une simple comparaison d’égalité. Mais même là, le gain reste supérieur à 6x.

Décision 3 : parallélisme et sortie tamponnée

bc-count distribue les fichiers sur tous les cœurs disponibles via un pool de fils d’exécution à base de tâches. Sur le Ryzen 7 5700G, ça fait 8 cœurs physiques et 16 fils logiques. Chaque fil prend un fichier dans la file, le traite, passe au suivant.

Le parcours de répertoires est lui aussi parallèle : un BFS (parcours en largeur) multi-cœur explore l’arbre de fichiers pendant que les compteurs travaillent.

Dernier détail qui compte : la sortie. Écrire chaque ligne de résultat avec un printf individuel génère des dizaines de milliers d’appels système write(). bc-count construit l’intégralité du tableau de résultats en mémoire, puis l’écrit en un seul appel write(). Un seul passage dans le noyau au lieu de 86 000.

Le comptage d’entrées de répertoire

En bonus, bc-count propose un mode entries qui compte les fichiers, répertoires et liens symboliques. Sur le même jeu de données :

Outil Temps Gain
bc-count entries 19 ms
find 87 ms 4,6x

19 millisecondes pour parcourir 86 000 fichiers et 5 800 répertoires. Le BFS multi-cœur fait la différence.

Ce que ça montre

wc n’est pas mal écrit. C’est un outil généraliste POSIX, portable sur des dizaines de systèmes. Il n’utilise pas SIMD parce qu’il doit tourner sur ARM, MIPS, SPARC. Il n’utilise pas de fils d’exécution parce que POSIX ne le garantit pas partout.

bc-count fait le choix inverse : cibler Linux x86-64 exclusivement, utiliser tout ce que le matériel propose, et accepter que la portabilité n’est pas un objectif. C’est un compromis. Mais quand tu travailles sur un poste Linux avec un processeur moderne, c’est un compromis qui te rend 566 ms au lieu de 3,5 secondes.

Le code source est disponible sur GitHub sous licence MIT.