Skip to content

Commit 575ab8d

Browse files
committed
[act.execution.σ.D8+] Serialize local Qwen and scientific GPU ownership
The authorized single-RTX deployment needs an explicit ownership transfer, not implicit co-residency. Stop the resident Qwen service under a process/file lock, prove enough memory was released, hold exclusion for the remote scientific job, and restore Qwen on every exit. Bind Kubernetes scheduling labels to the same torch CUDA capacity instrument used by the worker and actively enforce numeric mode before attestation.
1 parent 6f4652e commit 575ab8d

7 files changed

Lines changed: 293 additions & 10 deletions

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Exclusive host-GPU handoff between the resident local reasoner and Motif jobs."""
2+
from __future__ import annotations
3+
4+
from contextlib import contextmanager
5+
import fcntl
6+
import os
7+
from pathlib import Path
8+
import subprocess
9+
import threading
10+
import time
11+
from typing import Callable, Iterator, Sequence
12+
13+
import failures
14+
15+
16+
class SharedGpuCoordinator:
17+
"""Stop the resident Qwen service while one admitted scientific GPU job runs.
18+
19+
The process lock serializes controller threads; the file lock extends the same
20+
exclusion boundary across API processes. The service is restarted only when
21+
this coordinator observed it active before the handoff.
22+
"""
23+
24+
def __init__(self, *, lock_path: Path, service: str = "dirac-qwen.service",
25+
runner: Callable[..., subprocess.CompletedProcess] = subprocess.run,
26+
sleep: Callable[[float], None] = time.sleep,
27+
timeout_seconds: float = 120.0,
28+
poll_seconds: float = 1.0) -> None:
29+
self.lock_path = lock_path.resolve()
30+
self.service = service
31+
self._runner = runner
32+
self._sleep = sleep
33+
self.timeout_seconds = max(1.0, float(timeout_seconds))
34+
self.poll_seconds = max(.05, float(poll_seconds))
35+
self._thread_lock = threading.Lock()
36+
37+
def _run(self, command: Sequence[str], *, check: bool = True
38+
) -> subprocess.CompletedProcess:
39+
try:
40+
return self._runner(
41+
list(command), check=check, capture_output=True, text=True,
42+
timeout=min(30.0, self.timeout_seconds))
43+
except (OSError, subprocess.SubprocessError) as error:
44+
raise failures.DiracUnsupported(
45+
"shared GPU handoff command failed",
46+
details={"command": list(command),
47+
"error": f"{type(error).__name__}: {error}"}) from error
48+
49+
def _is_reasoner_active(self) -> bool:
50+
result = self._run(
51+
["systemctl", "--user", "is-active", self.service], check=False)
52+
if result.returncode == 0 and result.stdout.strip() == "active":
53+
return True
54+
if result.stdout.strip() in {"inactive", "failed", "unknown"}:
55+
return False
56+
raise failures.DiracUnsupported(
57+
"cannot establish local reasoner service state before GPU handoff",
58+
details={"service": self.service, "returncode": result.returncode,
59+
"stdout": result.stdout.strip(),
60+
"stderr": result.stderr.strip()})
61+
62+
def _free_memory_bytes(self) -> int:
63+
result = self._run([
64+
"nvidia-smi", "--query-gpu=memory.free",
65+
"--format=csv,noheader,nounits",
66+
])
67+
try:
68+
return int(result.stdout.splitlines()[0].strip()) * (1 << 20)
69+
except (ValueError, IndexError) as error:
70+
raise failures.DiracUnsupported(
71+
"cannot parse free GPU memory during shared GPU handoff",
72+
details={"stdout": result.stdout[:256]}) from error
73+
74+
def _wait_for_memory(self, required_bytes: int) -> None:
75+
deadline = time.monotonic() + self.timeout_seconds
76+
observed = 0
77+
while time.monotonic() <= deadline:
78+
observed = self._free_memory_bytes()
79+
if observed >= required_bytes:
80+
return
81+
self._sleep(self.poll_seconds)
82+
raise failures.DiracUnsupported(
83+
"local reasoner stopped but the scientific GPU memory requirement "
84+
"was not released before timeout",
85+
details={"required_bytes": required_bytes,
86+
"observed_free_bytes": observed,
87+
"timeout_seconds": self.timeout_seconds})
88+
89+
@contextmanager
90+
def exclusive(self, *, required_bytes: int) -> Iterator[None]:
91+
if type(required_bytes) is not int or required_bytes < 1:
92+
raise failures.DiracInternal(
93+
"shared GPU handoff requires a positive byte capacity")
94+
self.lock_path.parent.mkdir(parents=True, exist_ok=True)
95+
with self._thread_lock, self.lock_path.open("a+b") as lock_file:
96+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
97+
reasoner_was_active = False
98+
body_error: BaseException | None = None
99+
try:
100+
reasoner_was_active = self._is_reasoner_active()
101+
if reasoner_was_active:
102+
self._run(["systemctl", "--user", "stop", self.service])
103+
self._wait_for_memory(required_bytes)
104+
yield
105+
except BaseException as error:
106+
body_error = error
107+
raise
108+
finally:
109+
if reasoner_was_active:
110+
try:
111+
self._run(["systemctl", "--user", "start", self.service])
112+
except BaseException:
113+
if body_error is None:
114+
raise
115+
# Preserve the scientific execution failure as the primary
116+
# exception; systemd records the independent restart failure.
117+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
118+
119+
120+
def coordinator_from_environment(repository: Path) -> SharedGpuCoordinator | None:
121+
"""Build only under the operator's explicit same-GPU deployment grant."""
122+
if os.environ.get("DIRAC_ALLOW_SHARED_GPU_AI") != "1":
123+
return None
124+
return SharedGpuCoordinator(
125+
lock_path=Path(os.environ.get(
126+
"DIRAC_SHARED_GPU_LOCK",
127+
repository / ".runtime/shared-gpu/scientific-execution.lock")),
128+
service=os.environ.get("DIRAC_QWEN_SYSTEMD_SERVICE", "dirac-qwen.service"),
129+
timeout_seconds=float(os.environ.get(
130+
"DIRAC_SHARED_GPU_RELEASE_TIMEOUT_SECONDS", "120")),
131+
)

‎backend/executors/kubernetes_invocation.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,14 +155,16 @@ def __init__(self, *, adapter: Any, exchange_root: Path,
155155
container_image: str, max_controllers: int = 4,
156156
poll_seconds: float = .25, resource_broker: Any | None = None,
157157
attempt_store: Any | None = None,
158-
artifact_reader: Any | None = None) -> None:
158+
artifact_reader: Any | None = None,
159+
shared_gpu_coordinator: Any | None = None) -> None:
159160
self.adapter = adapter
160161
self.exchange_root = exchange_root.resolve()
161162
self.container_image = container_image
162163
self.poll_seconds = max(.05, float(poll_seconds))
163164
self.resource_broker = resource_broker
164165
self.attempt_store = attempt_store
165166
self.artifact_reader = artifact_reader
167+
self.shared_gpu_coordinator = shared_gpu_coordinator
166168
self._pool = ThreadPoolExecutor(
167169
max_workers=max(1, int(max_controllers)),
168170
thread_name_prefix="dirac-k8s-controller")
@@ -227,6 +229,13 @@ def execute(self, handler: Callable[..., HandlerResult], payload: dict,
227229
pass
228230
if route != "kubernetes":
229231
raise failures.DiracInternal(f"unsupported admitted execution route {route!r}")
232+
if self.shared_gpu_coordinator is not None:
233+
profile = ((ctx.spec.execution.get("scale_profile") or {})
234+
if ctx.spec is not None else {})
235+
required_bytes = int(profile.get("gpu_memory_bytes") or 1)
236+
with self.shared_gpu_coordinator.exclusive(
237+
required_bytes=required_bytes):
238+
return self._execute_remote(handler, payload, ctx)
230239
return self._execute_remote(handler, payload, ctx)
231240

232241
@staticmethod

‎backend/kernel.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,11 @@ def default_executor():
121121
"process_slots": 20, "scf_slots": 2, "campaign_credits": 1e12,
122122
})
123123
attempt_store = PostgresAttemptStore(lambda: psycopg.connect(DEFAULT_DSN))
124+
from execution_control.shared_gpu import coordinator_from_environment
124125
return KubernetesInvocationExecutor(
125126
adapter=adapter, exchange_root=exchange, container_image=worker_image,
126-
resource_broker=broker, attempt_store=attempt_store)
127+
resource_broker=broker, attempt_store=attempt_store,
128+
shared_gpu_coordinator=coordinator_from_environment(repository))
127129

128130

129131
def toolkit_versions() -> dict[str, str]:

‎backend/motif_worker.py‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,27 @@ def _gpu_evidence(request: dict[str, Any]) -> dict[str, Any]:
8484
f"worker GPU architecture {architecture} does not match admitted "
8585
f"constraint {requested_arch}")
8686
numeric_mode = str(request["determinism"].get("numeric_mode") or "")
87+
dtype_by_mode = {
88+
"fp64": getattr(torch, "float64", None),
89+
"fp32": getattr(torch, "float32", None),
90+
"tf32": getattr(torch, "float32", None),
91+
"bf16": getattr(torch, "bfloat16", None),
92+
"fp16": getattr(torch, "float16", None),
93+
}
94+
target_dtype = dtype_by_mode.get(numeric_mode)
95+
set_default_dtype = getattr(torch, "set_default_dtype", None)
96+
if target_dtype is not None and callable(set_default_dtype):
97+
set_default_dtype(target_dtype)
98+
backends = getattr(torch, "backends", None)
99+
cuda_backend = getattr(backends, "cuda", None)
100+
matmul_backend = getattr(cuda_backend, "matmul", None)
101+
cudnn_backend = getattr(backends, "cudnn", None)
102+
if numeric_mode in {"fp32", "tf32"}:
103+
expected_tf32 = numeric_mode == "tf32"
104+
if matmul_backend is not None:
105+
matmul_backend.allow_tf32 = expected_tf32
106+
if cudnn_backend is not None:
107+
cudnn_backend.allow_tf32 = expected_tf32
87108
memory_bytes = int(torch.cuda.get_device_properties(0).total_memory)
88109
admitted_memory_bytes = int(
89110
request["resource_request"].get("gpu_memory_bytes_min") or 0)
@@ -102,10 +123,6 @@ def _gpu_evidence(request: dict[str, Any]) -> dict[str, Any]:
102123
raise RuntimeError(
103124
f"worker numeric mode {numeric_mode!r} is not attested by default "
104125
f"dtype {actual_dtype!r}")
105-
backends = getattr(torch, "backends", None)
106-
cuda_backend = getattr(backends, "cuda", None)
107-
matmul_backend = getattr(cuda_backend, "matmul", None)
108-
cudnn_backend = getattr(backends, "cudnn", None)
109126
matmul_tf32 = getattr(matmul_backend, "allow_tf32", None)
110127
cudnn_tf32 = getattr(cudnn_backend, "allow_tf32", None)
111128
if numeric_mode in {"fp32", "tf32"}:

‎backend/tests/test_kubernetes_invocation.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -531,7 +531,7 @@ def test_worker_observes_compute_capability_and_rejects_architecture_mismatch(se
531531
"gpu_memory_bytes_min": 8 << 30},
532532
})
533533

534-
def test_worker_refuses_tf32_label_when_cuda_tf32_paths_are_disabled(self):
534+
def test_worker_configures_tf32_paths_to_match_the_admitted_numeric_mode(self):
535535
cuda = SimpleNamespace(
536536
is_available=lambda: True,
537537
device_count=lambda: 1,
@@ -554,8 +554,9 @@ def test_worker_refuses_tf32_label_when_cuda_tf32_paths_are_disabled(self):
554554
}
555555

556556
with mock.patch.dict("sys.modules", {"torch": torch}):
557-
with self.assertRaisesRegex(RuntimeError, "TF32 flags"):
558-
motif_worker._gpu_evidence(request)
557+
evidence = motif_worker._gpu_evidence(request)
558+
self.assertTrue(evidence["matmul_allow_tf32"])
559+
self.assertTrue(evidence["cudnn_allow_tf32"])
559560

560561
def test_unauthorized_cancel_cannot_touch_owner_cancellation_token(self):
561562
ledger = MemoryJobStore()
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
import subprocess
5+
6+
import pytest
7+
8+
import failures
9+
from execution_control.shared_gpu import SharedGpuCoordinator
10+
11+
12+
class CommandRunner:
13+
def __init__(self, *, active: bool = True, free_mib: list[int] | None = None):
14+
self.active = active
15+
self.free_mib = list(free_mib or [16_000])
16+
self.commands: list[tuple[str, ...]] = []
17+
18+
def __call__(self, command, **_kwargs):
19+
command = tuple(command)
20+
self.commands.append(command)
21+
if command[:3] == ("systemctl", "--user", "is-active"):
22+
state = "active" if self.active else "inactive"
23+
return subprocess.CompletedProcess(command, 0 if self.active else 3,
24+
state + "\n", "")
25+
if command[:3] == ("systemctl", "--user", "stop"):
26+
self.active = False
27+
return subprocess.CompletedProcess(command, 0, "", "")
28+
if command[:3] == ("systemctl", "--user", "start"):
29+
self.active = True
30+
return subprocess.CompletedProcess(command, 0, "", "")
31+
if command[0] == "nvidia-smi":
32+
value = self.free_mib.pop(0) if len(self.free_mib) > 1 else self.free_mib[0]
33+
return subprocess.CompletedProcess(command, 0, f"{value}\n", "")
34+
raise AssertionError(command)
35+
36+
37+
def test_active_reasoner_is_stopped_before_gpu_work_and_restarted(tmp_path: Path):
38+
runner = CommandRunner(active=True, free_mib=[2_000, 15_000])
39+
coordinator = SharedGpuCoordinator(
40+
lock_path=tmp_path / "gpu.lock", runner=runner, sleep=lambda _value: None,
41+
timeout_seconds=5, poll_seconds=.01)
42+
43+
with coordinator.exclusive(required_bytes=12_000 << 20):
44+
assert runner.active is False
45+
runner.commands.append(("scientific-gpu-work",))
46+
47+
assert runner.active is True
48+
assert runner.commands.index(("systemctl", "--user", "stop",
49+
"dirac-qwen.service")) < runner.commands.index(
50+
("scientific-gpu-work",))
51+
assert runner.commands[-1] == (
52+
"systemctl", "--user", "start", "dirac-qwen.service")
53+
54+
55+
def test_reasoner_is_restarted_when_scientific_work_fails(tmp_path: Path):
56+
runner = CommandRunner(active=True)
57+
coordinator = SharedGpuCoordinator(
58+
lock_path=tmp_path / "gpu.lock", runner=runner)
59+
60+
with pytest.raises(RuntimeError, match="scientific failure"):
61+
with coordinator.exclusive(required_bytes=1 << 20):
62+
raise RuntimeError("scientific failure")
63+
64+
assert runner.active is True
65+
66+
67+
def test_inactive_reasoner_is_not_started_by_the_coordinator(tmp_path: Path):
68+
runner = CommandRunner(active=False)
69+
coordinator = SharedGpuCoordinator(
70+
lock_path=tmp_path / "gpu.lock", runner=runner)
71+
72+
with coordinator.exclusive(required_bytes=1 << 20):
73+
pass
74+
75+
assert not any(command[:3] == ("systemctl", "--user", "start")
76+
for command in runner.commands)
77+
78+
79+
def test_insufficient_released_memory_fails_closed(tmp_path: Path, monkeypatch):
80+
runner = CommandRunner(active=True, free_mib=[100])
81+
ticks = iter([0.0, 0.0, 2.0, 2.0])
82+
monkeypatch.setattr("execution_control.shared_gpu.time.monotonic",
83+
lambda: next(ticks, 2.0))
84+
coordinator = SharedGpuCoordinator(
85+
lock_path=tmp_path / "gpu.lock", runner=runner, sleep=lambda _value: None,
86+
timeout_seconds=1, poll_seconds=.01)
87+
88+
with pytest.raises(failures.DiracUnsupported, match="was not released"):
89+
with coordinator.exclusive(required_bytes=10_000 << 20):
90+
raise AssertionError("must not execute")
91+
92+
assert runner.active is True

‎deploy/kubernetes/activate-motif-gpu-quota‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,23 @@ set -euo pipefail
44
# This is deliberately a gate, not a blind patch. A positive Kueue quota on a
55
# node that advertises zero GPUs admits work that Kubernetes can never place.
66
nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv,noheader >/dev/null
7+
# The worker attests capacity through torch.cuda, whose allocatable total can be
8+
# lower than nvidia-smi's board-memory total. Scheduling must bind to the same
9+
# instrument the worker will later verify.
10+
gpu_probe="$(backend/env/bin/python -c 'import torch; p=torch.cuda.get_device_properties(0); m,n=torch.cuda.get_device_capability(0); print(f"{m}.{n},{p.total_memory}")')"
11+
compute_cap="$(cut -d, -f1 <<<"$gpu_probe" | xargs)"
12+
memory_bytes="$(cut -d, -f2 <<<"$gpu_probe" | xargs)"
13+
case "$compute_cap" in
14+
12.*|10.*) gpu_arch="blackwell" ;;
15+
9.*) gpu_arch="hopper" ;;
16+
8.9) gpu_arch="ada" ;;
17+
8.*) gpu_arch="ampere" ;;
18+
*) echo "unsupported CUDA compute capability: $compute_cap" >&2; exit 1 ;;
19+
esac
20+
if ! [[ "$memory_bytes" =~ ^[0-9]+$ ]]; then
21+
echo "invalid measured GPU memory: $memory_bytes" >&2
22+
exit 1
23+
fi
724

825
operator_state="$(kubectl get clusterpolicy cluster-policy -o jsonpath='{.status.state}')"
926
if [[ "$operator_state" != "ready" ]]; then
@@ -17,6 +34,20 @@ if [[ "$allocatable" != "1" ]]; then
1734
exit 1
1835
fi
1936

37+
# These labels are scheduler constraints, not descriptive metadata. The API
38+
# only reports GPU readiness when the measured node profile and Pod selector
39+
# are identical.
40+
kubectl label node icu --overwrite \
41+
"dirac.io/gpu-arch=$gpu_arch" \
42+
"dirac.io/gpu-numeric-mode=fp32" \
43+
"dirac.io/gpu-memory-bytes=$memory_bytes"
44+
observed_labels="$(kubectl get node icu -o jsonpath='{.metadata.labels.dirac\.io/gpu-arch},{.metadata.labels.dirac\.io/gpu-numeric-mode},{.metadata.labels.dirac\.io/gpu-memory-bytes}')"
45+
expected_labels="$gpu_arch,fp32,$memory_bytes"
46+
if [[ "$observed_labels" != "$expected_labels" ]]; then
47+
echo "GPU scheduling labels failed verification: $observed_labels" >&2
48+
exit 1
49+
fi
50+
2051
resource_name="$(kubectl get clusterqueue motif -o jsonpath='{.spec.resourceGroups[0].flavors[0].resources[3].name}')"
2152
if [[ "$resource_name" != "nvidia.com/gpu" ]]; then
2253
echo "refusing fragile quota patch: resource index 3 is $resource_name" >&2
@@ -25,4 +56,4 @@ fi
2556

2657
kubectl patch clusterqueue motif --type=json \
2758
--patch='[{"op":"replace","path":"/spec/resourceGroups/0/flavors/0/resources/3/nominalQuota","value":"1"}]'
28-
echo "Motif GPU quota activated: 1"
59+
echo "Motif GPU quota activated: 1 ($gpu_arch fp32 $memory_bytes bytes)"

0 commit comments

Comments
 (0)