Skip to content

Commit 165ec46

Browse files
authored
feat(runner): add reusable timing collector for grading workflows (#160)
1 parent 22436a5 commit 165ec46

4 files changed

Lines changed: 464 additions & 88 deletions

File tree

openjudge/runner/grading_runner.py

Lines changed: 165 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import asyncio
1414
import copy
15+
from contextlib import nullcontext
1516
from dataclasses import dataclass
1617
from typing import Any, Callable, Dict, List, Tuple, Union
1718

@@ -29,6 +30,7 @@
2930
SemaphoreResourceExecutor,
3031
)
3132
from openjudge.utils.mapping import parse_data_with_mapper
33+
from openjudge.utils.timer import TimingCollector
3234

3335

3436
@dataclass
@@ -155,6 +157,8 @@ def __init__(
155157
aggregators: Union[BaseAggregator, Callable, List[Union[BaseAggregator, Callable]], None] = None,
156158
show_progress: bool = True,
157159
executor: BaseResourceExecutor | None = None,
160+
enable_timing: bool = False,
161+
timing_collector: TimingCollector | None = None,
158162
) -> None:
159163
"""Initialize the grading runner.
160164
@@ -169,6 +173,10 @@ def __init__(
169173
show_progress: Whether to display a progress bar during execution. Defaults to True.
170174
executor: Optional execution resource to manage task execution.
171175
Defaults to LocalController if not provided.
176+
enable_timing: Whether to collect latency metrics for the grading workflow.
177+
Defaults to False.
178+
timing_collector: Optional collector for storing timing records. When not
179+
provided and ``enable_timing=True``, a collector is created automatically.
172180
173181
Example:
174182
>>> # Initialize with multiple graders
@@ -182,6 +190,10 @@ def __init__(
182190
self.max_concurrency = max_concurrency
183191
self.show_progress = show_progress
184192
self.executor = executor or SemaphoreResourceExecutor(max_concurrency)
193+
self.enable_timing = enable_timing or timing_collector is not None
194+
self.timing_collector = timing_collector or (
195+
TimingCollector() if self.enable_timing else None
196+
)
185197

186198
# Handle aggregators
187199
if not aggregators:
@@ -198,6 +210,8 @@ async def _arun(
198210
grader: BaseGrader,
199211
mapper: Dict[str, str] | Callable | None,
200212
executor: BaseResourceExecutor,
213+
timing_collector: TimingCollector | None = None,
214+
timing_metadata: dict[str, Any] | None = None,
201215
) -> GraderResult:
202216
"""Run a single evaluation asynchronously.
203217
@@ -236,21 +250,48 @@ async def _arun(
236250
... }
237251
>>> result = await GradingRunner._arun(data, ContextGrader(), custom_mapper)
238252
"""
239-
try:
240-
data = parse_data_with_mapper(data, mapper)
241-
# Create an isolated grader instance for this evaluation to prevent state sharing
242-
isolated_grader = copy.deepcopy(grader)
243-
244-
# The grader itself handles the mapping internally
245-
return await isolated_grader.aevaluate(executor=executor, **data)
246-
except Exception as e:
247-
error_msg = f"Error in {grader.name} during evaluation: {str(e)}"
248-
logger.error(error_msg)
249-
return GraderError(
250-
name=grader.name,
251-
reason=f"Error in {grader.name} during evaluation",
252-
error=error_msg,
253+
timing_context = (
254+
timing_collector.measure(
255+
"grading_runner.single_evaluation",
256+
metadata={"grader_name": grader.name, **(timing_metadata or {})},
253257
)
258+
if timing_collector
259+
else nullcontext()
260+
)
261+
262+
with timing_context:
263+
try:
264+
data = parse_data_with_mapper(data, mapper)
265+
# Create an isolated grader instance for this evaluation to prevent state sharing
266+
isolated_grader = copy.deepcopy(grader)
267+
268+
# The grader itself handles the mapping internally
269+
return await isolated_grader.aevaluate(executor=executor, **data)
270+
except Exception as e:
271+
error_msg = f"Error in {grader.name} during evaluation: {str(e)}"
272+
logger.error(error_msg)
273+
return GraderError(
274+
name=grader.name,
275+
reason=f"Error in {grader.name} during evaluation",
276+
error=error_msg,
277+
)
278+
279+
def get_timing_records(self, name: str | None = None) -> list:
280+
"""Return collected timing records for the grading workflow."""
281+
if self.timing_collector is None:
282+
return []
283+
return self.timing_collector.get_records(name=name)
284+
285+
def get_timing_summary(self) -> dict[str, dict[str, float | int]]:
286+
"""Return aggregate timing metrics collected by the runner."""
287+
if self.timing_collector is None:
288+
return {}
289+
return self.timing_collector.get_summary()
290+
291+
def clear_timing_records(self) -> None:
292+
"""Clear previously collected timing records."""
293+
if self.timing_collector is not None:
294+
self.timing_collector.clear()
254295

255296
async def arun(
256297
self,
@@ -324,59 +365,85 @@ async def arun(
324365
... else:
325366
... print(f" Sample {i}: Error - {result.error}")
326367
"""
327-
# Create a dictionary to store result lists for each grader
328-
grader_results: RunnerResult = {name: [] for name in self.grader_configs.keys()}
329-
330-
# Create coroutines for all evaluators and all samples
331-
all_coroutines = []
332-
coroutine_info = [] # Track (grader_name, sample_index) for each coroutine
333-
334-
# Use the executor from self
335-
executor = self.executor
336-
337-
# Execute executor lifecycle
338-
for name, config in self.grader_configs.items():
339-
grader = config.grader
340-
mapper = config.mapper
341-
assert grader is not None
342-
343-
# Create coroutines for the current evaluator on all samples
344-
for i, case in enumerate(dataset):
345-
all_coroutines.append(
346-
self._arun(data=case, grader=grader, mapper=mapper, executor=executor),
347-
)
348-
coroutine_info.append(
349-
(name, i),
350-
) # Record grader name and sample index
351-
352-
# Execute all evaluator-sample coroutines concurrently
353-
if self.show_progress:
354-
all_results = await tqdm_asyncio.gather(
355-
*all_coroutines,
356-
desc="Evaluating a dataset",
357-
total=len(all_coroutines),
368+
timing_context = (
369+
self.timing_collector.measure(
370+
"grading_runner.dataset",
371+
metadata={
372+
"dataset_size": len(dataset),
373+
"grader_count": len(self.grader_configs),
374+
},
358375
)
359-
else:
360-
all_results = await asyncio.gather(*all_coroutines)
361-
362-
# Initialize lists for all graders
363-
for name in self.grader_configs.keys():
364-
grader_results[name] = [None] * len(dataset)
365-
366-
# Organize results by grader
367-
for (grader_name, sample_index), result in zip(coroutine_info, all_results):
368-
grader_results[grader_name][sample_index] = result
369-
370-
# Aggregate results
371-
if self.aggregators:
372-
for aggregator in self.aggregators:
373-
aggregator_name = aggregator.__name__
374-
grader_results[aggregator_name] = [None] * len(dataset)
375-
for i in range(len(dataset)):
376-
grader_results[aggregator_name][i] = aggregator(
377-
{grader_name: grader_results[grader_name][i] for grader_name in self.grader_configs.keys()},
376+
if self.timing_collector
377+
else nullcontext()
378+
)
379+
380+
with timing_context:
381+
# Create a dictionary to store result lists for each grader
382+
grader_results: RunnerResult = {name: [] for name in self.grader_configs.keys()}
383+
384+
# Create coroutines for all evaluators and all samples
385+
all_coroutines = []
386+
coroutine_info = [] # Track (grader_name, sample_index) for each coroutine
387+
388+
# Use the executor from self
389+
executor = self.executor
390+
391+
# Execute executor lifecycle
392+
for name, config in self.grader_configs.items():
393+
grader = config.grader
394+
mapper = config.mapper
395+
assert grader is not None
396+
397+
# Create coroutines for the current evaluator on all samples
398+
for i, case in enumerate(dataset):
399+
all_coroutines.append(
400+
self._arun(
401+
data=case,
402+
grader=grader,
403+
mapper=mapper,
404+
executor=executor,
405+
timing_collector=self.timing_collector,
406+
timing_metadata={
407+
"grader_config_name": name,
408+
"sample_index": i,
409+
},
410+
),
378411
)
379-
return grader_results
412+
coroutine_info.append(
413+
(name, i),
414+
) # Record grader name and sample index
415+
416+
# Execute all evaluator-sample coroutines concurrently
417+
if self.show_progress:
418+
all_results = await tqdm_asyncio.gather(
419+
*all_coroutines,
420+
desc="Evaluating a dataset",
421+
total=len(all_coroutines),
422+
)
423+
else:
424+
all_results = await asyncio.gather(*all_coroutines)
425+
426+
# Initialize lists for all graders
427+
for name in self.grader_configs.keys():
428+
grader_results[name] = [None] * len(dataset)
429+
430+
# Organize results by grader
431+
for (grader_name, sample_index), result in zip(coroutine_info, all_results):
432+
grader_results[grader_name][sample_index] = result
433+
434+
# Aggregate results
435+
if self.aggregators:
436+
for aggregator in self.aggregators:
437+
aggregator_name = aggregator.__name__
438+
grader_results[aggregator_name] = [None] * len(dataset)
439+
for i in range(len(dataset)):
440+
grader_results[aggregator_name][i] = aggregator(
441+
{
442+
grader_name: grader_results[grader_name][i]
443+
for grader_name in self.grader_configs.keys()
444+
},
445+
)
446+
return grader_results
380447

381448
async def arun_multiple_datasets(
382449
self,
@@ -468,26 +535,36 @@ async def arun_multiple_datasets(
468535
- When batch processing, individual arun() progress bars are disabled to avoid
469536
display conflicts with the batch-level progress bar.
470537
"""
471-
# Temporarily disable show_progress for individual arun calls to avoid progress bar conflicts
472-
original_show_progress = self.show_progress
473-
self.show_progress = False
474-
475-
try:
476-
# Create tasks for each dataset
477-
tasks = [self.arun(dataset, *args, **kwargs) for dataset in datasets]
478-
479-
# Execute all dataset tasks concurrently with progress bar
480-
if original_show_progress:
481-
all_results = await tqdm_asyncio.gather(
482-
*tasks,
483-
desc=f"Evaluating {len(tasks)} datasets",
484-
total=len(tasks),
485-
)
486-
else:
487-
all_results = await asyncio.gather(*tasks)
488-
489-
# Return results as a list
490-
return list(all_results)
491-
finally:
492-
# Restore original show_progress setting
493-
self.show_progress = original_show_progress
538+
timing_context = (
539+
self.timing_collector.measure(
540+
"grading_runner.multi_dataset",
541+
metadata={"dataset_count": len(datasets)},
542+
)
543+
if self.timing_collector
544+
else nullcontext()
545+
)
546+
547+
with timing_context:
548+
# Temporarily disable show_progress for individual arun calls to avoid progress bar conflicts
549+
original_show_progress = self.show_progress
550+
self.show_progress = False
551+
552+
try:
553+
# Create tasks for each dataset
554+
tasks = [self.arun(dataset, *args, **kwargs) for dataset in datasets]
555+
556+
# Execute all dataset tasks concurrently with progress bar
557+
if original_show_progress:
558+
all_results = await tqdm_asyncio.gather(
559+
*tasks,
560+
desc=f"Evaluating {len(tasks)} datasets",
561+
total=len(tasks),
562+
)
563+
else:
564+
all_results = await asyncio.gather(*tasks)
565+
566+
# Return results as a list
567+
return list(all_results)
568+
finally:
569+
# Restore original show_progress setting
570+
self.show_progress = original_show_progress

0 commit comments

Comments
 (0)