perf(scoring): parallelize per-requirement evaluation with asyncio.gather#63
perf(scoring): parallelize per-requirement evaluation with asyncio.gather#63renegatux wants to merge 1 commit into
Conversation
|
I think that's a great idea. Do you have any data on how much faster AutoRecLab becomes as a result? So far, I haven't noticed any significant delays when generating the requirements. I could imagine that, under certain circumstances, the regular fork-and-join process might actually take more time? Have you tested to what extent the OpenAI API supports this parallelization? We also use LangChain, among other things, which essentially uses the history of the last calls; we might need to check here as well to see to what extent errors could occur. |
|
Thanks for the feedback! No, we didn't run a full end-to-end benchmark, but we did a quick mock test and simulated 6 requirements with 3s LLM latency each: sequential took 18.0s, parallel 3.0s (6x speedup). The asyncio.gather overhead is microseconds compared to seconds of network I/O, so fork-and-join cost is basically negligible here. On the LangChain history concern: we checked query.py and graph.py, each Query.run() creates a fresh ChatOpenAI model and a fresh Agent instance (both are local variables in run(), not stored on self). The Agent class only holds state in self._* fields, no globals or class-level caches. So there's no shared conversation history between concurrent scoring calls – each one starts clean from LangChain's side. The only actually shared thing is the MCP tools cache and the cost tracker, but those are read-only / atomic during the scoring phase. |
Summary
Refactors
MinimalAgent.score_codeso that per-requirement evaluations run concurrently viaasyncio.gatherinstead of a sequentialforloop. Each requirement is evaluated by an independent LLM call, so parallelizing them is safe and substantially reduces wall-clock time.Motivation
Currently,
score_codeiterates overnode.requirementsand awaits one LLM call per requirement before moving to the next:This loop runs for every executed node — both drafts and tree-search iterations. With the default configuration (
num_draft_nodes=3,max_iterations=10) and N requirements per node, scoring contributes roughly13 * Nstrictly sequential LLM calls in the critical path of a single run.Change
async def _score_requirement(req).await asyncio.gather(...).import asyncioat the top of the module.No public API changes. No behavioral differences expected for individual requirements; only the order in which results return is non-deterministic, which the surrounding code does not rely on (
all_fulfilled, the feedback assembly loop, and the coverage confirmation step are all order-independent).Impact
Wall-clock time of the scoring phase scales with the slowest individual call instead of the sum of all calls. For a node with N requirements, this is up to an Nx speedup for that phase. Total API cost is unchanged (same number of calls).
Notes for reviewers
node,self.task_desc, andself._mcp_docsfrom the enclosing scope — the same references the original loop used.score_code(theReviewFunctioncall) already runs before this block, so the shared_MCP_CACHEinquery.pyis guaranteed to be warm before the parallel section. No cache-race window is introduced.Optional follow-up: bounded concurrency
The cached
MultiServerMCPClientis shared across all concurrentQuery.run()calls via a single stdio transport todocs_search_server. Iflangchain_mcp_adaptersdoes not safely multiplex concurrent tool calls over one stdio MCP client, or if a user's OpenAI tier has tight RPM limits, the unboundedgathercould become a problem.In that case, the fix is to bound the concurrency with a small semaphore:
This caps in-flight scoring calls at 3, which preserves most of the wall-clock benefit (~3x speedup) while removing any risk of overloading the shared MCP stdio channel. Happy to incorporate this into the PR directly if preferred.