Skip to content

Commit fde42cb

Browse files
feat(eval): add --concurrency flag for parallel item evaluation
--concurrency K sends up to K dataset items to the agent simultaneously, load-testing the agent under concurrent requests. Default 1 = sequential (existing behaviour unchanged). When K > 1, run_items() dispatches items to ThreadPoolExecutor(max_workers=K). Each item still runs --runs times sequentially (pass@K); parallelism is across items, not within an item's K runs. Results are collected in input order regardless of completion order. All progress callbacks and Langfuse logging are invoked from worker threads (Console and per-call httpx clients are thread-safe). JIRA: GDAI-1832
1 parent d0a608b commit fde42cb

5 files changed

Lines changed: 138 additions & 9 deletions

File tree

‎packages/gooddata-eval/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ Both provider name and provider id are accepted as the prefix.
9191
| Flag | Default | Description |
9292
|---|---|---|
9393
| `--runs K` | `2` | Independent runs per item (pass@K). An item passes if any run passes. |
94+
| `--concurrency K` | `1` | Number of items evaluated concurrently. `1` = sequential (default). Increase to load-test the agent under simultaneous requests. Progress output interleaves when K > 1. |
9495

9596
#### Output
9697

‎packages/gooddata-eval/src/gooddata_eval/cli/main.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ def _build_parser() -> argparse.ArgumentParser:
5252
),
5353
)
5454
run.add_argument("--runs", type=int, default=2, help="Independent runs per item (pass@K). Default 2.")
55+
run.add_argument(
56+
"--concurrency",
57+
type=int,
58+
default=1,
59+
help="Number of items evaluated concurrently (default 1 = sequential). "
60+
"Increase to load-test the agent under simultaneous requests.",
61+
)
5562
run.add_argument("--json", dest="json_path", help="Write a JSON report to this path.")
5663
run.add_argument("--quiet", action="store_true", help="Suppress per-item progress output.")
5764
run.add_argument(
@@ -270,6 +277,7 @@ def on_langfuse_item_done(
270277
on_run_done=on_run_done,
271278
on_item_done=on_item_done,
272279
on_langfuse_item_done=on_langfuse_item_done,
280+
concurrency=config.concurrency,
273281
)
274282
finally:
275283
if hasattr(backend, "close"):
@@ -309,6 +317,9 @@ def on_langfuse_item_done(
309317

310318
def main(argv: list[str] | None = None) -> int:
311319
args = parse_args(argv if argv is not None else sys.argv[1:])
320+
if hasattr(args, "concurrency") and args.concurrency < 1:
321+
print("error: --concurrency must be >= 1.", file=sys.stderr)
322+
return _EXIT_OPERATIONAL_ERROR
312323
try:
313324
host, token = resolve_connection(host=args.host, token=args.token, profile=args.profile)
314325
if args.command == "models":
@@ -321,6 +332,7 @@ def main(argv: list[str] | None = None) -> int:
321332
langfuse_dataset=args.langfuse_dataset,
322333
models=args.models or [],
323334
runs=args.runs,
335+
concurrency=args.concurrency,
324336
json_path=Path(args.json_path) if args.json_path else None,
325337
log_to_langfuse=args.langfuse,
326338
quiet=args.quiet,

‎packages/gooddata-eval/src/gooddata_eval/core/config.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class RunConfig:
1414
langfuse_dataset: str | None = None
1515
models: list[str] = field(default_factory=list)
1616
runs: int = 2
17+
concurrency: int = 1
1718
json_path: Path | None = None
1819
log_to_langfuse: bool = False
1920
quiet: bool = False

‎packages/gooddata-eval/src/gooddata_eval/core/runner.py‎

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"""Dataset run orchestration: per item, K single-turn runs, route by test_kind, aggregate pass@K."""
33

44
import time
5+
from concurrent.futures import ThreadPoolExecutor, as_completed
56
from dataclasses import dataclass, field
67
from functools import partial
78
from typing import Callable, Protocol
@@ -153,6 +154,7 @@ def run_items(
153154
on_run_done: Callable[[int, int, int, int, bool, float], None] | None = None,
154155
on_item_done: Callable[[int, int, ItemReport], None] | None = None,
155156
on_langfuse_item_done: Callable[[int, int, ItemReport], None] | None = None,
157+
concurrency: int = 1,
156158
) -> EvalReport:
157159
"""Run every item K times, routing by test_kind, and aggregate pass@K.
158160
@@ -162,19 +164,44 @@ def run_items(
162164
- on_run_done(index, total, run_index, runs, passed, latency) after each individual run
163165
- on_item_done(index, total, report) after an item is fully evaluated
164166
- on_langfuse_item_done(index, total, report) after non-skipped, non-errored items only
167+
168+
concurrency > 1 dispatches items to a ThreadPoolExecutor so multiple
169+
questions are sent to the agent simultaneously. Each item still runs
170+
--runs times sequentially (pass@K). Results are collected in input order.
165171
"""
166172
report = EvalReport(
167173
model=model, provider_name=provider_name, provider_type=provider_type, workspace_id=workspace_id
168174
)
169175
total = len(items)
170-
for index, item in enumerate(items, start=1):
171-
if on_item_start is not None:
172-
on_item_start(index, total, item)
176+
177+
def _process_item(index: int, item: DatasetItem) -> ItemReport:
178+
try:
179+
if on_item_start is not None:
180+
on_item_start(index, total, item)
181+
except Exception: # non-fatal — callback must not abort a parallel run
182+
pass
173183
run_cb = partial(_forward_run_event, on_run_done, index, total) if on_run_done is not None else None
174184
item_report = _run_one_item(item, backend, runs, on_run_done=run_cb)
175-
report.items.append(item_report)
176-
if on_item_done is not None:
177-
on_item_done(index, total, item_report)
178-
if on_langfuse_item_done is not None and not item_report.skipped and item_report.error is None:
179-
on_langfuse_item_done(index, total, item_report)
185+
try:
186+
if on_item_done is not None:
187+
on_item_done(index, total, item_report)
188+
if on_langfuse_item_done is not None and not item_report.skipped and item_report.error is None:
189+
on_langfuse_item_done(index, total, item_report)
190+
except Exception: # non-fatal
191+
pass
192+
return item_report
193+
194+
if concurrency <= 1:
195+
for index, item in enumerate(items, start=1):
196+
report.items.append(_process_item(index, item))
197+
else:
198+
# Dispatch concurrently; collect in original order.
199+
with ThreadPoolExecutor(max_workers=concurrency) as pool:
200+
futures = {pool.submit(_process_item, index, item): index for index, item in enumerate(items, start=1)}
201+
results: dict[int, ItemReport] = {}
202+
for future in as_completed(futures):
203+
idx = futures[future]
204+
results[idx] = future.result()
205+
for index in range(1, total + 1):
206+
report.items.append(results[index])
180207
return report

‎packages/gooddata-eval/tests/test_runner.py‎

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
# (C) 2026 GoodData Corporation
2+
import threading
3+
24
from gooddata_eval.core.evaluators import supported_test_kinds
35
from gooddata_eval.core.models import ChatResult, DatasetItem
4-
from gooddata_eval.core.runner import run_items
6+
from gooddata_eval.core.runner import ItemReport, run_items
57

68

79
def _viz_obj():
@@ -135,3 +137,89 @@ def test_run_items_does_not_invoke_langfuse_callback_for_skipped_items():
135137
on_langfuse_item_done=lambda idx, total, r: langfuse_calls.append(r.id),
136138
)
137139
assert langfuse_calls == []
140+
141+
142+
def test_run_items_concurrency_produces_all_results_in_order():
143+
"""With concurrency > 1 all items still appear in input order."""
144+
items = [
145+
DatasetItem(
146+
id=f"item-{i}",
147+
dataset_name="d",
148+
test_kind="visualization",
149+
question=f"q{i}",
150+
expected_output={"visualization": _viz_obj()},
151+
)
152+
for i in range(6)
153+
]
154+
backend = _FakeBackend([_chat_with(_viz_obj())] * 6)
155+
report = run_items(items, backend, runs=1, concurrency=3)
156+
assert report.total == 6
157+
assert [r.id for r in report.items] == [f"item-{i}" for i in range(6)]
158+
assert all(r.pass_at_k for r in report.items)
159+
160+
161+
def test_run_items_concurrency_1_and_sequential_produce_same_results():
162+
"""concurrency=1 and the default sequential path give identical reports."""
163+
items = [
164+
DatasetItem(
165+
id=f"i{i}",
166+
dataset_name="d",
167+
test_kind="visualization",
168+
question="q",
169+
expected_output={"visualization": _viz_obj()},
170+
)
171+
for i in range(4)
172+
]
173+
backend_a = _FakeBackend([_chat_with(_viz_obj())] * 4)
174+
backend_b = _FakeBackend([_chat_with(_viz_obj())] * 4)
175+
report_seq = run_items(items, backend_a, runs=1)
176+
report_par = run_items(items, backend_b, runs=1, concurrency=1)
177+
assert [r.id for r in report_seq.items] == [r.id for r in report_par.items]
178+
assert [r.pass_at_k for r in report_seq.items] == [r.pass_at_k for r in report_par.items]
179+
180+
181+
def test_run_items_concurrency_errored_item_does_not_crash_pool():
182+
"""An errored item is recorded but does not abort a concurrent run."""
183+
184+
class _BoomBackend:
185+
def ask(self, question: str) -> ChatResult:
186+
raise RuntimeError("agent down")
187+
188+
items = [
189+
DatasetItem(
190+
id=f"e{i}",
191+
dataset_name="d",
192+
test_kind="visualization",
193+
question="q",
194+
expected_output={"visualization": _viz_obj()},
195+
)
196+
for i in range(4)
197+
]
198+
report = run_items(items, _BoomBackend(), runs=1, concurrency=3)
199+
assert report.total == 4
200+
assert report.errored == 4
201+
assert [r.id for r in report.items] == [f"e{i}" for i in range(4)]
202+
203+
204+
def test_run_items_concurrency_callbacks_fire_for_all_items():
205+
"""on_item_done is called exactly once per item under concurrency > 1."""
206+
backend = _FakeBackend([_chat_with(_viz_obj())] * 5)
207+
lock = threading.Lock()
208+
done_ids: list = []
209+
210+
def on_done(index: int, total: int, report: ItemReport) -> None:
211+
with lock:
212+
done_ids.append(report.id)
213+
214+
items = [
215+
DatasetItem(
216+
id=f"c{i}",
217+
dataset_name="d",
218+
test_kind="visualization",
219+
question="q",
220+
expected_output={"visualization": _viz_obj()},
221+
)
222+
for i in range(5)
223+
]
224+
run_items(items, backend, runs=1, concurrency=3, on_item_done=on_done)
225+
assert sorted(done_ids) == [f"c{i}" for i in range(5)]

0 commit comments

Comments
 (0)