Skip to content

feat(accuracy): restore codegen grade concurrency (AIP-1094) - #1237

Open
debermudez wants to merge 18 commits into
mainfrom
dbermudez/aip-1094-restore-codegen-grade-concurrency
Open

feat(accuracy): restore codegen grade concurrency (AIP-1094)#1237
debermudez wants to merge 18 commits into
mainfrom
dbermudez/aip-1094-restore-codegen-grade-concurrency

Conversation

@debermudez

@debermudez debermudez commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Replaces the serializing asyncio.Lock in CodegenGradingWorker with an id → asyncio.Future demux table and a persistent reader task, allowing N concurrent grade_codegen() calls to run without blocking each other
  • Worker now non-blocking drains all queued stdin lines per cycle and calls codegen_metrics once with the full batch, so lighteval's ProcessPoolExecutor(max_workers=8) processes multiple problems in parallel — N concurrent grades complete in ~max(individual) instead of ~sum
  • Follow-up to fix(accuracy): grade LCB codegen in an out-of-process worker (#1145) #1175 (AIP-1089); closes AIP-1094

Changes

_codegen_worker.py

  • Added handle_batch(reqs, codegen_fn, compute_metrics_fn) — grades N requests in one codegen_metrics call, per-problem metrics demuxed from evaluate_generations' results: dict[int, list]
  • Replaced run_worker_loop with batch-drain version: blocking read of first request, then peek(0)-based drain of buffered requests, then one codegen_metrics call per cycle
  • Removed handle_request (dead code)

_codegen_worker_client.py

  • Dropped asyncio.Lock on the grade path; added _spawn_lock (spawn-only), _pending: dict[int, Future], _reader_task
  • Added _run_reader() — persistent task that reads stdout and resolves futures by id; stale ids (already timed out) are silently skipped
  • Added _mark_proven(), _dispatch_response() helpers
  • _handle_fault is idempotent (_proc is None guard); cancels all pending futures before killing
  • _kill() cancels and awaits _reader_task with self-task guard
  • aclose() uses set_exception (not cancel) so callers get CodegenWorkerError, not CancelledError
  • Removed _request()

Known tradeoff

A timeout on any in-flight grade kills the worker and faults all concurrent sibling futures with CodegenWorkerError. This is the accepted batch-coupling tradeoff documented in the issue.

Test plan

  • uv run pytest tests/unit/accuracy/ -v — new TestHandleBatch, TestRunWorkerLoopBatch, TestConcurrency classes all green
  • uv run pytest tests/component_integration/test_lcb_codegen_worker_e2e.py -v -s --run-slow — both single and concurrent e2e tests pass with pass@1 == 1.0

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Performance

    • Code-generation grading now processes multiple requests concurrently and in batches, improving throughput.
  • Reliability

    • Responses remain matched to the correct requests, even when completed out of order.
    • Improved handling for malformed requests, timeouts, cancellations, worker failures, and shutdowns.
    • Pending requests now receive clear errors when grading becomes unavailable.
  • Validation

    • Expanded coverage verifies concurrent grading, response matching, error recovery, batch processing, and metric handling.

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown

Try out this PR

Quick install:

pip install --upgrade --force-reinstall git+https://github.com/ai-dynamo/aiperf.git@98786ac5838e50877ce047d5890591bb5e709fff

Recommended with virtual environment (using uv):

uv venv --python 3.12 && source .venv/bin/activate
uv pip install --upgrade --force-reinstall git+https://github.com/ai-dynamo/aiperf.git@98786ac5838e50877ce047d5890591bb5e709fff

Last updated for commit: 98786acBrowse code

@github-actions github-actions Bot added the feat label Jul 31, 2026
@github-actions

Copy link
Copy Markdown

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The codegen worker now batches queued requests and computes ordered per-request metrics. The client supports concurrent grading through request IDs, response demultiplexing, timeout handling, worker recovery, and safe shutdown. Unit and integration tests cover these behaviors.

Changes

Codegen concurrency

Layer / File(s) Summary
Worker batch processing
src/aiperf/accuracy/graders/_codegen_worker.py, tests/unit/accuracy/test_codegen_worker.py
The worker drains queued input, processes valid requests in one codegen_fn call, computes metrics, preserves response order, and isolates malformed or request-level errors. Tests cover batching, ordering, metric filtering, and worker-loop behavior.
Client request demultiplexing
src/aiperf/accuracy/graders/_codegen_worker_client.py, tests/unit/accuracy/test_codegen_worker_client.py
The client tracks request IDs and pending futures, dispatches responses continuously, and handles concurrent calls, stale responses, timeouts, worker failures, cancellation, and shutdown.
Concurrent grading validation
tests/component_integration/test_lcb_codegen_worker_e2e.py, .gitignore
The integration test runs four concurrent grading requests and verifies successful pass@1 results. .gitignore adds a .worktrees/ rule.

Estimated code review effort: 4 (Complex) | ~60 minutes

Poem

A rabbit sends four tasks in flight,
Batches process each request right.
IDs guide answers through the night,
Stale replies vanish from sight.
Tests twitch noses: all is tight.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.63% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the restoration of concurrent codegen grading, which is the main change in the pull request.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/superpowers/plans/2026-07-29-codegen-grade-concurrency.md (1)

1-931: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Remove this plan document or move it out of version control.

This file is a step-by-step implementation plan documenting code changes and decisions for AIP-1094. The coding guidelines explicitly prohibit creating Markdown files for this purpose.

Do not commit this plan file to the repository. If the plan is useful during development, keep it outside of docs/ (e.g., in a local scratch file, a PR description, or an issue tracker) instead of committing it. If it must stay under docs/, confirm it's registered in docs/index.yml per the docs/**/*.md guideline.

Based on learnings, "**/*.md: Use Mermaid diagrams instead of ASCII art in Markdown files. Do not create Markdown files to document code changes or decisions."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/plans/2026-07-29-codegen-grade-concurrency.md` around lines
1 - 931, Remove the implementation plan document from version control rather
than committing it under docs. If the plan is needed during development, move it
to an approved external location or ensure any retained docs entry is registered
in docs/index.yml, while avoiding Markdown files used solely to document code
changes or decisions.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/aiperf/accuracy/graders/_codegen_worker_client.py`:
- Around line 295-300: Update aclose to acquire _spawn_lock before marking
shutdown, set _closing while holding that lock, and keep the lock through the
worker termination sequence so startup cannot race with shutdown. In
_ensure_worker, check _closing after acquiring _spawn_lock and return without
spawning when shutdown has begun, including when subprocess creation is still
pending.

In `@src/aiperf/accuracy/graders/_codegen_worker.py`:
- Around line 154-213: The _drain_buffered function currently uses
stdin.peek(0), which can block after the first request is consumed. Replace this
with a non-blocking availability check on the underlying descriptor, treating
b"" and BlockingIOError as no additional data; restore blocking mode before
run_worker_loop performs the next blocking readline().

---

Outside diff comments:
In `@docs/superpowers/plans/2026-07-29-codegen-grade-concurrency.md`:
- Around line 1-931: Remove the implementation plan document from version
control rather than committing it under docs. If the plan is needed during
development, move it to an approved external location or ensure any retained
docs entry is registered in docs/index.yml, while avoiding Markdown files used
solely to document code changes or decisions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 635bfcff-db3a-45e5-a51b-e35879968cb4

📥 Commits

Reviewing files that changed from the base of the PR and between 1d18295 and 8a03966.

📒 Files selected for processing (6)
  • docs/superpowers/plans/2026-07-29-codegen-grade-concurrency.md
  • src/aiperf/accuracy/graders/_codegen_worker.py
  • src/aiperf/accuracy/graders/_codegen_worker_client.py
  • tests/component_integration/test_lcb_codegen_worker_e2e.py
  • tests/unit/accuracy/test_codegen_worker.py
  • tests/unit/accuracy/test_codegen_worker_client.py

Comment thread src/aiperf/accuracy/graders/_codegen_worker_client.py
Comment thread src/aiperf/accuracy/graders/_codegen_worker.py
@debermudez
debermudez force-pushed the dbermudez/aip-1094-restore-codegen-grade-concurrency branch from 8a03966 to 54185dc Compare July 31, 2026 20:06
@debermudez

Copy link
Copy Markdown
Contributor Author

Addressing the outside-diff comment on docs/superpowers/plans/2026-07-29-codegen-grade-concurrency.md: removed in 54185dc. The file was a dev-time artifact that shouldn't have been committed.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
tests/unit/accuracy/test_codegen_worker_client.py (1)

82-95: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

TestSerialization no longer tests serialization; consider moving this test into TestConcurrency.

The comment at Line 83-84 states this class previously verified serialized execution and now verifies the opposite (concurrent execution without a lock). Keeping the old class name is confusing since a TestConcurrency class already exists immediately below with the same concurrency scope. Move test_concurrent_grades_return_correct_results into TestConcurrency and drop the now-empty TestSerialization class, or rename the class to reflect its new purpose.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/accuracy/test_codegen_worker_client.py` around lines 82 - 95, Move
test_concurrent_grades_return_correct_results from TestSerialization into the
existing TestConcurrency class, preserving its assertions and cleanup. Remove
the now-empty TestSerialization class and update any obsolete serialization
wording associated with the test.
src/aiperf/accuracy/graders/_codegen_worker.py (1)

80-158: 🚀 Performance & Scalability | 🔵 Trivial

handle_batch correctly isolates per-request failures and preserves order.

The malformed-JSON, non-dict, missing-field, batch-exception, and per-item-metric-exception paths all populate responses at the original index and are covered by the referenced unit tests (test_malformed_request_in_batch_does_not_affect_others, test_batch_exception_returns_error_for_all, test_response_order_matches_request_order). The blind except Exception at Line 134 and Line 151 flagged by Ruff is intentional per the docstring's "Never raises" guarantee, so isolated per-request/per-batch failures do not take down the worker loop.

One scalability note: _drain_buffered (called from run_worker_loop) can grow reqs unboundedly while queued input exists, and no response is written until the entire batch's codegen_fn call completes. Under a burst of many concurrent grade_codegen calls, this can inflate per-request latency and peak memory for large evaluation_sample/generated_code payloads. Consider capping batch size if this becomes an issue in practice.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/aiperf/accuracy/graders/_codegen_worker.py` around lines 80 - 158,
Consider adding a maximum batch-size limit to the _drain_buffered flow used by
run_worker_loop so reqs cannot grow without bound while input remains queued.
Process buffered requests in capped batches and preserve response ordering and
existing handle_batch error isolation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/aiperf/accuracy/graders/_codegen_worker.py`:
- Around line 161-204: The non-blocking loop in _drain_buffered must not submit
incomplete JSONL fragments. Before calling stdin.readline(), verify the bytes
returned by stdin.peek(0) contain b"\n"; if not, stop draining so blocking mode
is restored and the next run_worker_loop cycle can complete the line.

In `@tests/unit/accuracy/test_codegen_worker_client.py`:
- Around line 155-169: Update test_stale_id_after_timeout_does_not_crash to
issue the documented second grade_codegen call after the expected timeout, using
a real timeout and the existing worker setup. Preserve the initial
near-zero-timeout assertion, and ensure the follow-up request verifies the
worker remains usable without hanging or crashing.

---

Nitpick comments:
In `@src/aiperf/accuracy/graders/_codegen_worker.py`:
- Around line 80-158: Consider adding a maximum batch-size limit to the
_drain_buffered flow used by run_worker_loop so reqs cannot grow without bound
while input remains queued. Process buffered requests in capped batches and
preserve response ordering and existing handle_batch error isolation.

In `@tests/unit/accuracy/test_codegen_worker_client.py`:
- Around line 82-95: Move test_concurrent_grades_return_correct_results from
TestSerialization into the existing TestConcurrency class, preserving its
assertions and cleanup. Remove the now-empty TestSerialization class and update
any obsolete serialization wording associated with the test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 3fe99340-813b-4bf1-b636-3f7ca8643ed7

📥 Commits

Reviewing files that changed from the base of the PR and between 8a03966 and 54185dc.

📒 Files selected for processing (5)
  • src/aiperf/accuracy/graders/_codegen_worker.py
  • src/aiperf/accuracy/graders/_codegen_worker_client.py
  • tests/component_integration/test_lcb_codegen_worker_e2e.py
  • tests/unit/accuracy/test_codegen_worker.py
  • tests/unit/accuracy/test_codegen_worker_client.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • tests/component_integration/test_lcb_codegen_worker_e2e.py
  • src/aiperf/accuracy/graders/_codegen_worker_client.py
  • tests/unit/accuracy/test_codegen_worker.py

Comment thread src/aiperf/accuracy/graders/_codegen_worker.py
Comment thread tests/unit/accuracy/test_codegen_worker_client.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/aiperf/accuracy/graders/_codegen_worker.py (1)

191-196: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a pipe-backed partial-line test.

Use io.BufferedReader over os.pipe(). Write one JSONL request in two writes and assert that the request completes after the newline arrives. Existing BytesIO tests do not exercise peek(0) or the partial-line guard.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/aiperf/accuracy/graders/_codegen_worker.py` around lines 191 - 196, Add a
pipe-backed partial-line test for the worker’s JSONL request-reading path, using
io.BufferedReader over os.pipe(). Split one request across two writes, verify
the first partial write does not complete processing, then write the newline and
assert the request completes successfully; keep the test focused on exercising
peek(0) and the partial-line guard around the worker read loop.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/aiperf/accuracy/graders/_codegen_worker.py`:
- Around line 191-196: Add a pipe-backed partial-line test for the worker’s
JSONL request-reading path, using io.BufferedReader over os.pipe(). Split one
request across two writes, verify the first partial write does not complete
processing, then write the newline and assert the request completes
successfully; keep the test focused on exercising peek(0) and the partial-line
guard around the worker read loop.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 2b8b50ac-1a2f-42d9-b2fa-8d3f1d97c797

📥 Commits

Reviewing files that changed from the base of the PR and between 54185dc and 2e9f6ca.

📒 Files selected for processing (2)
  • src/aiperf/accuracy/graders/_codegen_worker.py
  • tests/unit/accuracy/test_codegen_worker_client.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/unit/accuracy/test_codegen_worker_client.py

@debermudez
debermudez force-pushed the dbermudez/aip-1094-restore-codegen-grade-concurrency branch from 2e9f6ca to b7f4edc Compare July 31, 2026 21:59
@debermudez

Copy link
Copy Markdown
Contributor Author

Fixed in b7f4edc — added TestRunWorkerLoopBatch.test_partial_jsonl_line_is_deferred_to_next_cycle which uses a real os.pipe() + BufferedReader, writes the request body without a trailing newline, asserts nothing is emitted, then writes the newline and asserts the response arrives. Exercises the O_NONBLOCK + partial-line guard path that BytesIO tests can't reach.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (7)
tests/unit/accuracy/test_codegen_worker.py (4)

135-147: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reuse the existing batch fixture instead of redefining it.

_nan_inf is identical to _fake_codegen_batch_ok at lines 32-38. Pass _fake_codegen_batch_ok and keep only the custom _nan_compute.

♻️ Proposed simplification
     def test_non_finite_metric_values_are_dropped(self) -> None:
         # NaN/Inf must not cross the JSONL boundary (repo NaN/Inf discipline).
-        def _nan_inf(
-            samples: list, generations: list, **_kwargs: Any
-        ) -> tuple[dict[str, Any], dict[int, list]]:
-            n = len(samples)
-            return {"pass@1": 1.0}, {i: [[True]] for i in range(n)}
-
         def _nan_compute(results: dict, **_kwargs: Any) -> dict[str, Any]:
             return {"pass@1": float("nan"), "extra": float("inf"), "ok": 1.0}
 
         req = {"id": 9, "evaluation_sample": [{}], "generated_code": [["x"]]}
-        resps = worker.handle_batch([req], _nan_inf, _nan_compute)
+        resps = worker.handle_batch([req], _fake_codegen_batch_ok, _nan_compute)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/accuracy/test_codegen_worker.py` around lines 135 - 147, Update
test_non_finite_metric_values_are_dropped to remove the duplicate _nan_inf
helper and pass the existing _fake_codegen_batch_ok fixture to
worker.handle_batch, retaining only the custom _nan_compute behavior.

202-203: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move threading and time imports to the file top.

The repository test guidelines require imports at the file top.

As per coding guidelines: "keep imports at the top".

♻️ Proposed change
     def test_partial_jsonl_line_is_deferred_to_next_cycle(self) -> None:
         # Exercises the O_NONBLOCK peek(0) + partial-line guard in _drain_buffered.
         # A partial write (no trailing newline) must not be submitted as a request;
         # only after the newline arrives should the line be processed.
-        import threading
-        import time
-
         req = self._req(42)

Add at the file top:

import threading
import time
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/accuracy/test_codegen_worker.py` around lines 202 - 203, Move the
threading and time imports from their local position near the affected test into
the module-level import section at the top of
tests/unit/accuracy/test_codegen_worker.py, leaving their usage unchanged.

Source: Coding guidelines


241-244: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Annotate the codegen_fn parameter.

codegen_fn has no type hint. TestRunWorkerLoopBatch._run already uses Callable[..., tuple[dict[str, Any], dict[int, list]]]. Use the same annotation here.

As per coding guidelines: "Add type hints to every function parameter and return value".

♻️ Proposed change
-    def _run(self, requests: list[bytes], codegen_fn) -> list[dict]:
+    def _run(
+        self,
+        requests: list[bytes],
+        codegen_fn: Callable[..., tuple[dict[str, Any], dict[int, list]]],
+    ) -> list[dict[str, Any]]:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/accuracy/test_codegen_worker.py` around lines 241 - 244, Update
the _run method’s codegen_fn parameter annotation to match
TestRunWorkerLoopBatch._run: Callable[..., tuple[dict[str, Any], dict[int,
list]]]. Preserve the existing requests annotation and list[dict] return
annotation.

Source: Coding guidelines


210-237: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Guard the shared BytesIO against cross-thread access.

The worker thread writes to out while the main thread calls out.seek(0) and out.read() at lines 224-225. io.BytesIO keeps one shared file position, so a concurrent write would land at the position set by the main thread. The current ordering is safe because the worker writes only after line 228, but the pattern breaks silently if the timing changes. Consider a small lock-protected writer wrapper, or snapshot with out.getvalue() instead of seek/read.

♻️ Proposed change
-            assert t.is_alive()
-            out.seek(0)
-            assert out.read() == b""  # nothing written yet
+            assert t.is_alive()
+            assert out.getvalue() == b""  # nothing written yet
@@
         assert not t.is_alive()
-        out.seek(0)
-        resps = [orjson.loads(ln) for ln in out if ln.strip()]
+        resps = [
+            orjson.loads(ln) for ln in out.getvalue().splitlines() if ln.strip()
+        ]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/accuracy/test_codegen_worker.py` around lines 210 - 237, Protect
the shared BytesIO access in the worker-thread test around the output inspection
after starting worker.run_worker_loop. Replace the main thread’s seek/read
pattern with a position-independent snapshot such as getvalue(), and use that
snapshot for both the empty-output assertion and final response parsing so
concurrent writes cannot alter the shared file position.
.gitignore (1)

60-60: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the duplicate ignore rule.

Line 56 already ignores .worktrees/.

♻️ Proposed change
 tests/scripts/.chaos_runs/
-.worktrees/
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.gitignore at line 60, Remove the duplicate `.worktrees/` entry from the
changed section, keeping the existing ignore rule at line 56 as the sole rule.
src/aiperf/accuracy/graders/_codegen_worker_client.py (2)

296-300: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Extract the pending-future failure loop.

The same loop appears in _handle_fault at lines 251-254. Extract a small helper so both paths stay consistent.

♻️ Proposed change
+    def _fail_pending(self, message: str) -> None:
+        for fut in list(self._pending.values()):
+            if not fut.done():
+                fut.set_exception(CodegenWorkerError(message))
+        self._pending.clear()
+
     async def aclose(self) -> None:
-        for fut in list(self._pending.values()):
-            if not fut.done():
-                fut.set_exception(CodegenWorkerError("grading worker closed"))
-        self._pending.clear()
+        self._fail_pending("grading worker closed")
         await self._kill()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/aiperf/accuracy/graders/_codegen_worker_client.py` around lines 296 -
300, Extract the pending-future failure loop from the current close path into a
small helper near the relevant methods, then call that helper from both the
close flow and `_handle_fault`. Preserve the existing behavior of setting
CodegenWorkerError("grading worker closed") on unfinished futures and clearing
`_pending` exactly once.

218-240: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Fault the worker if the reader raises an unexpected exception.

The try block only handles ValueError, ConnectionError, BrokenPipeError, orjson.JSONDecodeError, and CancelledError. Any other exception ends the reader task silently. Every pending caller then waits for its full timeout, and the task exception is only reported when the task is garbage collected.

Add a final except Exception that calls _handle_fault(). Note that BrokenPipeError is a subclass of ConnectionError, so it is redundant in the tuple at line 222.

🛠️ Proposed change
         except asyncio.CancelledError:
             pass
+        except Exception:
+            # A reader crash would leave every pending caller waiting for its
+            # timeout, so convert it into a worker fault.
+            await self._handle_fault()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/aiperf/accuracy/graders/_codegen_worker_client.py` around lines 218 -
240, Update the reader loop in the worker client to catch unexpected exceptions
with a final except Exception branch and await _handle_fault() before returning
or completing. Remove the redundant BrokenPipeError entry from the existing
ConnectionError tuple, while preserving the current CancelledError handling and
fault behavior for known read, parse, and dispatch failures.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/aiperf/accuracy/graders/_codegen_worker_client.py`:
- Around line 120-124: Update _ensure_worker to cancel and await the existing
_reader_task and _stderr_task before spawning a replacement whenever the current
process has exited, then clear the old worker task/process state as appropriate
before resetting _worker_proven. Ensure the respawn path cannot let stale
readers invoke _handle_fault against the new worker.
- Around line 193-200: Update the response handling around _pending.pop in the
worker client to detect resp values with id equal to None before treating the
response as stale, and return the protocol-fault result so callers fail fast.
Preserve existing handling for valid pending IDs, cancelled futures, and
unhashable IDs.
- Around line 115-118: Update the CancelledError handling in the request flow to
remove the cancelled request from _pending and re-raise the cancellation without
calling _handle_fault. Preserve worker usability so concurrent requests
continue, relying on _dispatch_response to discard any late response for the
stale request ID.
- Around line 106-108: Update the request-writing flow around the worker
client’s stdin write to explicitly validate that the process and stdin are
available instead of relying on assert, and catch BrokenPipeError and
ConnectionResetError from write or drain. Route these failures through
_handle_fault so the caller receives CodegenWorkerError and the pending
entry/future is cleaned up.

In `@src/aiperf/accuracy/graders/_codegen_worker.py`:
- Around line 113-122: Update the request-processing block around all_samples
and all_generations to read both evaluation_sample and generated_code into local
values before modifying any batch lists. Append to all_samples, all_generations,
and id_map only after both lookups succeed, preserving aligned entries when
malformed requests are caught by the existing exception handler.
- Around line 205-210: Update the stdin-reading fallback around the fd/`fcntl`
selection to run the read-all `stdin.read()` loop only when `fd < 0`, matching
the intended in-memory `BytesIO` path. When a real file descriptor exists but
`fcntl` is unavailable, skip the drain instead of blocking on the pipe, while
preserving the existing nonblocking read behavior when `fcntl` is available.

---

Nitpick comments:
In @.gitignore:
- Line 60: Remove the duplicate `.worktrees/` entry from the changed section,
keeping the existing ignore rule at line 56 as the sole rule.

In `@src/aiperf/accuracy/graders/_codegen_worker_client.py`:
- Around line 296-300: Extract the pending-future failure loop from the current
close path into a small helper near the relevant methods, then call that helper
from both the close flow and `_handle_fault`. Preserve the existing behavior of
setting CodegenWorkerError("grading worker closed") on unfinished futures and
clearing `_pending` exactly once.
- Around line 218-240: Update the reader loop in the worker client to catch
unexpected exceptions with a final except Exception branch and await
_handle_fault() before returning or completing. Remove the redundant
BrokenPipeError entry from the existing ConnectionError tuple, while preserving
the current CancelledError handling and fault behavior for known read, parse,
and dispatch failures.

In `@tests/unit/accuracy/test_codegen_worker.py`:
- Around line 135-147: Update test_non_finite_metric_values_are_dropped to
remove the duplicate _nan_inf helper and pass the existing
_fake_codegen_batch_ok fixture to worker.handle_batch, retaining only the custom
_nan_compute behavior.
- Around line 202-203: Move the threading and time imports from their local
position near the affected test into the module-level import section at the top
of tests/unit/accuracy/test_codegen_worker.py, leaving their usage unchanged.
- Around line 241-244: Update the _run method’s codegen_fn parameter annotation
to match TestRunWorkerLoopBatch._run: Callable[..., tuple[dict[str, Any],
dict[int, list]]]. Preserve the existing requests annotation and list[dict]
return annotation.
- Around line 210-237: Protect the shared BytesIO access in the worker-thread
test around the output inspection after starting worker.run_worker_loop. Replace
the main thread’s seek/read pattern with a position-independent snapshot such as
getvalue(), and use that snapshot for both the empty-output assertion and final
response parsing so concurrent writes cannot alter the shared file position.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 740b1553-a479-40e9-a264-8debc208ec7e

📥 Commits

Reviewing files that changed from the base of the PR and between 2e9f6ca and b7f4edc.

📒 Files selected for processing (6)
  • .gitignore
  • src/aiperf/accuracy/graders/_codegen_worker.py
  • src/aiperf/accuracy/graders/_codegen_worker_client.py
  • tests/component_integration/test_lcb_codegen_worker_e2e.py
  • tests/unit/accuracy/test_codegen_worker.py
  • tests/unit/accuracy/test_codegen_worker_client.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/component_integration/test_lcb_codegen_worker_e2e.py
  • tests/unit/accuracy/test_codegen_worker_client.py

Comment thread src/aiperf/accuracy/graders/_codegen_worker_client.py Outdated
Comment thread src/aiperf/accuracy/graders/_codegen_worker_client.py
Comment thread src/aiperf/accuracy/graders/_codegen_worker_client.py
Comment thread src/aiperf/accuracy/graders/_codegen_worker_client.py
Comment thread src/aiperf/accuracy/graders/_codegen_worker.py
Comment thread src/aiperf/accuracy/graders/_codegen_worker.py Outdated
@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.31776% with 25 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
.../aiperf/accuracy/graders/_codegen_worker_client.py 84.61% 16 Missing and 4 partials ⚠️
src/aiperf/accuracy/graders/_codegen_worker.py 94.04% 4 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/aiperf/accuracy/graders/_codegen_worker_client.py (1)

293-296: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Kill the process group after the worker leader exits.

Line 293 skips _kill_process_group() when proc.returncode is already set. The worker can exit before a lighteval sandbox child exits. That child remains in the dedicated process group while _ensure_worker() spawns a replacement.

Invoke process-group termination whenever proc exists. Treat an already-gone process group as successful cleanup.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/aiperf/accuracy/graders/_codegen_worker_client.py` around lines 293 -
296, Update the cleanup block around _kill_process_group in _ensure_worker so
process-group termination runs whenever proc exists, regardless of
proc.returncode. Preserve suppressing ProcessLookupError and awaiting the
process as needed, treating an already-gone group as successful cleanup before
spawning a replacement.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/aiperf/accuracy/graders/_codegen_worker_client.py`:
- Around line 110-129: Update the request flow around the stdin write and
response wait so the single timeout covers both `proc.stdin.drain()` and
`asyncio.wait_for(fut, ...)`. Establish one deadline before draining, use the
remaining time for the response await, and ensure `asyncio.CancelledError`
removes `req_id` from `_pending` whether cancellation happens during drain or
response handling; preserve timeout fault handling and error propagation.

---

Outside diff comments:
In `@src/aiperf/accuracy/graders/_codegen_worker_client.py`:
- Around line 293-296: Update the cleanup block around _kill_process_group in
_ensure_worker so process-group termination runs whenever proc exists,
regardless of proc.returncode. Preserve suppressing ProcessLookupError and
awaiting the process as needed, treating an already-gone group as successful
cleanup before spawning a replacement.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: fcc96d53-7355-4faa-86c7-d3d3ecdb1235

📥 Commits

Reviewing files that changed from the base of the PR and between b7f4edc and 6e8240e.

📒 Files selected for processing (3)
  • src/aiperf/accuracy/graders/_codegen_worker.py
  • src/aiperf/accuracy/graders/_codegen_worker_client.py
  • tests/unit/accuracy/test_codegen_worker_client.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/aiperf/accuracy/graders/_codegen_worker.py
  • tests/unit/accuracy/test_codegen_worker_client.py

Comment thread src/aiperf/accuracy/graders/_codegen_worker_client.py
@debermudez

Copy link
Copy Markdown
Contributor Author

Addressing the two comments from the latest review:

Kill process group after worker exits (lines 293-296): Fixed in 49ab2f5_kill() now calls _kill_process_group() regardless of proc.returncode, so lighteval's forked sandbox grandchildren are reaped even when the worker leader has already exited naturally. ProcessLookupError (group already gone) is suppressed by the existing handler in _kill_process_group. await proc.wait() only runs when returncode is None to avoid waiting on an already-reaped process.

Single deadline covering drain + response wait (lines 110-129): Not addressing in this PR. The current split (drain is unbudgeted, wait_for(fut, timeout) covers the response) is correct and safe — in practice drain() completes immediately for small JSONL payloads. Restructuring around a shared asyncio.timeout() deadline would change error-handling semantics across drain and response paths and is out of scope for this concurrency PR.

Signed-off-by: Elias Bermudez <dbermudez@nvidia.com>
Signed-off-by: Elias Bermudez <dbermudez@nvidia.com>
Signed-off-by: Elias Bermudez <dbermudez@nvidia.com>
Signed-off-by: Elias Bermudez <dbermudez@nvidia.com>
Signed-off-by: Elias Bermudez <dbermudez@nvidia.com>
…ustness

- Replace _drain_fd (broken: select() unreliable after BufferedReader pulls
  kernel data into userspace) and _drain_seekable with _drain_buffered that
  uses peek() for BufferedReader and a seekable fallback for BytesIO in tests.
  Remove unused _parse_batch and import select.
- aclose() now sets CodegenWorkerError on pending futures instead of calling
  cancel(), so shutdown does not propagate CancelledError to grader callers
  that only catch CodegenWorkerError.
- Extract _dispatch_response helper so _run_reader complexity stays within
  the C901 limit; unhashable req_id from a desynced worker now triggers a
  fault instead of killing the reader task with TypeError.
- grade_codegen calls await stdin.drain() after write() to respect backpressure.
- Add test_list_shaped_pass_at_1_is_preserved to TestHandleBatch (guards the
  silent-0.000 bug for list-shaped pass@1 from lighteval).
- Remove unused _ECHO_ID_IN_METRICS constant from client tests.

Signed-off-by: Elias Bermudez <dbermudez@nvidia.com>
…remove plan doc

Signed-off-by: Elias Bermudez <dbermudez@nvidia.com>
…test

Signed-off-by: Elias Bermudez <dbermudez@nvidia.com>
Signed-off-by: Elias Bermudez <dbermudez@nvidia.com>
…e-backed partial-line drain test

Signed-off-by: Elias Bermudez <dbermudez@nvidia.com>
…, and batch alignment

Signed-off-by: Elias Bermudez <dbermudez@nvidia.com>
…h in drain

Signed-off-by: Elias Bermudez <dbermudez@nvidia.com>
… exited

Signed-off-by: Elias Bermudez <dbermudez@nvidia.com>
…g-vs-exception race

Signed-off-by: Elias Bermudez <dbermudez@nvidia.com>
Signed-off-by: Elias Bermudez <dbermudez@nvidia.com>
…oid cancel/timeout interaction

Signed-off-by: Elias Bermudez <dbermudez@nvidia.com>
Python 3.11 asyncio.wait_for silently absorbs CancelledError when the
inner future is already resolved at the point cancellation is delivered.
_ECHO_OK responds fast enough that this race is hit consistently on 3.11.

Replace the proc-up poll with a gated worker that blocks until a "go"
file appears, ensuring fut is unresolved when grade.cancel() fires.
Release the gate after asserting CancelledError so the subsequent grade
succeeds without changing any production behaviour.

Signed-off-by: Elias Bermudez <dbermudez@nvidia.com>
…nse wait

timeout was applied independently to drain() and wait_for(fut), allowing
the total to reach 2*timeout. Compute a deadline before drain() and pass
the remaining duration (max(0.0, deadline - loop.time())) to each
wait_for call so the combined operation stays within timeout.

Signed-off-by: Elias Bermudez <dbermudez@nvidia.com>
@debermudez
debermudez force-pushed the dbermudez/aip-1094-restore-codegen-grade-concurrency branch from d197aab to 98786ac Compare August 3, 2026 19:08
@debermudez
debermudez marked this pull request as ready for review August 3, 2026 21:06

@ajcasagrande ajcasagrande left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

The design here is right — lighteval builds a fresh ProcessPoolExecutor per evaluate_generations call, so collapsing N requests into one codegen_metrics call is exactly the correct lever, and the id→future demux is clean. But there is one release-blocking defect that makes the whole LiveCodeBench benchmark report zeros, plus a dead except branch from the last commit.

Blocking

handle_batch nests the payload one level too deep, so every LCB grade silently returns pass@1 = 0.0 with ok: true. CodeExecutionGrader already sends lighteval's list form ([{...}] / [[code]]); origin/main forwarded that straight through, the PR wraps it again with .append(). lighteval's evaluate_generations then hands check_correctness a list where it expects a dict, run_test dies on sample["input_output"] with TypeError: list indices must be integers or slices, not str, and lighteval's own except Exception: pass swallows it into the [-2] "compile error" sentinel. No error surfaces anywhere.

Verified against the real worker subprocess and real lighteval 0.13.0, same request, same spawn parameters:

[PR #1237]    {'id': 1, 'ok': True, 'metrics': {'pass@1': 0.0}}
[origin/main] {'id': 1, 'ok': True, 'metrics': {'pass@1': 1.0}}

Note the shape of the failure: wrong answers still "pass" because their expected value is already 0.0. Only a known-correct solution exposes it — which no concurrent test in this PR uses.

Why CI is green anyway

Three independent gaps, all worth closing regardless of the fix:

  1. _fake_codegen_batch_ok only reads len(samples) and never dereferences an element, so TestHandleBatch passes identically with and without the bug. I confirmed the unit suite is green on this HEAD and with the fix applied.
  2. test_worker_grades_multiple_problems_concurrently grades 4 identical, all-correct problems, so every expected value is equal and misalignment is invisible.
  3. Both e2e tests are @pytest.mark.slow, and run-unit-tests.yml runs component_integration with -m 'not ... and not slow', so CI never executes them.

Suggested fix order

  1. Flatten the batch payload (blocking) — a ~6-line change; I verified it restores 6/6 correct verdicts on distinct mixed-outcome problems.
  2. Add a distinct-problem concurrent test that would have caught this, and get one real-lighteval batch test into CI.
  3. Reorder the except clauses so the drain-timeout branch is reachable.
  4. Decide on retrying faulted siblings, or document the measured blast radius.
  5. Cleanups (assert escaping aclose(), the no-op respawn test, stale line refs).

What's working well

  • Per-problem demux is correct once the nesting is fixed — verified 6/6 on distinct problems with mixed pass/fail.
  • _next_id stays monotonic across respawns, so a late response from a killed worker can never collide with a new id.
  • The _kill() self-task guard correctly avoids the self-await deadlock when the reader detects its own fault.
  • Draining stderr before unblocking callers is a genuinely subtle ordering fix and the comment earns its place.
  • Protocol-fd isolation from forked sandbox children is careful and well justified.

Separate, pre-existing (not this PR)

_ensure_worker always sets AIPERF_CODEGEN_DEATH_FD, which starts the worker's watcher thread — despite the module docstring promising "a fresh, single-threaded interpreter" and the whole point of #1145 being to keep lighteval's fork away from a multithreaded parent. With that thread present, lighteval grades a known-correct solution 0.0 on origin/main too. Identical on both branches, so not a regression from this PR, but it means these e2e tests are red on main as well. Probably deserves its own issue.

Reproduction scripts and logs for every claim above are available on request; each finding was validated against the real worker + lighteval 0.13.0 on CPython 3.12.10.

"error": f"malformed request: {exc!r}",
}
continue
all_samples.append(sample)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — this makes every LCB grade return pass@1 = 0.0.

sample here is already [{"input_output": ...}] and generation is already [[code]]CodeExecutionGrader builds them in lighteval's list form (code_execution.py, _build_evaluation_sample returns a 1-element list, generated_code = [[snippet]]). origin/main passed them straight through as samples_list/generations_list.

.append() adds a second level, so all_samples becomes [[{...}]]. lighteval then does:

# lighteval/tasks/tasks/lcb/codegen_metrics.py:572
inputs = [[(generations_list[index], samples_list[index], timeout), index] ...]

which hands check_correctness a list where it expects the sample dict, and run_test dies at codegen_metrics.py:365:

TypeError: list indices must be integers or slices, not str

lighteval swallows that in evaluate_generations_by_problem (except Exception: pass), leaving the [-2] compile-error sentinel — so the worker returns ok: true with pass@1: 0.0 and nothing is logged. Same symptom #1145 existed to fix.

Verified with the real worker subprocess, identical request and spawn parameters:

[PR #1237]    {'id': 1, 'ok': True, 'metrics': {'pass@1': 0.0}}
[origin/main] {'id': 1, 'ok': True, 'metrics': {'pass@1': 1.0}}

Suggested fix — flatten, and track each request's span so a multi-sample request still demuxes (pairs with the change on the raw_results line below):

# id_map: list[tuple[int, Any, int, int]]  # (req_idx, req_id, start, count)
start = len(all_samples)
all_samples.extend(sample)
all_generations.extend(generation)
id_map.append((i, req_id, start, len(all_samples) - start))

With this applied I get 6/6 correct verdicts on 6 distinct problems (3 solvable, 3 deliberately wrong) graded in a single batch through the real worker, and tests/unit/accuracy/ stays green.

else:
try:
metrics = compute_metrics_fn(
{0: raw_results[pos]},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pairs with the flattening fix above. Once all_samples is flat, pos (the index into id_map) is no longer the index into raw_results if any request ever carries more than one sample — index by the recorded span instead:

for req_idx, req_id, start, count in id_map:
    ...
    metrics = compute_metrics_fn(
        {j: raw_results[start + j] for j in range(count)},
        k_list=list(_LCB_PASS_AT_K),
    )

For today's one-sample-per-request grader this reduces to {0: raw_results[start]}, so it costs nothing and stops the indexing from silently desyncing if the grader ever batches problems itself.

Worth saying explicitly: the reconstruction logic here is otherwise correct. evaluate_generations keys its results by input index (codegen_metrics.py:572-588), and rebuilding a single-entry {0: ...} dict per request does yield the right per-problem pass@1 — I verified alignment across a shared batch with mixed pass/fail once the nesting was fixed. The nesting is the only defect in this function.

try:
proc.stdin.write(orjson.dumps(req) + b"\n")
await asyncio.wait_for(proc.stdin.drain(), max(0.0, deadline - loop.time()))
except (OSError, ConnectionError) as exc:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This clause swallows the TimeoutError below it, so the except TimeoutError branch on line 122 is dead code and _handle_fault() never runs on a drain timeout.

TimeoutError is an OSError subclass (PEP 3151), and since 3.11 asyncio.TimeoutError is TimeoutError:

TimeoutError.__mro__ = (TimeoutError, OSError, Exception, BaseException, object)

That matters because the whole point of commit 98786ac58 ("enforce single-request deadline across drain and response wait") was to kill a worker that has stopped consuming stdin. Reproduced with a worker that never reads stdin and a 4 MB payload (past the 64 KiB pipe buffer + 64 KiB high-water mark), timeout=0.5:

raised: CodegenWorkerError('failed to submit grading request: ')
__cause__ type: TimeoutError
worker._proc after the timeout: <Process 4069256>   <-- still alive
worker._start_failures: 0

The wedged worker survives and is reused by every subsequent grade, each paying its full timeout before the response-wait branch finally kills it. The message is misleading too — wait_for raises a bare TimeoutError() with no args, so {exc} renders empty and the log reads failed to submit grading request: with nothing after the colon.

except TimeoutError as exc:
    ...
except OSError as exc:      # ConnectionError is already an OSError
    ...

(The second try block at line 129 orders these correctly — it's only this one.)

f"start_failures={self._start_failures}); killed + respawning next grade"
+ (f"; stderr tail:\n{chr(10).join(tail)}" if tail else "")
)
for fut in list(self._pending.values()):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth quantifying the trade-off the description mentions, because it's larger than "a timeout faults concurrent siblings" suggests, and it fires on any fault — worker death, EOF, a malformed frame, a BrokenProcessPool inside handle_batch — not just timeouts.

Nothing retries the faulted siblings against the healthy respawn, so each one lands as a _grading_failure (correct=False, unparsed=True) — silent score depression rather than a visible error.

A/B on one identical workload — a worker that misbehaves exactly once (first spawn eats one line and exits, every respawn is healthy), 12 grades on a 5 ms stagger:

origin/main (serialized) : 11/12 graded
PR #1237                 :  6/12 graded

To be fair to the PR: all six losses are consistent with the coupling you already documented — this measures its blast radius, it doesn't show an additional undocumented fault. (I specifically tried to show that a grade arriving after the old worker died could be faulted by the previous _handle_fault, and could not: the window between _kill() nulling _proc and the _pending sweep measures ~54 µs, while a replacement spawn costs ~705 µs, so an arriving grade reliably loses that race. Not a finding.)

Suggestion: resubmit faulted-but-unanswered requests once against the respawned worker, or call out the ~6× amplification in the PR/issue so it's a conscious accuracy trade-off.


async def _run_reader(self) -> None:
"""Read worker responses and resolve the corresponding pending futures by id."""
assert self._proc is not None and self._proc.stdout

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, but this assert can escape aclose().

_kill() sets self._proc = None before awaiting proc.wait(). If the reader task runs for the first time inside that await, the assert fires — and _kill() suppresses only CancelledError, so the AssertionError propagates out of _kill()aclose()CodeExecutionGrader.aclose() on the shutdown path.

Measured:

AssertionError escaped 5/5 via _ensure_worker() + aclose() with no intervening yield
AssertionError escaped 0/5 via the public grade path

So it isn't reachable through grade_codegen() in normal operation (drain()/wait_for yield, letting the reader start first) — hence low severity. Two things make it worth fixing anyway: under python -O the assert is stripped and you get AttributeError: 'NoneType' object has no attribute 'stdout' instead, and _kill() suppressing only CancelledError means any reader-task exception escapes teardown.

Capturing the stream at task-creation time removes the assumption entirely:

self._reader_task = asyncio.create_task(self._run_reader(self._proc.stdout))

and widening the suppress in _kill() to also cover Exception keeps teardown total.


def _fake_codegen_boom(*_args: Any, **_kwargs: Any) -> tuple[dict[str, Any], Any]:
raise RuntimeError("sandbox exploded")
def _fake_codegen_batch_ok(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fake is why the payload-nesting bug lands green.

It only reads len(samples) and never dereferences an element, so it behaves identically whether samples is [{...}] or [[{...}]] — every TestHandleBatch case passes with and without the defect. I confirmed both directions: tests/unit/accuracy/ is green on this HEAD (516 passed) and green with the flattening fix applied (575 passed).

Cheapest way to make this fake load-bearing is to assert the contract it's standing in for:

def _fake_codegen_batch_ok(samples, generations, **_kwargs):
    # lighteval indexes samples_list[i] as the sample dict; catch nesting errors
    assert all(isinstance(s, dict) and "input_output" in s for s in samples), samples
    assert all(isinstance(g, list) and all(isinstance(c, str) for c in g)
               for g in generations), generations
    ...

That one assertion turns the entire existing TestHandleBatch class into a regression test for this bug at zero structural cost.

*[worker.grade_codegen(sample, code, timeout=240) for _ in range(n)]
)
assert len(results) == n
assert all(float(r["pass@1"]) == 1.0 for r in results), results

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion can't detect a per-problem misalignment, which is the main risk the batching change introduces.

All four grades use the same sample and the same correct solution, so every expected value is 1.0. Results can be swapped, duplicated, or misattributed between callers and the assertion still holds — it can only detect "all wrong", never "wrong per problem".

Using distinct problems with different expected verdicts closes that:

PROBLEMS = [  # (io pairs, solution, expected pass@1)
    ([("1 2\n", "3\n")],   "a, b = map(int, input().split())\nprint(a + b)", 1.0),
    ([("3 4\n", "12\n")],  "a, b = map(int, input().split())\nprint(a + b)", 0.0),
    ([("9 4\n", "5\n")],   "a, b = map(int, input().split())\nprint(a - b)", 1.0),
    ([("5\n", "25\n")],    "n = int(input())\nprint(n * n)",                1.0),
]
results = await asyncio.gather(*[w.grade_codegen(s, [[sol]], timeout=240)
                                 for s, sol, _ in problems])
for (_, _, expected), got in zip(problems, results, strict=True):
    assert float(got["pass@1"]) == expected

That exact shape fails on this HEAD (the three correct solutions all score 0.0) and passes with the flattening fix, so it pins the bug directly.

Also worth noting: both tests here are @pytest.mark.slow, and run-unit-tests.yml runs component_integration with -m 'not ... and not slow' — so CI never executes either of them. This batch runs in ~0.2 s locally; dropping the slow marker on at least one real-lighteval batch test (or adding a CI lane for -m slow) would give the change actual coverage.

Minor: the PR description's test plan uses --run-slow, which this repo doesn't define (pytest: error: unrecognized arguments: --run-slow). The working invocation is -m "component_integration and slow".

finally:
await w.aclose()

async def test_ensure_worker_respawns_after_worker_exits(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test has no assertions, and it doesn't exercise the code it names.

The docstring says "We verify by checking that _proc changes between grades" — there's no such check, or any check at all. And rather than calling _ensure_worker(), the body re-implements it inline (async with w._spawn_lock: if ...: await w._kill()), so the respawn branch it claims to cover never runs. Delete _ensure_worker's respawn path and this test still passes.

Driving it through the public API gives it teeth:

await w.grade_codegen(...)            # worker #1 serves, then exits
first_pid = w._proc.pid
await w._proc.wait()
metrics = await w.grade_codegen(...)  # must transparently respawn
assert w._proc.pid != first_pid
assert metrics == {"pass@1": 1.0}

While you're in this class: the Covers line NNN comments throughout TestCoverageGaps are already stale (e.g. "line 107-109" is now 108-110, "lines 316-317" is 336-338, "line 194" is 201-205, "line 254" is 262-265). Naming the behaviour instead of the line number keeps them from rotting. There's also some redundancy worth trimming — test_concurrent_grades_return_correct_results duplicates test_concurrent_grades_all_complete, aclose-with-pending is covered three times, and class _FakeStdin is unused.

@ajcasagrande

Copy link
Copy Markdown
Contributor

Definitive proof + root cause for the blocking finding

Following up on my review with an end-to-end reproduction through the real aiperf profile CLI, plus the exact cause.

Cause: one .append()

CodeExecutionGrader already builds lighteval's list form (code_execution.py:139,144):

evaluation_sample = _build_evaluation_sample(...)   # -> [ {"input_output": ...} ]
generated_code    = [[snippet]]                     # -> [ [str] ]

origin/main forwarded those straight through as samples_list / generations_list. This PR wraps them again (_codegen_worker.py:123-125):

all_samples.append(sample)          # sample IS [ {...} ]  ->  [ [ {...} ] ]
all_generations.append(generation)

lighteval then indexes per problem (codegen_metrics.py:572-573):

inputs = [[(generations_list[index], samples_list[index], timeout), index] ...]

so check_correctness receives a list where it expects the sample dict, and run_test dies at codegen_metrics.py:365 on sample["input_output"]:

TypeError: list indices must be integers or slices, not str

That exception is swallowed by lighteval's own evaluate_generations_by_problem (except Exception: pass), leaving the [-2] "compile error" sentinel. The worker therefore returns ok: true with pass@1: 0.0 — no exception, no log line, unparsed = 0. Every LiveCodeBench problem scores zero and the run looks healthy. That is the same failure mode issue #1145 existed to fix.

Note the shape of it: a wrong answer still "passes" its check, because its expected score is already 0.0. Only a known-correct solution exposes the bug — which no concurrent test here uses.

Proof

Four independent levels, all against real lighteval 0.13.0 (no stubs):

level origin/main this PR
one request, real worker subprocess, identical spawn args pass@1 = 1.0 pass@1 = 0.0
6 distinct problems in one drained batch correct mixed verdicts all 0.0
full aiperf profile CLI (below) 4/6 0/6
tests/unit/accuracy/ pass pass — catches nothing

The CLI run. Real lcb-codegeneration benchmark against the mock server's accuracy-oracle mode. The oracle is 6 real LCB problems from the v4_v5 subset, each answered by a lookup-table program mapping every test case's stdin to its expected stdout — so a served-correct row genuinely executes to pass@1 = 1.0. All 6 rows are correct solutions; the mock decides which come back wrong, via --random-seed 42 --accuracy-correct-rate 0.5. That decision is seeded per prompt, so it is order-independent and identical across arms — which makes the mock's own tally an independent oracle.

Only _codegen_worker.py and _codegen_worker_client.py differ between arms. Fresh mock per arm.

================ origin/main ================
mock  /accuracy : matched 6, correct 4, incorrect 2, unmatched 0
aiperf CSV      : OVERALL,4,6,0,0.6667
CROSS-CHECK     : mock served correct 4/6 | aiperf graded correct 4/6  -> MATCH

================ this PR ====================
mock  /accuracy : matched 6, correct 4, incorrect 2, unmatched 0
aiperf CSV      : OVERALL,0,6,0,0.0000
CROSS-CHECK     : mock served correct 4/6 | aiperf graded correct 0/6  -> MISMATCH
                  (4 known-correct answers graded wrong, unparsed=0)

origin/main agrees with the oracle exactly. This branch disagrees by precisely the 4 correct answers, silently.

Fix

Flatten instead of nesting, and track each request's span so the per-problem demux still holds:

id_map: list[tuple[int, Any, int, int]] = []   # (req_idx, req_id, start, count)
...
start = len(all_samples)
all_samples.extend(sample)
all_generations.extend(generation)
id_map.append((i, req_id, start, len(all_samples) - start))
...
for req_idx, req_id, start, count in id_map:
    metrics = compute_metrics_fn(
        {j: raw_results[start + j] for j in range(count)},
        k_list=list(_LCB_PASS_AT_K),
    )

With this applied I get 6/6 correct verdicts on 6 distinct mixed-outcome problems in a shared batch, and tests/unit/accuracy/ stays green. The rest of the design is sound — batching into one codegen_metrics call is the right lever (lighteval builds a fresh ProcessPoolExecutor per call), and the raw_results indexing is otherwise correct.

Correction to my earlier comment

In the review summary I said the pre-existing AIPERF_CODEGEN_DEATH_FD problem was caused by the watcher thread making the worker multithreaded. That was wrong — a daemon thread blocked in os.read is harmless here. A 2×2×2 bisect over {stdout-guard, at-fork close, thread} shows only guard + atfork fails:

CASE=guard                 pass@1=1.0
CASE=atfork                pass@1=1.0
CASE=thread                pass@1=1.0
CASE=guard+atfork          pass@1=0.0  raw={0: [[-1]]}   <-- culprit
CASE=guard+atfork+thread   pass@1=0.0  raw={0: [[-1]]}

_install_stdout_guard and _start_death_watcher each register an os.register_at_fork(after_in_child=close(fd)). Both re-fire at every lighteval fork (ProcessPoolExecutor worker → multiprocessing.Manager()mp.Process), closing fd numbers that multiprocessing may since have recycled for its own pipes. [[-1]] is lighteval's "result list empty" path — the forked child died.

This reproduces identically on origin/main, so it is pre-existing and out of scope for this PR (probably deserves its own issue). It does mean both e2e tests in test_lcb_codegen_worker_e2e.py are red on main too — they're slow-marked, so CI never runs them. I disabled that path identically in both arms above, which is why origin/main scores 66.67% rather than 0%.

@ajcasagrande

Copy link
Copy Markdown
Contributor

Pre-existing: the worker grades every LCB problem 0.0 on main (from #1175)

Separate from the batching bug in this PR, and not introduced by it — but it is the same worker, and it comes from your #1175, so flagging it here rather than in isolation. It also explains why the two e2e tests in this PR's test plan cannot pass as written.

On main, the LCB codegen worker grades every problem pass@1 = 0.0 whenever the client spawns it — which is always, since CodegenGradingWorker._ensure_worker unconditionally sets AIPERF_CODEGEN_DEATH_FD. A known-correct solution scores 0, with ok: true, no exception, no log line, and unparsed = 0 in the accuracy export.

This is the same symptom #1145 was filed to fix, reintroduced by the fix itself (817a8d84d, #1175). Independent of the batching bug in this PR — I found it while reviewing.

Root cause: two register_at_fork handlers closing recycled fd numbers

_codegen_worker.py registers two at-fork handlers:

_install_stdout_guard:  os.register_at_fork(after_in_child=lambda: _close_fd_quietly(protocol_fd))
_start_death_watcher:   os.register_at_fork(after_in_child=lambda: _close_fd_quietly(death_fd))

Each closes a raw fd number, and both re-fire at every subsequent fork. lighteval forks repeatedly per grade:

worker → ProcessPoolExecutor worker → multiprocessing.Manager() → mp.Process(_temp_run)

After the first fork closes fds N and M in the child, those numbers are free, and multiprocessing promptly reuses them for its own pipes and manager sockets. At the next fork down the chain the same handlers fire again and close the recycled descriptors, so _temp_run dies before appending a result. check_correctness then hits its empty-result path and returns the [-1] sentinel (codegen_metrics.py:513-516), which becomes pass@1 = 0.0.

Evidence

2×2×2 bisect over {stdout-guard, at-fork close, watcher thread}, in-process, one known-correct stdin/stdout solution:

CASE=none                  pass@1=1.0  raw={0: [[True]]}
CASE=guard                 pass@1=1.0  raw={0: [[True]]}
CASE=atfork                pass@1=1.0  raw={0: [[True]]}
CASE=thread                pass@1=1.0  raw={0: [[True]]}
CASE=guard+atfork          pass@1=0.0  raw={0: [[-1]]}   <-- culprit
CASE=guard+thread          pass@1=1.0  raw={0: [[True]]}
CASE=atfork+thread         pass@1=1.0  raw={0: [[True]]}
CASE=guard+atfork+thread   pass@1=0.0  raw={0: [[-1]]}

Only the pair fails. The watcher thread is not the cause — a daemon thread blocked in os.read is harmless here.

At the worker-process level, toggling only the env var:

worker, AIPERF_CODEGEN_DEATH_FD unset -> {"id":1,"ok":true,"metrics":{"pass@1":1.0}}
worker, AIPERF_CODEGEN_DEATH_FD set   -> {"id":1,"ok":true,"metrics":{"pass@1":0.0}}

Impact

  • Any --accuracy-benchmark lcb-codegeneration run reports ~0% regardless of model quality.
  • Silent: ok: true, unparsed = 0, no error surfaces to the grader or the accuracy report.
  • Both e2e tests in tests/component_integration/test_lcb_codegen_worker_e2e.py fail on main. They are @pytest.mark.slow, and run-unit-tests.yml runs component_integration with -m 'not ... and not slow', so CI never executes them.

Reproduction

uv pip install -e ".[dev,accuracy]"
uv run pytest tests/component_integration/test_lcb_codegen_worker_e2e.py \
    -v -m "component_integration and slow"
# both fail: assert 0.0 == 1.0

(Environment: CPython 3.12.10, lighteval 0.13.0, Linux.)

Suggested fix

Make each handler one-shot per process, so a nested fork can never close a recycled fd number:

def _close_once(holder: list[int | None]) -> None:
    fd = holder[0]
    if fd is None:
        return
    holder[0] = None          # never close this number again in this process
    _close_fd_quietly(fd)

_protocol_holder: list[int | None] = [protocol_fd]
os.register_at_fork(after_in_child=lambda: _close_once(_protocol_holder))

Verified against the same harness:

CASE=buggy        pass@1=0.0  raw={0: [[-1]]}
CASE=oneshot      pass@1=1.0  raw={0: [[True]]}

The intent of both handlers — keeping the protocol fd and death fd out of untrusted sandbox children — is preserved: the first fork still closes them, and the child no longer holds either descriptor.

Worth pairing with a CI lane that runs -m slow (or dropping the marker on one real-lighteval test), since nothing currently exercises this path.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants