|
2 | 2 | import logging |
3 | 3 | import os |
4 | 4 | import re |
| 5 | +import shutil |
5 | 6 | import subprocess |
6 | 7 | import time |
7 | 8 | import uuid |
@@ -93,6 +94,7 @@ def __init__( |
93 | 94 | head_memory_in_gb: Optional[int] = None, |
94 | 95 | runtime_env_pip_cache_size_gb: int = 30, # Ray default is 10 GB |
95 | 96 | force_clean_up: bool = True, |
| 97 | + enable_container_runtime: bool = False, |
96 | 98 | # SLURM Worker Configuration parameters |
97 | 99 | image: str = f"ghcr.io/aicell-lab/bioengine-worker:{bioengine.__version__}", |
98 | 100 | worker_workspace_dir: Optional[str] = None, |
@@ -135,6 +137,10 @@ def __init__( |
135 | 137 | head_memory_in_gb: Memory limit for head node in GB. If not set, Ray will auto-detect available memory. |
136 | 138 | runtime_env_pip_cache_size_gb: Size of pip cache for runtime environments in GB. Default 30. |
137 | 139 | force_clean_up: Force cleanup of previous Ray cluster on start. Default True. |
| 140 | + enable_container_runtime: Opt-in container-as-runtime for apps |
| 141 | + using ``@bioengine.app(container_image=…)`` (single-machine |
| 142 | + mode). Generates a podman-compatible CDI spec at startup for |
| 143 | + nested-GPU passthrough. Default False. |
138 | 144 | image: Container image for workers (SLURM mode). Default bioengine-worker. |
139 | 145 | worker_workspace_dir: Workspace directory mounted to worker containers (SLURM mode). |
140 | 146 | default_num_gpus: Default GPU count per worker. Default 1. |
@@ -174,6 +180,7 @@ def __init__( |
174 | 180 | "Supported modes are 'slurm', 'single-machine' and 'external-cluster'." |
175 | 181 | ) |
176 | 182 | self.mode = mode |
| 183 | + self.enable_container_runtime = bool(enable_container_runtime) |
177 | 184 |
|
178 | 185 | # Initialize cluster state and monitoring attributes |
179 | 186 | self.address = None |
@@ -582,6 +589,105 @@ def _update_symlink(self, ray_temp_dir: Path) -> None: |
582 | 589 | self.logger.error(f"Symlink '{symlink_path}' does not exist") |
583 | 590 | raise FileNotFoundError(f"Symlink '{symlink_path}' does not exist") |
584 | 591 |
|
| 592 | + async def _generate_cdi_spec(self) -> None: |
| 593 | + """Emit a podman-4.9.3-compatible CDI spec for nested-GPU passthrough. |
| 594 | +
|
| 595 | + Container-as-runtime replicas run in a nested podman container; the |
| 596 | + only GPU injector that works there is native podman CDI |
| 597 | + (``--device nvidia.com/gpu=all``). Two host-specific fixups are baked |
| 598 | + in because the recipe was validated on a double-nested cgroup-v1 host: |
| 599 | +
|
| 600 | + * ``--disable-hook update-ldcache`` — the ldcache hook runs ``ldconfig`` |
| 601 | + via a mount op that fails in the nested container; the driver libs |
| 602 | + are reached via ``LD_LIBRARY_PATH`` instead (exported below and read |
| 603 | + by ``bootstrap._with_pkg`` as a podman ``-e`` run option). |
| 604 | + * down-version to ``cdiVersion: 0.6.0`` and strip the 0.7.0-only |
| 605 | + ``additionalGids`` field — podman 4.9.3's CDI parser predates 0.7.0. |
| 606 | +
|
| 607 | + No-op with a warning if ``nvidia-ctk`` is absent or the process is not |
| 608 | + root (a rootless worker can't write ``/etc/cdi`` nor create a usable |
| 609 | + CUDA context anyway). |
| 610 | + """ |
| 611 | + if os.geteuid() != 0: |
| 612 | + self.logger.warning( |
| 613 | + "enable_container_runtime is set but the worker is not running " |
| 614 | + "as root; skipping CDI generation. GPU container apps will fail." |
| 615 | + ) |
| 616 | + return |
| 617 | + |
| 618 | + nvidia_ctk = shutil.which("nvidia-ctk") |
| 619 | + if not nvidia_ctk: |
| 620 | + self.logger.warning( |
| 621 | + "enable_container_runtime is set but 'nvidia-ctk' was not found; " |
| 622 | + "skipping CDI generation. GPU container apps will fail." |
| 623 | + ) |
| 624 | + return |
| 625 | + |
| 626 | + cdi_path = "/etc/cdi/nvidia.yaml" |
| 627 | + await asyncio.to_thread( |
| 628 | + lambda: Path(cdi_path).parent.mkdir(parents=True, exist_ok=True) |
| 629 | + ) |
| 630 | + |
| 631 | + proc = await asyncio.create_subprocess_exec( |
| 632 | + nvidia_ctk, |
| 633 | + "cdi", |
| 634 | + "generate", |
| 635 | + f"--output={cdi_path}", |
| 636 | + "--disable-hook", |
| 637 | + "update-ldcache", |
| 638 | + stdout=asyncio.subprocess.PIPE, |
| 639 | + stderr=asyncio.subprocess.PIPE, |
| 640 | + ) |
| 641 | + _, stderr = await proc.communicate() |
| 642 | + if proc.returncode != 0: |
| 643 | + raise subprocess.CalledProcessError( |
| 644 | + proc.returncode, |
| 645 | + "nvidia-ctk cdi generate", |
| 646 | + stderr=stderr.decode() if stderr else "Unknown error", |
| 647 | + ) |
| 648 | + |
| 649 | + def _downversion_spec() -> None: |
| 650 | + lines = Path(cdi_path).read_text().splitlines() |
| 651 | + out = [] |
| 652 | + for line in lines: |
| 653 | + if "additionalgids" in line.lower(): |
| 654 | + continue |
| 655 | + # additionalGids' numeric list items (e.g. `- 44`) would otherwise |
| 656 | + # orphan into the preceding deviceNodes list once the key is gone, |
| 657 | + # producing a bare number where podman expects a DeviceNode. No |
| 658 | + # other list entry in the spec is a bare integer, so drop them. |
| 659 | + if re.fullmatch(r"\s*-\s*\d+\s*", line): |
| 660 | + continue |
| 661 | + if line.startswith("cdiVersion:"): |
| 662 | + line = "cdiVersion: 0.6.0" |
| 663 | + out.append(line) |
| 664 | + Path(cdi_path).write_text("\n".join(out) + "\n") |
| 665 | + |
| 666 | + await asyncio.to_thread(_downversion_spec) |
| 667 | + |
| 668 | + # Point replicas' LD_LIBRARY_PATH at the host dir holding libcuda.so.1 |
| 669 | + # (CDI mounts driver libs at their host paths inside the container). |
| 670 | + ld_dir = "/usr/lib64" |
| 671 | + ldconfig = shutil.which("ldconfig") |
| 672 | + if ldconfig: |
| 673 | + proc = await asyncio.create_subprocess_exec( |
| 674 | + ldconfig, |
| 675 | + "-p", |
| 676 | + stdout=asyncio.subprocess.PIPE, |
| 677 | + stderr=asyncio.subprocess.DEVNULL, |
| 678 | + ) |
| 679 | + stdout, _ = await proc.communicate() |
| 680 | + for line in stdout.decode().splitlines(): |
| 681 | + if "libcuda.so.1" in line and "=>" in line: |
| 682 | + ld_dir = str(Path(line.split("=>")[-1].strip()).parent) |
| 683 | + break |
| 684 | + os.environ["BIOENGINE_CONTAINER_LD_LIBRARY_PATH"] = ld_dir |
| 685 | + |
| 686 | + self.logger.info( |
| 687 | + f"Generated CDI spec at {cdi_path} (cdiVersion 0.6.0); " |
| 688 | + f"container GPU LD_LIBRARY_PATH={ld_dir}" |
| 689 | + ) |
| 690 | + |
585 | 691 | async def _start_cluster(self) -> None: |
586 | 692 | """Start Ray cluster head node with configured ports and resources. |
587 | 693 |
|
@@ -614,6 +720,11 @@ async def _start_cluster(self) -> None: |
614 | 720 | # Check and set cluster ports |
615 | 721 | await asyncio.to_thread(self._set_cluster_ports) |
616 | 722 |
|
| 723 | + # Opt-in container-as-runtime: emit a podman-compatible CDI spec so |
| 724 | + # GPU apps can run inside their image (single-machine mode only). |
| 725 | + if self.enable_container_runtime: |
| 726 | + await self._generate_cdi_spec() |
| 727 | + |
617 | 728 | # Start ray as the head node with the specified parameters |
618 | 729 | args = [ |
619 | 730 | "start", |
|
0 commit comments