Kimyayd commited on
Commit
c8f8484
·
verified ·
1 Parent(s): e70a0ad

Adaptation ZeroGPU : evaluation par creneaux GPU, reprise sur quota epuise

Browse files
Files changed (4) hide show
  1. README.md +6 -2
  2. app.py +13 -12
  3. evaluator.py +89 -18
  4. requirements.txt +5 -4
README.md CHANGED
@@ -17,8 +17,12 @@ Classement public des modèles de reconnaissance vocale pour le **fongbe**,
17
  langue tonale parlée par environ 2,3 millions de personnes au Bénin.
18
 
19
  N'importe qui peut proposer un modèle du Hub : il est évalué
20
- automatiquement sur le CPU de ce Space, puis ajouté au classement. Aucun
21
- compte n'est nécessaire.
 
 
 
 
22
 
23
  ## Métriques
24
 
 
17
  langue tonale parlée par environ 2,3 millions de personnes au Bénin.
18
 
19
  N'importe qui peut proposer un modèle du Hub : il est évalué
20
+ automatiquement sur le GPU partagé de ce Space (ZeroGPU), puis ajouté au
21
+ classement. Aucun compte n'est nécessaire.
22
+
23
+ L'évaluation avance par tranches, chacune tenant dans un créneau GPU. Quand
24
+ le quota quotidien s'épuise, elle se met en pause et reprend d'elle-même à
25
+ la tranche suivante — un redémarrage du Space ne fait rien perdre non plus.
26
 
27
  ## Métriques
28
 
app.py CHANGED
@@ -172,9 +172,9 @@ Pour toutes ces mesures sauf le RTFx, **plus bas vaut mieux**.
172
  jeu de test : son score paraît excellent sans rien prouver. Il reste affiché
173
  par transparence, mais n'est pas comparable aux autres.
174
 
175
- ⏱️ **Vitesse.** Le RTFx dépend du matériel. Les modèles évalués sur le CPU du
176
- Space sont bien plus lents que ceux mesurés sur GPU L4 : ne comparez les
177
- vitesses qu'entre modèles évalués sur le même matériel.
178
  """
179
 
180
 
@@ -251,10 +251,10 @@ def submit(model_id: str, hf_username: str, contact: str, note: str,
251
  _cache.clear()
252
  return (
253
  f"### ✅ `{model_id}` est en file d'attente\n\n"
254
- "L'évaluation tourne sur le CPU du Space : comptez une à deux heures "
255
- "pour un modèle CTC de taille moyenne, bien davantage pour un gros "
256
- "modèle autorégressif. Le score apparaîtra au classement une fois "
257
- "calculé — suivez l'avancement dans l'onglet **File d'attente**."
258
  )
259
 
260
 
@@ -358,11 +358,12 @@ def build_ui() -> gr.Blocks:
358
  gr.Button("Rafraîchir").click(build_queue, None,
359
  [q_table, etat_line])
360
  gr.Markdown(
361
- "L'évaluation tourne sur le CPU partagé du Space : comptez "
362
- "**1 à 2 h** pour un modèle CTC de 300 M paramètres, et "
363
- "**plusieurs jours** pour un gros modèle autorégressif de "
364
- "type Whisper. Une évaluation interrompue par un "
365
- "redémarrage reprend elle s'était arrêtée.",
 
366
  elem_classes="fb-note")
367
  demo.load(build_queue, None, [q_table, etat_line])
368
 
 
172
  jeu de test : son score paraît excellent sans rien prouver. Il reste affiché
173
  par transparence, mais n'est pas comparable aux autres.
174
 
175
+ ⏱️ **Vitesse.** Le RTFx dépend du matériel, indiqué pour chaque score. Un
176
+ modèle mesuré sur le GPU partagé du Space n'est pas directement comparable à
177
+ un modèle mesuré sur L4 dédiée : ne comparez les vitesses qu'à matériel égal.
178
  """
179
 
180
 
 
251
  _cache.clear()
252
  return (
253
  f"### ✅ `{model_id}` est en file d'attente\n\n"
254
+ "L'évaluation tourne par tranches sur le GPU partagé du Space. Selon "
255
+ "le quota disponible, comptez de quelques minutes à quelques heures. "
256
+ "Le score apparaîtra au classement une fois calculé — suivez "
257
+ "l'avancement dans l'onglet **File d'attente**."
258
  )
259
 
260
 
 
358
  gr.Button("Rafraîchir").click(build_queue, None,
359
  [q_table, etat_line])
360
  gr.Markdown(
361
+ "L'évaluation tourne par tranches sur le GPU partagé du "
362
+ "Space (ZeroGPU). Le calcul lui-même est rapide quelques "
363
+ "minutes pour un modèle CTC mais le quota GPU quotidien "
364
+ "est limité : une évaluation peut s'étaler sur plusieurs "
365
+ "heures, en pause entre deux tranches. Une évaluation "
366
+ "interrompue reprend exactement là où elle s'était arrêtée.",
367
  elem_classes="fb-note")
368
  demo.load(build_queue, None, [q_table, etat_line])
369
 
evaluator.py CHANGED
@@ -37,6 +37,21 @@ import requests
37
 
38
  import fonbench_eval as fe
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  # --- configuration ------------------------------------------------------
41
 
42
  SUPABASE_URL = os.environ.get(
@@ -59,9 +74,18 @@ HEADERS = {"apikey": ANON_KEY, "Content-Type": "application/json"}
59
 
60
  WORKER_VERSION = "space-1.0"
61
  POLL_SECONDS = 60
62
- CHUNK_CTC = 50
63
- CHUNK_SEQ2SEQ = 10 # un Whisper met des minutes par énoncé sur CPU
64
- BATCH_CTC = 4
 
 
 
 
 
 
 
 
 
65
 
66
  # Architectures autorégressives : pipeline + découpage à 30 s, sinon les
67
  # énoncés longs font échouer Whisper.
@@ -203,7 +227,6 @@ class Transcriber:
203
  )
204
  self.model = AutoModelForSpeechSeq2Seq.from_pretrained(
205
  model_id, revision=revision, trust_remote_code=False,
206
- dtype=self.torch.float32,
207
  ).eval()
208
  self.pipe = pipeline(
209
  "automatic-speech-recognition",
@@ -236,23 +259,57 @@ class Transcriber:
236
  except Exception as exc: # noqa: BLE001 — modèle déjà spécialisé
237
  log(f"pas d'adaptateur fon ({type(exc).__name__}) — poids tels quels")
238
 
239
- def __call__(self, arrays: list) -> list[str]:
 
 
 
 
 
 
 
240
  if self.seq2seq:
241
- return [
242
- (self.pipe(a)["text"] or "").strip() for a in arrays
243
- ]
244
  out: list[str] = []
245
  for i in range(0, len(arrays), BATCH_CTC):
246
  batch = arrays[i:i + BATCH_CTC]
247
  inputs = self.processor(
248
  batch, sampling_rate=16000, return_tensors="pt", padding=True
249
  )
250
- with self.torch.inference_mode():
 
251
  logits = self.model(**inputs).logits
252
- ids = self.torch.argmax(logits, dim=-1)
253
  out.extend(t.strip() for t in self.processor.batch_decode(ids))
254
  return out
255
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
 
257
  # --- une évaluation -----------------------------------------------------
258
 
@@ -304,7 +361,8 @@ def run_job(job: dict) -> None:
304
  _state.update(message="doublon ignoré", model=None)
305
  return
306
 
307
- tr = Transcriber(model_id, revision)
 
308
  chunk = tr.chunk_size
309
 
310
  saved = job.get("progress") or {}
@@ -316,14 +374,14 @@ def run_job(job: dict) -> None:
316
  if next_chunk:
317
  log(f"↻ reprise à la tranche {next_chunk} ({next_chunk * chunk} énoncés)")
318
 
319
- # Les tailles de tranche sont calibrées pour qu'une tranche reste sous
320
- # les 20 min au-delà desquelles la base considère la tâche abandonnée :
321
- # 50 énoncés en CTC (~2 min), 10 seulement pour un Whisper sur CPU
322
- # (~13 min dans le pire cas). Chaque tranche vaut donc battement de cœur.
323
  n_chunks = (total + chunk - 1) // chunk
324
  skipped = 0
325
 
326
- for ci in range(next_chunk, n_chunks):
 
327
  rows = ds.select(range(ci * chunk, min((ci + 1) * chunk, total)))
328
  arrays, refs = [], []
329
  for row in rows:
@@ -335,7 +393,19 @@ def run_job(job: dict) -> None:
335
 
336
  if arrays:
337
  t0 = time.time()
338
- hyps = tr(arrays)
 
 
 
 
 
 
 
 
 
 
 
 
339
  compute_s += time.time() - t0
340
  audio_s += sum(len(a) for a in arrays) / 16000.0
341
  fe.accumulate(counters, refs, hyps)
@@ -349,6 +419,7 @@ def run_job(job: dict) -> None:
349
  })
350
  rtfx = audio_s / compute_s if compute_s else 0
351
  log(f" {done}/{total} — RTFx {rtfx:.2f}")
 
352
 
353
  if skipped:
354
  log(f"⚠ {skipped} énoncés illisibles écartés")
@@ -369,7 +440,7 @@ def run_job(job: dict) -> None:
369
  "rtfx": rtfx,
370
  "rtf": round(1 / rtfx, 4) if rtfx else None,
371
  "eval_seconds": round(compute_s, 1),
372
- "hardware": "HF Space CPU",
373
  "worker_version": WORKER_VERSION,
374
  },
375
  "p_error": None,
 
37
 
38
  import fonbench_eval as fe
39
 
40
+ # ZeroGPU : le GPU n'est attaché que le temps d'une fonction décorée. En
41
+ # dehors d'un Space ZeroGPU (tests locaux), le décorateur devient neutre.
42
+ try:
43
+ import spaces
44
+
45
+ GPU = spaces.GPU
46
+ ON_ZEROGPU = True
47
+ except Exception: # noqa: BLE001
48
+ ON_ZEROGPU = False
49
+
50
+ def GPU(*args, **kwargs): # noqa: D103
51
+ def deco(fn):
52
+ return fn
53
+ return deco(args[0]) if args and callable(args[0]) else deco
54
+
55
  # --- configuration ------------------------------------------------------
56
 
57
  SUPABASE_URL = os.environ.get(
 
74
 
75
  WORKER_VERSION = "space-1.0"
76
  POLL_SECONDS = 60
77
+
78
+ # Une tranche = un créneau GPU. ZeroGPU plafonne la durée d'un créneau, et
79
+ # plus on en demande, plus on attend : on vise ~10 s de calcul réel.
80
+ # Sur A10G, un CTC tourne autour de 100× temps réel (100 énoncés ≈ 700 s
81
+ # d'audio ≈ 7 s), un Whisper autour de 5× (20 énoncés ≈ 140 s ≈ 30 s).
82
+ GPU_DURATION = 120
83
+ CHUNK_CTC = 100
84
+ CHUNK_SEQ2SEQ = 20
85
+ BATCH_CTC = 8
86
+ # Quota journalier épuisé : on attend au lieu d'abandonner. Le point de
87
+ # reprise est déjà enregistré, rien n'est perdu.
88
+ QUOTA_WAIT_SECONDS = 1800
89
 
90
  # Architectures autorégressives : pipeline + découpage à 30 s, sinon les
91
  # énoncés longs font échouer Whisper.
 
227
  )
228
  self.model = AutoModelForSpeechSeq2Seq.from_pretrained(
229
  model_id, revision=revision, trust_remote_code=False,
 
230
  ).eval()
231
  self.pipe = pipeline(
232
  "automatic-speech-recognition",
 
259
  except Exception as exc: # noqa: BLE001 — modèle déjà spécialisé
260
  log(f"pas d'adaptateur fon ({type(exc).__name__}) — poids tels quels")
261
 
262
+ def transcribe(self, arrays: list) -> list[str]:
263
+ """Transcrit une tranche. À n'appeler que depuis un créneau GPU."""
264
+ torch = self.torch
265
+ dev = "cuda" if torch.cuda.is_available() else "cpu"
266
+ self.model.to(dev)
267
+ if self.pipe is not None:
268
+ self.pipe.device = torch.device(dev)
269
+
270
  if self.seq2seq:
271
+ return [(self.pipe(a)["text"] or "").strip() for a in arrays]
272
+
 
273
  out: list[str] = []
274
  for i in range(0, len(arrays), BATCH_CTC):
275
  batch = arrays[i:i + BATCH_CTC]
276
  inputs = self.processor(
277
  batch, sampling_rate=16000, return_tensors="pt", padding=True
278
  )
279
+ inputs = {k: v.to(dev) for k, v in inputs.items()}
280
+ with torch.inference_mode():
281
  logits = self.model(**inputs).logits
282
+ ids = torch.argmax(logits, dim=-1).cpu()
283
  out.extend(t.strip() for t in self.processor.batch_decode(ids))
284
  return out
285
 
286
+ def hardware(self) -> str:
287
+ try:
288
+ if self.torch.cuda.is_available():
289
+ return f"HF Space ZeroGPU ({self.torch.cuda.get_device_name(0)})"
290
+ except Exception: # noqa: BLE001
291
+ pass
292
+ return "HF Space CPU"
293
+
294
+
295
+ # Le modèle reste une variable de module : ZeroGPU exécute la fonction
296
+ # décorée dans un processus dérivé, qui hérite de la mémoire sans rien
297
+ # sérialiser. Le passer en argument recopierait tout le modèle à chaque appel.
298
+ _CURRENT: Transcriber | None = None
299
+
300
+
301
+ @GPU(duration=GPU_DURATION)
302
+ def gpu_transcribe(arrays: list) -> list[str]:
303
+ return _CURRENT.transcribe(arrays)
304
+
305
+
306
+ def _is_quota_error(exc: Exception) -> bool:
307
+ """Le quota ZeroGPU est-il épuisé (par opposition à une vraie panne) ?"""
308
+ texte = f"{type(exc).__name__} {exc}".lower()
309
+ return any(m in texte for m in ("quota", "gpu task aborted",
310
+ "no gpu is currently available",
311
+ "exceeded your"))
312
+
313
 
314
  # --- une évaluation -----------------------------------------------------
315
 
 
361
  _state.update(message="doublon ignoré", model=None)
362
  return
363
 
364
+ global _CURRENT
365
+ tr = _CURRENT = Transcriber(model_id, revision)
366
  chunk = tr.chunk_size
367
 
368
  saved = job.get("progress") or {}
 
374
  if next_chunk:
375
  log(f"↻ reprise à la tranche {next_chunk} ({next_chunk * chunk} énoncés)")
376
 
377
+ # Chaque tranche tient dans un créneau GPU et vaut battement de cœur :
378
+ # elle reste bien en deçà des 20 min au-delà desquelles la base
379
+ # considère la tâche abandonnée et la redonne.
 
380
  n_chunks = (total + chunk - 1) // chunk
381
  skipped = 0
382
 
383
+ ci = next_chunk
384
+ while ci < n_chunks:
385
  rows = ds.select(range(ci * chunk, min((ci + 1) * chunk, total)))
386
  arrays, refs = [], []
387
  for row in rows:
 
393
 
394
  if arrays:
395
  t0 = time.time()
396
+ try:
397
+ hyps = gpu_transcribe(arrays)
398
+ except Exception as exc: # noqa: BLE001
399
+ if not _is_quota_error(exc):
400
+ raise
401
+ # Quota ZeroGPU épuisé : on patiente et on refait la même
402
+ # tranche. Le point de reprise est déjà en base, rien n'est
403
+ # perdu — c'est précisément ce que le découpage permet.
404
+ log(f"⏸ quota GPU épuisé ({exc}) — reprise dans "
405
+ f"{QUOTA_WAIT_SECONDS // 60} min à la tranche {ci}")
406
+ _state.update(message="quota GPU épuisé — en attente")
407
+ time.sleep(QUOTA_WAIT_SECONDS)
408
+ continue
409
  compute_s += time.time() - t0
410
  audio_s += sum(len(a) for a in arrays) / 16000.0
411
  fe.accumulate(counters, refs, hyps)
 
419
  })
420
  rtfx = audio_s / compute_s if compute_s else 0
421
  log(f" {done}/{total} — RTFx {rtfx:.2f}")
422
+ ci += 1
423
 
424
  if skipped:
425
  log(f"⚠ {skipped} énoncés illisibles écartés")
 
440
  "rtfx": rtfx,
441
  "rtf": round(1 / rtfx, 4) if rtfx else None,
442
  "eval_seconds": round(compute_s, 1),
443
+ "hardware": tr.hardware(),
444
  "worker_version": WORKER_VERSION,
445
  },
446
  "p_error": None,
requirements.txt CHANGED
@@ -1,8 +1,9 @@
1
- # Roues CPU : la roue torch par défaut embarque CUDA (~2,5 Go) pour rien
2
- # sur un Space CPU basic, et fait dépasser la limite de build.
3
- --extra-index-url https://download.pytorch.org/whl/cpu
 
 
4
 
5
- torch==2.5.1
6
  transformers>=4.46,<5
7
  # <4.0 : les versions 4.x imposent torchcodec pour décoder l'audio, alors
8
  # qu'on décode nous-mêmes avec PyAV (Audio(decode=False)).
 
1
+ # ZeroGPU : ne PAS forcer les roues CPU de PyTorch (pas d'index /whl/cpu),
2
+ # sinon le GPU alloué reste inutilisable. Et la version doit figurer dans la
3
+ # liste acceptée par ZeroGPU : 2.8.0, 2.9.1, 2.10.0, 2.11.0.
4
+ torch==2.8.0
5
+ spaces
6
 
 
7
  transformers>=4.46,<5
8
  # <4.0 : les versions 4.x imposent torchcodec pour décoder l'audio, alors
9
  # qu'on décode nous-mêmes avec PyAV (Audio(decode=False)).