From e86d49adbe343724f039260e6563a6a088f9c560 Mon Sep 17 00:00:00 2001 From: arjunnai Date: Sun, 18 Oct 2026 01:22:44 -0400 Subject: [PATCH 1/5] Pass large worker inputs through sealed memfds Use request-local sealed buffers and a libc fallback when Python omits memfd_create. Preserve the bytes path and close mappings before sandbox cleanup. --- python/kcoral/runtime/engine.py | 8 +- python/kcoral/runtime/transfer.py | 100 ++++++++++++++++++++++ python/kcoral/runtime/worker.py | 43 +++++++--- tests/test_transfer.py | 132 ++++++++++++++++++++++++++++++ 4 files changed, 268 insertions(+), 15 deletions(-) create mode 100644 python/kcoral/runtime/transfer.py create mode 100644 tests/test_transfer.py diff --git a/python/kcoral/runtime/engine.py b/python/kcoral/runtime/engine.py index 2530a22d..1348c9a2 100644 --- a/python/kcoral/runtime/engine.py +++ b/python/kcoral/runtime/engine.py @@ -39,7 +39,7 @@ class Runtime(Protocol): def load_module(self, source: str) -> Any: ... def load_library(self, data: bytes) -> Any: ... def get_function(self, module: Any, name: str) -> Any: ... - def load_tensor(self, data: bytes, dtype: str, shape: list[int]) -> Any: ... + def load_tensor(self, data: bytes | memoryview, dtype: str, shape: list[int]) -> Any: ... def export_tensor(self, value: Any) -> tuple[str, list[int], bytes] | None: ... def forbid_gpu(self) -> AbstractContextManager[None]: ... def synchronize(self) -> None: ... @@ -116,7 +116,7 @@ def _execute_in_workspace( _materialize_file( workspace_dir, instruction.path, - program.blob_bytes[instruction.blob], + bytes(program.blob_bytes[instruction.blob]), ) env[instruction.id] = instruction.path elif isinstance(instruction, Upload): @@ -125,11 +125,11 @@ def _execute_in_workspace( env[instruction.id] = runtime.load_module(instruction.source) elif instruction.kind == "bytes": assert instruction.blob is not None - env[instruction.id] = program.blob_bytes[instruction.blob] + env[instruction.id] = bytes(program.blob_bytes[instruction.blob]) elif instruction.kind == "library": assert instruction.blob is not None env[instruction.id] = runtime.load_library( - program.blob_bytes[instruction.blob] + bytes(program.blob_bytes[instruction.blob]) ) else: assert ( diff --git a/python/kcoral/runtime/transfer.py b/python/kcoral/runtime/transfer.py new file mode 100644 index 00000000..257195fe --- /dev/null +++ b/python/kcoral/runtime/transfer.py @@ -0,0 +1,100 @@ +"""Request-local sealed buffers on the existing worker control socket.""" + +import array +import ctypes +import mmap +import os +import socket +import sys +from contextlib import ExitStack, contextmanager +from dataclasses import dataclass +from multiprocessing import reduction + +if sys.platform == "linux": + import fcntl + +MEMFD_THRESHOLD = 1 << 20 +_SEAL_NAMES = ("SEAL", "SHRINK", "GROW", "WRITE") + + +def _seals(): + return sum(getattr(fcntl, "F_SEAL_" + name, 1 << i) for i, name in enumerate(_SEAL_NAMES)) + + +def _memfd_create(): + create = getattr(os, "memfd_create", None) + fd = create("blob", 3) if create else ctypes.CDLL(None, use_errno=True).memfd_create(b"blob", 3) + if fd < 0: + raise OSError(ctypes.get_errno(), "memfd_create failed") + return fd + + +@dataclass(frozen=True) +class BlobHandle: + size: int + + +@contextmanager +def pack_blobs(blobs): + fds, packed = [], blobs.copy() + with ExitStack() as cleanup: + try: + if sys.platform == "linux": + for key, data in blobs.items(): + if len(data) < MEMFD_THRESHOLD: + continue + fd = _memfd_create() # MFD_CLOEXEC | MFD_ALLOW_SEALING + cleanup.callback(os.close, fd) + with os.fdopen(fd, "wb", closefd=False) as stream: + stream.write(data) + fcntl.fcntl(fd, getattr(fcntl, "F_ADD_SEALS", 1033), _seals()) + packed[key] = BlobHandle(len(data)) + fds.append(fd) + except (OSError, AttributeError, NotImplementedError): + cleanup.close() + fds, packed = [], blobs + yield packed, fds + + +def _recv_handle(conn): + fds = array.array("i") + try: + with socket.fromfd(conn.fileno(), socket.AF_UNIX, socket.SOCK_STREAM) as sock: + data, ancillary, flags, _ = sock.recvmsg( + 1, socket.CMSG_SPACE(256 * fds.itemsize), socket.MSG_CMSG_CLOEXEC + ) + for level, kind, payload in ancillary: + if level == socket.SOL_SOCKET and kind == socket.SCM_RIGHTS: + fds.frombytes(payload[: len(payload) - len(payload) % fds.itemsize]) + if len(fds) != 1 or data != bytes([1]) or flags & socket.MSG_CTRUNC: + raise OSError("invalid memfd handle message") + if getattr(reduction, "ACKNOWLEDGE", True): + sock.sendall(b"A") + return fds.pop() + finally: + for fd in fds: + os.close(fd) + + +@contextmanager +def mapped_blobs(conn, blobs): + descriptors = [(key, value) for key, value in blobs.items() if isinstance(value, BlobHandle)] + if not descriptors: + yield blobs + return + mapped = blobs.copy() + with ExitStack() as cleanup: + for key, descriptor in descriptors: + fd = _recv_handle(conn) + try: + if ( + fcntl.fcntl(fd, getattr(fcntl, "F_GET_SEALS", 1034)) & _seals() != _seals() + or os.fstat(fd).st_size != descriptor.size + ): + raise OSError("invalid memfd size or seals") + mapping = mmap.mmap(fd, descriptor.size, access=mmap.ACCESS_READ) + finally: + os.close(fd) + cleanup.callback(mapping.close) + mapped[key] = cleanup.enter_context(memoryview(mapping)) + yield mapped diff --git a/python/kcoral/runtime/worker.py b/python/kcoral/runtime/worker.py index e91078e7..eb6a4916 100644 --- a/python/kcoral/runtime/worker.py +++ b/python/kcoral/runtime/worker.py @@ -20,11 +20,14 @@ import threading import time from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, replace +from multiprocessing import reduction from multiprocessing.connection import wait from pathlib import Path +from kcoral.runtime import transfer from kcoral.runtime.engine import execute, read_captured_output +from kcoral.runtime.gpu import GPURuntime from kcoral.runtime.lease import GPULeases, GPUUnavailable, LeaseClient, NoopLease, NoopLeases from kcoral.server.events import EventLogger from kcoral.support import cuda as nvml @@ -159,15 +162,21 @@ def mark_cleanup_failed(exc: BaseException) -> None: if cleanup_error is None: cleanup_error = exc - outcome = execute( - program, - runtime, - lease=lease, - progress=lambda index: conn.send({"__instruction__": index}), - cleanup_failed=mark_cleanup_failed, - capture_dir=capture_dir, - workspace_dir=workspace_dir, - ) + with transfer.mapped_blobs(conn, program.blob_bytes) as blobs: + program.blob_bytes = ( + blobs + if type(runtime) is GPURuntime + else {key: bytes(value) for key, value in blobs.items()} + ) + outcome = execute( + program, + runtime, + lease=lease, + progress=lambda index: conn.send({"__instruction__": index}), + cleanup_failed=mark_cleanup_failed, + capture_dir=capture_dir, + workspace_dir=workspace_dir, + ) sandbox_cleanup_error = None if request_state is not None: try: @@ -534,7 +543,19 @@ def _run_in_workspace( held_since: float | None = None instruction_index: int | None = None try: - self._conn.send((program, workspace_dir)) + if hasattr(program, "blob_bytes"): + with transfer.pack_blobs(program.blob_bytes) as (blobs, fds): + message = replace(program, blob_bytes=blobs) if fds else program + self._conn.send((message, workspace_dir)) + for fd in fds: + try: + reduction.send_handle(self._conn, fd, self.pid) + except RuntimeError as exc: + if str(exc) != "did not receive acknowledgement of fd": + raise + raise OSError(str(exc)) from exc + else: + self._conn.send((program, workspace_dir)) while True: waited_from = time.monotonic() if not self._poll_response(remaining): # no answer by the deadline -> hung diff --git a/tests/test_transfer.py b/tests/test_transfer.py new file mode 100644 index 00000000..cfad0554 --- /dev/null +++ b/tests/test_transfer.py @@ -0,0 +1,132 @@ +"""Sealed-buffer transport through real worker lifetime boundaries.""" + +import gc +import os +import sys +import warnings +from multiprocessing import reduction + +import pytest +from support.runtime import FakeRuntime + +from kcoral.protocol import compute_blob_hash +from kcoral.runtime import transfer +from kcoral.runtime.lease import NoopLeases +from kcoral.runtime.worker import Worker, WorkerCrashed, WorkerTimeout +from kcoral.schemas import GetFunction, Program, Ref, Return, Run, Upload + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="requires Linux") +SOURCE = "def main(t): return [sum(t.data), t]" + + +class BufferRuntime(FakeRuntime): + def load_tensor(self, data, dtype, shape): + import torch + + assert isinstance(data, bytes) + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", message="The given buffer is not writable", category=UserWarning + ) + self.retained_tensor = torch.frombuffer(data, dtype=torch.uint8) + return super().load_tensor(data, dtype, shape) + + +def tensor_program(data, source=SOURCE): + digest = compute_blob_hash(data) + return Program( + instructions=[ + Upload("module", "module", source=source), + GetFunction("fn", Ref("module"), "main", cpu_only=True), + Upload("tensor", "tensor", blob=digest, dtype="uint8", shape=[len(data)]), + Run("result", Ref("fn"), [Ref("tensor")]), + Return("value", Ref("result")), + ], + blob_bytes={digest: data}, + ) + + +def fd_count(): + gc.collect() + return len(os.listdir("/proc/self/fd")) + + +@pytest.fixture +def worker(): + instance = Worker( + None, BufferRuntime, sandbox="none", max_requests=0, termination_grace_seconds=0.1 + ) + try: + yield instance + finally: + instance.close() + + +def check_result(worker, data, source=SOURCE): + program = tensor_program(data, source) + outcome = worker.run(program, 10, NoopLeases()).execution + assert outcome.status == "COMPLETED", outcome.error + values = outcome.results["value"]["value"] + assert values[0]["value"] == 3 * len(data) + assert list(outcome.binary_parts.values()) == [data] + assert next(iter(program.blob_bytes.values())) is data + + +@pytest.mark.parametrize("size", [4096, transfer.MEMFD_THRESHOLD]) +def test_large_and_small_inputs_have_identical_numeric_results(worker, size, monkeypatch): + monkeypatch.delattr(os, "memfd_create", raising=False) + sent, original = [], worker._conn.send + + def send(message): + if isinstance(message, tuple): + sent.extend( + isinstance(value, transfer.BlobHandle) for value in message[0].blob_bytes.values() + ) + return original(message) + + monkeypatch.setattr(worker._conn, "send", send) + pid, before = worker.pid, fd_count() + for _ in range(2): + check_result(worker, b"\3" * size) + assert worker.pid == pid and fd_count() == before + assert sent == [size >= transfer.MEMFD_THRESHOLD] * 2 + + +def test_memfd_unavailable_preserves_the_result(worker, monkeypatch): + def unavailable(*args): + raise OSError("memfd unavailable") + + monkeypatch.setattr(transfer, "_memfd_create", unavailable) + source = ( + "from kcoral.runtime import transfer\n" + "def unavailable(*args): raise OSError('memfd unavailable')\n" + "transfer._memfd_create = unavailable\n" + SOURCE + ) + before = fd_count() + check_result(worker, b"\3" * transfer.MEMFD_THRESHOLD, source) + assert fd_count() == before + + +@pytest.mark.parametrize("failure", ["crash", "timeout", "ack"]) +def test_large_input_failures_do_not_leak_parent_fds(worker, failure, monkeypatch): + data = b"\3" * transfer.MEMFD_THRESHOLD + source = ( + "import os\ndef main(t): os._exit(3)" + if failure == "crash" + else "import time\ndef main(t): time.sleep(10)" + ) + check_result(worker, data) + before = fd_count() + pid = worker.pid + with monkeypatch.context() as patch: + if failure == "ack": + + def failed_ack(*args): + raise RuntimeError("did not receive acknowledgement of fd") + + patch.setattr(reduction, "send_handle", failed_ack) + with pytest.raises(WorkerTimeout if failure == "timeout" else WorkerCrashed): + worker.run(tensor_program(data, source), 0.3, NoopLeases()) + assert worker.pid != pid + check_result(worker, data) + assert fd_count() == before From 5426334367e57aceda9a70225d26fefd77c577cc Mon Sep 17 00:00:00 2001 From: arjunnai Date: Sun, 18 Oct 2026 11:50:16 -0400 Subject: [PATCH 2/5] Pass large worker results through sealed memfds Send large results over the worker socket as sealed buffers. Validate size and seals before copying, and retain the HTTP response-size error. --- python/kcoral/runtime/transfer.py | 19 +++++++++-- python/kcoral/runtime/worker.py | 33 +++++++++++++++++-- python/kcoral/schemas.py | 1 + python/kcoral/server/app.py | 5 +-- tests/test_events.py | 32 +++++++++++++++++- tests/test_server_e2e.py | 11 +++++-- tests/test_transfer.py | 55 ++++++++++++++++++++++++++++++- 7 files changed, 144 insertions(+), 12 deletions(-) diff --git a/python/kcoral/runtime/transfer.py b/python/kcoral/runtime/transfer.py index 257195fe..f6b7c75e 100644 --- a/python/kcoral/runtime/transfer.py +++ b/python/kcoral/runtime/transfer.py @@ -29,6 +29,14 @@ def _memfd_create(): return fd +class HandleTimeout(OSError): + pass + + +class ResultSizeError(OSError): + pass + + @dataclass(frozen=True) class BlobHandle: size: int @@ -77,18 +85,25 @@ def _recv_handle(conn): @contextmanager -def mapped_blobs(conn, blobs): +def mapped_blobs(conn, blobs, max_bytes=None, ready=lambda: True): descriptors = [(key, value) for key, value in blobs.items() if isinstance(value, BlobHandle)] if not descriptors: yield blobs return + if max_bytes is not None: + sizes = [p.size if isinstance(p, BlobHandle) else len(p) for p in blobs.values()] + if any(type(n) is not int or n < 0 for n in sizes) or sum(sizes) > max_bytes: + raise ResultSizeError("results exceed the configured response-size limit") mapped = blobs.copy() with ExitStack() as cleanup: for key, descriptor in descriptors: + if not ready(): + raise HandleTimeout("worker result handles exceeded the execution timeout") fd = _recv_handle(conn) try: if ( - fcntl.fcntl(fd, getattr(fcntl, "F_GET_SEALS", 1034)) & _seals() != _seals() + descriptor.size < MEMFD_THRESHOLD + or fcntl.fcntl(fd, getattr(fcntl, "F_GET_SEALS", 1034)) & _seals() != _seals() or os.fstat(fd).st_size != descriptor.size ): raise OSError("invalid memfd size or seals") diff --git a/python/kcoral/runtime/worker.py b/python/kcoral/runtime/worker.py index eb6a4916..492b1baf 100644 --- a/python/kcoral/runtime/worker.py +++ b/python/kcoral/runtime/worker.py @@ -29,6 +29,7 @@ from kcoral.runtime.engine import execute, read_captured_output from kcoral.runtime.gpu import GPURuntime from kcoral.runtime.lease import GPULeases, GPUUnavailable, LeaseClient, NoopLease, NoopLeases +from kcoral.schemas import ProgramOutcome from kcoral.server.events import EventLogger from kcoral.support import cuda as nvml from kcoral.support import sandbox as sandboxing @@ -211,7 +212,17 @@ def mark_cleanup_failed(exc: BaseException) -> None: # Thread/file cleanup errors can be reported before the child check. # Track live children independently: namespace exit will kill them. response["__sandbox_children__"] = request_state.has_children() - conn.send(response) + result_bytes = sum(map(len, outcome.binary_parts.values())) + if result_bytes > program.max_return_bytes: + outcome._response_bytes = result_bytes + outcome.binary_parts = {} + outcome.results = {} + conn.send(response) + else: + with transfer.pack_blobs(outcome.binary_parts) as (outcome.binary_parts, fds): + conn.send(response) + for fd in fds: + reduction.send_handle(conn, fd, None) if retire_reason is not None: return @@ -575,6 +586,19 @@ def _run_in_workspace( if cleanup_error := message.get("__sandbox_cleanup_error__"): self._log_failure("sandbox_cleanup", RuntimeError(cleanup_error)) outcome = message["__outcome__"] + if isinstance(outcome, ProgramOutcome): + deadline = time.monotonic() + remaining + with transfer.mapped_blobs( + self._conn, + outcome.binary_parts, + program.max_return_bytes, + lambda: self._poll_response(max(0, deadline - time.monotonic())), + ) as blobs: + if blobs is not outcome.binary_parts: + outcome.binary_parts = { + key: bytes(data) for key, data in blobs.items() + } + remaining = deadline - time.monotonic() if message["__retire_reason__"] is not None: # An outcome precedes Python/CUDA finalizers. Keep them within # the execution deadline and wait for the complete process tree. @@ -642,7 +666,8 @@ def _run_in_workspace( self._proc.join(timeout=0.1) except Exception: pass - crash = WorkerCrashed(f"{self._description()} pipe failed: {exc}") + error_type = WorkerTimeout if isinstance(exc, transfer.HandleTimeout) else WorkerCrashed + crash = error_type(f"{self._description()} pipe failed: {exc}") crash.worker_id = self.worker_id crash.instruction_index = instruction_index crash.lease_wait_ms = lease_wait_ms @@ -655,7 +680,9 @@ def _run_in_workspace( if getattr(self, "_exit_status", None) is not None else self._proc.exitcode ) - self._abandon_and_respawn(leases, "crashed") + self._abandon_and_respawn( + leases, "timeout" if isinstance(exc, transfer.HandleTimeout) else "crashed" + ) raise crash from exc def _output_tail(self) -> str: diff --git a/python/kcoral/schemas.py b/python/kcoral/schemas.py index 897b1c67..bcd634b0 100644 --- a/python/kcoral/schemas.py +++ b/python/kcoral/schemas.py @@ -117,3 +117,4 @@ class ProgramOutcome: stderr: str = "" stdout_truncated: bool = False stderr_truncated: bool = False + _response_bytes: int = 0 # Internal raw-size lower bound when binary parts were omitted. diff --git a/python/kcoral/server/app.py b/python/kcoral/server/app.py index b15ae10f..09a910bc 100644 --- a/python/kcoral/server/app.py +++ b/python/kcoral/server/app.py @@ -483,12 +483,13 @@ def finished(http_status: int, *, finish_reason: str, **fields: object) -> None: payload["error"] = execution.error response_body, content_type = _encode_response(payload, execution.binary_parts) - if len(response_body) > config.max_response_bytes: + response_bytes = max(len(response_body), execution._response_bytes) + if response_bytes > config.max_response_bytes: finished( 500, finish_reason="server_error", error_kind="response_too_large", - response_bytes=len(response_body), + response_bytes=response_bytes, worker_id=outcome.worker_id, gpu_id=outcome.gpu_id, queue_ms=outcome.queue_ms, diff --git a/tests/test_events.py b/tests/test_events.py index 12c96518..df7f9dd2 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -4,7 +4,7 @@ import pytest from fastapi.testclient import TestClient -from support.programs import harness_instructions +from support.programs import harness_instructions, python_instructions from support.runtime import fake_runtime_factory from kcoral.config import ServerConfig @@ -326,6 +326,36 @@ def test_a_cpu_only_function_touching_the_gpu_is_a_warning_naming_both_requests( assert warnings[1]["instruction_id"] == "call" and warnings[1]["cuda_call"] == "cudaMalloc" +@pytest.mark.parametrize("violation", [False, True]) +def test_oversized_return_skips_staging_and_preserves_diagnostics(tmp_path, violation): + source = ( + "from kcoral.runtime import transfer\n" + "def blocked(): raise AssertionError('memfd staging attempted')\n" + "transfer._memfd_create = blocked\n" + "def main(): return b'x' * (1 << 20)" + ) + program = { + "instructions": [ + *python_instructions("large", source), + {"op": "return", "key": "large", "value": {"$ref": "large"}}, + *(VIOLATING_PROGRAM["instructions"] if violation else []), + ] + } + with _make_client(tmp_path, max_response_bytes=4096) as client: + worker = client.app.state.pool._workers[0] + pid = worker.pid + response = _post(client, program) + assert _post(client, STRUCTURAL_PROGRAM).status_code == 200 + assert worker.pid == pid + assert response.status_code == 500 and response.json()["error"]["kind"] == "response_too_large" + warnings = _named(_read_events(tmp_path), "gpu_access_violation") + assert len(warnings) == int(violation) + if violation: + assert warnings[0]["request_id"] == response.json()["request_id"] + assert warnings[0]["instruction_id"] == "call" and warnings[0]["cuda_call"] == "cudaMalloc" + assert warnings[0]["interfered_request_id"] is None + + # --- failures outside a request ---------------------------------------------- diff --git a/tests/test_server_e2e.py b/tests/test_server_e2e.py index d96ad3a9..b4590504 100644 --- a/tests/test_server_e2e.py +++ b/tests/test_server_e2e.py @@ -240,12 +240,17 @@ def test_completed_program_returns_only_selected_values(): assert data["stdout"] == "" and data["stderr"] == "" -def test_failed_instruction_stops_after_a_return_that_already_ran(): +@pytest.mark.parametrize("kind", ["runtime", "response_too_large"]) +def test_failed_instruction_stops_after_a_return_that_already_ran(kind): program = { "instructions": [ *harness_instructions("ok", "structural"), {"op": "return", "key": "ok", "value": {"$ref": "ok"}}, - *harness_instructions("bad", "nope"), + *python_instructions( + "bad", + "from kcoral.errors import ExecutionError\n" + f"def main(): raise ExecutionError({kind!r}, 'deliberate test failure')", + ), {"op": "return", "key": "never", "value": {"$ref": "ok"}}, ] } @@ -255,7 +260,7 @@ def test_failed_instruction_stops_after_a_return_that_already_ran(): assert response.status_code == 200 and data["status"] == "FAILED" assert set(data["results"]) == {"ok"} error = data["error"] - assert error["kind"] == "runtime" and error["instruction_index"] == 6 + assert error["kind"] == kind and error["instruction_index"] == 6 assert error["instruction_op"] == "run" and error["instruction_id"] == "bad" assert "Traceback" in error["traceback"] diff --git a/tests/test_transfer.py b/tests/test_transfer.py index cfad0554..02b97e9a 100644 --- a/tests/test_transfer.py +++ b/tests/test_transfer.py @@ -1,10 +1,13 @@ """Sealed-buffer transport through real worker lifetime boundaries.""" +import array import gc import os +import socket import sys import warnings -from multiprocessing import reduction +from contextlib import ExitStack +from multiprocessing import Pipe, reduction import pytest from support.runtime import FakeRuntime @@ -85,11 +88,20 @@ def send(message): return original(message) monkeypatch.setattr(worker._conn, "send", send) + received, receive = [], transfer._recv_handle + + def recv(conn): + fd = receive(conn) + received.append(fd) + return fd + + monkeypatch.setattr(transfer, "_recv_handle", recv) pid, before = worker.pid, fd_count() for _ in range(2): check_result(worker, b"\3" * size) assert worker.pid == pid and fd_count() == before assert sent == [size >= transfer.MEMFD_THRESHOLD] * 2 + assert len(received) == 2 * (size >= transfer.MEMFD_THRESHOLD) def test_memfd_unavailable_preserves_the_result(worker, monkeypatch): @@ -130,3 +142,44 @@ def failed_ack(*args): assert worker.pid != pid check_result(worker, data) assert fd_count() == before + + +@pytest.mark.parametrize( + "fault", ["unsealed", "wrong_size", "too_large", "extra", "ctrunc", "non_memfd"] +) +def test_hostile_handles_are_rejected_without_leaking(fault, monkeypatch): + sender, receiver = Pipe() + with ExitStack() as cleanup, sender, receiver: + if fault in ("unsealed", "wrong_size", "too_large"): + import fcntl + + stream = cleanup.enter_context(os.fdopen(transfer._memfd_create(), "wb")) + stream.truncate(transfer.MEMFD_THRESHOLD - (fault == "wrong_size")) + if fault != "unsealed": + fcntl.fcntl(stream.fileno(), getattr(fcntl, "F_ADD_SEALS", 1033), transfer._seals()) + fds = [stream.fileno()] + else: + fds = [ + cleanup.enter_context(open(os.devnull, "rb")).fileno() + for _ in range(1 if fault == "non_memfd" else 3 if fault == "ctrunc" else 2) + ] + with socket.fromfd(sender.fileno(), socket.AF_UNIX, socket.SOCK_STREAM) as sock: + sock.sendmsg( + [b"\1"], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, array.array("i", fds).tobytes())] + ) + if fault == "ctrunc": + space = socket.CMSG_SPACE + monkeypatch.setattr( + transfer.socket, "CMSG_SPACE", lambda _n: space(array.array("i").itemsize) + ) + before = fd_count() + with ( + pytest.raises(OSError), + transfer.mapped_blobs( + receiver, + {"blob": transfer.BlobHandle(transfer.MEMFD_THRESHOLD)}, + max_bytes=transfer.MEMFD_THRESHOLD - 1 if fault == "too_large" else None, + ), + ): + pass + assert fd_count() == before From 24c8b9b29dbc232df93407f356f6879f1917ef71 Mon Sep 17 00:00:00 2001 From: arjunnai Date: Tue, 20 Oct 2026 19:51:23 -0400 Subject: [PATCH 3/5] Reuse sealed buffers from the input cache Keep a bounded descriptor set with cache entries and duplicate handles for each send. Close cached descriptors on eviction and shutdown. --- python/kcoral/runtime/transfer.py | 8 +++++++- python/kcoral/runtime/worker.py | 4 ++-- python/kcoral/schemas.py | 2 ++ python/kcoral/server/app.py | 6 +++++- python/kcoral/server/cache.py | 32 +++++++++++++++++++++++++++++++ tests/test_transfer.py | 31 ++++++++++++++++++++++++++++++ 6 files changed, 79 insertions(+), 4 deletions(-) diff --git a/python/kcoral/runtime/transfer.py b/python/kcoral/runtime/transfer.py index f6b7c75e..3802c74e 100644 --- a/python/kcoral/runtime/transfer.py +++ b/python/kcoral/runtime/transfer.py @@ -43,7 +43,7 @@ class BlobHandle: @contextmanager -def pack_blobs(blobs): +def pack_blobs(blobs, cache=None): fds, packed = [], blobs.copy() with ExitStack() as cleanup: try: @@ -51,6 +51,12 @@ def pack_blobs(blobs): for key, data in blobs.items(): if len(data) < MEMFD_THRESHOLD: continue + cached_fd = cache.borrow_memfd(key) if cache is not None else None + if cached_fd is not None: + cleanup.callback(os.close, cached_fd) + packed[key] = BlobHandle(len(data)) + fds.append(cached_fd) + continue fd = _memfd_create() # MFD_CLOEXEC | MFD_ALLOW_SEALING cleanup.callback(os.close, fd) with os.fdopen(fd, "wb", closefd=False) as stream: diff --git a/python/kcoral/runtime/worker.py b/python/kcoral/runtime/worker.py index 492b1baf..fc5e56a2 100644 --- a/python/kcoral/runtime/worker.py +++ b/python/kcoral/runtime/worker.py @@ -555,8 +555,8 @@ def _run_in_workspace( instruction_index: int | None = None try: if hasattr(program, "blob_bytes"): - with transfer.pack_blobs(program.blob_bytes) as (blobs, fds): - message = replace(program, blob_bytes=blobs) if fds else program + with transfer.pack_blobs(program.blob_bytes, program._blob_cache) as (blobs, fds): + message = replace(program, blob_bytes=blobs, _blob_cache=None) self._conn.send((message, workspace_dir)) for fd in fds: try: diff --git a/python/kcoral/schemas.py b/python/kcoral/schemas.py index bcd634b0..ad87a5d9 100644 --- a/python/kcoral/schemas.py +++ b/python/kcoral/schemas.py @@ -92,6 +92,8 @@ class Program: # Filled by the HTTP front-end after multipart validation and cache lookup. blob_bytes: dict[str, bytes] = field(default_factory=dict) + _blob_cache: Any = None # Parent-only; removed before sending to workers. + # Trusted limits supplied by the front-end; never accepted in wire options. max_return_bytes: int = 256 * 1024**2 diff --git a/python/kcoral/server/app.py b/python/kcoral/server/app.py index 09a910bc..619680b3 100644 --- a/python/kcoral/server/app.py +++ b/python/kcoral/server/app.py @@ -176,7 +176,10 @@ async def close_resources(): try: await asyncio.to_thread(app.state.upload_executor.shutdown, wait=True) finally: - await app.state.pool.shutdown_async() + try: + await app.state.pool.shutdown_async() + finally: + app.state.cache.close() try: if config.router_endpoint is not None: @@ -359,6 +362,7 @@ def finished(http_status: int, *, finish_reason: str, **fields: object) -> None: **_program_shape(program), ) cache.pin(cache_keys) + program._blob_cache = cache # A crash the engine can attribute to an instruction still answers 200 # with a FAILED program; these carry its cause onto that record. crash_exitcode: int | None = None diff --git a/python/kcoral/server/cache.py b/python/kcoral/server/cache.py index 8d410363..b751b56f 100644 --- a/python/kcoral/server/cache.py +++ b/python/kcoral/server/cache.py @@ -26,6 +26,7 @@ is_blob_hash, verify_blob, ) +from kcoral.runtime import transfer from kcoral.schemas import FileUpload, Program @@ -33,9 +34,12 @@ class _Entry: data: bytes refcount: int = 0 + fd: int | None = None class ByteCache: + _MAX_FDS = 128 + def __init__(self, capacity_bytes: int, max_object_bytes: int | None = None) -> None: self._cap = capacity_bytes # Don't cache an object larger than this (default: 25% of capacity) so one @@ -44,6 +48,7 @@ def __init__(self, capacity_bytes: int, max_object_bytes: int | None = None) -> self._max_obj = max_object_bytes if max_object_bytes is not None else capacity_bytes // 4 self._entries: OrderedDict[str, _Entry] = OrderedDict() self._size = 0 + self._fd_count = 0 self._lock = threading.Lock() def get(self, key: str) -> bytes | None: @@ -65,6 +70,30 @@ def put(self, key: str, data: bytes) -> None: self._size += len(data) self._evict_locked() + def borrow_memfd(self, key: str) -> int | None: + """Duplicate an immutable entry buffer while eviction is excluded.""" + with self._lock: + entry = self._entries.get(key) + if entry is None: + return None + if entry.fd is None: + if self._fd_count >= self._MAX_FDS: + return None + with transfer.pack_blobs({key: entry.data}) as (_, fds): + if not fds: + return None + entry.fd = os.dup(fds[0]) + self._fd_count += 1 + return os.dup(entry.fd) + + def close(self) -> None: + with self._lock: + for entry in self._entries.values(): + if entry.fd is not None: + os.close(entry.fd) + self._entries.clear() + self._size = self._fd_count = 0 + def pin(self, keys: list[str]) -> None: with self._lock: for k in keys: @@ -89,6 +118,9 @@ def _evict_locked(self) -> None: if e.refcount == 0: del self._entries[key] self._size -= len(e.data) + if e.fd is not None: + os.close(e.fd) + self._fd_count -= 1 def __contains__(self, key: str) -> bool: return key in self._entries diff --git a/tests/test_transfer.py b/tests/test_transfer.py index 02b97e9a..8f311e8e 100644 --- a/tests/test_transfer.py +++ b/tests/test_transfer.py @@ -183,3 +183,34 @@ def test_hostile_handles_are_rejected_without_leaking(fault, monkeypatch): ): pass assert fd_count() == before + + +def test_cached_memfds_are_bounded_and_borrow_survives_eviction(monkeypatch): + from kcoral.server.cache import ByteCache + + size = transfer.MEMFD_THRESHOLD + before = fd_count() + cache = ByteCache(2 * size, max_object_bytes=size) + monkeypatch.setattr(cache, "_MAX_FDS", 1) + cache.put("a", b"a" * size) + fd = cache.borrow_memfd("a") + assert fd is not None + assert cache._fd_count == 1 + owned = cache._entries["a"].fd + cache.pin(["a"]) + cache.put("b", b"b" * size) + assert cache.borrow_memfd("b") is None + cache.put("c", b"c" * size) + cache.unpin(["a"]) + cache.put("d", b"d" * size) + with pytest.raises(OSError): + os.fstat(owned) + assert os.pread(fd, 1, 0) == b"a" + os.close(fd) + borrowed = cache.borrow_memfd("d") + assert borrowed is not None + cache.close() + assert os.pread(borrowed, 1, 0) == b"d" + os.close(borrowed) + cache.close() + assert fd_count() == before From 10a9e99009e52345a17e5ea6be7f78719f9612df Mon Sep 17 00:00:00 2001 From: arjunnai Date: Tue, 20 Oct 2026 19:52:33 -0400 Subject: [PATCH 4/5] Hash immutable upload snapshots in parallel Pipeline large blob hashes across uploads and resolve them before exposing instructions or transmitting blobs. Hash only owned snapshots. --- python/kcoral/client/program.py | 43 +++++++++++++++++++++++++++------ tests/test_client.py | 28 +++++++++++++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/python/kcoral/client/program.py b/python/kcoral/client/program.py index 7016c15b..3a7b6552 100644 --- a/python/kcoral/client/program.py +++ b/python/kcoral/client/program.py @@ -4,6 +4,7 @@ import os import sys +from concurrent.futures import Future, ThreadPoolExecutor from dataclasses import dataclass, field from typing import Any @@ -19,6 +20,9 @@ ) from kcoral.schemas import DTYPE_ITEM_SIZES +_HASH_POOL = ThreadPoolExecutor(max_workers=4, thread_name_prefix="kcoral-hash") +_HASH_THRESHOLD = 1 << 20 + @dataclass(frozen=True) class Register: @@ -50,7 +54,8 @@ class Program: """ _instructions: list[dict[str, Any]] = field(default_factory=list, init=False) - _blobs: dict[str, bytes] = field(default_factory=dict, init=False) + _blob_data: dict[str, bytes] = field(default_factory=dict, init=False) + _pending_blobs: dict[Future, bytes] = field(default_factory=dict, init=False, repr=False) _ids: set[str] = field(default_factory=set, init=False) _next_id: int = field(default=0, init=False) _return_keys: set[str] = field(default_factory=set, init=False) @@ -63,8 +68,35 @@ def instructions(self) -> list[dict[str, Any]]: The contained dictionaries are shared with this program. Treat them as read-only; use the builder methods to add instructions. """ + self._finish_blobs() return list(self._instructions) + @property + def _blobs(self) -> dict[str, bytes]: + self._finish_blobs() + return self._blob_data + + def _queue_blob(self, data: bytes): + if len(data) >= _HASH_THRESHOLD: + digest = _HASH_POOL.submit(compute_blob_hash, data) + self._pending_blobs[digest] = data + return digest + digest = compute_blob_hash(data) + self._blob_data.setdefault(digest, data) + return digest + + def _finish_blobs(self) -> None: + if not self._pending_blobs: + return + digests = {future: future.result() for future in self._pending_blobs} + for future, data in self._pending_blobs.items(): + self._blob_data.setdefault(digests[future], data) + for instruction in self._instructions: + digest = instruction.get("blob") + if isinstance(digest, Future): + instruction["blob"] = digests[digest] + self._pending_blobs.clear() + def upload_file(self, *, blob: Any, path: str, id: str | None = None) -> Register: """Snapshot bytes-like data as a file in the request workspace. @@ -167,8 +199,7 @@ def upload( if source is not None: raise TypeError("tensor upload does not accept 'source'") tensor_dtype, tensor_shape, raw = _tensor_fields(value, dtype=dtype, shape=shape) - blob_hash = compute_blob_hash(raw) - self._blobs.setdefault(blob_hash, raw) + blob_hash = self._queue_blob(raw) instruction = { "op": "upload", "kind": "tensor", @@ -185,8 +216,7 @@ def upload( raw = value if isinstance(value, bytes) else bytes(memoryview(value)) except TypeError as exc: raise TypeError("bytes upload requires a bytes-like 'value'") from exc - blob_hash = compute_blob_hash(raw) - self._blobs.setdefault(blob_hash, raw) + blob_hash = self._queue_blob(raw) instruction = { "op": "upload", "kind": "bytes", @@ -198,8 +228,7 @@ def upload( if dtype is not None or shape is not None: raise TypeError("library upload does not accept tensor fields") raw = value if isinstance(value, bytes) else bytes(memoryview(value)) - blob_hash = compute_blob_hash(raw) - self._blobs.setdefault(blob_hash, raw) + blob_hash = self._queue_blob(raw) instruction = { "op": "upload", "kind": "library", diff --git a/tests/test_client.py b/tests/test_client.py index 4a8dc7ca..2924cb2f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -912,3 +912,31 @@ def report_path(): assert (tmp_path / "outputs/nested/report").read_bytes() == b"input report" assert (tmp_path / "outputs/nested/empty").is_dir() assert (tmp_path / "outputs/.hidden").read_bytes() == b"" + + +def test_parallel_hashes_use_snapshots_and_resolve_before_serialization(monkeypatch): + import threading + + import kcoral.client.program as builder + + barrier = threading.Barrier(2) + original = builder.compute_blob_hash + + def digest(data): + barrier.wait(timeout=10) + return original(data) + + monkeypatch.setattr(builder, "compute_blob_hash", digest) + program = Program() + first, second = bytearray(b"a" * (1 << 20)), bytearray(b"b" * (1 << 20)) + program.upload(kind="bytes", value=first) + program.upload(kind="bytes", value=second) + first[:] = second + instructions = program.instructions + assert [item["blob"] for item in instructions] == [ + original(b"a" * len(first)), + original(bytes(second)), + ] + assert list(program._blobs.values()) == [b"a" * len(first), bytes(second)] + assert program.instructions == instructions + assert not program._pending_blobs From 9d6a60f3bb4d564409d8b4ca08e9081dfc230930 Mon Sep 17 00:00:00 2001 From: arjunnai Date: Fri, 23 Oct 2026 16:31:15 -0400 Subject: [PATCH 5/5] Document worker tensor transfer and the shared-memory path --- docs/index.md | 2 ++ docs/server-guide/tensor-transfer.md | 49 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 docs/server-guide/tensor-transfer.md diff --git a/docs/index.md b/docs/index.md index aa7bbf78..e287a780 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,6 +13,7 @@ services can build kernels for a separate GPU server. | Write a client program | [Write a client program](client-guide/writing-a-program.md), [KCoral Protocol](client-guide/protocol.md) | | Run Python, checks, profilers, or shell commands | [Builtin CLI Tools](client-guide/builtin-cli-tools.md) | | Operate a service | [Launch the server](server-guide/launch-the-server.md), [Logging](server-guide/logging.md) and [Launch the router](server-guide/router.md) | +| Understand large-tensor transfer cost | [Worker Tensor Transfer](server-guide/tensor-transfer.md) | | Compile remotely, then execute the returned library in another request | [Remote Compilation](tutorials/remote-compilation.md) | | Measure or automate a workload | [Benchmark a Kernel with KCoral](tutorials/benchmark-kernel.md) and [Agent Integration Guide](tutorials/agent-integration.md) | | Look up Python interfaces | [Python API](python-api/index.rst) | @@ -52,6 +53,7 @@ client-guide/builtin-cli-tools server-guide/launch-the-server server-guide/router server-guide/logging +server-guide/tensor-transfer ``` ```{toctree} diff --git a/docs/server-guide/tensor-transfer.md b/docs/server-guide/tensor-transfer.md new file mode 100644 index 00000000..518696cb --- /dev/null +++ b/docs/server-guide/tensor-transfer.md @@ -0,0 +1,49 @@ +# Worker Tensor Transfer + +A KCoral server runs each request in a worker process and moves the request's +tensors across the process boundary. For small inputs this is cheap. For large +tensors the copy dominates the request, so the server passes big blobs through +shared memory instead of serializing them over the worker control socket. This +page explains what crosses the boundary, when the shared-memory path is used, +and what it saves. + +## What crosses the boundary + +The server and each worker communicate over a control socket. By default a +request's inputs and results are pickled and written over that socket, including +on cache hits. Pickling and copying a few kilobytes is negligible, but a +64 MiB tensor is copied several times on the way in and again on the way out, +and that copying, not the GPU work, sets the request's wall time. + +## The shared-memory path + +Blobs of at least 1 MiB — inputs and results alike — are written into a sealed +[`memfd`](https://man7.org/linux/man-pages/man2/memfd_create.2.html) and the +file descriptor is handed to the worker over the same socket. The receiver +checks the seals and the exact size, then maps the buffer read-only; no bytes +are copied over the socket. Smaller blobs, non-Linux hosts, and Python builds +without `memfd_create` keep the pickling path, so the change is transparent to +clients. + +## Measured effect + +Vector add with 64 MiB tensors, medians of 30 trials in balanced order, +sandbox off, measured with the benchmark harness on two consumer GPUs: + +| GPU | worker | serialized | shared memory | +| --- | --- | ---: | ---: | +| RTX 5090 | reused | 997 ms | 643 ms (−36%) | +| RTX 5090 | fresh | 1335 ms | 940 ms (−30%) | +| RTX 2070 | reused | 982 ms | 657 ms (−33%) | +| RTX 2070 | fresh | 1310 ms | 940 ms (−28%) | + +A 4 MiB reused-worker request drops 17–28%. Requests below the 1 MiB threshold +do not use the shared-memory path and are unchanged. + +## Reproduce + +The numbers above come from a vector-add workload driven through the server over +loopback. Follow [Benchmark a Kernel with KCoral](../tutorials/benchmark-kernel.md) +to run a kernel end to end, and vary the tensor size and +`--max-requests-per-worker` (reused vs. fresh workers) to see the transfer cost +and the shared-memory path at each size.