- La PR #1773 de llama.cpp propose l’ajout d’une API qui restreint les candidats d’échantillonnage à l’aide d’une grammaire hors contexte, afin d’empêcher que le résultat généré ne s’écarte du format spécifié
- L’API reçoit une structure de grammaire fondée sur des points de code 32 bits, filtre ensuite les tokens candidats avec
llama_sample_grammar, puis répercute le token choisi dans l’état de la grammaire avecllama_grammar_accept_token - Des arguments
--grammaret--grammar-fileont été ajoutés àmainpour fournir une grammaire au format BNF étendu, avec comme exemples Chess, les expressions arithmétiques, JSON et la génération dans des plages de caractères japonais - Les tests ont été réalisés sur un M2 Max et un modèle 30B Q4_0 ; avec la grammaire, la sortie est contrainte à la notation d’échecs, aux expressions arithmétiques, au JSON et aux plages de caractères japonais, tandis que sans grammaire le modèle génère du texte généraliste ou du code sans rapport avec le prompt
- Les discussions se sont concentrées sur des contraintes d’usage réel comme la prise en charge des fichiers de grammaire, le traitement des lignes vides et des commentaires, l’inclusion ou non du parseur dans l’API, le masquage des logits sur GPU, le traitement par lots de tokens fixes et le surcoût en performances
Changements clés de la PR
- Il s’agit d’une PR qui ajoute un échantillonnage basé sur une grammaire à llama.cpp
- Elle s’appuie sur des travaux existants, notamment #1397 et le travail CFG de grantslatton
- La nouvelle API reçoit une grammaire hors contexte sérialisée pour guider et contraindre l’échantillonnage
- Des exemples de grammaire de type BNF ont été ajoutés à
mainpour spécifier la grammaire de génération
Structure de l’API Grammar
- L’API
llamareçoit une structure de grammaire hors contexte portant sur des points de code 32 bits - Les types d’éléments de grammaire représentent la fin d’une règle, le début d’une alternative, une référence de règle, un caractère, la borne supérieure d’une plage de caractères et l’ajout d’un caractère alternatif
- La fonction d’initialisation reçoit les informations suivantes
- un tableau de règles
- le nombre de règles
- l’indice de la règle de départ
llama_grammar_elementpossède untypeet unevalue, cette dernière étant utilisée comme point de code Unicode ou comme identifiant de règle
Méthode d’échantillonnage
- Le code d’échantillonnage grammatical modélise un automate à pile non déterministe
- Il maintient N piles pour représenter les états de parsing possibles
- L’échantillonnage des tokens fonctionne en deux étapes
llama_sample_grammarne conserve parmi les candidats que ceux compatibles avec au moins une pile de parsingllama_grammar_accept_tokenajoute le token sélectionné à l’état de la grammaire
Saisie de grammaire dans main
- Des arguments
--grammaret--grammar-fileont été ajoutés àmain - Ces deux arguments acceptent une grammaire simple de type BNF étendu pour contraindre le résultat généré
- Le parseur de grammaire est implémenté dans
examples/grammar-parser.{h,cpp} - Les fonctionnalités de grammaire prises en charge incluent les plages de caractères, le groupement et les opérateurs de répétition
- La règle
rootidentifie le point de départ de la grammaire - Des mises à jour ultérieures ont ajouté la prise en charge des fichiers de grammaire, des exemples supplémentaires, les commentaires de style shell, les lignes vides entre les règles et les retours à la ligne dans les groupes entre parenthèses
Exemples de test
- L’environnement de test mentionné est un modèle M2 Max, 30B
- Exemple Chess
- Avec
--grammar-file grammars/chess.gbnf, le modèle génère des notations d’échecs comme1. e4 e5,2. Nf3 Nc6 - Sans grammaire, le même prompt produit une phrase générale à propos de Sir Thomas Gresham
- Avec
- Exemple d’expression arithmétique
- Une grammaire inline impose une forme
expr "=" ws num "\n" - Le résultat est alors limité à des expressions arithmétiques comme
10 *a*1 +b*2 =640 - Sans grammaire, le modèle génère un fragment de code Go
- Une grammaire inline impose une forme
- Exemple JSON
- Avec
grammars/json.gbnf, le modèle génère une structure JSON du type{ "fullName": ..., "address": ... } - Sans grammaire, il produit une prose de type présentation personnelle
- Avec
- Exemple japonais
grammars/japanese.gbnfautorise l’hiragana, le katakana, la ponctuation et les plages CJK- Avec la grammaire, une liste fondée sur des caractères japonais est générée
- Sans grammaire, le modèle produit une liste à étapes en anglais
Revue et discussions de conception
- Une proposition a été faite pour fournir la grammaire via un fichier, et la prise en charge des grammar files ainsi que des exemples ont ensuite été ajoutées
- Parmi les problèmes relevés lors des premiers usages figuraient un conflit avec
--prompt-cacheet un crash provoqué par des lignes vides dans la grammaire - La prise en charge des lignes vides et des commentaires, ainsi que l’autorisation des retours à la ligne dans les groupes entre parenthèses, ont été incluses dans des commits ultérieurs
- Une discussion a porté sur l’inclusion du parseur dans l’API de llama.cpp
- Si le parseur reste hors de l’API, les utilisateurs downstream risquent de devoir le copier pour prendre en charge la fonctionnalité
- L’auteur voulait limiter les modifications de
llama.cpplui-même, tout en reconnaissant qu’intégrer le parseur à l’API serait plus pratique
- Il a aussi été proposé que
llama_grammarconserve une copie binaire de la grammaire, afin que l’utilisateur n’ait pas à garantir la durée de vie de la copie transmise
Discussions sur les performances et l’optimisation
- Il est compris qu’à l’heure actuelle, même lorsque les
N > 1tokens suivants sont déterminés de manière unique par la grammaire, ils sont échantillonnés un par un - Une discussion a eu lieu sur le fait qu’une évaluation par lots de plusieurs tokens fixes pourrait fortement accélérer l’inférence dans ce cas
- L’auteur a répondu qu’il faut tout de même évaluer les tokens et que le goulot d’étranglement semble se situer de ce côté ; selon lui, une évaluation par lots au niveau des chaînes pourrait constituer une piste d’optimisation
- L’idée de compiler la grammaire sur GPU sous forme de tenseur de transition d’état, ou d’effectuer un masquage des logits sur GPU pour l’ensemble du vocabulaire, a également été évoquée
torch-grammara été cité comme approche similaire, réalisant sur GPU un masquage des logits imposé par la grammaire sur l’ensemble du vocabulaire
Observations de performance
- L’auteur précise n’avoir testé que l’inférence CPU et n’avoir pas constaté d’impact majeur sur les performances dans la plage de ses essais
- Mesures observées sur M2 Max
- l’échantillonnage non contraint est d’environ 0.5ms/token
- l’échantillonnage avec grammaire est d’environ 6ms/token
- l’évaluation des tokens sur un 13B Q4_K est d’environ 70ms/token
- Un autre utilisateur a signalé un cas où le débit passe d’environ 20T/s à 13T/s sur un 13B
- L’auteur estime généralement le surcoût de la grammaire à environ 5ms/token, tout en notant que certaines grammaires ont eu un impact plus important et qu’il pourrait exister des cas pathologiques
Discussions liées aux extensions
- L’idée d’utiliser une entrée JSON Schema convertie en BNF a été mentionnée
- L’auteur indique disposer d’une branche locale fonctionnant avec l’exemple du README de jsonformer, ainsi que d’un script Python qui génère une BNF JSON conforme à un JSON Schema
- Par la suite,
examples : generate JSON according to schema #1887a été mentionné en lien avec cette PR - Il a été répondu que cette approche est indépendante des variantes de modèle et peut aussi être utilisée avec des modèles fine-tunés
- Une PR distincte ajoutant l’échantillonnage basé sur une grammaire à whisper.cpp a également été mentionnée
1 commentaires
Commentaires sur Hacker News
Voici comment je comprends le fonctionnement. Le modèle de langage émet un token à la fois à partir du prompt comme point de départ, et une conversation avec un LLM peut aussi se voir comme une séquence de tokens fournie par l’utilisateur, puis partiellement générée par le modèle, puis prolongée à nouveau par l’utilisateur
Cette technique de grammaire permet un contrôle beaucoup plus fin des tokens. Par exemple, si on lui donne
Give me the address of the White House as JSON: {"street": ", le LLM peut renvoyer1600 Pennsylvania Ave NW", puis au moment où l’on voit le guillemet fermant, l’utilisateur injecte", "City": "pour obtenirWashington, DC"Mais comme c’est basé sur une grammaire, cela peut faire bien plus que du JSON. J’avais aussi vu auparavant sur Twitter une proposition intéressante : qu’OpenAI accepte une grammaire context-free déterministe comme argument d’API, ou mieux encore, un petit binaire WASM servant directement de sampler, de quelques Ko, exécutable avec quelques Mo de mémoire, ce qui pourrait considérablement étendre les capacités des LLM
https://twitter.com/grantslatton/status/1637692033115762688
Le réglage
temperatureajuste la probabilité qu’un token autre que le mieux classé soit choisi, afin de réduire les sorties répétitives. Forcer un LLM à respecter une grammaire consiste généralement à filtrer cette liste avant la sélection du token, tout en conservant malgré tout l’aléa contrôlé par la temperatureUne fonctionnalité plus avancée consisterait à autoriser un retour en arrière quand l’IA est bloquée et ne peut plus produire une sortie valide
À mon avis, l’essentiel de cette PR sur l’échantillonnage basé sur la grammaire est que llama.cpp utilise la grammaire pour restreindre le prochain token de sortie à un ensemble limité de tokens possibles
Elle a été écrite par l’un des développeurs de la bibliothèque Outlines, qui est aussi une bonne bibliothèque de workflow pour LLM
Même si l’on envoie des règles DCFG ou leur version compilée dans la requête, je ne vois pas en quoi cela changerait fondamentalement la façon dont le modèle prédit les tokens. Si le modèle prédit quelque chose qui ne respecte pas la grammaire demandée, cela veut-il dire qu’il faut réinjecter le prompt jusqu’à ce qu’il tombe juste ?
Il faut souligner que cela ne fait qu’imposer des contraintes grammaticales sur le texte généré par le modèle, et non assurer un véritable alignement du contenu. C’est utile lorsqu’il faut garantir qu’un serveur renvoie du JSON bien formé, mais cela ne résoudra probablement pas beaucoup des problèmes actuels d’alignement en génération de langage
Par exemple, aujourd’hui Llama ou GPT se trompent souvent dans les libellés de blocs de code Markdown. On peut les forcer à mettre un libellé avec l’échantillonnage basé sur la grammaire, mais on ne peut pas forcer que ce soit le bon, car cela dépend du contexte. Et il est difficile d’espérer de bonnes sorties après avoir créé un nouveau langage spécifique à un domaine sans aligner ensuite le modèle sur ce langage
On peut par exemple imaginer un système qui hot-swap à la demande une LoRA pour Markdown et un fichier de grammaire Markdown
J’aime vraiment beaucoup ça. J’avais déjà créé Constrained Text Generation Studio (https://github.com/Hellisotherpeople/Constrained-Text-Genera...), et un article associé a même été présenté à COLING 2022 (https://paperswithcode.com/paper/most-language-models-can-be...)
Malgré tout, j’ai toujours pensé que ce type d’approche, ainsi que les idées connexes listées dans cet article, allaient dans la bonne direction : https://arxiv.org/abs/2306.03081
Il va maintenant falloir réfléchir à la façon de construire des grammaires capables d’imposer des choses comme le nombre de syllabes ou des règles syntaxiques. Les LLM actuels sont très mauvais pour ce genre de tâche à cause de leur mode de tokenisation
Cela a aussi été implémenté pour PyTorch : https://github.com/Shopify/torch-grammar. Il existe aussi une version bricolée de text-generation-inference qui l’utilise, je peux la partager si besoin
Pour une énumération de chaînes à choix multiple, en pratique l’équivalent d’une liste déroulante, je me demande s’il ne vaudrait pas mieux, au moment du choix final, considérer la probabilité jointe totale sous les logits donnés plutôt qu’un algorithme glouton
Quand le token de départ est partagé par plusieurs éléments de la liste, cela pourrait amener à préférer la bonne option plutôt qu’une option contenant simplement le token initial le plus fréquent. Bien sûr, une fois qu’une partie des logits a été mise à zéro, il faut réajuster les probabilités pour que cela ait encore un sens
Cette « bibliothèque » de grammaires a été citée comme exemple de formats : https://github.com/antlr/grammars-v4
On y trouve de tout, de l’assembleur et du C++ à GLSL, des langages de script, l’arithmétique, les jeux, les raccourcis freedesktop, LLVM IR, Verilog, jusqu’à des formats plus atypiques
Quelqu’un peut m’expliquer simplement ? Je connais un peu les LLM, mais je ne vois pas bien ce que Georgi fait ici ni pourquoi certains sont aussi enthousiastes
Un programme comme ChatGPT « interprète » ensuite ce vecteur de probabilités pour choisir, c’est-à-dire échantillonner, un des tokens les plus probables et produire du texte. Mais cette méthode est parfois trop souple : même si on veut une sortie JSON, elle peut choisir un token qui ne respecte pas la grammaire JSON et produire un JSON invalide
Une façon de « forcer » le LLM à générer quelque chose comme du JSON consiste à modifier le processus d’échantillonnage. Au lieu de choisir parmi n’importe quels tokens probables, on ne garde d’abord que ceux compatibles avec la grammaire JSON, puis on échantillonne parmi ce sous-ensemble
Le LLM génère le texte token par token. D’abord, un énorme réseau de neurones attribue une probabilité à tous les tokens possibles, puis la procédure d’échantillonnage utilise ces probabilités pour en choisir un, avant de répéter l’opération
La procédure d’échantillonnage n’étant pas un réseau de neurones, on peut la modifier de nombreuses façons. On peut faire de l’échantillonnage glouton en choisissant toujours le token le plus probable, mais en général un choix aléatoire pondéré par les probabilités est préférable. Cela apporte de la diversité et réduit le risque de boucler. En revanche, n’importe quel token dont la probabilité n’est pas nulle peut être choisi, ce qui peut produire un JSON invalide. Cette PR met la probabilité de tous les tokens grammaticalement invalides à 0, afin qu’ils ne puissent pas être sélectionnés
On peut aussi imaginer d’autres modifications intéressantes du processus d’échantillonnage. Si, en échantillonnant token par token, on arrive dans une impasse sans aucun choix possible, on pourrait autoriser un retour en arrière. Mieux encore, on peut considérer plusieurs choix à chaque étape pour construire un arbre des sorties possibles, puis sélectionner à la fin le chemin dont la probabilité totale est la plus élevée. Si on considère tous les choix, on obtient un arbre complet qui se ramifie selon le nombre de tokens possibles et grossit de façon exponentielle ; on peut donc l’élaguer en ne gardant par exemple que les 5 meilleurs chemins à chaque étape. C’est la recherche en faisceau. Comme le coût d’exécution du réseau de neurones qui produit les probabilités est très élevé, multiplier ce coût par 5 est généralement trop lourd pour les LLM, donc on l’utilise rarement, mais c’est possible et cela améliore un peu les résultats. On peut aussi imaginer utiliser une recherche arborescente Monte-Carlo, comme dans les moteurs d’échecs
On lui donne donc une grammaire disant que la réponse doit se terminer après une majuscule suivie de lettres minuscules, d’un deux-points, d’une espace et de chiffres. Alors, lors du choix du premier token, seuls les tokens compatibles avec ce motif sont pris en compte, puis seuls ceux compatibles avec le motif suivant sont considérés à leur tour
Ce type de grammaire permet de faire cela d’une manière souple et utile
Je trouve ça intéressant et je vais essayer de l’intégrer dans ce que je fais. Mais j’ai l’impression que, du point de vue de la Bitter Lesson, ce n’est peut-être pas la meilleure approche au-delà du très court terme : http://www.incompleteideas.net/IncIdeas/BitterLesson.html
Il existe aussi un projet qui utilise une méthode similaire : https://github.com/automorphic-ai/trex
Playground : https://automorphic.ai/playground
J’aimerais qu’on me recommande des articles ou des synthèses expliquant comment se font le sampling/décodage à l’ère des réseaux neuronaux de bout en bout. Je sais comment le décodage se faisait en traduction automatique et en reconnaissance vocale à l’époque des HMM, par exemple avec des choses comme le https://en.wikipedia.org/wiki/Viterbi_algorithm ou le https://en.wikipedia.org/wiki/Beam_search
De nos jours, j’ai l’impression que les gens font simplement du mode « greedy », mais je n’en suis pas sûr. S’il y a des ressources à recommander sur ce sujet, je suis preneur
rwkv.cppa une implémentation Python relativement propre ici : https://github.com/saharNooby/rwkv.cpp/blob/master/rwkv/samp...https://platform.openai.com/docs/api-reference/completions/c...
Bien sûr, on sait maintenant que GPT-4 a une architecture de mélange d’experts, donc les calculs y sont parallélisés en interne. Cela inclut aussi une méthode qui modifie les logits via des termes de pénalité de présence/fréquence.