Quand un dépôt Hugging Face publie une nouvelle version d'un fichier de poids, elle ressemble en général beaucoup à la précédente. Le client la retélécharge quand même en entier.
crabunk mesure ce que ça coûte. Il découpe les deux versions en chunks dont les frontières suivent le contenu au lieu d'une position fixe (FastCDC), hache chaque chunk, et compare les deux listes. Ce qui en sort, c'est le nombre d'octets qu'un client possédant déjà l'ancienne version aurait eu à télécharger.
Il reconstruit ensuite la nouvelle version à partir des chunks, ce qui permet de vérifier ce chiffre au lieu de le croire sur parole.
cargo build --release
export PATH="$PWD/target/release:$PATH"Les commandes qui suivent tournent telles quelles, sur des dépôts publics. Il n'y a pas de token à configurer.
La plupart des fichiers de poids sont écrits une fois et plus jamais touchés,
donc il faut commencer par trouver un dépôt où ce n'est pas le cas. scan
remonte l'historique et compare les sha256 que le Hub publie déjà, ce qui évite
de télécharger quoi que ce soit.
crabunk scan --repo openai-community/gpt2 --file model.safetensors --revisions 302 contenu(s) distinct(s) de model.safetensors sur les 30 dernières révisions de openai-community/gpt2
607a30d783dfa663caf39e06633721c8d4cfcd7e 548 105 171 octets 6 révision(s) 2022-10-20T09:34:54.000Z
Upload model.safetensors with huggingface_hub (#12)
909a290700bd99135e67c64eefc166960b67cfd2 664 749 225 octets 1 révision(s) 2022-09-29T15:22:02.000Z
Convert weights to .safetensors (#6)
crabunk compare --repo openai-community/gpt2 --file model.safetensors \
--from 909a290700bd99135e67c64eefc166960b67cfd2 \
--to 607a30d783dfa663caf39e06633721c8d4cfcd7e
La dernière ligne est déjà la commande de l'étape suivante.
crabunk compare --repo facebook/opt-125m --file pytorch_model.bin \
--from 38f3ba082934fed762f5793691948826277d82dc \
--to 27dcfa74d334bc871f3234de431e71c6eeba5dd6→ lecture de facebook/opt-125m@38f3ba08:pytorch_model.bin
250 535 293 octets, 3519 chunks
→ lecture de facebook/opt-125m@27dcfa74:pytorch_model.bin
250 540 281 octets, 3520 chunks
crabunk : FastCDC, déduplication par contenu
dépôt facebook/opt-125m
fichier pytorch_model.bin
chunking min 16 384 / cible 65 536 / max 262 144 octets
base 38f3ba08 250 535 293 octets 3519 chunks (3519 distincts) moyenne 71 195
cible 27dcfa74 250 540 281 octets 3520 chunks (3520 distincts) moyenne 71 176
chunks de la cible 3520
déjà présents localement 3416
dédupliqués dans la cible 0
à transférer 104
téléchargement naïf 250 540 281 octets
téléchargement dédupliqué 7 768 358 octets
économie 242 771 923 octets (96.90 %)
Le message de commit de la seconde révision dit correct checkpoints see: metaseq PR#164. C'est donc une correction de poids, pas une resérialisation. Les
deux fichiers ne diffèrent que de 4 988 octets en taille, et 104 chunks sur
3 520 changent.
L'avancement va sur stderr, le résultat sur stdout. Rediriger l'un laisse l'autre tranquille.
sync fait ce que compare se contente de calculer. Il remplit un store
adressé par contenu avec la version de base, y ajoute les chunks de la cible qui
manquent, puis rebâtit la cible à partir du store.
crabunk sync --repo facebook/opt-125m --file pytorch_model.bin \
--from 38f3ba082934fed762f5793691948826277d82dc \
--to 27dcfa74d334bc871f3234de431e71c6eeba5dd6 \
--store /tmp/crabunk-store --output /tmp/rebuilt.bin→ lecture de facebook/opt-125m@38f3ba08:pytorch_model.bin
250 535 293 octets, 3519 chunks
→ lecture de facebook/opt-125m@27dcfa74:pytorch_model.bin
250 540 281 octets, 3520 chunks
→ reconstruction : 250 540 281 octets, identique à la cible
crabunk : reconstruction depuis le store
dépôt facebook/opt-125m
fichier pytorch_model.bin
base 38f3ba08 250 535 293 octets 3519 chunks (3519 distincts) moyenne 71 195
cible 27dcfa74 250 540 281 octets 3520 chunks (3520 distincts) moyenne 71 176
store 0 chunks avant, 3623 après
écrit pour la base 3519 chunks 250 535 293 octets
écrit pour la cible 104 chunks 7 768 358 octets <- le transfert réel
prédit par les manifestes 7 768 358 octets
reconstruction 250 540 281 octets (manifeste identique à celui de la cible)
Ce que le store a réellement écrit pour la cible et ce que la comparaison des manifestes annonçait tombent sur le même nombre, 7 768 358 octets.
Reste à savoir si le fichier reconstruit est bien le bon. Le Hub publie le sha256 de chaque révision, ce qui donne un arbitre extérieur au projet.
shasum -a 256 /tmp/rebuilt.bin | cut -d' ' -f1
curl -s "https://huggingface.co/api/models/facebook/opt-125m/tree/27dcfa74d334bc871f3234de431e71c6eeba5dd6?path=pytorch_model.bin" \
| python3 -c "import sys,json; print(next(e['lfs']['oid'] for e in json.load(sys.stdin) if e['path']=='pytorch_model.bin'))"2d74da6615135c58cf3cf9ad4cb11e7c613ff9e55fe658a47ab83b6c8d1174a9
2d74da6615135c58cf3cf9ad4cb11e7c613ff9e55fe658a47ab83b6c8d1174a9
Quand la reconstruction ne correspond pas, sync sort en code d'erreur, donc la
commande s'utilise telle quelle dans un script.
crabunk compare --repo facebook/opt-125m --file pytorch_model.bin \
--from 38f3ba082934fed762f5793691948826277d82dc \
--to 27dcfa74d334bc871f3234de431e71c6eeba5dd6 \
--json 2>/dev/null | jq '{naive: .naive_transfer_bytes, dedup: .deduplicated_transfer_bytes, pct: .savings_percent}'{
"naive": 250540281,
"dedup": 7768358,
"pct": 96.89935767254927
}Le JSON complet porte aussi le réglage du chunker et le détail de ce qui a été
réutilisé ou transféré. sync accepte le même --json.
while read -r MIN AVG MAX; do
printf '%-6s %-6s %-6s ' "$MIN" "$AVG" "$MAX"
crabunk compare --repo facebook/opt-125m --file pytorch_model.bin \
--from 38f3ba082934fed762f5793691948826277d82dc \
--to 27dcfa74d334bc871f3234de431e71c6eeba5dd6 \
--min-size "$MIN" --avg-size "$AVG" --max-size "$MAX" --json 2>/dev/null \
| jq -r '"\(.deduplicated_transfer_bytes) octets \(.savings_percent*100|round/100) %"'
done <<'EOF'
4K 16K 64K
16K 64K 256K
64K 256K 1M
256K 1M 4M
EOF4K 16K 64K 2673415 octets 98.93 %
16K 64K 256K 7768358 octets 96.9 %
64K 256K 1M 29001312 octets 88.42 %
256K 1M 4M 95729412 octets 61.79 %
Les suffixes K, M et G valent 1024, pas 1000. Plus les chunks sont fins,
mieux ils isolent la différence, et plus le manifeste grossit. Ici, passer d'une
cible de 1 Mio à 16 Kio fait tomber le transfert de 95 Mo à 2,7 Mo, mais le
manifeste passe de 227 à 13 889 chunks.
Trois paires trouvées avec scan et mesurées avec compare, au réglage par
défaut (min 16 Kio, cible 64 Kio, max 256 Kio).
| dépôt et fichier | v1 | v2 | naïf | dédupliqué | économie |
|---|---|---|---|---|---|
microsoft/phi-2, model-00001-of-00002.safetensors |
4 982 467 864 o | 4 995 584 424 o | 4 995 584 424 o | 42 642 385 o | 99,15 % |
facebook/opt-125m, pytorch_model.bin |
250 535 293 o | 250 540 281 o | 250 540 281 o | 7 768 358 o | 96,90 % |
openai-community/gpt2, model.safetensors |
664 749 225 o | 548 105 171 o | 548 105 171 o | 57 780 335 o | 89,46 % |
Les deux autres lignes du tableau se reproduisent avec :
crabunk compare --repo openai-community/gpt2 --file model.safetensors \
--from 909a290700bd99135e67c64eefc166960b67cfd2 \
--to 75e09b43581151bd1d9ef6700faa605df408979f
# attention, celle-ci télécharge deux shards de 5 Go
crabunk compare --repo microsoft/phi-2 --file model-00001-of-00002.safetensors \
--from 834565c23f9b28b96ccbeabe614dd906b6db551a \
--to 810d367871c1d460086d9f82db8696f2e0a0fcd0Sur phi-2, 588 chunks changent sur 70 338. Le client du Hub télécharge 5 Go, un client dédupliqué en télécharge 42 Mo.
gpt2 est le cas que je trouve le plus parlant. Les deux fichiers sont deux
sérialisations safetensors des mêmes poids, avec des en-têtes de longueurs
différentes. Tout le contenu utile se retrouve donc décalé, et pas d'une
quantité constante. Un découpage à taille fixe se serait désynchronisé au
premier écart et n'aurait presque plus rien réutilisé ensuite. Le CDC retrouve
ses frontières juste après : 292 chunks sur 7 062 sont nouveaux, le reste est
repris tel quel.
La mesure a aussi fait apparaître quelque chose que je ne cherchais pas. La
version gpt2 de 2022-09-29 compte 9 035 chunks pour 6 917 empreintes distinctes,
donc 2 118 de ses chunks sont dupliqués à l'intérieur du même fichier. C'est le
poids d'embedding écrit deux fois, wte et lm_head étant liés. La version
suivante n'a plus un seul doublon interne.
Ces trois lignes m'ont demandé de balayer 21 couples (dépôt, fichier) avec
scan. Trois seulement avaient deux contenus distincts dans leurs 25 dernières
révisions, ce qui est moins que ce à quoi je m'attendais.
Le découpage travaille en flux, donc le programme ne garde jamais en mémoire
plus de max_size octets du fichier, quelle que soit sa taille.
/usr/bin/time -l crabunk compare --repo openai-community/gpt2 --file model.safetensors \
--from 909a290700bd99135e67c64eefc166960b67cfd2 \
--to 75e09b43581151bd1d9ef6700faa605df408979f 2>&1 >/dev/null | grep resident 16678912 maximum resident set size
Environ 16 Mo de pointe pour lire un fichier de 664 Mo. Le chiffre bouge de quelques centaines de kilooctets d'une exécution à l'autre.
FastCDC (Xia et al., USENIX ATC 2016), dans src/domain/chunker.rs. La seule
dépendance externe du découpage est blake3, pour hacher les chunks.
Le rolling hash Gear vaut fp = (fp << 1) + GEAR[octet], soit un décalage et
une addition par octet. Les octets anciens sortent d'eux-mêmes par le haut du
mot de 64 bits. Là où un hash de Rabin doit soustraire l'octet qui quitte la
fenêtre, il n'y a ici rien à faire.
Les min_size premiers octets d'un chunk ne sont pas hachés du tout, ce que le
papier appelle cut-point skipping. Aucune frontière ne peut y tomber, donc le
calcul serait perdu.
Le normalized chunking utilise deux masques au lieu d'un. Avant d'avoir atteint
la taille cible, on exige plus de bits nuls et la coupe devient rare. Après, on
en exige moins et elle devient probable. La distribution se resserre donc autour
de la cible. Sur 4 Mio de données aléatoires avec une cible de 1 Kio, aucun
chunk ne sature max_size et la moyenne mesurée est de 1 138 octets.
Les masques ne sont pas recopiés d'une table publiée. Ils sont construits en
const fn en répartissant n bits sur les 48 bits de poids faible selon un pas
premier avec 48. La probabilité de coupe reste 2^-n, et la fenêtre effective
atteint environ 48 octets, là où un masque (1 << n) - 1 ne regarderait que les
n derniers octets. La table Gear est dérivée d'une graine fixe par SplitMix64,
pour la même raison. C'est reproductible, et un générateur de dix lignes se
relit plus facilement qu'une table de 256 constantes hexadécimales.
La graine Gear et la longueur des empreintes font partie du format. Les changer invalide tout manifeste déjà calculé.
Hexagonale. Les dépendances pointent toujours vers l'intérieur.
src/
domain/ le cœur, sans I/O ni framework
chunker.rs FastCDC : table Gear, masques, ChunkerConfig, StreamChunker
chunk.rs ChunkHash, ChunkSpan, Chunk
manifest.rs Manifest (pavage contigu garanti), ManifestBuilder
dedup.rs comparaison de deux manifestes vers un DedupReport
reference.rs RepoId, FilePath, Revision (newtypes validés)
ports/ les traits que le cœur attend de l'extérieur
file_source.rs FileSource, RevisionCatalog, FileHistory
chunk_store.rs ChunkStore
hasher.rs ChunkHasher
progress.rs ProgressSink
application/
compare_revisions.rs chiffrer ce qu'il faudrait transférer
sync_revision.rs le faire, et vérifier la reconstruction
adapters/
hugging_face.rs le Hub via hf-hub, seul module qui connaît HTTP
fs_chunk_store.rs store sur disque, un chunk par fichier
blake3_hasher.rs
in_memory_source.rs fixtures locales pour les scénarios Gherkin
stderr_progress.rs
cli.rs clap, rendu texte et JSON
main.rs composition root
Statique partout. Les cas d'usage sont génériques sur leurs ports, les
implémentations sont fixées dans main.rs et monomorphisées. Pas d'async-trait
non plus : les ports asynchrones déclarent -> impl Future<Output = …> + Send
plutôt qu'un async fn, uniquement pour pouvoir écrire le + Send, parce qu'un
async fn en trait laisse la Send-ness indéterminée pour un appelant générique.
enum_dispatch ne s'est jamais imposé. Aucun endroit du projet ne stocke
plusieurs implémentations d'un même port dans une collection. Là où deux sources
coexistent, dans les tests Gherkin qui tapent soit sur des fixtures soit sur le
Hub, deux branches appelant une fonction générique suffisent.
dyn Trait apparaît à deux endroits, tous deux justifiés en commentaire à
l'endroit exact. Dans FileSourceError::Transport, la cause vient d'une pile
réseau dont le cœur ne connaît pas les types, et std::error::Error::source
impose cette forme de toute façon. Dans cli.rs, la composition root agrège des
erreurs hétérogènes dont le shell ne fait rien d'autre que les afficher.
Le port de lecture inverse le contrôle. FileSource::read_blocks prend un sink
et laisse l'adapter piloter la boucle de lecture, au lieu de rendre un
AsyncRead. Le cœur n'a ainsi à nommer aucun trait d'I/O ni aucun runtime, et le
fichier n'est jamais détenu en entier.
Le port de store est synchrone. ChunkStore est appelé depuis la boucle qui
découpe le flux, où un await par chunk coûterait plus cher que l'I/O locale
qu'il masque. Un store distant demanderait un autre port, asynchrone et
travaillant par lots.
cargo test
CRABUNK_E2E_NETWORK=1 cargo test # ajoute le scénario réel contre le Hub, environ 1,2 Go
cargo clippy --all-targets --all-features71 tests unitaires et 11 scénarios Gherkin. Quatre méritent un mot.
an_inserted_byte_resynchronises_locally insère un octet au milieu de 4 Mio et
vérifie que 99,94 % des octets restent partagés. Pris seul, ce chiffre ne dit pas
d'où il vient. Son contre-test,
fixed_size_chunking_shifts_everything_after_an_insertion, refait la même mesure
avec un découpage à taille fixe et obtient 50,00 %, exactement la moitié qui
précède l'insertion. C'est ce second test qui montre que le premier mesure le
CDC et pas un fichier facile à découper.
streaming_matches_whole_buffer_chunking_whatever_the_block_size vérifie que le
chunker en flux rend exactement les mêmes frontières que le chunker sur tampon
complet, pour des blocs de 1 octet à 100 Kio.
the_bytes_actually_written_match_the_predicted_transfer compare ce que le
store a réellement écrit pour la cible avec ce que la comparaison des manifestes
avait annoncé. Les deux doivent tomber sur le même nombre.
a_store_returning_wrong_bytes_is_caught vérifie qu'un store qui rend autre
chose que ce qu'on lui a confié est démasqué à la relecture.
Les scénarios Gherkin sont dans tests/features/. Ils décrivent le comportement
observable, sans jamais nommer FastCDC ni un masque. deduplication.feature et
reconstruction.feature tournent sur fixtures locales en quelques dizaines de
millisecondes. hugging_face.feature est marqué @network et exclu par défaut.
compare et sync prennent une paire de révisions d'un même chemin dans un même
dépôt. Pas de traitement par lot, pas de comparaison croisée entre dépôts.
L'adapter Hub construit un HFRepository<RepoTypeModel> en dur, donc les
datasets et les Spaces restent hors de portée.
Il manque surtout tout le côté serveur, et ça se voit dans le fait que
Manifest ne dérive ni Serialize ni Deserialize. Un manifeste est recalculé
à chaque exécution et n'est jamais publié, ce qui oblige sync à lire la cible
en entier rien que pour savoir comment elle se découpe. Un vrai système comme Xet
ou borg fait l'inverse : le serveur détient le manifeste et ne sert que les
chunks qu'on lui réclame, et le client ne télécharge alors que les octets comptés
ici. C'est le prolongement le plus intéressant à écrire.
Rien n'est compressé et rien n'est jamais supprimé du store. Après un sync, le
répertoire pèse la taille de la version de base plus celle des chunks nouveaux,
et il grossira à chaque révision suivante.
Enfin, deux machines détenant chacune une partie des chunks ne savent pas se les échanger. Il n'y a pas de synchronisation P2P.
Apache 2.0. Le texte complet est dans LICENSE.