Scoring-phase progress; pool: fix double-release + cross-loop reuse

Scoring progress: docker-exec benches (humaneval etc.) score for
minutes with zero feedback -- the bar sat at 'generating 100%' and
looked hung. evaluate() now takes on_scored(i, n) (atomic counter,
fires from worker threads), run_eval passes it through, and the CLI
shows 'scoring 42/164' on the bar + milestone log lines every 10%
(also fixes the phase match: 'scoring' never matched the capitalized
'Scoring predictions...' status message, so the bar never even
switched its label).

PooledAdapter:
- one release per acquire: the exception path released True (inner
  finally) AND False (except handler), double-decrementing _inflight
  (over-admission) and applying the x0.7 backoff twice
- AdaptiveGate: rebuild the Condition + probe task when the event loop
  changes -- pools are cached across benchmarks and the CLI runs
  asyncio.run() per bench/repeat; a loop-bound Condition from a closed
  loop raises 'bound to a different event loop' under contention

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
sora 2026-09-14 03:14:08 +00:00
parent f5c2e4c5af
commit a4d4592864
4 changed files with 61 additions and 10 deletions

View File

@ -704,14 +704,27 @@ def _cmd_eval_run(args) -> int:
_tag = f'[{_idx}/{total_runs}] ' if total_runs > 1 else '' _tag = f'[{_idx}/{total_runs}] ' if total_runs > 1 else ''
if _reporter is not None: if _reporter is not None:
_reporter.log(f'{_tag}{_name}: {_narration(msg)}') _reporter.log(f'{_tag}{_name}: {_narration(msg)}')
if 'scoring' in msg: _m = msg.lower()
if 'scoring' in _m:
_reporter.set_phase('scoring') _reporter.set_phase('scoring')
elif 'generating model responses' in msg: elif 'generating model responses' in _m:
_reporter.set_phase('generating') _reporter.set_phase('generating')
elif 'writing' in msg: elif 'writing' in _m:
_reporter.set_phase('writing') _reporter.set_phase('writing')
else: else:
_print_phase(_console, _idx, total_runs, _name, msg) _print_phase(_console, _idx, total_runs, _name, msg)
def on_scored(done, total_s, _reporter=progress_reporter,
_cb=status_callback):
"""Per-sample scoring progress: docker-exec benches score
for minutes with zero feedback otherwise (bar sits at
'generating 100%' and looks hung)."""
if _reporter is not None:
_reporter.set_phase(f'scoring {done}/{total_s}')
# milestone lines: pipes/logs without a live bar see movement
if _cb and total_s and (done % max(1, total_s // 10) == 0
or done == total_s):
_cb(f'Scoring {done}/{total_s} samples')
_gen_kw = {**bench_cfg, **(getattr(args, '_gen_override', {}) or {})} _gen_kw = {**bench_cfg, **(getattr(args, '_gen_override', {}) or {})}
# max_input_tokens must be a SEPARATE run_eval param (it drives # max_input_tokens must be a SEPARATE run_eval param (it drives
# truncation in assemble(), not a gen_kwarg the adapter sees) -- # truncation in assemble(), not a gen_kwarg the adapter sees) --
@ -733,6 +746,7 @@ def _cmd_eval_run(args) -> int:
gen_profile=getattr(args, 'profile', ''), gen_profile=getattr(args, 'profile', ''),
progress_reporter=progress_reporter, progress_reporter=progress_reporter,
status_callback=status_callback, status_callback=status_callback,
on_scored=on_scored,
repeat=_rep + 1)) repeat=_rep + 1))
_m = next((v for k, v in report.metrics.items() _m = next((v for k, v in report.metrics.items()
if k != 'extraction_failure_rate'), None) if k != 'extraction_failure_rate'), None)

View File

@ -27,6 +27,7 @@ def evaluate(
model: str = '', model: str = '',
judge: Optional[Callable] = None, judge: Optional[Callable] = None,
extra_metadata: Optional[Dict] = None, extra_metadata: Optional[Dict] = None,
on_scored: Optional[Callable[[int, int], None]] = None,
) -> EvalReport: ) -> EvalReport:
"""Score a dataset against raw predictions. """Score a dataset against raw predictions.
@ -115,6 +116,22 @@ def evaluate(
return result return result
workers = getattr(recipe, 'exec_workers', 1) workers = getattr(recipe, 'exec_workers', 1)
n_total = len(samples)
import itertools
_scored = itertools.count(1) # next() is atomic: safe from worker threads
def _counted(sample, pred):
# docker/subprocess scoring is minute-scale per sample; surface
# per-sample progress or the run looks frozen at 'generating 100%'
r = judge_one(sample, pred)
if on_scored is not None:
try:
on_scored(next(_scored), n_total)
except Exception:
pass
return r
if workers > 1 and len(samples) > 1: if workers > 1 and len(samples) > 1:
# parallel judging: docker/subprocess execution is embarrassingly # parallel judging: docker/subprocess execution is embarrassingly
# parallel (one container per sample); text scorers are cheap and # parallel (one container per sample); text scorers are cheap and
@ -122,10 +139,10 @@ def evaluate(
import concurrent.futures import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool: with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
results = list(pool.map(judge_one, samples, predictions)) results = list(pool.map(_counted, samples, predictions))
else: else:
for sample, pred in zip(samples, predictions): for sample, pred in zip(samples, predictions):
results.append(judge_one(sample, pred)) results.append(_counted(sample, pred))
report = EvalReport( report = EvalReport(
dataset=ds_name, dataset=ds_name,

View File

@ -104,10 +104,16 @@ class PooledAdapter(ModelAdapter):
try: try:
i = self.adapters.index(adapter) i = self.adapters.index(adapter)
await self._gates[i].acquire() await self._gates[i].acquire()
ok = False
try: try:
out = await adapter.generate(messages, tools=tools, **kw) out = await adapter.generate(messages, tools=tools, **kw)
ok = True
finally: finally:
self._gates[i].release(True) # ONE release per acquire: the old code released True in
# this finally AND False in the except handler, double-
# decrementing _inflight (gate over-admits) and applying
# the x0.7 backoff twice per failure
self._gates[i].release(ok)
self.usage = self.usage + out.usage self.usage = self.usage + out.usage
self.stats['ok'] += 1 self.stats['ok'] += 1
self._mark(adapter, True) self._mark(adapter, True)
@ -117,8 +123,6 @@ class PooledAdapter(ModelAdapter):
except Exception as e: # dead/overloaded instance -> next except Exception as e: # dead/overloaded instance -> next
last_exc = e last_exc = e
self._mark(adapter, False) self._mark(adapter, False)
with contextlib.suppress(ValueError):
self._gates[self.adapters.index(adapter)].release(False)
# 4xx (e.g. 400 overloaded) still worth trying ANOTHER instance: # 4xx (e.g. 400 overloaded) still worth trying ANOTHER instance:
# one backend's state can differ from the rest # one backend's state can differ from the rest
continue continue
@ -158,14 +162,28 @@ class AdaptiveGate:
self._cond: Optional[asyncio.Condition] = None self._cond: Optional[asyncio.Condition] = None
self._task: Optional[asyncio.Task] = None self._task: Optional[asyncio.Task] = None
self._stopped = False self._stopped = False
self._loop = None # loop the cond/probe-task are bound to
self.stats = {'probe': 0, 'ramp': 0, 'hold_queue': 0, 'backoff_fail': 0, self.stats = {'probe': 0, 'ramp': 0, 'hold_queue': 0, 'backoff_fail': 0,
'backoff_queue': 0} 'backoff_queue': 0}
# ---- gate semantics ---- # ---- gate semantics ----
async def acquire(self) -> None: async def acquire(self) -> None:
if self._cond is None: # lazy init in the running loop loop = asyncio.get_running_loop()
if self._cond is None or self._loop is not loop or self._stopped:
# lazy init OR LOOP CHANGE: pools are cached across benchmarks,
# and the CLI runs asyncio.run() per bench (per repeat!) -- a new
# run means a new event loop while this gate survives. A Condition
# is loop-bound: reusing the old one raises "bound to a different
# event loop" under contention, and the old probe task died with
# the closed loop. Rebuild both; inflight resets to 0 (nothing is
# in flight on a fresh loop by construction).
self._loop = loop
self._cond = asyncio.Condition() self._cond = asyncio.Condition()
self._task = asyncio.get_event_loop().create_task(self._probe_loop()) self._inflight = 0
self._stopped = False
if self._task is not None:
self._task.cancel() # dead task from the closed loop; no-op
self._task = loop.create_task(self._probe_loop())
while self._inflight >= max(1, int(self.limit)): while self._inflight >= max(1, int(self.limit)):
await self._cond.acquire() await self._cond.acquire()
try: try:

View File

@ -629,6 +629,7 @@ async def run_eval(
prompt_style: str = 'strict_letter', prompt_style: str = 'strict_letter',
gen_profile: str = '', gen_profile: str = '',
repeat: int = 1, repeat: int = 1,
on_scored=None,
) -> EvalReport: ) -> EvalReport:
"""Generate + score in one call. Model spec examples: """Generate + score in one call. Model spec examples:
'mock', 'mock:boxed', 'openai/http://gpu03:8000/v1?qwen3-8b', 'deploy:vllm/qwen3-8b'. 'mock', 'mock:boxed', 'openai/http://gpu03:8000/v1?qwen3-8b', 'deploy:vllm/qwen3-8b'.
@ -780,6 +781,7 @@ async def run_eval(
extra_metadata={'gen_input_tokens': usage.input_tokens, extra_metadata={'gen_input_tokens': usage.input_tokens,
'gen_output_tokens': usage.output_tokens, 'gen_output_tokens': usage.output_tokens,
'gen_total_tokens': usage.total_tokens}, 'gen_total_tokens': usage.total_tokens},
on_scored=on_scored,
) )
report.model = model_spec report.model = model_spec
report.dataset = name report.dataset = name