Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3d0dbe5a8 | ||
|
|
06f562cb3d | ||
|
|
d98feb3c65 | ||
|
|
3322a78856 | ||
|
|
bb3f5b5cb7 | ||
|
|
4f99090212 | ||
|
|
06202d8f2a |
No files matched your search
@@ -0,0 +1,84 @@
|
||||
# Local stage host transport
|
||||
|
||||
`SharedMemoryConnector` uses a persistent host ring for ordinary local stage
|
||||
payloads by default. Models keep the existing `put`/`get` and typed payload
|
||||
contracts; no model-specific sender, receiver or deployment change is required.
|
||||
This applies to both V1 and MRV2 paths using this connector, including CPU codec
|
||||
codes and CPU hidden states. Remote connectors use their existing transports.
|
||||
|
||||
Each producer process and destination stage lazily allocates a 4 MiB ring.
|
||||
Small CPU tensor trees use a msgpack header and raw tensor regions. Plain
|
||||
serialized payloads can use the same ring. Frames larger than a quarter of the
|
||||
ring, and frames arriving while it is full, use the existing per-key SHM path.
|
||||
The fallback preserves delivery without blocking a model step on ring capacity.
|
||||
Large or unsupported tensor trees do not allocate an unused contiguous snapshot.
|
||||
|
||||
## Ownership and delivery
|
||||
|
||||
The producer copies the payload before `put` returns. Readers hold the ring's
|
||||
file lock while copying into independently owned CPU storage, then mark the
|
||||
frame consumed. Multiple receiving processes may compete for a key; one reader
|
||||
claims it. Existing TP fanout remains responsible for distributing that owned
|
||||
payload to peer ranks. This transport does not provide multicast.
|
||||
|
||||
Small tensor leaves share one owned allocation. Large multi-leaf messages use
|
||||
separate allocations, so retaining small metadata does not retain a large
|
||||
hidden state. No view of ring storage escapes the receive boundary.
|
||||
|
||||
Cancellation retires exact keys or tracked numeric chunk suffixes. Replacing
|
||||
an unclaimed key retires its previous frame, including transitions between
|
||||
the ring and per-key SHM. Descriptors include monotonic frame positions; an old
|
||||
descriptor cannot read a newer publication after wraparound. Keys must have
|
||||
one active publisher, as assumed by the existing stage request routing.
|
||||
|
||||
Only producers register ring allocations with Python's shared-memory resource
|
||||
tracker. Readers map files without taking ownership. Producer process identity
|
||||
includes its start time, and available Linux pidfds detect producer exit, so
|
||||
old frames cannot be delivered after a producer restart. Normal close removes
|
||||
owned allocations and discovery markers. Resource tracking covers allocation
|
||||
cleanup when the owning application exits; an abnormal exit can leave small
|
||||
discovery markers, which contain no payload data.
|
||||
Live receivers release cached mappings after a producer exits and prune stale
|
||||
markers during discovery within their deployment scope.
|
||||
|
||||
## Configuration and observation
|
||||
|
||||
Connector extras are shared by the endpoints of an edge. For example:
|
||||
|
||||
```yaml
|
||||
connectors:
|
||||
shm:
|
||||
name: SharedMemoryConnector
|
||||
extra:
|
||||
host_ring_bytes: 4194304
|
||||
```
|
||||
|
||||
Set `host_ring_bytes: 0` at both endpoints to use the per-key path, including
|
||||
controlled comparisons or deployments with older receivers. Nonzero capacities
|
||||
must be between 4 KiB and 64 MiB. All ring endpoints must support this protocol.
|
||||
The launcher supplies a common deployment scope; discovery scans only that
|
||||
scope's directory. Receive attempts do not wait for another process's file lock.
|
||||
An unchanged empty channel skips its frame lock. Channel registration and
|
||||
retirement advance a serialized discovery stamp, so empty polling does not
|
||||
rescan the directory and new producers remain discoverable without a timer.
|
||||
Ring endpoints must share a Linux PID namespace. Deployments that share SHM
|
||||
across different PID namespaces must use `host_ring_bytes: 0`.
|
||||
|
||||
`health()` exposes `host_ring_puts`, `host_ring_gets` and `host_ring_fallbacks`
|
||||
alongside the existing counters. Ring setup failure logs once per edge and uses
|
||||
per-key SHM. CPU payload size and the cost of serialization determine the benefit;
|
||||
reduced transfer latency does not imply higher model throughput.
|
||||
|
||||
## Scope
|
||||
|
||||
This mechanism reduces host serialization copies and per-message segment work.
|
||||
It does not retain worker outputs on the GPU, allocate a CUDA arena, or replace
|
||||
GPU stream synchronization. It also does not impose a global byte budget on
|
||||
queued snapshots, downstream model caches or client audio delivery. The bounded
|
||||
ring with a compatibility fallback is not end-to-end streaming backpressure.
|
||||
|
||||
Regression tests are in
|
||||
`tests/distributed/omni_connectors/test_shm_ring.py`, collected by the existing
|
||||
`core_model and cpu` CI lane. They cover ownership, tensor types, typed payloads,
|
||||
pressure fallback, cancellation, overwrite, deadlines, competing processes and
|
||||
producer restart.
|
||||
@@ -31,10 +31,10 @@ from vllm_omni.distributed.omni_connectors.utils.config import ConnectorSpec
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shm_sender(build_adapter):
|
||||
@pytest.fixture(params=[0, 4 << 20], ids=["per-key-shm", "host-ring"])
|
||||
def shm_sender(build_adapter, request):
|
||||
adapter, _ = build_adapter(stage_id=0)
|
||||
connector = SharedMemoryConnector({"stage_id": 0})
|
||||
connector = SharedMemoryConnector({"stage_id": 0, "host_ring_bytes": request.param})
|
||||
adapter.connector = connector
|
||||
adapter.custom_process_next_stage_input_func = lambda **kwargs: OmniPayloadStruct(
|
||||
codes=CodesStruct(audio=torch.tensor([1], dtype=torch.long))
|
||||
@@ -111,13 +111,14 @@ def test_shm_release_reclaims_undrained_chunks_after_finish(shm_sender):
|
||||
|
||||
adapter.release_shm_resources(ext_id)
|
||||
# The release is queued behind sends; nothing is unlinked on the caller.
|
||||
assert f"{ext_id}_0_1" in connector._pending_keys
|
||||
assert adapter._reclaimed_shm_total == 0
|
||||
while adapter._pending_save_reqs:
|
||||
adapter._send_single_request(adapter._pending_save_reqs.popleft())
|
||||
|
||||
assert adapter._reclaimed_shm_total == 2
|
||||
assert not any(k.startswith(f"{ext_id}_0_") for k in connector._pending_keys)
|
||||
assert connector.get("0", "1", f"{ext_id}_0_1") is None
|
||||
assert connector.get("0", "1", f"{ext_id}_0_2") is None
|
||||
assert connector.get("0", "1", sibling_key)[0] == "sibling"
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
@pytest.fixture()
|
||||
def connector():
|
||||
c = SharedMemoryConnector({})
|
||||
# These cases inspect the per-key segment/lock protocol. The enabled ring
|
||||
# protocol and its pressure fallback are covered in test_shm_ring.py.
|
||||
c = SharedMemoryConnector({"host_ring_bytes": 0})
|
||||
yield c
|
||||
c.close()
|
||||
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Cross-process ownership, pressure and compatibility of the default host path."""
|
||||
|
||||
import fcntl
|
||||
import multiprocessing
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import asdict
|
||||
from multiprocessing import shared_memory
|
||||
from pathlib import Path
|
||||
|
||||
import msgspec
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
from PIL import Image
|
||||
from vllm.outputs import CompletionOutput, RequestOutput
|
||||
|
||||
from vllm_omni.data_entry_keys import CodesStruct, MetaStruct, OmniPayloadStruct
|
||||
from vllm_omni.distributed.omni_connectors.connectors.shm_connector import SharedMemoryConnector
|
||||
from vllm_omni.distributed.omni_connectors.utils.serialization import OmniSerializer
|
||||
from vllm_omni.distributed.omni_connectors.utils.tensor_frame import prepare_tensor_frame
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def edge():
|
||||
scope = uuid.uuid4().hex
|
||||
sender = SharedMemoryConnector({"stage_id": 0, "wakeup_scope": scope, "host_ring_bytes": 4096})
|
||||
receiver = SharedMemoryConnector({"stage_id": 1, "wakeup_scope": scope, "host_ring_bytes": 4096})
|
||||
yield sender, receiver
|
||||
receiver.close()
|
||||
sender.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"dtype",
|
||||
[
|
||||
torch.bool,
|
||||
torch.uint8,
|
||||
torch.int16,
|
||||
torch.int32,
|
||||
torch.int64,
|
||||
torch.float16,
|
||||
torch.bfloat16,
|
||||
torch.float32,
|
||||
torch.float64,
|
||||
torch.complex64,
|
||||
],
|
||||
)
|
||||
def test_owned_tensor_tree_preserves_values_shapes_and_types(edge, dtype):
|
||||
sender, receiver = edge
|
||||
tensor = torch.arange(24).to(dtype).reshape(4, 6).T
|
||||
data = {
|
||||
"codes": {"audio": tensor},
|
||||
"other": (tensor[0], [torch.empty(0, dtype=dtype), torch.tensor(1)]),
|
||||
"__tensor__": False,
|
||||
"meta": {"finished": torch.tensor(False)},
|
||||
}
|
||||
ok, size, metadata = sender.put("0", "1", "tree", data)
|
||||
assert ok and "host_ring" in metadata
|
||||
output, read_size = receiver.get("0", "1", "tree", metadata)
|
||||
assert read_size == size and output["__tensor__"] is False
|
||||
assert type(output["other"]) is list and type(output["other"][1]) is list
|
||||
assert output["other"][1][0].shape == (0,)
|
||||
assert output["other"][1][1].shape == ()
|
||||
assert output["codes"]["audio"].dtype == dtype
|
||||
assert torch.equal(output["codes"]["audio"], tensor)
|
||||
tensor.fill_(0)
|
||||
assert torch.count_nonzero(output["codes"]["audio"]) > 0
|
||||
for index in range(100):
|
||||
sender.put("0", "1", f"wrap{index}", {"codes": torch.full((56,), index)})
|
||||
assert receiver.get("0", "1", f"wrap{index}")[0]["codes"][0] == index
|
||||
assert torch.count_nonzero(output["codes"]["audio"]) > 0
|
||||
|
||||
|
||||
def test_conjugate_tensor_and_scalar_finish(edge):
|
||||
sender, receiver = edge
|
||||
tensor = torch.tensor([1 + 2j, 3 - 4j]).conj()
|
||||
sender.put("0", "1", "conjugate", {"condition": tensor, "finished": torch.tensor(True)})
|
||||
result = receiver.get("0", "1", "conjugate")[0]
|
||||
assert torch.equal(result["condition"], tensor)
|
||||
assert result["finished"].shape == () and result["finished"].item() is True
|
||||
|
||||
|
||||
def test_shared_typed_payload_keeps_omitted_defaults_and_cpu_tensors(edge):
|
||||
sender, receiver = edge
|
||||
payload = OmniPayloadStruct(
|
||||
codes=CodesStruct(audio=torch.arange(28).reshape(1, 28)),
|
||||
meta=MetaStruct(finished=torch.tensor(False), left_context_size=0),
|
||||
kv_metadata={"binary_handle": b"\x00\xff\x10"},
|
||||
)
|
||||
ok, _, metadata = sender.put("0", "1", "typed", payload)
|
||||
assert ok and "host_ring" in metadata
|
||||
result = receiver.get("0", "1", "typed")[0]
|
||||
assert set(result) == {"codes", "meta", "kv_metadata"}
|
||||
assert set(result["codes"]) == {"audio"}
|
||||
assert torch.equal(result["codes"]["audio"], payload.codes.audio)
|
||||
assert result["meta"]["left_context_size"] == 0
|
||||
assert result["kv_metadata"]["binary_handle"] == b"\x00\xff\x10"
|
||||
|
||||
|
||||
def test_source_can_be_overwritten_as_soon_as_put_returns(edge):
|
||||
sender, receiver = edge
|
||||
source = torch.arange(56)
|
||||
sender.put("0", "1", "source-reuse", source)
|
||||
source.fill_(-1)
|
||||
assert receiver.get("0", "1", "source-reuse")[0].tolist() == list(range(56))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dtype", [np.bool_, np.int32, np.float16, np.float64, np.complex64])
|
||||
def test_mixed_numpy_image_and_tensor_keep_shared_wire_semantics(edge, dtype):
|
||||
sender, receiver = edge
|
||||
array = np.arange(24).astype(dtype).reshape(4, 6)[::-1, ::2]
|
||||
image = Image.fromarray(np.arange(18, dtype=np.uint8).reshape(2, 3, 3))
|
||||
payload = {"codes": torch.arange(28), "array": array, "image": image, "slice": slice(1, 7, 2)}
|
||||
_, _, metadata = sender.put("0", "1", "mixed-native", payload)
|
||||
assert "host_ring" in metadata
|
||||
output = receiver.get("0", "1", "mixed-native", metadata)[0]
|
||||
assert np.array_equal(output["array"], array)
|
||||
assert output["array"].dtype == array.dtype
|
||||
assert not output["array"].flags.writeable
|
||||
assert output["image"].mode == image.mode and output["image"].tobytes() == image.tobytes()
|
||||
assert output["slice"] == [1, 7, 2]
|
||||
assert torch.equal(output["codes"], payload["codes"])
|
||||
array.fill(0)
|
||||
image.paste(0, (0, 0, 3, 2))
|
||||
assert np.count_nonzero(output["array"]) and any(output["image"].tobytes())
|
||||
|
||||
|
||||
def test_large_array_rejects_unused_framing_snapshot(monkeypatch):
|
||||
def unexpected_snapshot(value):
|
||||
pytest.fail("a too-large array must not be snapshotted while testing ring admission")
|
||||
|
||||
monkeypatch.setattr(OmniSerializer.encoder, "_enc_hook", unexpected_snapshot)
|
||||
assert prepare_tensor_frame({"array": np.zeros(1024)}, max_bytes=1024) is None
|
||||
|
||||
|
||||
def test_native_scalar_restore_preserves_nested_markers_and_input_containers():
|
||||
array = np.arange(3)
|
||||
image = Image.fromarray(np.array([[1, 2]], dtype=np.uint8))
|
||||
tensor = torch.arange(2)
|
||||
payload = {"ids": list(range(8192)), "mixed": [1.5, None, (array, {"image": image, "tensor": tensor})]}
|
||||
decoded = msgspec.msgpack.decode(OmniSerializer.serialize(payload))
|
||||
output = OmniSerializer.restore(decoded)
|
||||
assert output is not decoded and output["ids"] is not decoded["ids"]
|
||||
assert output["ids"] == payload["ids"]
|
||||
assert np.array_equal(output["mixed"][2][0], array)
|
||||
assert output["mixed"][2][1]["image"].tobytes() == image.tobytes()
|
||||
assert torch.equal(output["mixed"][2][1]["tensor"], tensor)
|
||||
assert isinstance(decoded["mixed"][2][0], dict)
|
||||
|
||||
|
||||
def test_fallback_miss_does_not_register_an_unclaimed_allocation(edge, monkeypatch, caplog):
|
||||
sender, receiver = edge
|
||||
key = "fallback-claim-" + uuid.uuid4().hex
|
||||
_, _, metadata = sender.put("0", "1", key, torch.arange(4096))
|
||||
assert "shm" in metadata
|
||||
original = shared_memory.SharedMemory
|
||||
calls = []
|
||||
|
||||
def tracked_open(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return original(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(shared_memory, "SharedMemory", tracked_open)
|
||||
with open(f"/dev/shm/shm_{key}_lockfile.lock", "rb+") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
assert receiver.get("0", "1", key) is None
|
||||
assert not calls
|
||||
assert torch.equal(receiver.get("0", "1", key)[0], torch.arange(4096))
|
||||
assert receiver.get("0", "1", key, metadata) is None
|
||||
assert not any(record.levelname == "ERROR" for record in caplog.records)
|
||||
|
||||
|
||||
def test_tensor_frames_preserve_shared_output_reconstruction(edge):
|
||||
sender, receiver = edge
|
||||
completion = asdict(CompletionOutput(index=0, text="", token_ids=[1], cumulative_logprob=0.0, logprobs=None))
|
||||
completion["multimodal_output"] = {"codes": torch.arange(28)}
|
||||
payload = {
|
||||
"request_id": "output",
|
||||
"prompt": None,
|
||||
"prompt_token_ids": [1],
|
||||
"prompt_logprobs": None,
|
||||
"outputs": [completion],
|
||||
"finished": False,
|
||||
}
|
||||
_, _, metadata = sender.put("0", "1", "output", payload)
|
||||
assert "host_ring" in metadata
|
||||
result = receiver.get("0", "1", "output", metadata)[0]
|
||||
assert isinstance(result, RequestOutput)
|
||||
assert isinstance(result.outputs[0], CompletionOutput)
|
||||
assert torch.equal(result.outputs[0].multimodal_output["codes"], torch.arange(28))
|
||||
|
||||
|
||||
def test_large_tensor_does_not_share_owner_with_small_metadata():
|
||||
with SharedMemoryConnector({}) as connector:
|
||||
payload = {"hidden": torch.ones(16384), "finished": torch.tensor(False)}
|
||||
assert connector.put("0", "1", "large-owner", payload)[0]
|
||||
output = connector.get("0", "1", "large-owner")[0]
|
||||
assert output["finished"].untyped_storage().nbytes() == 1
|
||||
assert output["hidden"].untyped_storage().nbytes() == 65536
|
||||
|
||||
|
||||
def test_full_ring_and_large_frame_use_existing_shm(edge):
|
||||
sender, receiver = edge
|
||||
payload = {"codes": torch.arange(56)}
|
||||
metadata = {}
|
||||
for index in range(12):
|
||||
ok, _size, metadata[index] = sender.put("0", "1", f"full{index}", payload)
|
||||
assert ok
|
||||
assert any("host_ring" in item for item in metadata.values())
|
||||
assert any("shm" in item for item in metadata.values())
|
||||
# Unclaimed earlier frames pin the ring; later requests still arrive.
|
||||
for index in reversed(range(12)):
|
||||
assert torch.equal(receiver.get("0", "1", f"full{index}")[0]["codes"], payload["codes"])
|
||||
ok, _size, large_metadata = sender.put("0", "1", "large", torch.arange(4096))
|
||||
assert ok and "shm" in large_metadata
|
||||
assert torch.equal(receiver.get("0", "1", "large", large_metadata)[0], torch.arange(4096))
|
||||
assert "host_ring" in sender.put("0", "1", "recovered", payload)[2]
|
||||
assert receiver.get("0", "1", "recovered") is not None
|
||||
|
||||
|
||||
def test_reput_across_ring_and_fallback_and_stale_descriptor(edge):
|
||||
sender, receiver = edge
|
||||
_, _, old = sender.put("0", "1", "reuse", torch.tensor([11]))
|
||||
_, _, current = sender.put("0", "1", "reuse", torch.tensor([22]))
|
||||
assert receiver.get("0", "1", "reuse", old) is None
|
||||
assert receiver.get("0", "1", "reuse", current)[0].item() == 22
|
||||
sender.put("0", "1", "reuse", torch.tensor([33]))
|
||||
sender.put("0", "1", "reuse", torch.full((4096,), 44))
|
||||
assert receiver.get("0", "1", "reuse")[0][0].item() == 44
|
||||
sender.put("0", "1", "reuse", torch.full((4096,), 55))
|
||||
sender.put("0", "1", "reuse", torch.tensor([66]))
|
||||
assert receiver.get("0", "1", "reuse")[0].item() == 66
|
||||
assert not os.path.exists("/dev/shm/reuse")
|
||||
|
||||
|
||||
def test_cancel_without_receiver_scan_returns_credit_and_prefix_is_exact(edge):
|
||||
sender, receiver = edge
|
||||
for key in ("req_0_0", "req_0_1", "req_0_10other", "request_0_0"):
|
||||
assert sender.put("0", "1", key, torch.arange(56))[0]
|
||||
assert sender.cleanup_prefix("req_0_") == 2
|
||||
assert receiver.get("0", "1", "req_0_0") is None
|
||||
assert receiver.get("0", "1", "req_0_10other") is not None
|
||||
assert receiver.get("0", "1", "request_0_0") is not None
|
||||
assert "host_ring" in sender.put("0", "1", "next", torch.arange(56))[2]
|
||||
|
||||
|
||||
def test_flat_scope_and_metadata_work_across_different_parents(monkeypatch):
|
||||
monkeypatch.setattr(os, "getppid", lambda: 100)
|
||||
sender = SharedMemoryConnector({"stage_id": 0, "wakeup_scope": "flat-scope"})
|
||||
monkeypatch.setattr(os, "getppid", lambda: 200)
|
||||
receiver = SharedMemoryConnector({"stage_id": 1, "wakeup_scope": "flat-scope"})
|
||||
try:
|
||||
assert sender._wake_directory == receiver._wake_directory
|
||||
assert sender.put("0", "1", "scope", torch.tensor([7]))[0]
|
||||
assert receiver.get("0", "1", "scope")[0].item() == 7
|
||||
finally:
|
||||
sender.close()
|
||||
receiver.close()
|
||||
|
||||
|
||||
def test_empty_poll_reuses_discovery_and_never_waits_for_a_ring_lock(edge, monkeypatch):
|
||||
sender, receiver = edge
|
||||
sender.put("0", "1", "prime-poll", torch.arange(3))
|
||||
assert receiver.get("0", "1", "prime-poll") is not None
|
||||
|
||||
def unexpected_work(*args, **kwargs):
|
||||
pytest.fail("an empty, unchanged channel must not rescan discovery or acquire its frame lock")
|
||||
|
||||
monkeypatch.setattr("vllm_omni.distributed.omni_connectors.connectors.shm_ring.glob.iglob", unexpected_work)
|
||||
monkeypatch.setattr(fcntl, "flock", unexpected_work)
|
||||
for index in range(128):
|
||||
assert receiver.get("0", "1", f"missing-poll-{index}") is None
|
||||
monkeypatch.undo()
|
||||
sender.put("0", "1", "new-poll", torch.arange(5))
|
||||
assert receiver.get("0", "1", "new-poll")[0].tolist() == list(range(5))
|
||||
|
||||
|
||||
def test_new_producer_is_discovered_after_a_miss_even_when_the_clock_does_not_advance(edge, monkeypatch):
|
||||
sender, receiver = edge
|
||||
sender.put("0", "1", "first-producer", torch.arange(3))
|
||||
assert receiver.get("0", "1", "first-producer") is not None
|
||||
assert receiver.get("0", "1", "second-producer") is None
|
||||
old_version = receiver._host_ring._discovery_versions["0", "1"]
|
||||
monkeypatch.setattr("vllm_omni.distributed.omni_connectors.connectors.shm_ring.time.time_ns", lambda: 0)
|
||||
with SharedMemoryConnector(sender.config) as second:
|
||||
second.put("0", "1", "second-producer", torch.arange(5))
|
||||
assert receiver.get("0", "1", "second-producer")[0].tolist() == list(range(5))
|
||||
new_version = receiver._host_ring._discovery_versions["0", "1"]
|
||||
assert new_version[2] == old_version[2] + 1
|
||||
|
||||
|
||||
def test_one_receiver_discovers_each_input_edge_at_the_same_registry_version(edge):
|
||||
sender, receiver = edge
|
||||
with SharedMemoryConnector({**sender.config, "stage_id": 2}) as second:
|
||||
sender.put("0", "1", "fanin-first", torch.arange(3))
|
||||
second.put("2", "1", "fanin-second", torch.arange(5))
|
||||
assert receiver.get("0", "1", "fanin-first")[0].tolist() == list(range(3))
|
||||
assert receiver.get("2", "1", "fanin-second")[0].tolist() == list(range(5))
|
||||
|
||||
|
||||
def test_discovery_attaches_all_producers_even_when_the_first_one_has_the_requested_key(edge, monkeypatch):
|
||||
sender, receiver = edge
|
||||
with SharedMemoryConnector(sender.config) as second:
|
||||
first_metadata = sender.put("0", "1", "multi-first", torch.arange(3))[2]
|
||||
second_metadata = second.put("0", "1", "multi-second", torch.arange(5))[2]
|
||||
paths = [
|
||||
f"/dev/shm/{receiver._host_ring.directory}/{metadata['host_ring']['name']}"
|
||||
for metadata in (first_metadata, second_metadata)
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
"vllm_omni.distributed.omni_connectors.connectors.shm_ring.glob.iglob", lambda _: iter(paths)
|
||||
)
|
||||
assert receiver.get("0", "1", "multi-first")[0].tolist() == list(range(3))
|
||||
assert receiver.get("0", "1", "multi-second")[0].tolist() == list(range(5))
|
||||
|
||||
|
||||
def test_closed_channel_releases_reader_cache_while_the_producer_process_is_still_alive(edge):
|
||||
sender, receiver = edge
|
||||
sender.put("0", "1", "close-cache", torch.arange(3))
|
||||
assert receiver.get("0", "1", "close-cache") is not None
|
||||
channel = next(iter(receiver._host_ring.readers.values()))
|
||||
fd = channel.fd
|
||||
sender.close()
|
||||
assert receiver.get("0", "1", "not-published-after-close") is None
|
||||
assert not receiver._host_ring.readers
|
||||
with pytest.raises(OSError):
|
||||
os.fstat(fd)
|
||||
|
||||
|
||||
def test_disabled_ring_and_unsupported_tree_keep_legacy_serializer(edge):
|
||||
sender, receiver = edge
|
||||
with SharedMemoryConnector({"host_ring_bytes": 0}) as legacy:
|
||||
_, _, metadata = legacy.put("0", "1", "legacy", {"codes": torch.arange(3)})
|
||||
assert "shm" in metadata
|
||||
assert receiver.get("0", "1", "legacy", metadata)[0]["codes"].tolist() == [0, 1, 2]
|
||||
assert prepare_tensor_frame({"codes": torch.arange(3), "opaque": object()}) is None
|
||||
assert sender.put("0", "1", "falsey", False)[0]
|
||||
assert receiver.get("0", "1", "falsey")[0] is False
|
||||
|
||||
|
||||
def test_tensor_free_lists_reuse_native_serialization():
|
||||
payload = {"prompt_token_ids": list(range(8192)), "condition": [0.25] * 8192}
|
||||
with SharedMemoryConnector({}) as connector:
|
||||
frame = prepare_tensor_frame(payload)
|
||||
assert isinstance(frame, bytes)
|
||||
assert frame == connector.serialize_obj(payload)
|
||||
assert connector.put("0", "1", "native-lists", payload)[0]
|
||||
assert connector.get("0", "1", "native-lists")[0] == payload
|
||||
|
||||
|
||||
def test_scalar_lists_and_literal_extensions_preserve_wire_semantics():
|
||||
literal = msgspec.msgpack.Ext(42, bytes(20))
|
||||
payload = {"condition": [0.25] * 8192, "codes": torch.arange(28), "extension": literal}
|
||||
with SharedMemoryConnector({}) as connector:
|
||||
_, _, metadata = connector.put("0", "1", "mixed-lists", payload)
|
||||
assert "host_ring" in metadata
|
||||
result = connector.get("0", "1", "mixed-lists")[0]
|
||||
assert result["condition"] == payload["condition"]
|
||||
assert torch.equal(result["codes"], payload["codes"])
|
||||
assert result["extension"].code == literal.code
|
||||
assert result["extension"].data == literal.data
|
||||
|
||||
|
||||
def _locked_writer(name, ready, release):
|
||||
fd = os.open(f"/dev/shm/{name}", os.O_RDWR)
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX)
|
||||
ready.set()
|
||||
assert release.wait(10)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_metadata", [False, True])
|
||||
def test_deadline_receive_never_waits_for_another_process_ring_lock(edge, use_metadata):
|
||||
sender, receiver = edge
|
||||
_, _, metadata = sender.put("0", "1", "locked", torch.arange(3))
|
||||
ctx = multiprocessing.get_context("spawn")
|
||||
ready, release = ctx.Event(), ctx.Event()
|
||||
writer = ctx.Process(target=_locked_writer, args=(metadata["host_ring"]["name"], ready, release))
|
||||
writer.start()
|
||||
try:
|
||||
assert ready.wait(15)
|
||||
start = time.monotonic()
|
||||
assert (
|
||||
receiver.get_with_deadline("0", "1", "locked", metadata if use_metadata else None, deadline=start + 1)
|
||||
is None
|
||||
)
|
||||
assert time.monotonic() - start < 1
|
||||
finally:
|
||||
release.set()
|
||||
writer.join(15)
|
||||
if writer.is_alive():
|
||||
writer.kill()
|
||||
writer.join()
|
||||
assert writer.exitcode == 0
|
||||
assert receiver.get("0", "1", "locked", metadata if use_metadata else None)[0].tolist() == [0, 1, 2]
|
||||
|
||||
|
||||
def _competing_reader(scope, barrier, results):
|
||||
with SharedMemoryConnector({"stage_id": 1, "wakeup_scope": scope}) as receiver:
|
||||
barrier.wait(timeout=20)
|
||||
result = receiver.get("0", "1", "shared")
|
||||
results.put(None if result is None else result[0]["codes"].tolist())
|
||||
|
||||
|
||||
def test_two_reader_processes_claim_exactly_once_and_close_does_not_unlink_owner():
|
||||
scope = uuid.uuid4().hex
|
||||
ctx = multiprocessing.get_context("spawn")
|
||||
barrier, results = ctx.Barrier(3), ctx.Queue()
|
||||
with SharedMemoryConnector({"stage_id": 0, "wakeup_scope": scope}) as sender:
|
||||
_, _, metadata = sender.put("0", "1", "shared", {"codes": torch.arange(56)})
|
||||
readers = [ctx.Process(target=_competing_reader, args=(scope, barrier, results)) for _ in range(2)]
|
||||
for reader in readers:
|
||||
reader.start()
|
||||
try:
|
||||
barrier.wait(timeout=30)
|
||||
outputs = [results.get(timeout=20) for _ in readers]
|
||||
assert outputs.count(None) == 1
|
||||
assert next(output for output in outputs if output is not None) == list(range(56))
|
||||
assert os.path.exists(f"/dev/shm/{metadata['host_ring']['name']}")
|
||||
finally:
|
||||
for reader in readers:
|
||||
reader.join(15)
|
||||
if reader.is_alive():
|
||||
reader.kill()
|
||||
reader.join()
|
||||
results.close()
|
||||
assert all(reader.exitcode == 0 for reader in readers)
|
||||
|
||||
|
||||
def test_ring_setup_failure_keeps_connector_usable(edge, monkeypatch):
|
||||
sender, receiver = edge
|
||||
|
||||
def no_space(*args):
|
||||
raise OSError(28, "No space left on device")
|
||||
|
||||
monkeypatch.setattr(os, "posix_fallocate", no_space)
|
||||
ok, _, metadata = sender.put("0", "1", "no-ring-space", torch.arange(3))
|
||||
assert ok and "shm" in metadata
|
||||
assert receiver.get("0", "1", "no-ring-space")[0].tolist() == [0, 1, 2]
|
||||
|
||||
|
||||
def test_discovery_stamp_failure_keeps_fallback_and_cleanup_usable(edge, monkeypatch):
|
||||
sender, receiver = edge
|
||||
|
||||
def stamp_denied(*args, **kwargs):
|
||||
raise PermissionError("directory timestamp update denied")
|
||||
|
||||
monkeypatch.setattr(os, "utime", stamp_denied)
|
||||
ok, _, metadata = sender.put("0", "1", "no-ring-stamp", torch.arange(3))
|
||||
assert ok and "shm" in metadata
|
||||
assert not sender._host_ring.producers
|
||||
assert not list(Path(f"/dev/shm/{sender._host_ring.directory}").iterdir())
|
||||
assert receiver.get("0", "1", "no-ring-stamp")[0].tolist() == [0, 1, 2]
|
||||
sender.close()
|
||||
|
||||
|
||||
def test_close_is_idempotent_and_removes_owned_ring(edge):
|
||||
sender, receiver = edge
|
||||
_, _, metadata = sender.put("0", "1", "close", torch.arange(3))
|
||||
receiver.get("0", "1", "close")
|
||||
sender.close()
|
||||
sender.close()
|
||||
receiver.close()
|
||||
receiver.close()
|
||||
assert not os.path.exists(f"/dev/shm/{metadata['host_ring']['name']}")
|
||||
|
||||
|
||||
def _orphan_producer(scope, pipe):
|
||||
producer = SharedMemoryConnector({"stage_id": 0, "wakeup_scope": scope})
|
||||
metadata = producer.put("0", "1", "restart", torch.tensor([11]))[2]
|
||||
pipe.send(metadata)
|
||||
pipe.recv()
|
||||
pipe.close()
|
||||
os._exit(0)
|
||||
|
||||
|
||||
def test_dead_producer_payload_cannot_reach_a_reused_key():
|
||||
scope = uuid.uuid4().hex
|
||||
ctx = multiprocessing.get_context("spawn")
|
||||
parent, child = ctx.Pipe()
|
||||
process = ctx.Process(target=_orphan_producer, args=(scope, child))
|
||||
process.start()
|
||||
child.close()
|
||||
old = parent.recv()
|
||||
try:
|
||||
with SharedMemoryConnector({"stage_id": 1, "wakeup_scope": scope}) as receiver:
|
||||
# Cache the live channel without claiming the pending frame.
|
||||
assert receiver.get("0", "1", "not-published") is None
|
||||
channel = next(iter(receiver._host_ring.readers.values()))
|
||||
fd = channel.fd
|
||||
parent.send("exit")
|
||||
process.join(15)
|
||||
assert process.exitcode == 0
|
||||
assert receiver.get("0", "1", "restart", old) is None
|
||||
assert not receiver._host_ring.readers
|
||||
with pytest.raises(OSError):
|
||||
os.fstat(fd)
|
||||
with SharedMemoryConnector({"stage_id": 0, "wakeup_scope": scope}) as replacement:
|
||||
replacement.put("0", "1", "restart", torch.tensor([22]))
|
||||
assert receiver.get("0", "1", "restart")[0].item() == 22
|
||||
finally:
|
||||
if process.is_alive():
|
||||
process.kill()
|
||||
process.join()
|
||||
# The multiprocessing parent still owns this test's resource tracker.
|
||||
# Retire only the intentionally orphaned allocation and its exact marker.
|
||||
segment = shared_memory.SharedMemory(name=old["host_ring"]["name"])
|
||||
segment.close()
|
||||
segment.unlink()
|
||||
directory = old["host_ring"]["name"].rsplit("_", 4)[0]
|
||||
try:
|
||||
os.unlink(f"/dev/shm/{directory}/{old['host_ring']['name']}")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
try:
|
||||
os.rmdir(f"/dev/shm/{directory}")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
parent.close()
|
||||
@@ -56,8 +56,8 @@ class OmniConnectorBase(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def cleanup(self, request_id: str) -> None:
|
||||
"""Clean up resources for a request."""
|
||||
def cleanup(self, request_id: str) -> bool | None:
|
||||
"""Clean up resources; backends may report whether a payload was retired."""
|
||||
pass
|
||||
|
||||
def get_with_deadline(
|
||||
|
||||
@@ -17,7 +17,10 @@ from typing import Any
|
||||
from vllm_omni.entrypoints.stage_utils import shm_read_bytes, shm_write_bytes
|
||||
|
||||
from ..utils.logging import get_connector_logger
|
||||
from ..utils.serialization import OmniSerializer
|
||||
from ..utils.tensor_frame import TensorFrame, prepare_tensor_frame
|
||||
from .base import OmniConnectorBase
|
||||
from .shm_ring import HostRingTransport
|
||||
|
||||
logger = get_connector_logger(__name__)
|
||||
|
||||
@@ -55,7 +58,8 @@ class SharedMemoryConnector(OmniConnectorBase):
|
||||
def __init__(self, config: dict[str, Any]):
|
||||
self.config = config
|
||||
self.stage_id = config.get("stage_id", -1)
|
||||
scope = config.get("extra", {}).get("wakeup_scope")
|
||||
extra = config.get("extra", config)
|
||||
scope = extra.get("wakeup_scope")
|
||||
self._wake_directory = (
|
||||
f"/dev/shm/omni_shm_wake_{os.getuid()}_{hashlib.sha256(str(scope).encode()).hexdigest()[:24]}"
|
||||
if scope is not None
|
||||
@@ -67,7 +71,13 @@ class SharedMemoryConnector(OmniConnectorBase):
|
||||
"puts": 0,
|
||||
"gets": 0,
|
||||
"bytes_transferred": 0,
|
||||
"host_ring_puts": 0,
|
||||
"host_ring_gets": 0,
|
||||
"host_ring_fallbacks": 0,
|
||||
}
|
||||
# One bounded host fast path per edge, allocated only on the first put.
|
||||
# A zero capacity preserves the per-key protocol for controlled comparisons.
|
||||
self._host_ring = HostRingTransport(self._wake_directory, int(extra.get("host_ring_bytes", 4 << 20)))
|
||||
# Receiver side: FIFO read end (plus a write end of our own, so the
|
||||
# FIFO never reports EOF when no sender has it open) and a counter of
|
||||
# drained wakeups. Each receiver owns its path, including across restarts.
|
||||
@@ -200,8 +210,32 @@ class SharedMemoryConnector(OmniConnectorBase):
|
||||
data: Any,
|
||||
) -> tuple[bool, int, dict[str, Any] | None]:
|
||||
try:
|
||||
self.reap_consumed()
|
||||
payload = self.serialize_obj(data)
|
||||
# Ring put() reclaims its own edge under the publication lock.
|
||||
# Keep the per-key sweep here without taking every ring lock twice.
|
||||
self._reap_segments()
|
||||
use_ring = self._host_ring.capacity and str(from_stage).isdigit() and str(to_stage).isdigit()
|
||||
frame = (
|
||||
prepare_tensor_frame(data, max_bytes=self._host_ring.capacity // 4 - 16 - len(put_key.encode()))
|
||||
if use_ring
|
||||
else None
|
||||
)
|
||||
payload: TensorFrame | bytes = frame if frame is not None else self.serialize_obj(data)
|
||||
ring_meta = self._host_ring.put(str(from_stage), str(to_stage), put_key, payload)
|
||||
if ring_meta is not None:
|
||||
# A previous put may have used the per-key fallback.
|
||||
with self._pending_keys_lock:
|
||||
had_segment = put_key in self._pending_keys
|
||||
if had_segment:
|
||||
self._cleanup_segment(put_key)
|
||||
size = ring_meta["size"]
|
||||
self._wake_receiver(to_stage)
|
||||
self._metrics["puts"] += 1
|
||||
self._metrics["host_ring_puts"] += 1
|
||||
self._metrics["bytes_transferred"] += size
|
||||
return True, size, {"host_ring": ring_meta, "size": size}
|
||||
self._metrics["host_ring_fallbacks"] += 1
|
||||
if isinstance(payload, TensorFrame):
|
||||
payload = self.serialize_obj(data)
|
||||
size = len(payload)
|
||||
|
||||
lock_file = f"/dev/shm/shm_{put_key}_lockfile.lock"
|
||||
@@ -214,6 +248,8 @@ class SharedMemoryConnector(OmniConnectorBase):
|
||||
metadata = {"shm": meta, "size": size}
|
||||
with self._pending_keys_lock:
|
||||
self._pending_keys[put_key] = None
|
||||
# A successful fallback replaces an unclaimed ring version, too.
|
||||
self._host_ring.cancel(key=put_key)
|
||||
self._wake_receiver(to_stage)
|
||||
|
||||
self._metrics["puts"] += 1
|
||||
@@ -236,7 +272,7 @@ class SharedMemoryConnector(OmniConnectorBase):
|
||||
obj = self.deserialize_obj(data_bytes)
|
||||
result = (obj, int(shm_handle.get("size", 0)))
|
||||
return result
|
||||
except BlockingIOError:
|
||||
except (BlockingIOError, FileNotFoundError):
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"SharedMemoryConnector shm get failed for req : {e}")
|
||||
@@ -250,13 +286,15 @@ class SharedMemoryConnector(OmniConnectorBase):
|
||||
|
||||
def _get_by_key(self, get_key: str) -> tuple[Any, int] | None:
|
||||
"""Read a SHM segment addressed purely by *get_key*."""
|
||||
shm = None
|
||||
try:
|
||||
shm = shm_pkg.SharedMemory(name=get_key)
|
||||
if shm is None or shm.size == 0:
|
||||
# Inspect size without registering a reader before it owns the
|
||||
# delivery lock. Competing readers must not re-register an already
|
||||
# consumed allocation with the application's resource tracker.
|
||||
size = os.stat(f"/dev/shm/{get_key}").st_size
|
||||
if size == 0:
|
||||
return None
|
||||
lock_file = f"/dev/shm/shm_{get_key}_lockfile.lock"
|
||||
shm_handle = {"name": get_key, "size": shm.size}
|
||||
shm_handle = {"name": get_key, "size": size}
|
||||
result = self._get_data_with_lock(lock_file, shm_handle)
|
||||
if result is not None:
|
||||
with self._pending_keys_lock:
|
||||
@@ -264,20 +302,9 @@ class SharedMemoryConnector(OmniConnectorBase):
|
||||
return result
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except ValueError as e:
|
||||
# A receiver can observe a newly-created POSIX SHM object before
|
||||
# the writer has finished sizing it. Treat that as "not ready yet"
|
||||
# so async polling can retry without a traceback.
|
||||
if "empty file" in str(e):
|
||||
return None
|
||||
logger.debug("_get_by_key: unexpected error reading SHM segment %s", get_key, exc_info=True)
|
||||
return None
|
||||
except Exception:
|
||||
logger.debug("_get_by_key: unexpected error reading SHM segment %s", get_key, exc_info=True)
|
||||
return None
|
||||
finally:
|
||||
if shm:
|
||||
shm.close()
|
||||
|
||||
def get(
|
||||
self,
|
||||
@@ -286,6 +313,23 @@ class SharedMemoryConnector(OmniConnectorBase):
|
||||
get_key: str,
|
||||
metadata=None,
|
||||
) -> tuple[Any, int] | None:
|
||||
if isinstance(metadata, dict) and get_key in metadata:
|
||||
metadata = metadata[get_key]
|
||||
ring_meta = metadata.get("host_ring") if isinstance(metadata, dict) else None
|
||||
ring_result = self._host_ring.get(str(from_stage), str(to_stage), get_key, ring_meta)
|
||||
if ring_result is not None:
|
||||
obj, size = ring_result
|
||||
if isinstance(obj, bytes):
|
||||
obj = self.deserialize_obj(obj)
|
||||
else:
|
||||
# Preserve the centralized serializer's output reconstruction,
|
||||
# including RequestOutput/CompletionOutput dictionaries.
|
||||
obj = OmniSerializer.restore(obj)
|
||||
self._metrics["gets"] += 1
|
||||
self._metrics["host_ring_gets"] += 1
|
||||
return obj, size
|
||||
if ring_meta is not None:
|
||||
return None
|
||||
if metadata is not None:
|
||||
if isinstance(metadata, dict) and get_key in metadata:
|
||||
metadata = metadata.get(get_key)
|
||||
@@ -312,7 +356,10 @@ class SharedMemoryConnector(OmniConnectorBase):
|
||||
|
||||
Returns True when an unconsumed segment was actually unlinked.
|
||||
"""
|
||||
key = request_id
|
||||
ring_cancelled = self._host_ring.cancel(key=request_id)
|
||||
return self._cleanup_segment(request_id) or bool(ring_cancelled)
|
||||
|
||||
def _cleanup_segment(self, key: str) -> bool:
|
||||
unlinked = False
|
||||
with self._pending_keys_lock:
|
||||
self._pending_keys.pop(key, None)
|
||||
@@ -343,10 +390,14 @@ class SharedMemoryConnector(OmniConnectorBase):
|
||||
"""
|
||||
with self._pending_keys_lock:
|
||||
keys = [k for k in self._pending_keys if k.startswith(key_prefix) and k[len(key_prefix) :].isdigit()]
|
||||
return sum(self.cleanup(key) for key in keys)
|
||||
return sum(self._cleanup_segment(key) for key in keys) + self._host_ring.cancel(prefix=key_prefix)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Unlink all remaining tracked SHM segments."""
|
||||
# A constructor failure may be followed by the base destructor.
|
||||
if not hasattr(self, "_host_ring"):
|
||||
return
|
||||
self._host_ring.close()
|
||||
with self._pending_keys_lock:
|
||||
keys = list(self._pending_keys)
|
||||
for key in keys:
|
||||
@@ -355,6 +406,10 @@ class SharedMemoryConnector(OmniConnectorBase):
|
||||
|
||||
def reap_consumed(self) -> None:
|
||||
"""Bounded round-robin sweep; receivers unlink SHM in another process."""
|
||||
self._host_ring.reap()
|
||||
self._reap_segments()
|
||||
|
||||
def _reap_segments(self) -> None:
|
||||
with self._pending_keys_lock:
|
||||
for _ in range(min(64, len(self._pending_keys))):
|
||||
key, _ = self._pending_keys.popitem(last=False)
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Bounded host frames shared by local stage processes.
|
||||
|
||||
Each producer/destination edge owns one mmap, with a lock on that same file.
|
||||
Readers copy before marking a frame consumed, and all readers participate in
|
||||
the same lock. Pressure returns False to the existing per-key SHM path; this
|
||||
fast path does not implement end-to-end stream admission or GPU ownership.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import glob
|
||||
import hashlib
|
||||
import mmap
|
||||
import os
|
||||
import select
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from multiprocessing import shared_memory as shm_pkg
|
||||
|
||||
from ..utils.logging import get_connector_logger
|
||||
from ..utils.tensor_frame import TensorFrame, read_tensor_frame
|
||||
|
||||
logger = get_connector_logger(__name__)
|
||||
|
||||
_HEADER_SIZE = 64
|
||||
_HEADER = struct.Struct("<8sQQQQQ") # magic, capacity, head, tail, creator pid, process start
|
||||
_MAGIC = b"OMSHMR01"
|
||||
_FRAME = struct.Struct("<IIBBHI") # total, payload size, kind, state, key length, reserved
|
||||
_ALIGN = 16
|
||||
_READY, _DONE = 0, 1
|
||||
_BYTES, _TENSORS, _PADDING = 0, 1, 2
|
||||
_STATE_OFFSET = 9
|
||||
_DEFAULT_BYTES = 4 << 20
|
||||
|
||||
|
||||
def _align(size: int) -> int:
|
||||
return (size + _ALIGN - 1) & ~(_ALIGN - 1)
|
||||
|
||||
|
||||
def _process_start(pid: int) -> int:
|
||||
with open(f"/proc/{pid}/stat") as file:
|
||||
return int(file.read().rsplit(")", 1)[1].split()[19])
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _registry_update(directory: str) -> Iterator[int]:
|
||||
"""Publish a strictly changing discovery stamp at channel birth/retirement.
|
||||
|
||||
Kernel directory mtimes can coincide within a clock tick. Serialize marker
|
||||
updates and preserve the previous stamp before the filesystem changes it.
|
||||
Receivers can then cache discovery without a timer or a per-message scan.
|
||||
"""
|
||||
fd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX)
|
||||
previous = os.fstat(fd).st_mtime_ns
|
||||
yield fd
|
||||
stamp = max(previous + 1, time.time_ns())
|
||||
os.utime(fd, ns=(stamp, stamp))
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Channel:
|
||||
name: str
|
||||
fd: int
|
||||
mapping: mmap.mmap
|
||||
capacity: int
|
||||
owner: bool
|
||||
creator_pid: int
|
||||
creator_start: int
|
||||
segment: shm_pkg.SharedMemory | None = None
|
||||
registry: str | None = None
|
||||
pidfd: int | None = None
|
||||
cursor: int = 0
|
||||
index: dict[str, int] = field(default_factory=dict)
|
||||
positions: deque[tuple[int, str]] = field(default_factory=deque)
|
||||
|
||||
@classmethod
|
||||
def open(cls, name: str, capacity: int | None = None) -> _Channel:
|
||||
owner = capacity is not None
|
||||
segment = None
|
||||
mapping = None
|
||||
pidfd = None
|
||||
fd = -1
|
||||
try:
|
||||
if capacity is not None:
|
||||
# Only the producer registers the allocation with Python's
|
||||
# resource tracker. Readers map the existing file directly.
|
||||
segment = shm_pkg.SharedMemory(name=name, create=True, size=_HEADER_SIZE + capacity)
|
||||
fd = os.open(f"/dev/shm/{name}", os.O_RDWR | os.O_NOFOLLOW)
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | (0 if owner else fcntl.LOCK_NB))
|
||||
try:
|
||||
if capacity is not None:
|
||||
os.posix_fallocate(fd, 0, _HEADER_SIZE + capacity)
|
||||
size = os.fstat(fd).st_size
|
||||
if size < _HEADER_SIZE:
|
||||
raise ValueError("incomplete SHM ring header")
|
||||
mapping = mmap.mmap(fd, size)
|
||||
if capacity is not None:
|
||||
# Fault once per edge, outside the per-message path.
|
||||
mapping[:] = bytes(size)
|
||||
_HEADER.pack_into(mapping, 0, _MAGIC, capacity, 0, 0, os.getpid(), _process_start(os.getpid()))
|
||||
magic, actual_capacity, head, tail, creator_pid, creator_start = _HEADER.unpack_from(mapping)
|
||||
if magic != _MAGIC or actual_capacity != size - _HEADER_SIZE or not 0 <= tail <= head:
|
||||
raise ValueError("invalid SHM ring header")
|
||||
if not owner:
|
||||
try:
|
||||
pidfd = os.pidfd_open(creator_pid)
|
||||
except (AttributeError, OSError):
|
||||
# Older Linux kernels use the process-start check on receive.
|
||||
pidfd = None
|
||||
if _process_start(creator_pid) != creator_start:
|
||||
raise FileNotFoundError("SHM ring producer was replaced")
|
||||
return cls(
|
||||
name, fd, mapping, actual_capacity, owner, creator_pid, creator_start, segment=segment, pidfd=pidfd
|
||||
)
|
||||
finally:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
except BaseException:
|
||||
if mapping is not None:
|
||||
mapping.close()
|
||||
if fd >= 0:
|
||||
os.close(fd)
|
||||
if segment is not None:
|
||||
segment.close()
|
||||
segment.unlink()
|
||||
if pidfd is not None:
|
||||
os.close(pidfd)
|
||||
raise
|
||||
|
||||
@contextmanager
|
||||
def locked(self, *, blocking: bool = True) -> Iterator[None]:
|
||||
fcntl.flock(self.fd, fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(self.fd, fcntl.LOCK_UN)
|
||||
|
||||
def bounds(self) -> tuple[int, int]:
|
||||
_magic, _capacity, head, tail, _pid, _start = _HEADER.unpack_from(self.mapping)
|
||||
return head, tail
|
||||
|
||||
def producer_alive(self) -> bool:
|
||||
if self.pidfd is not None:
|
||||
return not select.select([self.pidfd], [], [], 0)[0]
|
||||
try:
|
||||
return _process_start(self.creator_pid) == self.creator_start
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
def frame(self, position: int) -> tuple[int, int, int, int, int, int]:
|
||||
frame = _FRAME.unpack_from(self.mapping, _HEADER_SIZE + position % self.capacity)
|
||||
total, length, kind, _state, key_length, _reserved = frame
|
||||
if total < _FRAME.size or total % _ALIGN or position % self.capacity + total > self.capacity:
|
||||
raise ValueError("invalid SHM ring frame size")
|
||||
if kind != _PADDING and _FRAME.size + length + key_length > total:
|
||||
raise ValueError("invalid SHM ring payload bounds")
|
||||
return frame
|
||||
|
||||
def scan(self) -> None:
|
||||
head, tail = self.bounds()
|
||||
self.cursor = max(self.cursor, tail)
|
||||
while self.cursor < head:
|
||||
position = self.cursor
|
||||
total, _length, kind, state, key_length, _reserved = self.frame(position)
|
||||
self.cursor += total
|
||||
if kind != _PADDING and state == _READY:
|
||||
start = _HEADER_SIZE + position % self.capacity + _FRAME.size
|
||||
key = self.mapping[start : start + key_length].decode()
|
||||
self.index[key] = position
|
||||
self.positions.append((position, key))
|
||||
# Remove positions retired by another reader or overwritten after wrap.
|
||||
while self.positions and self.positions[0][0] < tail:
|
||||
position, key = self.positions.popleft()
|
||||
if self.index.get(key) == position:
|
||||
del self.index[key]
|
||||
|
||||
def consume(self, position: int) -> None:
|
||||
self.mapping[_HEADER_SIZE + position % self.capacity + _STATE_OFFSET] = _DONE
|
||||
|
||||
def reclaim(self) -> None:
|
||||
head, tail = self.bounds()
|
||||
while tail < head:
|
||||
total, _length, kind, state, _key_length, _reserved = self.frame(tail)
|
||||
if kind != _PADDING and state != _DONE:
|
||||
break
|
||||
tail += total
|
||||
struct.pack_into("<Q", self.mapping, 24, tail)
|
||||
|
||||
def cancel(self, key: str | None = None, prefix: str | None = None) -> int:
|
||||
self.scan()
|
||||
count = 0
|
||||
candidates = (
|
||||
(((key, self.index[key]),) if key in self.index else ()) if key is not None else tuple(self.index.items())
|
||||
)
|
||||
for candidate, position in candidates:
|
||||
matches = candidate == key or (
|
||||
prefix is not None and candidate.startswith(prefix) and candidate[len(prefix) :].isdigit()
|
||||
)
|
||||
if matches:
|
||||
if self.frame(position)[3] == _READY:
|
||||
self.consume(position)
|
||||
count += 1
|
||||
del self.index[candidate]
|
||||
self.reclaim()
|
||||
return count
|
||||
|
||||
def close(self) -> None:
|
||||
self.mapping.close()
|
||||
os.close(self.fd)
|
||||
if self.pidfd is not None:
|
||||
os.close(self.pidfd)
|
||||
if self.segment is not None:
|
||||
self.segment.close()
|
||||
try:
|
||||
self.segment.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
if self.registry is not None:
|
||||
try:
|
||||
with _registry_update(os.path.dirname(self.registry)) as directory_fd:
|
||||
os.unlink(os.path.basename(self.registry), dir_fd=directory_fd)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as error:
|
||||
# The allocation is already retired. A failed discovery stamp
|
||||
# must not mask setup failure or prevent the per-key fallback.
|
||||
logger.debug("Failed to retire host ring marker %s: %s", self.registry, error)
|
||||
|
||||
|
||||
class HostRingTransport:
|
||||
"""Private SharedMemoryConnector fast path; no new model-specific API."""
|
||||
|
||||
def __init__(self, scope: str, capacity: int = _DEFAULT_BYTES):
|
||||
if capacity < 0 or capacity and not 4096 <= capacity <= 64 << 20:
|
||||
raise ValueError("host_ring_bytes must be zero or between 4096 and 67108864")
|
||||
self.capacity = _align(capacity)
|
||||
scope_id = hashlib.sha256(scope.encode()).hexdigest()[:24]
|
||||
self.directory = f"omni_shm_ring_v1_{os.getuid()}_{scope_id}"
|
||||
self.prefix = f"{self.directory}_"
|
||||
self.producers: dict[tuple[str, str], _Channel] = {}
|
||||
self._disabled_edges: set[tuple[str, str]] = set()
|
||||
self.readers: dict[str, _Channel] = {}
|
||||
self._discovery_versions: dict[tuple[str, str], tuple[int, int, int]] = {}
|
||||
self._lock = threading.RLock()
|
||||
self._closed = False
|
||||
|
||||
def put(self, source: str, destination: str, key: str, payload: bytes | TensorFrame) -> dict | None:
|
||||
"""Return an immutable descriptor, or None without consuming ring credit."""
|
||||
encoded_key = key.encode()
|
||||
length = payload.size if isinstance(payload, TensorFrame) else len(payload)
|
||||
total = _align(_FRAME.size + len(encoded_key) + length)
|
||||
if not self.capacity or not source.isdigit() or not destination.isdigit():
|
||||
return None
|
||||
# Keep one exceptional payload from monopolizing a channel.
|
||||
if total > self.capacity // 4 or len(encoded_key) > 65535:
|
||||
return None
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
raise RuntimeError("host ring is closed")
|
||||
if (source, destination) in self._disabled_edges:
|
||||
return None
|
||||
channel = self.producers.get((source, destination))
|
||||
if channel is None:
|
||||
name = f"{self.prefix}{source}_{destination}_{os.getpid()}_{uuid.uuid4().hex}"
|
||||
try:
|
||||
os.makedirs(f"/dev/shm/{self.directory}", mode=0o700, exist_ok=True)
|
||||
channel = _Channel.open(name, self.capacity)
|
||||
channel.registry = f"/dev/shm/{self.directory}/{name}"
|
||||
# Discovery is confined to this deployment directory. The
|
||||
# marker appears only after the allocation is ready.
|
||||
with _registry_update(os.path.dirname(channel.registry)) as directory_fd:
|
||||
marker = os.open(name, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600, dir_fd=directory_fd)
|
||||
os.close(marker)
|
||||
except OSError as error:
|
||||
if channel is not None:
|
||||
channel.close()
|
||||
self._disabled_edges.add((source, destination))
|
||||
logger.warning(
|
||||
"Host ring unavailable on edge %s -> %s; using per-key SHM: %s", source, destination, error
|
||||
)
|
||||
return None
|
||||
self.producers[source, destination] = channel
|
||||
with channel.locked():
|
||||
channel.reclaim()
|
||||
head, tail = channel.bounds()
|
||||
position = head % channel.capacity
|
||||
padding = channel.capacity - position if position + total > channel.capacity else 0
|
||||
if padding + total > channel.capacity - (head - tail):
|
||||
return None
|
||||
if padding:
|
||||
_FRAME.pack_into(channel.mapping, _HEADER_SIZE + position, padding, 0, _PADDING, _DONE, 0, 0)
|
||||
head += padding
|
||||
position = 0
|
||||
start = _HEADER_SIZE + position + _FRAME.size
|
||||
channel.mapping[start : start + len(encoded_key)] = encoded_key
|
||||
start += len(encoded_key)
|
||||
if isinstance(payload, TensorFrame):
|
||||
view = memoryview(channel.mapping)
|
||||
try:
|
||||
payload.write(view, start)
|
||||
finally:
|
||||
view.release()
|
||||
else:
|
||||
channel.mapping[start : start + length] = payload
|
||||
_FRAME.pack_into(
|
||||
channel.mapping,
|
||||
_HEADER_SIZE + position,
|
||||
total,
|
||||
length,
|
||||
_TENSORS if isinstance(payload, TensorFrame) else _BYTES,
|
||||
_READY,
|
||||
len(encoded_key),
|
||||
0,
|
||||
)
|
||||
# Retire an earlier version while holding the same lock as all readers.
|
||||
channel.cancel(key=key)
|
||||
struct.pack_into("<Q", channel.mapping, 16, head + total)
|
||||
channel.index[key] = head
|
||||
channel.positions.append((head, key))
|
||||
channel.cursor = head + total
|
||||
return {"name": channel.name, "position": head, "size": length}
|
||||
|
||||
def _reader(self, name: str) -> _Channel:
|
||||
if not name.startswith(f"omni_shm_ring_v1_{os.getuid()}_") or not name.replace("_", "").isalnum():
|
||||
raise ValueError("invalid local SHM ring name")
|
||||
channel = self.readers.get(name)
|
||||
if channel is None:
|
||||
channel = _Channel.open(name)
|
||||
self.readers[name] = channel
|
||||
return channel
|
||||
|
||||
def _receive(self, channel: _Channel, key: str, descriptor: dict | None) -> tuple[object, int] | None:
|
||||
if not channel.producer_alive():
|
||||
self.readers.pop(channel.name, None)
|
||||
channel.close()
|
||||
self._discovery_versions.clear()
|
||||
return None
|
||||
# This optimistic check only rejects a miss; successful reads still
|
||||
# take the shared lock. An unchanged publication cursor cannot have
|
||||
# introduced a new key since this receiver's last locked scan.
|
||||
if key not in channel.index and struct.unpack_from("<Q", channel.mapping, 16)[0] == channel.cursor:
|
||||
return None
|
||||
try:
|
||||
fcntl.flock(channel.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
return None
|
||||
try:
|
||||
channel.scan()
|
||||
position = channel.index.get(key)
|
||||
if position is None or descriptor is not None and position != descriptor["position"]:
|
||||
return None
|
||||
_total, length, kind, state, key_length, _reserved = channel.frame(position)
|
||||
del channel.index[key]
|
||||
if state != _READY:
|
||||
return None
|
||||
start = _HEADER_SIZE + position % channel.capacity + _FRAME.size + key_length
|
||||
view = memoryview(channel.mapping)[start : start + length]
|
||||
try:
|
||||
# No reference to ring storage escapes this locked boundary.
|
||||
result = read_tensor_frame(view) if kind == _TENSORS else bytes(view)
|
||||
finally:
|
||||
view.release()
|
||||
channel.consume(position)
|
||||
return result, length
|
||||
finally:
|
||||
fcntl.flock(channel.fd, fcntl.LOCK_UN)
|
||||
|
||||
def get(self, source: str, destination: str, key: str, descriptor: dict | None) -> tuple[object, int] | None:
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
return None
|
||||
if descriptor is not None:
|
||||
try:
|
||||
result = self._receive(self._reader(descriptor["name"]), key, descriptor)
|
||||
if result is None:
|
||||
self._reap_readers()
|
||||
return result
|
||||
except (FileNotFoundError, BlockingIOError):
|
||||
return None
|
||||
if not self.capacity or not source.isdigit() or not destination.isdigit():
|
||||
return None
|
||||
edge_prefix = f"{self.prefix}{source}_{destination}_"
|
||||
for name, channel in tuple(self.readers.items()):
|
||||
if name.startswith(edge_prefix):
|
||||
result = self._receive(channel, key, None)
|
||||
if result is not None:
|
||||
return result
|
||||
directory = f"/dev/shm/{self.directory}"
|
||||
try:
|
||||
status = os.stat(directory)
|
||||
except FileNotFoundError:
|
||||
self._discovery_versions.clear()
|
||||
self._reap_readers()
|
||||
return None
|
||||
version = (status.st_dev, status.st_ino, status.st_mtime_ns)
|
||||
edge = (source, destination)
|
||||
if version == self._discovery_versions.get(edge):
|
||||
return None
|
||||
self._reap_readers()
|
||||
# A new producer advances the stamp before publishing any frames,
|
||||
# retaining first-message discovery without scanning every miss.
|
||||
discovered_result = None
|
||||
discovery_complete = True
|
||||
for path in glob.iglob(f"/dev/shm/{self.directory}/{edge_prefix}*"):
|
||||
name = os.path.basename(path)
|
||||
if name in self.readers:
|
||||
continue
|
||||
try:
|
||||
channel = self._reader(name)
|
||||
except FileNotFoundError:
|
||||
# A published marker whose allocation or producer is gone
|
||||
# cannot become valid again: allocation names are unique.
|
||||
try:
|
||||
with _registry_update(directory) as directory_fd:
|
||||
os.unlink(name, dir_fd=directory_fd)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as error:
|
||||
logger.debug("Failed to prune host ring marker %s: %s", path, error)
|
||||
continue
|
||||
except (BlockingIOError, ValueError):
|
||||
# Attachment may race a writer holding the frame lock.
|
||||
# Retry discovery after it releases, even at this stamp.
|
||||
discovery_complete = False
|
||||
continue
|
||||
if discovered_result is None:
|
||||
discovered_result = self._receive(channel, key, None)
|
||||
# Attach every producer before caching this edge's stamp. Returning
|
||||
# as soon as one frame is found would hide its unvisited peers.
|
||||
if discovery_complete:
|
||||
self._discovery_versions[edge] = version
|
||||
else:
|
||||
self._discovery_versions.pop(edge, None)
|
||||
return discovered_result
|
||||
|
||||
def cancel(self, key: str | None = None, prefix: str | None = None) -> int:
|
||||
with self._lock:
|
||||
count = 0
|
||||
for channel in self.producers.values():
|
||||
with channel.locked():
|
||||
count += channel.cancel(key, prefix)
|
||||
return count
|
||||
|
||||
def reap(self) -> None:
|
||||
with self._lock:
|
||||
for channel in self.producers.values():
|
||||
with channel.locked():
|
||||
channel.reclaim()
|
||||
self._reap_readers()
|
||||
|
||||
def _reap_readers(self) -> None:
|
||||
for name, channel in tuple(self.readers.items()):
|
||||
if not channel.producer_alive() or not os.path.exists(f"/dev/shm/{name}"):
|
||||
channel.close()
|
||||
del self.readers[name]
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
for channel in self.producers.values():
|
||||
with channel.locked():
|
||||
channel.scan()
|
||||
for position in channel.index.values():
|
||||
channel.consume(position)
|
||||
channel.close()
|
||||
for channel in self.readers.values():
|
||||
channel.close()
|
||||
self.producers.clear()
|
||||
self.readers.clear()
|
||||
self._discovery_versions.clear()
|
||||
try:
|
||||
os.rmdir(f"/dev/shm/{self.directory}")
|
||||
except (FileNotFoundError, OSError):
|
||||
# Other producers in this deployment may still own rings.
|
||||
pass
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import asdict, fields, is_dataclass
|
||||
@@ -205,7 +205,7 @@ class OmniMsgpackDecoder:
|
||||
return self._decode_pil_image(obj)
|
||||
|
||||
# Process values recursively first
|
||||
processed = {k: self._post_process(v) for k, v in obj.items()}
|
||||
processed = {k: self._post_process(v) if isinstance(v, (dict, list, tuple)) else v for k, v in obj.items()}
|
||||
|
||||
# Check if this looks like an OmniRequestOutput (check before RequestOutput
|
||||
# since OmniRequestOutput may also have some RequestOutput-like fields)
|
||||
@@ -223,10 +223,13 @@ class OmniMsgpackDecoder:
|
||||
return processed
|
||||
|
||||
if isinstance(obj, list):
|
||||
return [self._post_process(item) for item in obj]
|
||||
# Native msgpack already owns scalar values. Only containers can
|
||||
# contain wire markers; avoid a recursive call for every token or
|
||||
# float in long model payloads, while preserving new-list semantics.
|
||||
return [self._post_process(item) if isinstance(item, (dict, list, tuple)) else item for item in obj]
|
||||
|
||||
if isinstance(obj, tuple):
|
||||
return tuple(self._post_process(item) for item in obj)
|
||||
return tuple(self._post_process(item) if isinstance(item, (dict, list, tuple)) else item for item in obj)
|
||||
|
||||
return obj
|
||||
|
||||
@@ -350,6 +353,10 @@ class OmniSerde:
|
||||
"""Deserialize bytes to an object."""
|
||||
return self.decoder.decode(data)
|
||||
|
||||
def restore(self, obj: object) -> object:
|
||||
"""Restore wire objects from an already decoded transport tree."""
|
||||
return self.decoder._post_process(obj)
|
||||
|
||||
|
||||
# Global instance for simple interface
|
||||
OmniSerializer = OmniSerde()
|
||||
@@ -0,0 +1,175 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Owned CPU tensor trees with a msgpack header and raw tensor regions.
|
||||
|
||||
Tensor references use message-scoped msgpack extensions, preserving literal
|
||||
extensions and native scalar containers without reserved dictionary keys. It never
|
||||
exposes a view of transport storage to a caller. Unsupported objects retain
|
||||
the ordinary OmniSerializer path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import math
|
||||
import struct
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
import msgspec
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from .serialization import OmniSerializer
|
||||
|
||||
_LENGTH = struct.Struct("<I")
|
||||
_ENCODER = msgspec.msgpack.Encoder()
|
||||
_REFERENCE = struct.Struct("<16sI")
|
||||
_REFERENCE_CODE = 42
|
||||
_DECODER = msgspec.msgpack.Decoder()
|
||||
|
||||
|
||||
def _align(size: int) -> int:
|
||||
return (size + 15) & ~15
|
||||
|
||||
|
||||
class _UnsupportedTreeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class TensorFrame:
|
||||
header: bytes
|
||||
tensors: list[torch.Tensor]
|
||||
offsets: list[int]
|
||||
size: int
|
||||
|
||||
def write(self, buffer: memoryview, start: int) -> None:
|
||||
"""Copy directly into caller-owned writable storage, while it is locked."""
|
||||
_LENGTH.pack_into(buffer, start, len(self.header))
|
||||
buffer[start + 4 : start + 4 + len(self.header)] = self.header
|
||||
data_start = start + _align(4 + len(self.header))
|
||||
# c_char is an anchor only; all bounds are checked by the ring writer.
|
||||
anchor = ctypes.c_char.from_buffer(buffer)
|
||||
base = ctypes.addressof(anchor) + data_start
|
||||
for tensor, offset in zip(self.tensors, self.offsets, strict=True):
|
||||
if tensor.nbytes:
|
||||
ctypes.memmove(base + offset, tensor.data_ptr(), tensor.nbytes)
|
||||
|
||||
|
||||
def prepare_tensor_frame(payload: object, *, max_bytes: int | None = None) -> TensorFrame | bytes | None:
|
||||
"""Encode native containers once, or prepare their dense CPU tensor regions.
|
||||
|
||||
Scalar/token lists stay in msgpack's C encoder. Tensor-free messages reuse
|
||||
those encoded bytes, rather than constructing and discarding a tagged tree.
|
||||
Sources remain caller-owned until the synchronous put() returns.
|
||||
"""
|
||||
tensors: list[torch.Tensor] = []
|
||||
offsets: list[int] = []
|
||||
descriptors: list[tuple[str, tuple[int, ...], int]] = []
|
||||
data_size = 0
|
||||
nonce: bytes | None = None
|
||||
|
||||
def encode_tensor(value: object) -> object:
|
||||
nonlocal data_size, nonce
|
||||
if type(value) is not torch.Tensor:
|
||||
# Reuse the common wire representations for mixed model payloads.
|
||||
# Large arrays/images keep the ordinary path without first making
|
||||
# an extra byte snapshot just to discover that the frame cannot fit.
|
||||
if max_bytes is not None:
|
||||
if isinstance(value, np.ndarray) and value.nbytes > max_bytes:
|
||||
raise _UnsupportedTreeError
|
||||
if isinstance(value, Image.Image) and value.width * value.height * len(value.getbands()) > max_bytes:
|
||||
raise _UnsupportedTreeError
|
||||
return OmniSerializer.encoder._enc_hook(value)
|
||||
if value.device.type != "cpu" or value.layout != torch.strided or value.is_quantized:
|
||||
raise _UnsupportedTreeError
|
||||
offset = _align(data_size)
|
||||
index = len(tensors)
|
||||
tensors.append(value)
|
||||
offsets.append(offset)
|
||||
descriptors.append((str(value.dtype).removeprefix("torch."), tuple(value.shape), offset))
|
||||
data_size = offset + value.nbytes
|
||||
if max_bytes is not None and data_size > max_bytes:
|
||||
raise _UnsupportedTreeError
|
||||
if nonce is None:
|
||||
nonce = uuid.uuid4().bytes
|
||||
return msgspec.msgpack.Ext(_REFERENCE_CODE, _REFERENCE.pack(nonce, index))
|
||||
|
||||
try:
|
||||
tree = msgspec.msgpack.Encoder(enc_hook=encode_tensor).encode(payload)
|
||||
if not tensors:
|
||||
return tree
|
||||
header = _ENCODER.encode([descriptors, tree, nonce])
|
||||
except (_UnsupportedTreeError, TypeError, OverflowError, RecursionError):
|
||||
return None
|
||||
size = _align(4 + len(header)) + data_size
|
||||
if max_bytes is not None and size > max_bytes:
|
||||
return None
|
||||
# Only materialize non-contiguous/flagged storage after the entire message
|
||||
# is known to fit. Unsupported and large frames avoid an unused copy.
|
||||
for index, tensor in enumerate(tensors):
|
||||
if tensor.is_conj():
|
||||
tensor = tensor.resolve_conj()
|
||||
if tensor.is_neg():
|
||||
tensor = tensor.resolve_neg()
|
||||
if not tensor.is_contiguous():
|
||||
tensor = tensor.contiguous()
|
||||
tensors[index] = tensor
|
||||
return TensorFrame(header, tensors, offsets, size)
|
||||
|
||||
|
||||
def read_tensor_frame(buffer: memoryview) -> object:
|
||||
"""Decode into independent CPU allocations before returning ring credit."""
|
||||
if len(buffer) < 4:
|
||||
raise ValueError("truncated tensor frame")
|
||||
header_size = _LENGTH.unpack_from(buffer)[0]
|
||||
data_start = _align(4 + header_size)
|
||||
if data_start > len(buffer):
|
||||
raise ValueError("tensor frame header exceeds payload")
|
||||
header_view = buffer[4 : 4 + header_size]
|
||||
try:
|
||||
descriptors, tree, nonce = _DECODER.decode(header_view)
|
||||
finally:
|
||||
header_view.release()
|
||||
# All leaves share one independently owned allocation. This amortizes CPU
|
||||
# allocation/copy dispatch across small codec groups and hidden-state leaves.
|
||||
data_size = len(buffer) - data_start
|
||||
# A small metadata tensor must not keep a large, already consumed hidden
|
||||
# state alive. Pack small trees; give large multi-leaf trees separate owners.
|
||||
storage = torch.empty(data_size, dtype=torch.uint8) if data_size <= 65536 or len(descriptors) == 1 else None
|
||||
anchor = ctypes.c_char.from_buffer(buffer)
|
||||
if data_size and storage is not None:
|
||||
ctypes.memmove(storage.data_ptr(), ctypes.addressof(anchor) + data_start, data_size)
|
||||
tensors = []
|
||||
for name, shape, offset in descriptors:
|
||||
dtype = getattr(torch, name, None)
|
||||
if not isinstance(dtype, torch.dtype):
|
||||
raise ValueError("unknown tensor frame dtype")
|
||||
if any(type(dim) is not int or dim < 0 for dim in shape) or type(offset) is not int or offset < 0:
|
||||
raise ValueError("invalid tensor frame region")
|
||||
count = math.prod(shape)
|
||||
size = count * dtype.itemsize
|
||||
start = data_start + offset
|
||||
if start + size > len(buffer):
|
||||
raise ValueError("tensor frame region exceeds payload")
|
||||
if size and storage is not None:
|
||||
tensor = storage[offset : offset + size].view(dtype).reshape(shape)
|
||||
else:
|
||||
tensor = torch.empty(shape, dtype=dtype)
|
||||
if size:
|
||||
ctypes.memmove(tensor.data_ptr(), ctypes.addressof(anchor) + start, size)
|
||||
tensors.append(tensor)
|
||||
|
||||
def restore_reference(code: int, data: memoryview) -> object:
|
||||
if code == _REFERENCE_CODE and len(data) == _REFERENCE.size:
|
||||
reference_nonce, index = _REFERENCE.unpack(data)
|
||||
if reference_nonce == nonce:
|
||||
if index >= len(tensors):
|
||||
raise ValueError("invalid tensor frame reference")
|
||||
return tensors[index]
|
||||
# Literal user extensions keep their existing msgpack wire semantics.
|
||||
return msgspec.msgpack.Ext(code, bytes(data))
|
||||
|
||||
return msgspec.msgpack.decode(tree, ext_hook=restore_reference)
|
||||
Reference in new issue
Block a user