Skip to content

Claude/score paths worker cpu xxdxvp - #147

Merged
maximusunc merged 4 commits into
mainfrom
claude/score-paths-worker-cpu-xxdxvp
Sep 1, 2026
Merged

Claude/score paths worker cpu xxdxvp#147
maximusunc merged 4 commits into
mainfrom
claude/score-paths-worker-cpu-xxdxvp

Conversation

@maximusunc

Copy link
Copy Markdown
Collaborator

No description provided.

score_paths was the last CPU-bound worker still offloading to a
ThreadPoolExecutor, so its four concurrent scorings ran in the same process
as the event loop the heartbeat pings from. Most of a scoring's time is the
feature build -- convert_path_to_components walks every edge of every path in
pure Python -- which holds the GIL; only the LMDB reads and the torch forward
release it. Under load the loop thread's turnaround degrades from
milliseconds to seconds, and once the heartbeat goes unrefreshed past
HEARTBEAT_TTL_SEC (15s) peers stop counting the worker as alive and can
XCLAIM its in-flight tasks out from under it. Past
worker_loop_stall_exit_sec (60s) the loop watchdog force-exits the pod. This
is the same failure arax_pathfinder hit on asyncio.to_thread and
aragorn_score / arax_rank hit before them; nothing here was different except
that it hadn't been migrated yet.

Scoring now goes through ProcessPoolManager like its siblings:

- score_paths_task is the child entrypoint. Only the response_id and the
  task's log level cross the boundary -- the message is loaded, scored and
  saved inside the child, so the payload never lands on the parent's heap
  either.
- The child attaches its own QueryLogHandler and hands the formatted records
  back with the result; the parent folds them into the task's query logger,
  so the per-query scoring lines (feature build stats, score ranges) still
  reach the query's log list rather than only container stderr.
- Per-child state (biolink Toolkit, embeddings LMDB, the MLP) is built on
  first use rather than in the pool initializer, so a bad checkpoint or an
  unreadable LMDB fails one task with a traceback instead of killing children
  at startup and leaving the pool rebuilding itself in a loop. Each child
  caps torch to one intra-op thread: the pool is already sized to the pod's
  CPU allocation, so a full thread pool per child just oversubscribes it.
- The read-only LMDB is opened with lock=False in every child, so the pages
  are shared through the page cache instead of copied per child.
- The parent validates the embeddings cache and the weights file at startup,
  before any child spawns, so a bad mount still fails fast instead of
  surfacing as every task failing one at a time.

This also brings the OOM self-heal and the pool_task_timeout_sec (300s)
per-task ceiling to this stream; scoring previously had no timeout at all, so
a pathological message could hold its slot indefinitely.

Pool size comes from resolve_pool_workers (cgroup-aware, POOL_MAX_WORKERS
overrides) and doubles as the in-flight task limit, matching arax_rank.

The dispatch-concurrency regression test now covers score_paths, and gains a
second invariant: these workers must offload through ProcessPoolManager, and
must not instantiate a ThreadPoolExecutor or use asyncio.to_thread.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpZWD8DD34dqV3mMkJHzky
score_paths now bounds a scoring run with its own
score_paths_task_timeout_sec (210s) rather than the shared 300s
pool_task_timeout_sec. Scoring runs at the tail of a query whose lookups were
already bounded by the identical 210s lookup_timeout, so a scoring that
outlives that budget is past the point of being useful to the client.
Per-Deployment override via SCORE_PATHS_TASK_TIMEOUT_SEC.

The reclaim idle floor for the stream moves 60s -> 240s to match. That floor
has to exceed the worst-case legitimate task duration or a peer can XCLAIM a
message out from under a worker that is simply slow, and the ceiling now
makes that worst case an explicit 210s. This is the same shape the lookup
workers already use: a 210s internal timeout with the floor just above it.

Also drops the "process/thread pools" wording from the README's pool-worker
note, since score_paths was the thread-pool one.
Moving scoring into a process pool gave each child its own copy of the 61 MB
checkpoint, where the thread pool had loaded it once. Memory-mapping the
weights gets that back: torch.load(mmap=True) returns file-backed MAP_PRIVATE
tensors, and load_state_dict(assign=True) makes those tensors be the module's
parameters rather than a destination to copy into -- the default allocates
fresh storage per child and undoes the sharing. Scoring only reads them (eval
mode, inference_mode), so nothing triggers a copy-on-write fault and the pages
stay shared for the life of the pod.

Verified against the real checkpoint: parameters and the forward pass are
bit-identical to the plain load, the file shows up in /proc/self/maps, and two
spawned children that each run a full forward pass report Shared_Clean
58.5 MB / Private_Dirty 0.0 MB for the mapping -- the pages are shared, not
copied.

mmap needs torch's zipfile checkpoint format (its default since 1.6). A
checkpoint re-saved in the legacy format raises RuntimeError, so that falls
back to a plain private-copy load with a warning rather than failing every
task; the fallback path is exercised and the exception type confirmed.

The biolink Toolkit remains genuinely per-child -- live Python objects, no
equivalent trick -- which is now noted in _ensure_scoring_state alongside the
two things that are shared, since POOL_MAX_WORKERS is the lever for it.
@codecov

codecov Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0.98039% with 101 lines in your changes missing coverage. Please review.
✅ Project coverage is 54.39%. Comparing base (806a000) to head (7d10775).
⚠️ Report is 6 commits behind head on main.

Files with missing lines Patch % Lines
workers/score_paths/worker.py 0.00% 101 Missing ⚠️
Files with missing lines Coverage Δ
shepherd_utils/config.py 95.37% <100.00%> (+0.04%) ⬆️
shepherd_utils/reclaim.py 77.41% <ø> (ø)
workers/score_paths/worker.py 0.00% <0.00%> (ø)

... and 3 files with indirect coverage changes


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 731f987...7d10775. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Production logs show a pod dying mid-task with no traceback: a cgroup OOM
SIGKILL, which no in-process handler can catch (a Python MemoryError would
have hit the "Error scoring paths" handler and logged). Four tasks were in
flight, three of them 387,583 analyses each.

Scoring a message in one pass held three representations of the same data at
once -- the float16 row list, np.stack's copy of it, and the float32 cast:

     analyses    rows    stack    float32     peak
      138,824    2.18     2.18       4.37     8.74 GiB
      387,583    6.10     6.10      12.20    24.40 GiB
       79,906    1.26     1.26       2.51     5.03 GiB

The last line logged was a 79,906-analysis feature build completing; the very
next statement was its np.stack(...).astype(np.float32), a 1.26 GiB stack plus
a 2.51 GiB allocation on top of three 387k row lists that had been
accumulating for 138s. Those messages then outlived their worker, got
reclaimed, killed the next pod the same way, and were finally dead-lettered by
the poison-pill breaker after three deliveries.

Scoring now runs every SCORE_CHUNK_SIZE (4096) rows and frees the chunk, so
peak is ~208 MB whatever the message size instead of scaling with it. Rows are
copied straight into a float32 batch rather than stacked as float16 and cast,
so only one array of the batch exists at a time; float16 converts exactly, so
the input matrix is unchanged. The summary log lines keep their shape, with
build and MLP time accumulated separately now that the two interleave and the
score range tracked as running aggregates rather than a per-analysis list.

Verified against the real checkpoint at 1, 100, 4095, 4096, 4097, 8192 and
10000 rows: every score is bit-identical to the one-shot path, the reported
count/min/max/mean match, and the largest float32 batch allocated for a 60k-row
message is 4096 rows (132 MiB) rather than 60000 (1.89 GiB).

Note this bounds the scoring memory, not the message itself: a 387k-analysis
TRAPI payload still has to be decoded into the child's heap. What changes is
that the multi-GiB feature arrays no longer sit on top of it, the cost is one
child's rather than the parent's, and it is returned to the OS when the child
recycles.

The chunking is pinned by a static test, since CI cannot import this module --
torch, lmdb and bmt live only in the worker image.
@maximusunc
maximusunc merged commit 366f643 into main Sep 1, 2026
2 checks passed
@maximusunc
maximusunc deleted the claude/score-paths-worker-cpu-xxdxvp branch September 1, 2026 11:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants