Skip to content

Test job pooling configurations for scenario evaluations (sequential today) #422

Description

@TonsOfFun

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:

results = @scenarios.flat_map do |scenario|
  @models.map do |spec|
    evaluate_with_context(scenario, spec).tap { |result| @on_result&.call(result) }
  end
end

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:

  1. 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.
  2. Job-per-scenario fan-outEvaluationRunJob 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.
  3. 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:

    persisted = []
    on_result = lambda do |result|
      raise ArgumentError, "..." unless expected.include?(pair) && !persisted.include?(pair)
      persist(run, records.fetch(result.scenario.key), result)
      persisted << pair
    end

    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.

Related

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions