Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

crabunk

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.

Prise en main

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.

1. Trouver deux versions à comparer

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 30
2 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.

2. Mesurer

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.

3. Vérifier que le compte est bon

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.

4. Sortie machine

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.

5. Régler la taille de chunk

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
EOF
4K     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.

Résultats mesurés

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   810d367871c1d460086d9f82db8696f2e0a0fcd0

Sur 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.

Empreinte mémoire

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.

L'algorithme

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é.

Architecture

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

Dispatch

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.

Deux décisions moins évidentes

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.

Tests

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-features

71 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.

Hors scope

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.

Licence

Apache 2.0. Le texte complet est dans LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages