Files
2026-07-13 13:39:38 +08:00

301 lines
12 KiB
Python

from __future__ import annotations
import asyncio
import math
from collections.abc import Awaitable, Callable
from multiprocessing.context import BaseContext
from typing import Any, Literal
from .. import utils
from ..job import JobContext, JobExecutorType, JobProcess, RunningJobInfo
from ..log import logger
from ..utils import aio
from ..utils.hw.cpu import get_cpu_monitor
from . import inference_executor, job_proc_executor, job_thread_executor
from .job_executor import JobExecutor
EventTypes = Literal[
"process_created",
"process_started",
"process_ready",
"process_closed",
"process_job_launched",
]
MAX_CONCURRENT_INITIALIZATIONS = min(math.ceil(get_cpu_monitor().cpu_count()), 4)
class ProcPool(utils.EventEmitter[EventTypes]):
def __init__(
self,
*,
initialize_process_fnc: Callable[[JobProcess], Any],
job_entrypoint_fnc: Callable[[JobContext], Awaitable[None]],
session_end_fnc: Callable[[JobContext], Awaitable[None]] | None,
simulation_end_fnc: Callable[[Any], Any] | None,
num_idle_processes: int,
initialize_timeout: float,
close_timeout: float,
session_end_timeout: float,
inference_executor: inference_executor.InferenceExecutor | None,
job_executor_type: JobExecutorType,
mp_ctx: BaseContext,
memory_warn_mb: float,
memory_limit_mb: float,
http_proxy: str | None,
loop: asyncio.AbstractEventLoop,
) -> None:
super().__init__()
self._job_executor_type = job_executor_type
self._mp_ctx = mp_ctx
self._initialize_process_fnc = initialize_process_fnc
self._job_entrypoint_fnc = job_entrypoint_fnc
self._session_end_fnc = session_end_fnc
self._simulation_end_fnc = simulation_end_fnc
self._close_timeout = close_timeout
self._session_end_timeout = session_end_timeout
self._inf_executor = inference_executor
self._initialize_timeout = initialize_timeout
self._loop = loop
self._memory_limit_mb = memory_limit_mb
self._memory_warn_mb = memory_warn_mb
self._default_num_idle_processes = num_idle_processes
self._http_proxy = http_proxy
self._target_idle_processes = num_idle_processes
self._init_sem = asyncio.Semaphore(MAX_CONCURRENT_INITIALIZATIONS)
self._warmed_proc_queue = asyncio.Queue[JobExecutor]()
self._executors: list[JobExecutor] = []
self._spawn_tasks: set[asyncio.Task[None]] = set()
self._close_tasks: set[asyncio.Task[None]] = set()
self._monitor_tasks: set[asyncio.Task[None]] = set()
self._started = False
self._closed = False
self._idle_ready = asyncio.Event()
self._jobs_waiting_for_process = 0
@property
def processes(self) -> list[JobExecutor]:
return self._executors
def get_by_job_id(self, job_id: str) -> JobExecutor | None:
return next(
(x for x in self._executors if x.running_job and x.running_job.job.id == job_id),
None,
)
async def start(self) -> None:
if self._started:
return
self._started = True
self._main_atask = asyncio.create_task(self._main_task())
if self._default_num_idle_processes > 0:
# wait for the idle processes to be warmed up (by the main task)
# use a timeout so start() doesn't block forever if initialization fails
try:
await asyncio.wait_for(
self._idle_ready.wait(),
timeout=self._initialize_timeout + 2,
)
except asyncio.TimeoutError:
logger.warning("timed out waiting for idle processes to initialize")
async def aclose(self) -> None:
if not self._started:
return
self._closed = True
await aio.cancel_and_wait(self._main_atask)
async def _acquire_proc(self, job_id: str) -> JobExecutor:
MAX_ACQUIRE_ATTEMPTS = 3
for attempt in range(MAX_ACQUIRE_ATTEMPTS):
if (
self._warmed_proc_queue.empty()
and len(self._spawn_tasks) < self._jobs_waiting_for_process
):
# spawn a new process if there are no idle processes
task = asyncio.create_task(self._proc_spawn_task())
self._spawn_tasks.add(task)
task.add_done_callback(self._spawn_tasks.discard)
if self._warmed_proc_queue.empty():
logger.warning(
"no warmed process available for job, waiting for one to be created",
extra={"job_id": job_id},
)
# race the queue against every in-flight spawn task
while True:
if not self._warmed_proc_queue.empty():
return self._warmed_proc_queue.get_nowait()
spawns = [t for t in self._spawn_tasks if not t.done()]
# retry if all in-flight spawns have completed without producing a proc
if not spawns:
break
get_task = asyncio.ensure_future(self._warmed_proc_queue.get())
try:
await asyncio.wait([get_task, *spawns], return_when=asyncio.FIRST_COMPLETED)
finally:
if not get_task.done():
get_task.cancel()
if get_task.done() and not get_task.cancelled():
return get_task.result()
logger.warning(
"all in-flight spawns failed to initialize, retrying",
extra={"job_id": job_id, "attempt": attempt + 1},
)
logger.error(
"failed to acquire process for job after %d attempts",
MAX_ACQUIRE_ATTEMPTS,
extra={"job_id": job_id},
)
raise RuntimeError(f"no process became available after {MAX_ACQUIRE_ATTEMPTS} attempts")
async def launch_job(self, info: RunningJobInfo) -> None:
MAX_LAUNCH_ATTEMPTS = 3
for attempt in range(MAX_LAUNCH_ATTEMPTS):
self._jobs_waiting_for_process += 1
try:
proc = await self._acquire_proc(info.job.id)
finally:
self._jobs_waiting_for_process -= 1
try:
await proc.launch_job(info)
self.emit("process_job_launched", proc)
return
except Exception:
close_task = asyncio.create_task(proc.aclose())
self._close_tasks.add(close_task)
close_task.add_done_callback(self._close_tasks.discard)
if attempt == MAX_LAUNCH_ATTEMPTS - 1:
logger.error(
"failed to launch job on process after %d attempts",
MAX_LAUNCH_ATTEMPTS,
extra={"job_id": info.job.id},
)
raise
logger.warning(
"failed to launch job on process, retrying with a new process",
extra={"job_id": info.job.id, "attempt": attempt + 1},
)
def set_target_idle_processes(self, num_idle_processes: int) -> None:
self._target_idle_processes = num_idle_processes
@property
def target_idle_processes(self) -> int:
return self._target_idle_processes
@utils.log_exceptions(logger=logger)
async def _proc_spawn_task(self) -> None:
proc: JobExecutor
if self._job_executor_type == JobExecutorType.THREAD:
proc = job_thread_executor.ThreadJobExecutor(
initialize_process_fnc=self._initialize_process_fnc,
job_entrypoint_fnc=self._job_entrypoint_fnc,
session_end_fnc=self._session_end_fnc,
simulation_end_fnc=self._simulation_end_fnc,
initialize_timeout=self._initialize_timeout,
close_timeout=self._close_timeout,
session_end_timeout=self._session_end_timeout,
inference_executor=self._inf_executor,
ping_interval=2.5,
high_ping_threshold=0.5,
http_proxy=self._http_proxy,
loop=self._loop,
)
elif self._job_executor_type == JobExecutorType.PROCESS:
proc = job_proc_executor.ProcJobExecutor(
initialize_process_fnc=self._initialize_process_fnc,
job_entrypoint_fnc=self._job_entrypoint_fnc,
session_end_fnc=self._session_end_fnc,
simulation_end_fnc=self._simulation_end_fnc,
initialize_timeout=self._initialize_timeout,
close_timeout=self._close_timeout,
session_end_timeout=self._session_end_timeout,
inference_executor=self._inf_executor,
mp_ctx=self._mp_ctx,
loop=self._loop,
ping_interval=2.5,
ping_timeout=60,
high_ping_threshold=0.5,
memory_warn_mb=self._memory_warn_mb,
memory_limit_mb=self._memory_limit_mb,
http_proxy=self._http_proxy,
)
else:
raise ValueError(f"unsupported job executor: {self._job_executor_type}")
self._executors.append(proc)
initialized = False
try:
async with self._init_sem:
if not self._closed:
self.emit("process_created", proc)
await proc.start()
self.emit("process_started", proc)
await proc.initialize()
self.emit("process_ready", proc)
self._warmed_proc_queue.put_nowait(proc)
if self._warmed_proc_queue.qsize() >= self._default_num_idle_processes:
self._idle_ready.set()
initialized = True
except Exception:
logger.exception("error initializing process", extra=proc.logging_extra())
except asyncio.CancelledError:
pass
if not initialized:
self._executors.remove(proc)
await proc.aclose()
self.emit("process_closed", proc)
return
monitor_task = asyncio.create_task(self._monitor_process_task(proc))
self._monitor_tasks.add(monitor_task)
monitor_task.add_done_callback(self._monitor_tasks.discard)
@utils.log_exceptions(logger=logger)
async def _monitor_process_task(self, proc: JobExecutor) -> None:
try:
await proc.join()
self.emit("process_closed", proc)
finally:
self._executors.remove(proc)
@utils.log_exceptions(logger=logger)
async def _main_task(self) -> None:
try:
while not self._closed:
current_pending = self._warmed_proc_queue.qsize() + len(self._spawn_tasks)
target = max(
min(self._target_idle_processes, self._default_num_idle_processes),
self._jobs_waiting_for_process,
)
to_spawn = target - current_pending
for _ in range(to_spawn):
task = asyncio.create_task(self._proc_spawn_task())
self._spawn_tasks.add(task)
task.add_done_callback(self._spawn_tasks.discard)
await asyncio.sleep(0.1)
except asyncio.CancelledError:
await aio.cancel_and_wait(*self._spawn_tasks)
await asyncio.gather(*[proc.aclose() for proc in self._executors])
await asyncio.gather(*self._close_tasks)
await asyncio.gather(*self._monitor_tasks)