
Le goulot du scanner : de FileManager à POSIX
Un seul appel système a tout changé.
Le premier scanner utilisait FileManager.enumerator. Vous auriez fait pareil. C’est le choix qui s’impose : du Swift idiomatique, une API propre, la gestion des liens symboliques et des erreurs, un comportement conforme à la doc. On appelle, on reçoit une séquence d’URL, on lit les attributs de chacune. Quelques dizaines de lignes et tout le système de fichiers vous appartient.
Ça marchait. Les résultats étaient justes. Et c’était assez lent pour me faire reconsidérer mes choix de carrière.
Voilà ce que FileManager fait pour chaque fichier : il traverse la frontière entre espace utilisateur et noyau, demande les attributs, récupère un dictionnaire, repasse en espace utilisateur. Un aller-retour par fichier. Pour 5,2 millions de fichiers, ça donne 5,2 millions de traversées de la frontière noyau. Le noyau possède déjà toute l’information en mémoire, prête à servir. Mais l’API vous force à la demander fichier par fichier, comme si on commandait un million d’articles en exigeant une livraison séparée pour chacun.
getattrlistbulk, c’est la réponse qu’Apple avait depuis OS X 10.10. Un seul appel système par répertoire. Le noyau renvoie tous les attributs de toutes les entrées du répertoire dans un unique tampon compact. Nom, type, taille, date de modification : tout d’un coup, en une seule lecture. C’est ce que fait Finder. C’est ce que fait Spotlight. L’API est documentée dans la couche BSD du SDK macOS. La page man est détaillée, les constantes d’attributs bien nommées. Simplement, ce n’est pas la première idée quand on écrit du Swift dans les règles de l’art.
graph LR
subgraph FM["Approche FileManager"]
FM1[Entrée de répertoire] -->|"attributesOfItem()<br/>1 appel système 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
L’implémentation vit dans POSIXDirectoryScanner. On descend au niveau des appels système C, ce qui veut dire manipuler des pointeurs bruts.
getattrlistbulk écrit dans un tampon que vous fournissez. Ce tampon contient une séquence compacte d’enregistrements d’attributs à longueur variable. On le parcourt en lisant une longueur sur 4 octets au début de chaque enregistrement, puis en avançant le pointeur d’autant. À l’intérieur de chaque enregistrement, les champs sont empaquetés dans l’ordre demandé, avec des contraintes d’alignement qui varient selon le type.
Un point sur lequel la documentation Apple est catégorique : ne déréférencez jamais ces pointeurs directement pour des entiers multi-octets. L’alignement du tampon n’est pas garanti. Sur les architectures qui l’imposent, c’est une erreur de bus. Sur les autres, c’est une corruption silencieuse. Il faut passer par memcpy dans une variable locale.
// Correct: memcpy before reading a 4-byte field
uint32_t size;
memcpy(&size, ptr, sizeof(size));
ptr += sizeof(size);
// Wrong: direct dereference may fault on unaligned reads
uint32_t size = *((uint32_t *)ptr);
En pratique, les lectures non alignées fonctionnent sur Apple Silicon. On respecte quand même la spécification, parce que « ça marche sur ma machine » n’a jamais été un argument de sûreté mémoire.
Deux cas particuliers ont exigé un traitement dédié.
Le premier : les volumes FAT32. Les disques externes formatés en FAT32 ne se comportent pas comme les autres sous getattrlistbulk. Certaines combinaisons d’attributs ne sont pas prises en charge, et l’appel renvoie ENOTSUP ou des enregistrements tronqués. Le scanner le détecte et se rabat proprement plutôt que de produire des données corrompues.
Le second : les disques cloud. Les fichiers iCloud évincés vers le cloud exposent deux attributs de taille : ATTR_FILE_DATALENGTH (la taille logique) et ATTR_FILE_ALLOCSIZE (les octets réellement présents sur disque). Pour un analyseur de disque, ce sont les octets physiques qui comptent. Lire DATALENGTH sur un fichier évincé peut déclencher un rapatriement réseau, téléchargeant le contenu depuis le cloud. C’est problématique à trois titres : c’est lent, ça consomme de la bande passante, et le chiffre renvoyé ment sur l’occupation réelle du stockage local.
La détection des disques cloud vérifie la présence de /Library/CloudStorage/ et /Library/Mobile Documents/ dans le chemin du volume. Quand ces chemins sont repérés, le scanner demande ALLOCSIZE au lieu de DATALENGTH. Les chiffres d’utilisation restent ancrés dans ce qui consomme réellement de l’espace sur la machine.
Le pipeline après cette refonte :
graph TD
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]
Un répertoire, un appel système. Le parcours du tampon est une boucle serrée sur de la mémoire contiguë. L’arbre se construit par parcours en profondeur, piloté par une pile de travail.
Les mesures après la bascule :
| Type de scanner | Nœuds | Gain en cache chaud |
|---|---|---|
| FileManager | 5,2M | référence |
| POSIX / getattrlistbulk | 5,2M | 1,7x plus rapide |
Compilation Debug, M3 Pro. Les compilations Release seraient plus rapides, mais j’ai mesuré ce que j’avais sous la main. La distinction cache chaud/froid est importante : le cache du système de fichiers peut faire varier ces chiffres d’un facteur 2 à 4 selon que l’OS a récemment parcouru les mêmes répertoires. Les chiffres « chaud » sont pris après trois scans du même disque. Le premier scan d’un utilisateur tombe sur un cache froid.
Le gain de 1,7x en cache chaud provient quasi intégralement de la réduction des appels système. Le travail reste le même : visiter chaque entrée, enregistrer nom, taille et type. Ce qui change, c’est le nombre de fois où l’on franchit la frontière du noyau.
Le POSIXDirectoryScanner remplace l’ancien scanner sans toucher au reste, grâce au DirectoryScannerProtocol. Le reste de l’application ignore quel scanner tourne. La frontière protocolaire a confiné le changement.
Chaque plateforme a son étage d’API idiomatique et celui que le système utilise pour de vrai.
Pour la majorité du code, l’étage idiomatique est le bon. Mais quand on fait quelque chose que la plateforme exécute des milliers de fois par seconde, ça vaut le coup de regarder ce qu’elle utilise en interne. Finder parcourt ses répertoires avec getattrlistbulk. Spotlight aussi. L’API existe depuis OS X 10.10.
FileManager convenait au prototype. getattrlistbulk convenait à la production. Le gain de 1,7x était bien réel, mais un peu vexant : j’avais visé 2,5-3x. Le temps de scan restant est dominé par la construction de l’arbre, 5,2 millions d’allocations FileNode et de constructions de chaînes qu’aucun changement d’appel système ne peut corriger. Parfois le goulot d’étranglement se déplace. Il n’y a qu’à le suivre.
References
- getattrlistbulk(2) man page: Apple Developer Library
- File System Programming Guide: Apple Developer Library
- POSIX: Wikipedia