Heavy routes run in worker processes, one per heavy slot (issue #133, BUG-1493)
The single ThreadingHTTPServer ran pure-Python v5 score / block_scan / report reference under one GIL, so the gate's two slots shared a core. start_server now starts a spawn pool (workers = heavy concurrency, PYTHONHASHSEED=0, engine pre-imported); those three routes call the same compute method in a worker. Over HTTP: score alone 4.74 s, two at once 5.01 / 4.58 s (1.06x; in-process 1.56x). Results equal except per-process fingerprints; report markdown equal. ~165 MB extra. A crashed pool answers inline and restarts; off with JYOTISH_HEAVY_COMPUTE_PROCESSES=0; tests calling compute methods unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5fd79a5649
commit
8d1cb4a53b
@@ -0,0 +1 @@
|
||||
"""Process pool for the heaviest API computations (issue #133)."""
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Run the heaviest compute endpoints in worker processes (issue #133).
|
||||
|
||||
The API server is one ``ThreadingHTTPServer`` process. Its heavy routes are
|
||||
pure-Python number crunching, so two requests inside the gate's two slots
|
||||
still shared one core under the GIL: one score alone 1.19 s, two at once
|
||||
2.35-2.6 s each. Here the same compute methods run on a fresh
|
||||
``JyotishComputeService`` in a spawn-started worker, one worker per heavy slot,
|
||||
so two requests use two cores. The heavy-compute gate still decides who runs.
|
||||
|
||||
- Enabled only by the server's ``main()`` (and ``JYOTISH_HEAVY_COMPUTE_PROCESSES``
|
||||
not ``0``); tests that call compute methods in process are unchanged.
|
||||
- Workers start with ``PYTHONHASHSEED=0`` (ERR-111: Shadbala fingerprints vary
|
||||
with the hash seed), and are started up front so the first user does not
|
||||
pay the import.
|
||||
- A crashed pool is replaced; the request that hit it runs in process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import multiprocessing
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from concurrent.futures.process import BrokenProcessPool
|
||||
from typing import Any
|
||||
|
||||
ENV_PROCESSES = "JYOTISH_HEAVY_COMPUTE_PROCESSES"
|
||||
POOLED_METHODS = frozenset({
|
||||
"_compute_rectification_v5_score",
|
||||
"_compute_rectification_v5_block_scan",
|
||||
"professional_report_reference",
|
||||
})
|
||||
|
||||
_guard = threading.Lock()
|
||||
_executor: ProcessPoolExecutor | None = None
|
||||
_workers = 0
|
||||
_config: tuple[int, list[str]] | None = None
|
||||
_service = None
|
||||
|
||||
|
||||
def _worker_init(paths: list[str]) -> None:
|
||||
for path in reversed(paths):
|
||||
if path not in sys.path:
|
||||
sys.path.insert(0, path)
|
||||
|
||||
|
||||
def _worker_call(method: str, body: dict[str, Any]) -> Any:
|
||||
global _service
|
||||
from scripts.jyotish_api_server import BadRequest, JyotishComputeService
|
||||
|
||||
if _service is None:
|
||||
_service = JyotishComputeService()
|
||||
if method == "professional_report_reference":
|
||||
from scripts.professional_report_reference import (
|
||||
ProfessionalReportReferenceInputError,
|
||||
build_professional_report_reference,
|
||||
)
|
||||
try:
|
||||
return build_professional_report_reference(_service, body)
|
||||
except ProfessionalReportReferenceInputError as exc:
|
||||
raise BadRequest(str(exc)) from exc
|
||||
return getattr(_service, method)(body)
|
||||
|
||||
|
||||
def _warm() -> int:
|
||||
from scripts import jyotish_api_server # noqa: F401 - import the engine once per worker
|
||||
|
||||
return os.getpid()
|
||||
|
||||
|
||||
def enabled_by_env() -> bool:
|
||||
return os.environ.get(ENV_PROCESSES, "1").strip().lower() not in {"0", "false", "no", "off"}
|
||||
|
||||
|
||||
def start(workers: int, paths: list[str]) -> bool:
|
||||
"""Start the pool with ``workers`` processes; False when switched off."""
|
||||
global _executor, _workers, _config
|
||||
if workers < 1 or not enabled_by_env():
|
||||
return False
|
||||
_config = (workers, list(paths))
|
||||
with _guard:
|
||||
if _executor is not None:
|
||||
return True
|
||||
previous = os.environ.get("PYTHONHASHSEED")
|
||||
os.environ["PYTHONHASHSEED"] = "0"
|
||||
try:
|
||||
executor = ProcessPoolExecutor(
|
||||
max_workers=workers,
|
||||
mp_context=multiprocessing.get_context("spawn"),
|
||||
initializer=_worker_init,
|
||||
initargs=(paths,),
|
||||
)
|
||||
# Start every worker now, while the hash seed is in the environment.
|
||||
for future in [executor.submit(_warm) for _ in range(workers)]:
|
||||
future.result(timeout=120)
|
||||
finally:
|
||||
if previous is None:
|
||||
os.environ.pop("PYTHONHASHSEED", None)
|
||||
else:
|
||||
os.environ["PYTHONHASHSEED"] = previous
|
||||
_executor, _workers = executor, workers
|
||||
return True
|
||||
|
||||
|
||||
def active() -> bool:
|
||||
return _executor is not None
|
||||
|
||||
|
||||
def run(method: str, body: dict[str, Any], inline) -> Any:
|
||||
"""``method`` in a worker when the pool is up, else ``inline(body)`` in this process."""
|
||||
executor = _executor
|
||||
if executor is None or method not in POOLED_METHODS:
|
||||
return inline(body)
|
||||
try:
|
||||
return executor.submit(_worker_call, method, body).result()
|
||||
except BrokenProcessPool:
|
||||
_replace_broken(executor)
|
||||
return inline(body)
|
||||
|
||||
|
||||
def _replace_broken(broken: ProcessPoolExecutor) -> None:
|
||||
global _executor
|
||||
with _guard:
|
||||
if _executor is broken:
|
||||
_executor = None
|
||||
broken.shutdown(wait=False, cancel_futures=True)
|
||||
if _config is not None:
|
||||
threading.Thread(target=start, args=_config, name="heavy-pool-restart", daemon=True).start()
|
||||
|
||||
|
||||
def stop() -> None:
|
||||
global _executor
|
||||
with _guard:
|
||||
executor, _executor = _executor, None
|
||||
if executor is not None:
|
||||
executor.shutdown(wait=True, cancel_futures=True)
|
||||
Reference in New Issue
Block a user