You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Scenario evaluations run strictly sequentially. A 33-question catalog takes ~2.5–3.5 minutes of wall clock that is almost entirely spent waiting on one provider call at a time, and a 100-question catalog is ~10 minutes. The work is IO-bound and embarrassingly parallel, so this is throughput left on the floor.
Worth testing pooling configurations against real suites rather than picking a number — the same exercise we ran for the RubyKaigi ragents demo.
EvaluationRunJob enqueues one job for the whole suite, not one per scenario, so the queue's own concurrency never comes into play. Measured on a live host: 4.6s/scenario on a light suite, 6.5s/scenario on a tool-heavy one (13 scenarios in 84.9s).
What to test
The interesting question is not "add threads" but which shape wins, and where the ceiling is:
In-runner thread pool — a bounded pool inside Runner#call, width configurable. Simplest, keeps one job per run, keeps the Report assembly where it is.
Job-per-scenario fan-out — EvaluationRunJob enqueues one job per (scenario × model) and a completion check assembles the Report. Uses the host's existing queue concurrency, survives a worker restart, but needs a barrier and makes partial-run reporting harder.
Job-per-model, sequential within — a middle ground for multi-model comparisons, where each cohort is independent.
Vary pool width (1, 2, 4, 8, 16) against a real catalog and record wall clock, provider error rate, and cost. The useful output is a recommended default plus the shape of the curve where it stops helping.
Constraints any design has to respect
on_result is not thread-safe.ScenarioEvaluationRunner closes over a bare Array:
Concurrent results race both the duplicate check and the append.
Host adapters may hold thread-local state. The scenario_evaluation_adapter_resolver path (fix(evals): preserve grades, import suites, and publish reports #414) lets a host supply its own runner; one such adapter wraps replays in PublishingDomain.with_publishing_domain, which stores the domain thread-locally. A worker thread would not inherit it, so any pooling has to either propagate that context explicitly or leave the parallelism decision to the adapter.
Provider rate limits become the real ceiling. 33-wide against one provider key will rate-limit before it saturates anything else. A pool width that is fast on a mock provider may be slower end-to-end on a live one once retries start.
ActiveRecord connection pool. Each worker persisting a result needs a connection; pool width above ActiveRecord::Base.connection_pool.size trades provider waiting for connection waiting.
Why it matters beyond speed
A catalog that takes minutes is a catalog people run rarely. The evaluation loop is most useful when it is cheap enough to run on every agent change, which is a throughput problem before it is a features problem.
Scenario evaluations run strictly sequentially. A 33-question catalog takes ~2.5–3.5 minutes of wall clock that is almost entirely spent waiting on one provider call at a time, and a 100-question catalog is ~10 minutes. The work is IO-bound and embarrassingly parallel, so this is throughput left on the floor.
Worth testing pooling configurations against real suites rather than picking a number — the same exercise we ran for the RubyKaigi ragents demo.
Where the sequencing is
ActiveAgent::Evals::Runner#call:EvaluationRunJobenqueues one job for the whole suite, not one per scenario, so the queue's own concurrency never comes into play. Measured on a live host: 4.6s/scenario on a light suite, 6.5s/scenario on a tool-heavy one (13 scenarios in 84.9s).What to test
The interesting question is not "add threads" but which shape wins, and where the ceiling is:
Runner#call, width configurable. Simplest, keeps one job per run, keeps the Report assembly where it is.EvaluationRunJobenqueues one job per (scenario × model) and a completion check assembles the Report. Uses the host's existing queue concurrency, survives a worker restart, but needs a barrier and makes partial-run reporting harder.Vary pool width (1, 2, 4, 8, 16) against a real catalog and record wall clock, provider error rate, and cost. The useful output is a recommended default plus the shape of the curve where it stops helping.
Constraints any design has to respect
on_resultis not thread-safe.ScenarioEvaluationRunnercloses over a bare Array:Concurrent results race both the duplicate check and the append.
Host adapters may hold thread-local state. The
scenario_evaluation_adapter_resolverpath (fix(evals): preserve grades, import suites, and publish reports #414) lets a host supply its own runner; one such adapter wraps replays inPublishingDomain.with_publishing_domain, which stores the domain thread-locally. A worker thread would not inherit it, so any pooling has to either propagate that context explicitly or leave the parallelism decision to the adapter.Provider rate limits become the real ceiling. 33-wide against one provider key will rate-limit before it saturates anything else. A pool width that is fast on a mock provider may be slower end-to-end on a live one once retries start.
ActiveRecord connection pool. Each worker persisting a result needs a connection; pool width above
ActiveRecord::Base.connection_pool.sizetrades provider waiting for connection waiting.Why it matters beyond speed
A catalog that takes minutes is a catalog people run rarely. The evaluation loop is most useful when it is cheap enough to run on every agent change, which is a throughput problem before it is a features problem.
Related