Le goulot du scanner : un appel système par répertoire
Toute abstraction prélève une taxe unitaire. À 50 fichiers, on ne la remarque pas ; à 5,2 millions, la taxe, c’est toute la facture.
FileManager interroge le kernel fichier par fichier, comme un touriste qui demande son chemin rue après rue. getattrlistbulk demande la carte complète.
Quand faut-il percer une couche d’abstraction, et comment contenir les dégâts ?
La taxe d’abstraction
Le premier scanner utilisait FileManager.enumerator. On aurait tous fait pareil : du Swift idiomatique, une API propre, la gestion des liens symboliques et des erreurs, un comportement conforme à la doc. Quelques dizaines de lignes et tout le système de fichiers est à nous.
Ça marchait, les résultats étaient justes, et c’était assez lent pour me faire remettre mes choix de vie en question.
Le point contre-intuitif : le kernel n’était pas le goulot. contentsOfDirectory(at:includingPropertiesForKeys:) peut précharger les attributs en lot par répertoire.1 Le kernel avait tout sous la main. Le ralentissement venait de l’emballage.
Chaque fichier obtient sa propre allocation d’URL, son bridging ObjC-Swift, sa construction de dictionnaire resourceValues. À 50 fichiers, cette cérémonie est invisible. À 5,2 millions, elle domine le profil. Le kernel cache les métadonnées de répertoire en mémoire ; la question, c’est le surcoût que l’API ajoute à chaque lecture. Confortable pour quelques dizaines, punitif pour des millions.
Mesurer, puis couper
Ne devinez pas où se trouve le goulot. Profilez.
Avant de descendre vers une API de plus bas niveau, il faut cartographier quelle couche possède réellement le coût. Chaque alternative POSIX élimine une partie du surcoût mais laisse le coût unitaire intact. C’est un arbre de décision :
getdirentries64 renvoie les entrées de répertoire en masse, noms et types de fichiers inclus, mais pas la taille ni la date de modification. Il faut encore un appel stat par fichier. On élimine l’emballage Swift, pas le problème d’un appel système par fichier.
fts_open / fts_read est une API POSIX de parcours d’arborescence de plus haut niveau, mais elle traite une entrée à la fois et appelle stat par entrée par défaut.2 Plus haut niveau que les appels système bruts, toujours unitaire.
Le parallélisme (via TaskGroup) répartit le coût unitaire sur les cœurs sans l’éliminer. Quatre threads qui font du travail redondant, ce n’est pas plus rapide qu’un seul thread qui fait le bon travail.
Chaque option retire une couche de surcoût mais laisse la structure de coût fondamentale intacte : une interaction kernel par fichier, un résultat par appel. L’objectif n’est pas d’accélérer le chemin unitaire : c’est de l’éliminer.
La trappe de secours
getattrlistbulk est l’API qu’Apple avait depuis OS X 10.10. Un seul appel système par répertoire : le kernel empaquète tous les attributs de toutes les entrées du répertoire dans un unique tampon. Nom, type, taille, date de modification : tout d’un coup, en une seule lecture. Finder et Spotlight utilisent très probablement cette API ou son équivalent.3 Elle est documentée dans la couche BSD du SDK macOS. Ce n’est pas la première idée quand on écrit du Swift dans les règles de l’art.
graph LR
accTitle: FileManager vs getattrlistbulk system call comparison
accDescr: FileManager calls resourceValues() per item with per-item overhead, producing one FileNode at a time. getattrlistbulk makes one syscall per directory and returns all entries in a packed buffer, achieving 1.7x faster throughput for 5.2 million nodes.
subgraph FM["Approche FileManager"]
FM1[Entrée de répertoire] -->|"resourceValues()<br/>surcoût par fichier"| FM2[Un seul dict d'attributs]
FM2 -->|répéter par fichier| FM3[FileNode]
end
subgraph PX["Approche getattrlistbulk"]
PX1[Répertoire] -->|"getattrlistbulk()<br/>1 appel système par répertoire"| PX2["Tampon compact<br/>(toutes les entrées, tous les attributs)"]
PX2 -->|parcourir le tampon| PX3[FileNode par entrée]
end
FM3 -.->|"5,2M nœuds"| OUT[ScanResult]
PX3 -.->|"5,2M nœuds : 1,7x plus rapide"| OUT
Un détail d’implémentation qui vaut le détour
Le parcours du tampon est de l’arithmétique de pointeurs sur des données binaires compactes : lire une longueur sur 4 octets, avancer, extraire les champs.4 Un détail mérite qu’on s’y arrête.
Quand on lit des champs depuis un tampon brut, l’approche sûre est un memcpy dans une variable locale plutôt qu’un cast direct du pointeur. La raison n’est pas un défaut matériel (Apple Silicon et Intel gèrent les lectures non alignées) mais les règles de strict aliasing du C : le compilateur suppose que des pointeurs de types différents ne pointent jamais vers la même mémoire. Un cast comme *(uint32_t *)ptr sur un char * est un comportement indéfini, et le compilateur est libre de mal compiler.5
// Correct : memcpy avant de lire un champ 4 octets
uint32_t size;
memcpy(&size, ptr, sizeof(size));
ptr += sizeof(size);
// Incorrect : violation du strict aliasing, comportement indéfini en C
uint32_t size = *((uint32_t *)ptr);
En pratique, le cast direct fonctionne sur toutes les architectures Apple des quinze dernières années. On utilise quand même memcpy, parce que « ça marche sur ma machine » n’est pas un argument de correction quand le standard C dit le contraire.
Cas limites : le prix de la trappe
Quand on perce une abstraction, on hérite des cas limites qu’elle gérait pour nous.
Volumes FAT32. Les disques externes formatés en FAT32 ne prennent pas en charge toutes les combinaisons d’attributs. L’appel renvoie ENOTSUP, et le scanner se rabat sur FileManager pour l’ensemble du volume.6 Une trappe de secours a un prix : les replis vous appartiennent.
Disques iCloud. Un fichier iCloud évincé renvoie zéro pour ATTR_FILE_ALLOCSIZE (octets sur disque), parce que les données ne sont pas là. Si on utilise ce chiffre tel quel, toute la bibliothèque iCloud disparaît de la carte disque. Le correctif : détecter les chemins cloud et utiliser ATTR_FILE_DATALENGTH (taille logique) à la place.78 C’est le genre de subtilité que les API de haut niveau masquent, et que les API de bas niveau exposent.
Confinement : percer, puis s’arrêter
L’implémentation vit dans POSIXDirectoryScanner, isolée derrière un DirectoryScannerProtocol. Le reste de l’app appelle le protocole. Il ignore si c’est FileManager ou des appels système bruts qui répondent.
C’est la leçon de conception clé. On perce exactement une couche, derrière une frontière propre, puis on s’arrête. La trappe de secours reste confinée dans sa boîte. Le reste du code demeure idiomatique. Si Apple sort un jour une meilleure API, on remplace une seule conformance et rien d’autre ne change.
graph TD
accTitle: Recursive directory scanning with attribute buffering
accDescr: Starting from a volume root, each directory is scanned with getattrlistbulk in one syscall. The packed attribute buffer is parsed into FileNode structs. Child directories recurse. Files accumulate into the tree, producing a final ScanResult.
V[Racine du volume] --> D[Répertoire]
D -->|getattrlistbulk<br/>un seul appel système| B[Tampon d'attributs]
B -->|analyse de la structure compacte| N[FileNode]
N -->|sous-répertoire ?| D
N -->|fichier| T[Construction de l'arbre]
T --> SR[ScanResult]
Le goulot se déplace
Tous les chronométrages proviennent de l’instrumentation PerfLogWriter, compilation Debug (-Onone) sur M3 Pro (18 Go). Protocole : 3 scans de chauffe pour remplir le cache du système de fichiers, mesure au 4e. Quatre passages par méthode, médiane retenue.9
| Métrique | FileManager | POSIX | Gain |
|---|---|---|---|
| Scan (5,2M nœuds) | 43,6 s | 25,7 s | 1,70× |
| Sauvegarde du cache | 7,1 s | 3,6 s | 1,97× |
| Bout en bout | 50,7 s | 29,3 s | 1,73× |
Le choix du cache chaud comme méthodologie est délibéré : les temps en cache froid dépendent de trop de variables système incontrôlables pour donner des bases de comparaison reproductibles. L’écart en cache froid serait vraisemblablement plus marqué, mais je ne l’ai jamais mesuré de façon systématique.10
Le gain de 1,7x était bien réel, mais un peu vexant : j’avais visé 2,5-3x. Le temps de scan restant dit pourquoi : 5,2 millions d’allocations FileNode et de constructions de chaînes qu’aucun changement d’appel système ne peut corriger. Le travail s’est déplacé de « interroger le kernel » à « construire l’arbre ».
Parfois on optimise la bonne chose et le goulot se déplace. Ce n’est pas un échec : c’est de l’information. On le suit. Ce coût de construction de l’arbre devient le sujet de l’article suivant : remplacer 5,2 millions d’instances de classes allouées sur le tas par un tableau de structs à plat.
Références
- getattrlistbulk(2) man page : miroir des pages man macOS
- File System Programming Guide : Apple Developer Library
- POSIX : Wikipedia
Footnotes
-
FileManager.contentsOfDirectory(at:includingPropertiesForKeys:)peut précharger les clés de ressources demandées en lot par répertoire viagetattrlist(). Le surcoût unitaire provient de la construction d’uneURLpour chaque entrée, du bridging Objective-C vers Swift et de la construction du dictionnaireresourceValues. Qu’il y ait un aller-retour kernel par fichier ou un par répertoire dépend de l’APIFileManagerutilisée et des clés demandées. ↩ -
Le flag
FTS_NOSTATsupprime l’appelstatpar entrée quand seul le type de fichier (disponible viad_typedans l’entrée de répertoire) est nécessaire. L’article décrit le comportement par défaut. ↩ -
Apple n’a jamais documenté publiquement quels appels système Finder ou Spotlight utilisent en interne. Des investigations communautaires via DTrace suggèrent des API d’attributs en lot, mais « suggérer » et « confirmer » sont deux choses différentes. ↩
-
On alloue un tampon de taille fixe (le scanner utilise 256 Ko) et on appelle
getattrlistbulken boucle. Chaque appel remplit le tampon avec autant d’enregistrements que possible. Quand le kernel renvoie 0, le répertoire est épuisé. Pour un répertoire de 50 000 entrées, il faudra peut-être une poignée d’appels plutôt qu’un seul, mais chacun renvoie des centaines d’entrées d’un coup. ↩ -
Le standard C (C11 §6.5/7) définit comme comportement indéfini l’accès à un objet via un pointeur de type incompatible.
memcpycontourne le problème car il opère surchar *, autorisé à aliaser n’importe quel type. En pratique, le Clang d’Apple avec-fno-strict-aliasing(le défaut de nombreux projets) ne compilerait pas le cast direct de travers, mais se reposer sur des flags de compilateur pour la correction est fragile. ↩ -
La détection est réactive, pas proactive : le scanner tente
getattrlistbulksur le premier répertoire et vérifie le code de retour. S’il obtientENOTSUPou un nombre d’attributs nul, il bascule sur le cheminFileManagerpour l’ensemble du volume. Pas de sonde préalable. ↩ -
Ce sont des chemins en espace utilisateur au sein du répertoire personnel, pas des points de montage de volumes. iCloud Drive n’apparaît pas comme un volume séparé ; c’est une arborescence sur le volume système APFS, gérée par File Provider. ↩
-
La correspondance par préfixe de chemin est une heuristique, pas un contrat d’API. Apple pourrait déplacer ces répertoires dans une future version de macOS. Il n’existe pas d’API publique pour demander « ce chemin est-il hébergé dans le cloud ? » Le framework File Provider expose des informations de domaine, mais l’interroger fichier par fichier pendant un scan réintroduirait le surcoût unitaire. La vérification par chemin est un compromis pragmatique : fragile en théorie, stable sur toutes les versions de macOS testées jusqu’ici. ↩
-
Avec seulement quatre passages, la médiane est la moyenne des 2e et 3e valeurs une fois triées. Ce n’est pas un estimateur robuste : un seul outlier la déplace sensiblement. Les chiffres doivent être lus comme indicatifs, pas comme précis. Un protocole plus rigoureux utiliserait n ≥ 7 passages et rapporterait l’étendue ou l’écart-type. ↩
-
Ceci est une compilation Debug (
-Onone), qui gonfle le surcoût du runtime Swift (ARC, dispatch dynamique, vérification de bornes) de façon disproportionnée côté FileManager. Le ratio de 1,7x en Release pourrait différer, puisque les optimisations du compilateur réduiraient le surcoût d’abstraction unitaire. Sans données de profilage isolant le temps passé en appels système du surcoût du runtime, la contribution exacte de chaque facteur reste inconnue. ↩
