Skip to content

Parallelize sequence-properties property computation across CPU cores#16

Closed
PaulNewling wants to merge 10 commits into
mainfrom
feat/seqprops-parallel-compute
Closed

Parallelize sequence-properties property computation across CPU cores#16
PaulNewling wants to merge 10 commits into
mainfrom
feat/seqprops-parallel-compute

Conversation

@PaulNewling

Copy link
Copy Markdown
Collaborator

Summary

Make the sequence-properties block compute large datasets faster on the first run by parallelizing the Python property kernel across CPU cores — without changing any output byte, so the platform's content-addressed cache still dedups. Today the compute loops row-by-row on a single core; a big bulk-MiXCR / peptide input has to grind through serially.

  • software/src/pipeline.py — per-row compute now runs through a ProcessPoolExecutor (_pmap), with results reassembled in input order and a sequential fallback below 2000 rows. The existing sort + charge/pI quantization boundary is untouched, so output stays byte-identical regardless of worker count. A worker that dies uncleanly (e.g. OOM-killed) now surfaces as a clear RuntimeError with item/worker counts instead of a cryptic BrokenProcessPool.
  • software/src/main.py — worker count is resolved from the environment (PL_COMPUTE_WORKERSos.cpu_count()). No new CLI argument, so the exec step's command line — and therefore its content-addressed id — is unchanged.
  • workflow/src/main.tpl.tengo — the Python compute step now requests cpu(8) / mem("8GiB"). These are scheduling meta-inputs (verified against workflow-tengo render.lib.tengo: meta-inputs are excluded from dedup), so the bump does not invalidate cached results and the CID stays stable run-to-run.

Determinism rule throughout: parallelism is across rows (each row is an independent, pure computation; reassembled in input order; final sort restores byte order) — never inside a float reduction. Worker count is a speed knob only.

Safety net (built first, Phase A)

  • Golden master (software/tests/regression/test_golden_master.py) — byte-exact (sha256) full-output snapshot for every mode / coverage tier / edge case (peptide incl. NA cases, antibody full+Fv with per-clone missing-region & empty-CDR3, CDR3-only, partial, TCR). Generated once from the pre-refactor code and never regenerated — direct proof the parallelization changed no bytes.
  • Scale invariants (test_scale_invariants.py) — row/key preservation + byte-stability at 5000 rows.
  • Worker-count invariance (test_parallel_invariance.py) — workers=1workers=4 byte-identical on both the sequential and the real pool path (n=3000), plus exception-propagation.

Streaming (memory) — deferred by decision

Measured peak memory against the new 8 GiB ceiling:

Mode Per-row ~6 GiB (safe) at 1M rows ≥5M rows
Antibody + Fv ~6.2 KiB/row ~935k rows ~6.4 GiB ≥30 GiB
Peptide ~3.6 KiB/row ~1.6M rows ~3.8 GiB

8 GiB comfortably covers datasets up to ~1M clonotypes; it fails only at multi-million-row extremes. A streaming I/O path (peak memory O(batch) instead of O(dataset)) is fully specified in the plan but intentionally not implemented — build it only if inputs reach several million rows. Full plan + decision: docs/superpowers/plans/2026-06-10-sequence-properties-parallel-compute.md.

Verification

  • uv run pytest (from software/): 221 passed (incl. slow pool-path and 5000-row byte-stability cases).
  • pnpm run build:dev: green (7/7 turbo tasks).
  • Changeset: minor for .software, .workflow, and the root block package.

Follow-ups (non-blocking)

  • Optionally log a warning when PL_COMPUTE_WORKERS is set but unparseable (currently falls back silently to os.cpu_count()).

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request parallelizes sequence property computations across CPU cores using a ProcessPoolExecutor in Python, while ensuring byte-identical outputs. It introduces a golden-master regression test suite, unit tests for parallel invariance, and updates the Tengo workflow to request 8 CPUs and 8GiB of memory. The review feedback highlights two important improvements for the parallel execution: using the spawn multiprocessing context instead of fork to prevent deadlocks when using polars in child processes (along with better executor lifecycle management on exceptions), and using os.sched_getaffinity(0) to resolve the worker count in containerized environments to avoid CPU thrashing.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread software/src/pipeline.py
Comment on lines +78 to +86
with ProcessPoolExecutor(max_workers=workers) as ex:
try:
return list(ex.map(fn, items, chunksize=chunksize))
except BrokenProcessPool as exc:
raise RuntimeError(
f"compute worker pool broke while processing {len(items)} items "
f"with {workers} workers — a worker process was killed (most likely "
f"out of memory). Reduce the input size or raise the step's memory."
) from exc

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Using the default fork start method on Linux with multithreaded libraries like polars can lead to deadlocks in child processes. Additionally, if a worker process raises an exception, the default behavior of ProcessPoolExecutor is to wait for all pending tasks to complete before propagating the exception, which can cause significant hangs on large datasets.

We can resolve both issues by:

  1. Explicitly using the spawn multiprocessing context to safely start worker processes.
  2. Managing the executor lifecycle to cancel pending futures and shut down immediately when an exception is encountered.
Suggested change
with ProcessPoolExecutor(max_workers=workers) as ex:
try:
return list(ex.map(fn, items, chunksize=chunksize))
except BrokenProcessPool as exc:
raise RuntimeError(
f"compute worker pool broke while processing {len(items)} items "
f"with {workers} workers — a worker process was killed (most likely "
f"out of memory). Reduce the input size or raise the step's memory."
) from exc
import multiprocessing
ctx = multiprocessing.get_context("spawn")
ex = ProcessPoolExecutor(max_workers=workers, mp_context=ctx)
try:
return list(ex.map(fn, items, chunksize=chunksize))
except BrokenProcessPool as exc:
ex.shutdown(wait=False, cancel_futures=True)
raise RuntimeError(
f"compute worker pool broke while processing {len(items)} items "
f"with {workers} workers — a worker process was killed (most likely "
f"out of memory). Reduce the input size or raise the step's memory."
) from exc
except Exception:
ex.shutdown(wait=False, cancel_futures=True)
raise
finally:
ex.shutdown(wait=True)

Comment thread software/src/pipeline.py
Comment on lines +55 to +67
def resolve_workers(workers: int | None) -> int:
"""How many worker processes to use. Explicit arg wins (used by tests and
by main.py once it reads the platform's CPU allocation). Falls back to the
PL_COMPUTE_WORKERS env var, then os.cpu_count(). The RESULT never depends on
this number — only the wall-clock does — so an over- or under-estimate is a
speed concern, never a correctness one.
"""
if workers is not None:
return max(1, int(workers))
env = os.environ.get("PL_COMPUTE_WORKERS")
if env and env.isdigit() and int(env) > 0:
return int(env)
return max(1, os.cpu_count() or 1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

In containerized environments (such as Docker, Kubernetes, or cloud runners), os.cpu_count() returns the physical host's CPU count rather than the container's allocated CPU limit/quota. Spawning too many worker processes can lead to severe CPU thrashing and out-of-memory (OOM) issues.

Using os.sched_getaffinity(0) (where available) correctly respects the container's CPU limits.

Suggested change
def resolve_workers(workers: int | None) -> int:
"""How many worker processes to use. Explicit arg wins (used by tests and
by main.py once it reads the platform's CPU allocation). Falls back to the
PL_COMPUTE_WORKERS env var, then os.cpu_count(). The RESULT never depends on
this numberonly the wall-clock doesso an over- or under-estimate is a
speed concern, never a correctness one.
"""
if workers is not None:
return max(1, int(workers))
env = os.environ.get("PL_COMPUTE_WORKERS")
if env and env.isdigit() and int(env) > 0:
return int(env)
return max(1, os.cpu_count() or 1)
def resolve_workers(workers: int | None) -> int:
"""How many worker processes to use. Explicit arg wins (used by tests and
by main.py once it reads the platform's CPU allocation). Falls back to the
PL_COMPUTE_WORKERS env var, then the container-aware CPU count (via affinity),
and finally os.cpu_count().
"""
if workers is not None:
return max(1, int(workers))
env = os.environ.get("PL_COMPUTE_WORKERS")
if env and env.isdigit() and int(env) > 0:
return int(env)
try:
return max(1, len(os.sched_getaffinity(0)))
except AttributeError:
return max(1, os.cpu_count() or 1)

@PaulNewling PaulNewling deleted the feat/seqprops-parallel-compute branch June 11, 2026 02:04
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.

1 participant