Module 7 — File d'attente et charge concurrente
Tant que vous testez seul, tout marche. À deux utilisateurs, ça marche encore. À cinq, la latence explose. À dix, la démonstration devient inutilisable. Ce n'est presque jamais un problème de modèle : c'est un problème de file d'attente mal configurée. Gradio expose un mécanisme puissant pour gérer les requêtes concurrentes, mais il faut savoir le régler, faute de quoi les paramètres par défaut ne conviennent qu'aux démonstrations vues par une seule personne à la fois.
Ce que fait la file d'attente
Sans file d'attente, Gradio traite chaque requête au moment où elle arrive. Deux utilisateurs qui cliquent en même temps déclenchent deux appels simultanés à votre fonction Python, ce qui suppose que votre fonction soit réellement parallélisable. En Python, à cause du GIL, deux appels simultanés sur du code CPU-lié se sérialisent en pratique ; sur un modèle qui utilise le GPU, la mémoire vidéo se sature vite ; sur une API distante, on multiplie les appels payants sans contrôle. Le résultat est une expérience utilisateur imprévisible.
demo.queue() active la file d'attente : Gradio devient l'unique ordonnanceur, il accepte toutes les requêtes qui arrivent, les met en file, et n'en traite qu'un nombre limité en parallèle. Les utilisateurs qui attendent voient une position dans la file (« vous êtes 3ᵉ sur 12 ») et une estimation d'attente. La différence pour l'utilisateur est immense : au lieu d'une page qui pédale sans rien afficher, il voit un signe explicite que sa requête est bien reçue et sera traitée.
Depuis Gradio 4, la file d'attente est activée par défaut sur toutes les démonstrations, avec des paramètres neutres qui conviennent à un usage local. Le vrai travail consiste à ajuster ces paramètres à votre modèle et à votre matériel.
Le paramètre qui compte : default_concurrency_limit
Le paramètre le plus important est default_concurrency_limit de demo.queue(). Il fixe le nombre maximum de requêtes traitées en même temps pour la démonstration.
demo = gr.Interface(fn=..., inputs=..., outputs=...)
demo.queue(default_concurrency_limit=5)
demo.launch()
Avec la valeur 5, cinq appels tournent en parallèle et le sixième attend qu'un des cinq se termine. Le bon réglage dépend de la nature du calcul. Pour un modèle GPU qui sature la mémoire vidéo, la valeur est souvent 1 ou 2 (deux inférences en parallèle sur une seule carte se battent pour la mémoire et finissent plus lentes que si elles s'étaient exécutées l'une après l'autre). Pour un appel à une API distante qui bloque en attente réseau, la valeur peut monter à 20 ou 50, car Python attend simplement, sans consommer de ressources locales. Pour un calcul CPU pur, la valeur logique est le nombre de cœurs disponibles.
Un événement particulier peut avoir sa propre limite via .click(..., concurrency_limit=1), qui écrase la valeur par défaut. C'est utile quand une seule action de l'interface est lourde (« générer une image ») alors que les autres sont légères.
max_size : le point de refus explicite
Le second paramètre à connaître est max_size : il fixe le nombre maximum de requêtes acceptées dans la file d'attente. Au-delà, les nouvelles requêtes sont refusées immédiatement avec un message d'erreur, plutôt que d'être empilées indéfiniment.
demo.queue(default_concurrency_limit=2, max_size=20)
Un refus explicite est bien meilleur qu'une attente de dix minutes pour finalement ne rien recevoir. L'utilisateur peut réessayer plus tard, et la démonstration ne s'écroule pas sous l'accumulation de connexions ouvertes. Sur un espace Hugging Face qui va vivre plusieurs mois, cette valeur est un garde-fou indispensable.
Combiner concurrence et diffusion progressive
La diffusion progressive du module 5 se combine parfaitement avec la file d'attente. Une génération qui prend dix secondes, diffusée jeton par jeton, occupe l'un des créneaux de concurrence pendant ces dix secondes ; les utilisateurs suivants attendent leur tour dans la file, avec la position affichée, et reçoivent leur diffusion à leur tour. Le tout sans une ligne de code supplémentaire.
Il faut toutefois se méfier d'un piège spécifique aux générateurs et aux modèles GPU : si votre fonction est un générateur qui bloque le GPU pendant toute la génération, la limite de concurrence doit rester à 1. Deux générations sur la même carte se marchent dessus, chacune divise la vitesse de l'autre par deux, et le résultat est que la seconde requête n'est pas servie deux fois plus vite mais deux fois plus lentement — pire qu'un traitement séquentiel.
Durée maximale d'un appel : progress et arrêt
Une requête qui prend anormalement longtemps bloque un créneau et pénalise tous les utilisateurs qui suivent. Deux mécanismes existent pour se protéger. Le premier est le paramètre time_limit sur un événement, qui interrompt la fonction après un nombre de secondes fixé (nécessite Gradio ≥ 4.30). Le second est plus subtil : ajouter un paramètre gr.Progress() à votre fonction et l'appeler régulièrement (progress(0.5, "traitement en cours")) permet à Gradio de détecter les fonctions qui ne rendent jamais la main et de couper les connexions mortes.
def repondre(message, historique, progress=gr.Progress()):
progress(0, "chargement du contexte")
contexte = charger_contexte(message)
progress(0.5, "génération")
reponse = modele(contexte, message)
progress(1.0, "terminé")
return reponse
Le composant gr.Progress affiche une barre de progression au-dessus du composant de sortie, ce qui est un plus ergonomique en soi, et sert de battement de cœur au serveur.
Mesurer, jamais deviner
Le nombre optimal de créneaux ne se devine pas, il se mesure. Un script simple qui envoie N requêtes en parallèle et mesure le temps total suffit.
import time
import concurrent.futures
from gradio_client import Client
client = Client("http://127.0.0.1:7860")
def un_appel(i):
debut = time.perf_counter()
client.predict("Bonjour", api_name="/chat")
return time.perf_counter() - debut
for n in [1, 2, 4, 8, 16]:
with concurrent.futures.ThreadPoolExecutor(max_workers=n) as ex:
durees = list(ex.map(un_appel, range(n)))
print(f"{n} utilisateurs concurrents : moyenne {sum(durees)/n:.2f} s")
La lecture attendue est claire : la moyenne reste stable tant que N ≤ default_concurrency_limit, puis grimpe linéairement quand N dépasse la limite (les excédents attendent leur tour). Le point où la moyenne commence à monter donne la bonne valeur.
Sur un modèle GPU, on observe souvent un phénomène supplémentaire : à N = 2 sur une carte fatiguée, la moyenne monte avant même de dépasser la limite, parce que deux inférences saturent la mémoire vidéo. C'est le signal qu'il faut redescendre la limite à 1.
Un utilisateur qui voit « position 4 sur 7, attente estimée 30 s » supporte l'attente. Le même utilisateur qui voit une page figée sans indication ferme l'onglet au bout de dix secondes. Une file bien configurée avec max_size et affichage de position améliore la satisfaction perçue même quand elle augmente la latence médiane.
En résumé
demo.queue(default_concurrency_limit=N)fixe le nombre de requêtes traitées en parallèle ; typiquement 1-2 pour un modèle GPU, 20+ pour une API distante, le nombre de cœurs pour un calcul CPU pur.max_sizerefuse explicitement les requêtes au-delà d'un seuil, ce qui protège de l'accumulation de connexions ouvertes et donne à l'utilisateur un signal exploitable.- Sur un générateur qui bloque le GPU, garder la concurrence à 1 : deux générations parallèles sur une seule carte s'exécutent plus lentement qu'en séquentiel.
- Le composant
gr.Progress()sert d'affichage utilisateur et de battement de cœur serveur ;time_limitsur un événement borne la durée d'un appel.
Le module suivant s'attaque à la question qui vient invariablement après une démonstration réussie : « je peux la partager avec mon collègue ? ». Le lien de partage temporaire de Gradio en est la réponse en une seule ligne.