Coin web de Frédéric Péters

git-acab (optimisations d’un script Python)

Je pourrais commencer par une introduction gentillette, sur un acronyme alternatif, façon «â€¯All Cats Are Beautiful » mais gagnons du temps, droit au but, je ne suis pas particulièrement féru de la maréchaussée,

(Allez voir tout le travail de Loki Gwynbleidd)

Partant de là, comme l’adolescent peut graver au compas ACAB sur son banc de fond classe (côte fenêtres), pourquoi ne pas le graver dans le code ? En effet le message est court et comme il tient en hexadécimal il peut tout à fait se retrouver dans l’identifiant d’un commit, d’ailleurs on en trouve :

…/django $ git log --oneline | grep --color acab
7acabbb980 Merge pull request #750 from vanschelven/master
de9acabf12 Updated contributing documentation to contain the new release [...]
0b71ffacab Improved urlresolvers so that URLconfs can be passed objects [...]

Mais faut-il tout laisser à la chance ? Avoir juste 3 lignes parmi 30 mille ? (29936 au moment de cette commande) ? Bien sûr que non. Sur quelle base l’identifiant du commit est-il calculé ? On trouve la réponse rapidement, sans rien avoir à décortiquer; ça se base sur quelques métadonnées, qu’on peut obtenir via la commande git cat-file, ex:

 $ git cat-file commit HEAD
tree 58bdcd551485a6d920a4c9424845af4beb91578a
parent 1c6980920999b59337667cd0ef533b1384cacab0
author Frédéric Péters <fpeters@0d.be> 1629963213 +0200
committer Frédéric Péters <fpeters@0d.be> 1629963593 +0200

add success emoji

Si on prend ça, qu’on préfixe par le mot commit, un espace, le nombre de caractères de l’extrait ci-dessus, un octet nul, puis le contenu de l’extrait, c’est-à-dire si on fait la commande

 $ printf "commit %s\0" $(git cat-file commit HEAD | wc -c);
   git cat-file commit HEAD)

et qu’on en récupère le hash SHA1 :

$ (printf "commit %s\0" $(git cat-file commit HEAD | wc -c);
   git cat-file commit HEAD) | sha1sum -
fcf1fc0f29755cc49b030e5b680bcacabb7fea9c  -

et on peut comparer au commit en question, c’est bien la même valeur :

$ git show HEAD | head -1
commit fcf1fc0f29755cc49b030e5b680bcacabb7fea9c

Équipé de ces informations, on voit qu’on obtiendra une valeur différente si on peut faire varier les quelques métadonnées en jeu, et c’est assez facile puisque le 1629963213 qui apparaît c’est la date·heure (en nombre de secondes depuis le 1er janvier 1970), il y a juste à faire varier ça, de seconde en seconde, jusqu’à tomber sur une valeur qui contiendra la chaine souhaitée.

# partir du nombre de secondes depuis le 1er janvier 1970 qu'on a là maintenant
timestamp = int(time.time())
# puis boucler
while True:
    # en modifiant la date du commit
    subprocess.run(
        [
            'git',
            'commit',
            '--no-verify',  # pour ne pas exécuter les éventuels hooks
            '--amend',
            '--no-edit',
            '--date=%s' % time.strftime(
                 '%Y-%m-%d %H:%M:%S', time.localtime(timestamp)),
        ],
        capture_output=True,
    )
    # ensuite on regarde le nouvel hash obtenu
    p = subprocess.run(['git', 'rev-parse', 'HEAD'], capture_output=True)
    # et si c'est bon
    if 'acab' in p.stdout.decode():
        # on arrête
        break
    # ou alors on continue en essayant avec la seconde précédente
    timestamp -= 1

Ça fait le travail mais c’est un peu trop discret, on pourrait plutôt vouloir l’acab en début de hash, côté code ça n’est pas bien compliqué, il y a juste la fin à modifier,

    # et si c'est bon
    if p.stdout.decode().startswith('acab'):
        # on arrête
        break

Là par contre ça commence à prendre un certain temps, en local sur mon moyen·vieux·tout·est·relatif laptop ça teste 50 possibilités par seconde.

Comme à coup sûr le temps est passé sur l’exécution à répétition de la commande git (ce qui demande du noyau un changement de contexte d’exécution), le bon plan serait d’éviter ça et une solution ça serait d’utiliser GitPython, une réimplémentation de gros bouts de Git en Python. Je ne la fais pas longue les fondamentaux sont à peu près ici :

repo = git.Repo()
commit = repo.commit('HEAD')
while True:
    # (itérer une valeur pour authored_timestamp)
    commit = commit.replace(authored_date=authored_timestamp)
    if commit.hexcha.startswith('acab'):
        repo.active_branch.set_commit(commit)
        break

Et clairement niveau performance, c’est un autre monde, 3150 tests par seconde, 63× mieux.

Comme il faut parfois quelques centaines de milliers de tests pour trouver un résultat, ça passe de l’heure à la minute, c’est vraiment très bien.

Il y a par contre au cours de ces tests un problème qui se révèle, ces milliers de tests il se trouvent être des commits écrits dans le dépôt et ça a beau être des métadonnées, ça prend à un moment un peu de place quand même. À expérimenter ici, appeler git prune pour retirer les références inutilisées tous les 10000 tests n’amène pas de ralentissement sensible et préserve les disques.

Pour éviter ça, on a fait un pas en avant vers GitPython, on peut refaire un pas en arrière et prendre une autre direction, revenir au début de ce billet, sur la manière dont Git calcule le hash et faire ça par nous-mêmes, sans véritablement créer de commit, le créer uniquement une fois qu’on a trouvé des valeurs de date·heures qui conviennent. (valeurs au pluriel, en cours de route je me suis dit qu’on pouvait aussi faire varier la date+heure de la ligne commiter).

Là ça peut devenir un peu moche comme code (ou c’est la fatigue), prendre le résultat du git cat-file, en faire un pseudo-gabarit dans lequel on remplacera au fur et à mesure des itérations les date·heures.

Un peu moche mais ça fonctionne parfaitement, les disques sont laissés tranquilles et ça carbure à 350000 tests par seconde, 110× mieux que la méthode précédente, et en pratique désormais un résultat est obtenu en moins d’une seconde.

On peut se dire là qu’on arrête les frais (on aurait d’ailleurs pu le dire plus tôt, cf xkcd).

En conclusion donc, pour les optimisations, avant de sortir des outils sophistiqués il y a d’abord des évidences à toujours considérer : 1/ éviter de lancer des processus externes, 2/ éviter d’écrire des fichiers.

Le code complet des différentes itérations est bien sûr disponible, git-acab.

Souvenir de gloire au JT…

 

7 septembre 2021, 07:52