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.
|
||||
@@ -1,61 +0,0 @@
|
||||
# MiniCPM-o 4.5 input encoder CUDA graphs
|
||||
|
||||
The vision transformer and stateless audio encoder reuse vLLM's encoder graph
|
||||
manager for exact input shapes. Each encoder retains at most four graphs by
|
||||
default, and captures on the second call with the same shape and CUDA stream.
|
||||
Startup profiling can consume slots. Graphs are never evicted: once the cache
|
||||
is full, other shapes run eagerly for the lifetime of the model.
|
||||
|
||||
Use `--hf-overrides` to configure both encoders:
|
||||
|
||||
```json
|
||||
{
|
||||
"encoder_cuda_graph": true,
|
||||
"encoder_cuda_graph_max_graphs": 4,
|
||||
"encoder_cuda_graph_min_capture_calls": 2,
|
||||
"encoder_cuda_graph_min_free_bytes": 1073741824,
|
||||
"encoder_cuda_graph_share_pools": true
|
||||
}
|
||||
```
|
||||
|
||||
`max_graphs` is a nonnegative integer per encoder; zero disables capture.
|
||||
`min_capture_calls` is an integer of at least two. Increasing it reduces capture
|
||||
of short-lived shapes. Admission history is bounded to four times `max_graphs`,
|
||||
so an evicted history entry must accumulate its calls again. Increasing the
|
||||
graph cap can improve coverage but retains more GPU memory. Existing graphs
|
||||
are not replaced, avoiding repeated capture costs when traffic changes.
|
||||
|
||||
By default, graphs in the same encoder on the same device and replay stream
|
||||
share a memory pool and capture stream. Different encoders and replay streams
|
||||
remain isolated. Graph inputs and output buffers are allocated outside capture;
|
||||
only transient capture allocations share the pool. Returned outputs remain
|
||||
cloned. A late capture waits for earlier replay work and output clones before
|
||||
reusing the pool. Set `encoder_cuda_graph_share_pools` to `false` for an A/B
|
||||
comparison with a separate pool and capture stream per graph.
|
||||
|
||||
The graph count is **not a byte limit**. Sharing pools reuses intermediate
|
||||
storage but does not eliminate per-shape static input and output buffers.
|
||||
Before a new capture, the adapter checks device-free memory against
|
||||
`min_free_bytes`, a nonnegative integer (default: 1 GiB; zero disables the check).
|
||||
Below this floor the call runs eagerly, and a later call may try admission
|
||||
again. Existing graphs can still replay. This check is conservative about
|
||||
reusable allocator memory and does not predict capture allocations, reserve
|
||||
memory, or guarantee that capture fits. Size the floor for the deployment;
|
||||
disable encoder graphs when their additional memory cannot be accommodated.
|
||||
|
||||
Each adapter exposes `get_cumulative_stats()`: graph hits, total misses, hit
|
||||
rate, graph count, and separate ineligible, capacity, warmup and memory misses.
|
||||
These include calls rejected before the upstream manager, so a full cache's
|
||||
lost coverage is visible. Vision uses `vpm._encoder_graph`; audio uses
|
||||
`_audio_encoder_graph` on the model. The counters are per adapter, not global.
|
||||
|
||||
Capture failures propagate the original exception and make that adapter
|
||||
unusable. Subsequent calls raise without retrying capture or running CUDA eager
|
||||
work. Restart the worker, optionally with encoder graphs disabled. This is a
|
||||
fatal contract, not automatic recovery: a failed CUDA capture can leave the
|
||||
device context unusable. `capture_failures` records this terminal state.
|
||||
|
||||
`--enforce-eager` always disables these graphs. CPU, grad/autocast, stateful
|
||||
streaming audio and the other model-specific eager fallbacks remain unchanged.
|
||||
The adapter clones graph outputs through upstream postprocessing so retained
|
||||
embeddings survive later replays.
|
||||
@@ -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()
|
||||
@@ -125,10 +125,9 @@ def test_sleep_client_error_does_not_mark_stages(sleep_capable_engine, mocker):
|
||||
app = _make_app(sleep_capable_engine)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/v1/omni/sleep", json={"stage_ids": [9], "level": 1})
|
||||
with pytest.raises(ValueError, match="unknown stage id 9"):
|
||||
client.post("/v1/omni/sleep", json={"stage_ids": [9], "level": 1})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "unknown stage id 9" in response.json()["detail"]
|
||||
assert app.state.sleeping_stages == set()
|
||||
|
||||
|
||||
@@ -214,19 +213,6 @@ def test_wakeup_failure_returns_error_and_keeps_sleeping_set(sleep_capable_engin
|
||||
assert app.state.sleeping_stages == {0}
|
||||
|
||||
|
||||
def test_wakeup_client_error_returns_400_and_keeps_sleeping_set(sleep_capable_engine, mocker):
|
||||
sleep_capable_engine.wake_up = mocker.AsyncMock(side_effect=ValueError("unknown stage id 9"))
|
||||
app = _make_app(sleep_capable_engine)
|
||||
app.state.sleeping_stages = {0, 9}
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/v1/omni/wakeup", json={"stage_ids": [9]})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "unknown stage id 9" in response.json()["detail"]
|
||||
assert app.state.sleeping_stages == {0, 9}
|
||||
|
||||
|
||||
def test_wakeup_engine_not_support(sleep_incapable_engine):
|
||||
app = _make_app(sleep_incapable_engine)
|
||||
app.state.sleeping_stages = {0}
|
||||
|
||||
@@ -353,28 +353,6 @@ def test_sleep_waits_for_in_flight_generate_admission():
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_sleep_rejects_invalid_stage_ids_without_blocking_admission():
|
||||
"""Invalid stage_ids must raise before _paused is set.
|
||||
|
||||
Raising afterwards leaves no sleeping tags behind, so wake_up() early-returns
|
||||
"already warm" and nothing ever clears _paused: generate() hangs forever.
|
||||
"""
|
||||
|
||||
async def run() -> None:
|
||||
omni = _make_omni(stage_types=["llm", "diffusion"])
|
||||
|
||||
with pytest.raises(ValueError, match=r"Invalid stage_ids \[99\]"):
|
||||
await omni.sleep(stage_ids=[99], level=1, mode="abort")
|
||||
|
||||
assert omni._paused is False
|
||||
assert omni._hold_admission_until_resume is False
|
||||
omni._clear_frontend_mm_cache.assert_not_awaited()
|
||||
omni.collective_rpc.assert_not_awaited()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_wake_up_routes_ar_via_collective_rpc_and_diffusion_to_worker_rpc():
|
||||
async def run() -> None:
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.model_executor.models.minicpmo_4_5.encoder_cuda_graph import EncoderCudaGraph
|
||||
from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni_llm import (
|
||||
SiglipVisionConfig,
|
||||
SiglipVisionTransformer,
|
||||
)
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
pytestmark = [pytest.mark.core_model]
|
||||
|
||||
|
||||
def _make_graph(forward, **kwargs):
|
||||
from vllm.config import ModelConfig, VllmConfig
|
||||
from vllm.config.multimodal import MultiModalConfig
|
||||
|
||||
config = VllmConfig()
|
||||
# The manager only needs multimodal limits; no checkpoint is loaded here.
|
||||
config.model_config = ModelConfig.__new__(ModelConfig)
|
||||
config.model_config.multimodal_config = MultiModalConfig()
|
||||
return EncoderCudaGraph(forward, config, **kwargs)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_cpu_and_grad_paths_remain_eager():
|
||||
graph = _make_graph(lambda x, mask: x.sin() if mask is None else x.sin() + mask)
|
||||
x = torch.randn(2, 3, requires_grad=True)
|
||||
graph(x, None).sum().backward()
|
||||
torch.testing.assert_close(x.grad, x.detach().cos())
|
||||
assert not graph.graphs
|
||||
assert not graph._seen
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
@torch.inference_mode()
|
||||
def test_replay_refreshes_inputs_preserves_outputs_and_bounds_shapes():
|
||||
graph = _make_graph(lambda x, mask: x.sin() if mask is None else x.sin() + mask, max_graphs=2)
|
||||
x = torch.randn(2, 8, device="cuda")
|
||||
mask = torch.randn_like(x)
|
||||
graph(x, mask)
|
||||
old = graph(x, mask)
|
||||
expected_old = old.clone()
|
||||
x.mul_(2)
|
||||
mask.add_(3)
|
||||
torch.testing.assert_close(graph(x, mask), x.sin() + mask)
|
||||
torch.testing.assert_close(old, expected_old)
|
||||
graph(x, None)
|
||||
torch.testing.assert_close(graph(x, None), x.sin())
|
||||
assert len(graph.graphs) == 2
|
||||
from vllm.v1.worker.encoder_cudagraph import EncoderCudaGraphManager
|
||||
|
||||
assert all(isinstance(entry, EncoderCudaGraphManager) for entry in graph.graphs.values())
|
||||
assert sum(entry.graph_hits for entry in graph.graphs.values()) == 3
|
||||
assert graph.vllm_config.compilation_config.encoder_cudagraph_token_budgets == []
|
||||
for size in range(3, 24):
|
||||
other = torch.randn(size, 8, device="cuda")
|
||||
torch.testing.assert_close(graph(other, None), other.sin())
|
||||
assert len(graph.graphs) == 2
|
||||
assert len(graph._seen) <= 8
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
@torch.inference_mode()
|
||||
def test_vision_graph_handles_changed_mask_and_retained_embeddings():
|
||||
config = SiglipVisionConfig(
|
||||
hidden_size=64,
|
||||
intermediate_size=128,
|
||||
num_hidden_layers=2,
|
||||
num_attention_heads=4,
|
||||
image_size=28,
|
||||
patch_size=14,
|
||||
)
|
||||
config._attn_implementation = "eager"
|
||||
model = SiglipVisionTransformer(config).eval().cuda()
|
||||
pixels = torch.randn(2, 3, 14, 56, device="cuda")
|
||||
sizes = torch.tensor([[2, 2], [1, 2]], dtype=torch.int32)
|
||||
mask = torch.tensor([[[1, 1, 1, 1]], [[1, 1, 0, 0]]], device="cuda", dtype=torch.bool)
|
||||
reference = model(pixels, mask, sizes).last_hidden_state
|
||||
model._encoder_graph = _make_graph(model._encode_last_hidden_state)
|
||||
model(pixels, mask, sizes)
|
||||
output = model(pixels, mask, sizes).last_hidden_state
|
||||
torch.testing.assert_close(output, reference)
|
||||
assert len(model._encoder_graph.graphs) == 1
|
||||
# Same padded shape, different valid positions and image content.
|
||||
pixels.add_(1)
|
||||
sizes[1] = torch.tensor([1, 3])
|
||||
mask[1, 0, 2] = True
|
||||
actual = model(pixels, mask, sizes).last_hidden_state
|
||||
graph = model._encoder_graph
|
||||
model._encoder_graph = None
|
||||
expected = model(pixels, mask, sizes).last_hidden_state
|
||||
torch.testing.assert_close(actual, expected)
|
||||
torch.testing.assert_close(output, reference)
|
||||
model._encoder_graph = graph
|
||||
detailed = model(pixels, mask, sizes, output_hidden_states=True)
|
||||
assert len(detailed.hidden_states) == 3
|
||||
assert len(graph.graphs) == 1
|
||||
|
||||
|
||||
def _audio_model(device):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from transformers import WhisperConfig
|
||||
|
||||
from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni_llm import (
|
||||
MiniCPMO45OmniLLMForConditionalGeneration,
|
||||
MiniCPMWhisperEncoder,
|
||||
MultiModalProjector,
|
||||
)
|
||||
|
||||
config = WhisperConfig(
|
||||
d_model=32,
|
||||
encoder_layers=2,
|
||||
encoder_attention_heads=4,
|
||||
encoder_ffn_dim=64,
|
||||
num_mel_bins=80,
|
||||
max_source_positions=1500,
|
||||
)
|
||||
config._attn_implementation = "sdpa"
|
||||
model = MiniCPMO45OmniLLMForConditionalGeneration.__new__(MiniCPMO45OmniLLMForConditionalGeneration)
|
||||
torch.nn.Module.__init__(model)
|
||||
model.config = SimpleNamespace(audio_chunk_length=1.0, audio_pool_step=5)
|
||||
model.apm = MiniCPMWhisperEncoder(config)
|
||||
model.audio_projection_layer = MultiModalProjector(32, 48)
|
||||
model.audio_avg_pooler = torch.nn.AvgPool1d(5, stride=5)
|
||||
model.audio_encoder_layer = -1
|
||||
model.audio_past_key_values = None
|
||||
return model.eval().to(device)
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
@torch.inference_mode()
|
||||
def test_audio_graph_refreshes_mask_and_matches_eager():
|
||||
model = _audio_model("cuda")
|
||||
data = {
|
||||
"audio_features": torch.randn(2, 80, 100, device="cuda"),
|
||||
"audio_feature_lens": torch.tensor([[100], [80]], device="cuda"),
|
||||
}
|
||||
expected = model.get_audio_hidden_states(data)
|
||||
graph = _make_graph(model._encode_audio_features)
|
||||
model._audio_encoder_graph = graph
|
||||
model.get_audio_hidden_states(data)
|
||||
actual = model.get_audio_hidden_states(data)
|
||||
for a, b in zip(actual, expected, strict=True):
|
||||
torch.testing.assert_close(a, b)
|
||||
assert len(graph.graphs) == 1
|
||||
data["audio_features"].add_(1)
|
||||
data["audio_feature_lens"][1, 0] = 30
|
||||
changed = model.get_audio_hidden_states(data)
|
||||
model._audio_encoder_graph = None
|
||||
for a, b in zip(changed, model.get_audio_hidden_states(data), strict=True):
|
||||
torch.testing.assert_close(a, b)
|
||||
for a, b in zip(actual, expected, strict=True):
|
||||
torch.testing.assert_close(a, b)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
@torch.inference_mode()
|
||||
def test_streaming_audio_does_not_replay_stateless_graph():
|
||||
model = _audio_model("cpu")
|
||||
|
||||
def forbidden(*args):
|
||||
raise AssertionError("streaming KV must not use the stateless graph")
|
||||
|
||||
model._audio_encoder_graph = forbidden
|
||||
data = {"audio_features": torch.randn(1, 80, 100), "audio_feature_lens": torch.tensor([[100]])}
|
||||
for _ in range(2):
|
||||
output = model.get_audio_embedding_streaming(data)
|
||||
assert output[0][0].shape == (10, 48)
|
||||
assert model.audio_past_key_values.get_seq_length() == 100
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
@torch.inference_mode()
|
||||
def test_fp16_audio_keeps_host_overflow_check_eager():
|
||||
model = _audio_model("cpu").half()
|
||||
|
||||
def forbidden(*args):
|
||||
raise AssertionError("FP16 overflow guard cannot run inside capture")
|
||||
|
||||
model._audio_encoder_graph = forbidden
|
||||
data = {
|
||||
"audio_features": torch.randn(1, 80, 100, dtype=torch.float16),
|
||||
"audio_feature_lens": torch.tensor([[100]]),
|
||||
}
|
||||
assert model.get_audio_hidden_states(data)[0].shape == (10, 48)
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
@torch.inference_mode()
|
||||
def test_one_off_shapes_do_not_exhaust_capture_admission():
|
||||
graph = _make_graph(torch.sin, max_graphs=1)
|
||||
for size in range(1, 20):
|
||||
graph(torch.zeros(size, device="cuda"))
|
||||
assert len(graph._seen) == 4
|
||||
assert not graph.graphs
|
||||
x = torch.randn(32, device="cuda")
|
||||
graph(x)
|
||||
torch.testing.assert_close(graph(x), x.sin())
|
||||
assert len(graph.graphs) == 1
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
@torch.inference_mode()
|
||||
def test_managers_do_not_share_buffers_between_streams():
|
||||
graph = _make_graph(torch.sin)
|
||||
streams = [torch.cuda.Stream(), torch.cuda.Stream()]
|
||||
outputs = []
|
||||
for i, stream in enumerate(streams):
|
||||
with torch.cuda.stream(stream):
|
||||
value = torch.full((4, 8), float(i + 1), device="cuda")
|
||||
graph(value)
|
||||
output = graph(value)
|
||||
graph(value + 1)
|
||||
outputs.append((output, value.sin()))
|
||||
for stream in streams:
|
||||
torch.cuda.current_stream().wait_stream(stream)
|
||||
assert len(graph.graphs) == 2
|
||||
for actual, expected in outputs:
|
||||
torch.testing.assert_close(actual, expected)
|
||||
assert not graph.vllm_config.compilation_config.encoder_cudagraph_token_budgets
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize(
|
||||
"options",
|
||||
[
|
||||
{"max_graphs": -1},
|
||||
{"max_graphs": True},
|
||||
{"min_capture_calls": 1},
|
||||
{"min_capture_calls": 2.5},
|
||||
{"min_free_bytes": -1},
|
||||
],
|
||||
)
|
||||
def test_invalid_admission_options(options):
|
||||
with pytest.raises(ValueError, match="must be an integer"):
|
||||
_make_graph(torch.sin, **options)
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
@torch.inference_mode()
|
||||
def test_configurable_admission_and_capacity_miss_accounting():
|
||||
graph = _make_graph(torch.sin, max_graphs=1, min_capture_calls=3)
|
||||
x = torch.randn(2, 8, device="cuda")
|
||||
for _ in range(2):
|
||||
torch.testing.assert_close(graph(x), x.sin())
|
||||
assert not graph.graphs
|
||||
torch.testing.assert_close(graph(x), x.sin())
|
||||
torch.testing.assert_close(graph(x + 1), (x + 1).sin())
|
||||
for _ in range(5):
|
||||
other = torch.randn(3, 8, device="cuda")
|
||||
torch.testing.assert_close(graph(other), other.sin())
|
||||
stats = graph.get_cumulative_stats()
|
||||
assert stats["num_graphs"] == 1
|
||||
assert stats["graph_hits"] == 2
|
||||
assert stats["graph_misses"] == 7
|
||||
assert stats["capacity_misses"] == 5
|
||||
assert stats["warmup_misses"] == 2
|
||||
assert stats["hit_rate"] == pytest.approx(2 / 9)
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
@torch.inference_mode()
|
||||
def test_memory_admission_defers_capture_but_preserves_existing_replay(monkeypatch):
|
||||
graph = _make_graph(torch.sin, min_free_bytes=1024)
|
||||
x = torch.randn(2, 8, device="cuda")
|
||||
graph(x)
|
||||
monkeypatch.setattr(current_omni_platform, "get_free_memory", lambda device: 1023)
|
||||
for _ in range(3):
|
||||
torch.testing.assert_close(graph(x), x.sin())
|
||||
assert not graph.graphs
|
||||
assert graph.get_cumulative_stats()["memory_misses"] == 3
|
||||
monkeypatch.setattr(current_omni_platform, "get_free_memory", lambda device: 1024)
|
||||
old = graph(x)
|
||||
assert len(graph.graphs) == 1
|
||||
monkeypatch.setattr(current_omni_platform, "get_free_memory", lambda device: 0)
|
||||
torch.testing.assert_close(graph(x + 1), (x + 1).sin())
|
||||
torch.testing.assert_close(old, x.sin())
|
||||
assert graph.get_cumulative_stats()["graph_hits"] == 2
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
@torch.inference_mode()
|
||||
def test_capture_failure_is_fatal_without_retry_or_eager_fallback(monkeypatch):
|
||||
from unittest.mock import Mock
|
||||
|
||||
forward = Mock(side_effect=torch.sin)
|
||||
graph = _make_graph(forward)
|
||||
x = torch.randn(2, 8, device="cuda")
|
||||
graph(x)
|
||||
capture = Mock(side_effect=RuntimeError("injected capture failure"))
|
||||
monkeypatch.setattr(graph, "_capture", capture)
|
||||
with pytest.raises(RuntimeError, match="injected capture failure"):
|
||||
graph(x)
|
||||
for value in (x, torch.randn(3, 8, device="cuda"), torch.randn(2, 8)):
|
||||
with pytest.raises(RuntimeError, match="restart the worker"):
|
||||
graph(value)
|
||||
capture.assert_called_once()
|
||||
forward.assert_called_once()
|
||||
assert not graph.graphs
|
||||
assert not graph._seen
|
||||
assert not graph._seen_calls
|
||||
assert graph.get_cumulative_stats()["capture_failures"] == 1
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
@torch.inference_mode()
|
||||
def test_zero_capacity_disables_capture():
|
||||
graph = _make_graph(torch.sin, max_graphs=0)
|
||||
x = torch.randn(2, 8, device="cuda")
|
||||
for _ in range(3):
|
||||
torch.testing.assert_close(graph(x), x.sin())
|
||||
assert not graph.graphs
|
||||
assert not graph._seen
|
||||
assert graph.get_cumulative_stats()["capacity_misses"] == 3
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
@pytest.mark.parametrize("share_pools", [False, True])
|
||||
@torch.inference_mode()
|
||||
def test_pool_reuse_preserves_outputs_across_late_capture_and_arbitrary_replay(share_pools):
|
||||
graph = _make_graph(lambda x: (x.sin() + 2).cos(), share_pools=share_pools)
|
||||
retained = []
|
||||
for size in (64, 128, 32, 96):
|
||||
x = torch.randn(size, 64, device="cuda")
|
||||
graph(x)
|
||||
retained.append((graph(x), (x.sin() + 2).cos()))
|
||||
# Replay older shapes between captures, including smaller -> larger.
|
||||
for previous in (64, size, 64):
|
||||
value = torch.randn(previous, 64, device="cuda")
|
||||
torch.testing.assert_close(graph(value), (value.sin() + 2).cos())
|
||||
for actual, expected in retained:
|
||||
torch.testing.assert_close(actual, expected)
|
||||
pools = {entry.graph_pool for entry in graph.graphs.values()}
|
||||
assert len(pools) == (1 if share_pools else 4)
|
||||
assert len(graph._capture_pools) == (1 if share_pools else 0)
|
||||
for size in (96, 32, 128, 64, 128, 32):
|
||||
value = torch.randn(size, 64, device="cuda")
|
||||
torch.testing.assert_close(graph(value), (value.sin() + 2).cos())
|
||||
for actual, expected in retained:
|
||||
torch.testing.assert_close(actual, expected)
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
@torch.inference_mode()
|
||||
def test_shared_pools_are_isolated_by_encoder_and_replay_stream():
|
||||
graphs = [_make_graph(torch.sin), _make_graph(torch.cos)]
|
||||
streams = [torch.cuda.Stream(), torch.cuda.Stream()]
|
||||
retained = []
|
||||
for graph in graphs:
|
||||
for stream in streams:
|
||||
with torch.cuda.stream(stream):
|
||||
for size in (16, 32):
|
||||
x = torch.randn(size, 64, device="cuda")
|
||||
graph(x)
|
||||
retained.append((graph(x), graph.forward(x)))
|
||||
graph(x + 1)
|
||||
for stream in streams:
|
||||
torch.cuda.current_stream().wait_stream(stream)
|
||||
pools = {entry.graph_pool for graph in graphs for entry in graph.graphs.values()}
|
||||
assert len(pools) == 4
|
||||
assert all(len(graph._capture_pools) == 2 for graph in graphs)
|
||||
for actual, expected in retained:
|
||||
torch.testing.assert_close(actual, expected)
|
||||
@@ -1,6 +1,3 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
@@ -100,8 +97,6 @@ class _FakeVisionModel:
|
||||
|
||||
|
||||
class _FakeAudioModel:
|
||||
_encode_audio_features = MiniCPMO45OmniLLMForConditionalGeneration._encode_audio_features
|
||||
|
||||
def __init__(self, audio_encoder: _FakeAudioEncoder, audio_encoder_layer: int) -> None:
|
||||
self.config = _AudioConfig()
|
||||
self.apm = audio_encoder
|
||||
|
||||
@@ -278,20 +278,6 @@ def tiny_sd3_builder() -> str:
|
||||
)
|
||||
|
||||
|
||||
def tiny_sdxl_builder() -> str:
|
||||
# The UNet's architecture (block_out_channels, transformer_layers_per_block, etc.)
|
||||
# is hardcoded in SDXLUNet2DConditionModel rather than read from config, so it
|
||||
# can't be shrunk here; only the two CLIP text encoders are reduced.
|
||||
return build_tiny_from_configs(
|
||||
"StableDiffusionXLPipeline",
|
||||
"stabilityai/stable-diffusion-xl-base-1.0",
|
||||
transform={
|
||||
"text_encoder": _shrink_flux_clip_text_encoder,
|
||||
"text_encoder_2": _shrink_flux_clip_text_encoder,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def tiny_flux2_builder() -> str:
|
||||
def shrink_text_encoder(config: dict) -> dict:
|
||||
config["tie_word_embeddings"] = False
|
||||
|
||||
@@ -181,10 +181,4 @@ DIFFUSION_TEST_SETTINGS = {
|
||||
supported_tasks=[DiffusionTasks.TEXT_TO_IMAGE],
|
||||
model_type_marker=ModelTypeMarker.DIFFUSION,
|
||||
),
|
||||
"StableDiffusionXLPipeline": DiffusionModelTestOpts(
|
||||
model="stabilityai/stable-diffusion-xl-base-1.0",
|
||||
builder=diff_model_builders.tiny_sdxl_builder,
|
||||
supported_tasks=[DiffusionTasks.TEXT_TO_IMAGE],
|
||||
extra_test_groups=[[DiffusionAccs.CPU_OFFLOAD]],
|
||||
),
|
||||
}
|
||||
@@ -84,6 +84,7 @@ EXCLUDED_MODELS = [
|
||||
"HiDreamImagePipeline",
|
||||
"HiDreamO1ImagePipeline",
|
||||
"DreamZeroPipeline",
|
||||
"StableDiffusionXLPipeline",
|
||||
"Gr00tN1d7Pipeline",
|
||||
"Pi0Pipeline",
|
||||
"Pi05Pipeline",
|
||||
|
||||
@@ -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)
|
||||
@@ -959,12 +959,6 @@ class AsyncOmni(AsyncOmniBase, EngineClient):
|
||||
engines have no EngineCore pause to hold, so ``wake_up`` restores
|
||||
admission and ``sleep → wake → generate`` keeps working.
|
||||
"""
|
||||
# Validate before touching admission state. Raising after
|
||||
# ``_paused = True`` would wedge generate() for good: nothing was
|
||||
# slept, so wake_up() has no tags to restore and never reaches the
|
||||
# block that clears ``_paused``.
|
||||
ar_stage_ids, diffusion_stage_ids = self._split_stage_ids_by_type(stage_ids)
|
||||
|
||||
# Block admission before any sleep RPC so generate() waits on
|
||||
# _pause_cond during the drain/offload window. Wait until generate()
|
||||
# coroutines that already passed the pause check have submitted (or
|
||||
@@ -972,6 +966,8 @@ class AsyncOmni(AsyncOmniBase, EngineClient):
|
||||
async with self._pause_cond:
|
||||
self._paused = True
|
||||
await self._pause_cond.wait_for(lambda: getattr(self, "_admitting", 0) == 0)
|
||||
|
||||
ar_stage_ids, diffusion_stage_ids = self._split_stage_ids_by_type(stage_ids)
|
||||
# EngineCore.sleep resets receiver caches itself; only clear P0 here.
|
||||
if 0 in ar_stage_ids:
|
||||
await self._clear_frontend_mm_cache()
|
||||
|
||||
@@ -3062,8 +3062,6 @@ async def omni_sleep(request: OmniSleepRequest, raw_request: Request):
|
||||
raise HTTPException(status_code=501, detail="Engine does not support sleep")
|
||||
try:
|
||||
acks = await engine_client.sleep(stage_ids=request.stage_ids, level=request.level)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST.value, detail=str(e)) from e
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value, detail=f"Failed to sleep: {e}") from e
|
||||
finally:
|
||||
@@ -3088,8 +3086,6 @@ async def omni_wakeup(request: OmniWakeupRequest, raw_request: Request):
|
||||
acks = await engine_client.wake_up(stage_ids=request.stage_ids)
|
||||
except NotImplementedError:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST.value, detail=str(e)) from e
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value, detail=f"Failed to wake up: {e}") from e
|
||||
for sid in request.stage_ids:
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Exact-shape MiniCPM adapters for vLLM encoder CUDA graph management."""
|
||||
|
||||
from collections import Counter
|
||||
from collections.abc import Callable, Hashable
|
||||
from copy import copy
|
||||
from math import prod
|
||||
|
||||
import torch
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.model_executor.models.interfaces import SupportsEncoderCudaGraph
|
||||
from vllm.v1.worker.encoder_cudagraph import EncoderCudaGraphManager
|
||||
from vllm.v1.worker.encoder_cudagraph_defs import (
|
||||
EncoderCudaGraphCaptureInputs,
|
||||
EncoderCudaGraphConfig,
|
||||
EncoderCudaGraphReplayBuffers,
|
||||
EncoderItemSpec,
|
||||
)
|
||||
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
|
||||
def _copy_exact(target: torch.Tensor, source: torch.Tensor) -> None:
|
||||
target.copy_(source)
|
||||
|
||||
|
||||
class _ExactShapeEncoder(SupportsEncoderCudaGraph):
|
||||
"""One already-packed local encoder batch is one indivisible manager item.
|
||||
|
||||
Packing and distributed placement belong to the caller. Keeping the batch
|
||||
intact preserves attention masks and avoids introducing padding semantics.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, forward: Callable[..., torch.Tensor], inputs: tuple[torch.Tensor | None, ...], output_shape: torch.Size
|
||||
):
|
||||
self.forward = forward
|
||||
self.templates = tuple(None if x is None else (x.shape, x.dtype, x.device) for x in inputs)
|
||||
self.output_shape = output_shape
|
||||
self.tokens = prod(output_shape[:-1]) or 1
|
||||
self.keys = [str(i) for i, x in enumerate(inputs) if x is not None]
|
||||
|
||||
def get_encoder_cudagraph_config(self) -> EncoderCudaGraphConfig:
|
||||
return EncoderCudaGraphConfig(
|
||||
modalities=["image", "audio"],
|
||||
buffer_keys=self.keys,
|
||||
out_hidden_size=self.output_shape[-1],
|
||||
padding_logics=dict.fromkeys(self.keys, _copy_exact),
|
||||
)
|
||||
|
||||
def get_encoder_cudagraph_budget_range(self, vllm_config: VllmConfig) -> tuple[int, int]:
|
||||
return self.tokens, self.tokens
|
||||
|
||||
def get_encoder_cudagraph_item_specs(self, mm_kwargs: dict[str, torch.Tensor]) -> list[EncoderItemSpec]:
|
||||
return [EncoderItemSpec(input_size=self.tokens, output_tokens=self.tokens)]
|
||||
|
||||
def select_encoder_cudagraph_items(
|
||||
self, mm_kwargs: dict[str, torch.Tensor], indices: list[int]
|
||||
) -> dict[str, torch.Tensor]:
|
||||
assert indices == [0]
|
||||
return dict(mm_kwargs)
|
||||
|
||||
def prepare_encoder_cudagraph_capture_inputs(
|
||||
self,
|
||||
token_budget: int,
|
||||
max_batch_size: int,
|
||||
max_frames_per_batch: int,
|
||||
device: torch.device,
|
||||
dtype: torch.dtype,
|
||||
path: str = "default",
|
||||
axis_keys: tuple[Hashable, ...] | None = (),
|
||||
) -> EncoderCudaGraphCaptureInputs:
|
||||
return EncoderCudaGraphCaptureInputs(
|
||||
{
|
||||
str(i): torch.zeros(shape, dtype=input_dtype, device=input_device)
|
||||
for i, template in enumerate(self.templates)
|
||||
if template is not None
|
||||
for shape, input_dtype, input_device in [template]
|
||||
}
|
||||
)
|
||||
|
||||
def prepare_encoder_cudagraph_replay_buffers(
|
||||
self,
|
||||
mm_kwargs: dict[str, torch.Tensor],
|
||||
max_batch_size: int,
|
||||
max_frames_per_batch: int,
|
||||
path: str = "default",
|
||||
) -> EncoderCudaGraphReplayBuffers:
|
||||
return EncoderCudaGraphReplayBuffers(mm_kwargs)
|
||||
|
||||
def encoder_cudagraph_forward(self, inputs: dict[str, torch.Tensor], path: str = "default") -> torch.Tensor:
|
||||
return self.forward(*(inputs.get(str(i)) for i in range(len(self.templates))))
|
||||
|
||||
def encoder_eager_forward(self, mm_kwargs: dict[str, torch.Tensor], path: str = "default") -> torch.Tensor:
|
||||
return self.encoder_cudagraph_forward(mm_kwargs, path)
|
||||
|
||||
def postprocess_encoder_output(
|
||||
self,
|
||||
outputs: dict[str, torch.Tensor],
|
||||
indices: list[int],
|
||||
per_item_out_tokens: list[int],
|
||||
dest: dict[int, torch.Tensor] | list[torch.Tensor | None],
|
||||
clone: bool = False,
|
||||
batch_mm_kwargs: dict[str, torch.Tensor] | None = None,
|
||||
) -> None:
|
||||
output = outputs["default"]
|
||||
# Callers retain embeddings across subsequent replays.
|
||||
dest[0] = output.clone() if clone else output
|
||||
|
||||
|
||||
class EncoderCudaGraph:
|
||||
"""Capture repeated shapes without padding or changing attention semantics.
|
||||
|
||||
The owner must bypass this object for training, stateful KV caches and
|
||||
data-dependent Python branches. Entries on the same device and replay
|
||||
stream share a graph pool and capture stream. Different replay streams
|
||||
and encoder instances remain isolated. Graphs must not run concurrently
|
||||
on a shared pool, and their intermediate tensors must not escape capture;
|
||||
callers get a clone so a later replay cannot overwrite retained embeddings.
|
||||
Unseen shapes after the cap run eagerly instead of growing GPU memory.
|
||||
Admission is first-come, with no eviction or automatic recapture. Startup
|
||||
profiling can consume slots. The free-memory floor is a pre-capture guard,
|
||||
not a bound on pool bytes or a guarantee that capture will fit.
|
||||
|
||||
Capture errors are fatal for this instance: propagate the original error
|
||||
and reject subsequent calls without retrying or falling back to CUDA eager
|
||||
execution, since a failed capture may leave the CUDA context unusable.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
forward: Callable[..., torch.Tensor],
|
||||
vllm_config: VllmConfig,
|
||||
*,
|
||||
max_graphs: int = 4,
|
||||
min_capture_calls: int = 2,
|
||||
min_free_bytes: int = 1 << 30,
|
||||
share_pools: bool = True,
|
||||
):
|
||||
for name, value, minimum in (
|
||||
("max_graphs", max_graphs, 0),
|
||||
("min_capture_calls", min_capture_calls, 2),
|
||||
("min_free_bytes", min_free_bytes, 0),
|
||||
):
|
||||
if type(value) is not int or value < minimum:
|
||||
raise ValueError(f"{name} must be an integer >= {minimum}")
|
||||
self.forward = forward
|
||||
self.vllm_config = vllm_config
|
||||
self.max_graphs = max_graphs
|
||||
self.min_capture_calls = min_capture_calls
|
||||
self.min_free_bytes = min_free_bytes
|
||||
self.share_pools = share_pools
|
||||
self._capture_pools: dict[tuple, tuple[tuple[int, int], torch.cuda.Stream]] = {}
|
||||
self.graphs: dict[tuple, EncoderCudaGraphManager] = {}
|
||||
self._seen: dict[tuple, torch.Size] = {}
|
||||
self._seen_calls: Counter[tuple] = Counter()
|
||||
self._misses: Counter[str] = Counter()
|
||||
self._capture_failed = False
|
||||
|
||||
def get_cumulative_stats(self) -> dict[str, int | float]:
|
||||
"""Include eager admission misses that never reach an upstream manager."""
|
||||
hits = sum(entry.graph_hits for entry in self.graphs.values())
|
||||
misses = sum(self._misses.values()) + sum(entry.graph_misses for entry in self.graphs.values())
|
||||
return {
|
||||
"graph_hits": hits,
|
||||
"graph_misses": misses,
|
||||
"hit_rate": hits / (hits + misses) if hits + misses else 0.0,
|
||||
"num_graphs": len(self.graphs),
|
||||
"ineligible_misses": self._misses["ineligible"],
|
||||
"capacity_misses": self._misses["capacity"],
|
||||
"warmup_misses": self._misses["warmup"],
|
||||
"memory_misses": self._misses["memory"],
|
||||
"capture_failures": int(self._capture_failed),
|
||||
}
|
||||
|
||||
def __call__(self, *inputs: torch.Tensor | None) -> torch.Tensor:
|
||||
if self._capture_failed:
|
||||
raise RuntimeError("Encoder CUDA graph capture previously failed; restart the worker before reuse")
|
||||
tensors = [value for value in inputs if value is not None]
|
||||
if (
|
||||
not tensors
|
||||
or any(value.device.type != "cuda" for value in tensors)
|
||||
or len({value.device for value in tensors}) != 1
|
||||
or torch.is_grad_enabled()
|
||||
or torch.is_autocast_enabled("cuda")
|
||||
or torch.cuda.is_current_stream_capturing()
|
||||
):
|
||||
self._misses["ineligible"] += 1
|
||||
return self.forward(*inputs)
|
||||
# Do not share mutable replay buffers across independent CUDA streams.
|
||||
key = (
|
||||
torch.cuda.current_stream(tensors[0].device).cuda_stream,
|
||||
tuple(None if x is None else (x.shape, x.dtype, x.device) for x in inputs),
|
||||
)
|
||||
entry = self.graphs.get(key)
|
||||
if entry is None:
|
||||
if len(self.graphs) >= self.max_graphs:
|
||||
self._misses["capacity"] += 1
|
||||
return self.forward(*inputs)
|
||||
if key not in self._seen:
|
||||
# Avoid paying capture latency for one-off shapes. Bound even
|
||||
# the admission metadata for streams with arbitrary resolutions.
|
||||
if len(self._seen) >= self.max_graphs * 4:
|
||||
oldest = next(iter(self._seen))
|
||||
self._seen.pop(oldest)
|
||||
self._seen_calls.pop(oldest)
|
||||
output = self.forward(*inputs)
|
||||
self._seen[key] = output.shape
|
||||
self._seen_calls[key] = 1
|
||||
self._misses["warmup"] += 1
|
||||
return output
|
||||
self._seen_calls[key] = min(self._seen_calls[key] + 1, self.min_capture_calls)
|
||||
if self._seen_calls[key] < self.min_capture_calls:
|
||||
self._misses["warmup"] += 1
|
||||
return self.forward(*inputs)
|
||||
if self.min_free_bytes and current_omni_platform.get_free_memory(tensors[0].device) < self.min_free_bytes:
|
||||
self._misses["memory"] += 1
|
||||
return self.forward(*inputs)
|
||||
try:
|
||||
entry = self._capture(inputs, self._seen[key])
|
||||
except Exception:
|
||||
self._capture_failed = True
|
||||
self._seen.clear()
|
||||
self._seen_calls.clear()
|
||||
raise
|
||||
self.graphs[key] = entry
|
||||
self._seen.pop(key)
|
||||
self._seen_calls.pop(key)
|
||||
return entry.execute({str(i): x for i, x in enumerate(inputs) if x is not None})[0]
|
||||
|
||||
def _capture(self, inputs: tuple[torch.Tensor | None, ...], output_shape: torch.Size) -> EncoderCudaGraphManager:
|
||||
tensor = next(x for x in inputs if x is not None)
|
||||
adapter = _ExactShapeEncoder(self.forward, inputs, output_shape)
|
||||
# Do not mutate the runner configuration. This adapter has already
|
||||
# packed and placed a local batch, so manager-level DP must be disabled.
|
||||
config = copy(self.vllm_config)
|
||||
config.compilation_config = copy(config.compilation_config)
|
||||
config.compilation_config.encoder_cudagraph_token_budgets = [adapter.tokens]
|
||||
config.compilation_config.encoder_cudagraph_max_vision_items_per_batch = 1
|
||||
config.compilation_config.encoder_cudagraph_max_frames_per_batch = 0
|
||||
config.parallel_config = copy(config.parallel_config)
|
||||
config.parallel_config.tensor_parallel_size = 1
|
||||
manager = EncoderCudaGraphManager(config, tensor.device, tensor.dtype, adapter)
|
||||
replay_stream = torch.cuda.current_stream(tensor.device)
|
||||
pool_key = (tensor.device, replay_stream.cuda_stream)
|
||||
if self.share_pools and pool_key in self._capture_pools:
|
||||
pool, stream = self._capture_pools[pool_key]
|
||||
else:
|
||||
pool = torch.cuda.graph_pool_handle()
|
||||
stream = torch.cuda.Stream(device=tensor.device)
|
||||
# A late capture must wait for prior replays and output clones before
|
||||
# reusing their pool. Reusing the capture stream also lets the allocator
|
||||
# reuse freed blocks from earlier captures. Manager input/output buffers
|
||||
# are allocated outside capture; only transient intermediates share it.
|
||||
stream.wait_stream(replay_stream)
|
||||
with torch.cuda.stream(stream):
|
||||
manager.capture(graph_pool=pool)
|
||||
replay_stream.wait_stream(stream)
|
||||
if self.share_pools:
|
||||
self._capture_pools[pool_key] = (pool, stream)
|
||||
return manager
|
||||
@@ -55,8 +55,6 @@ from transformers.modeling_outputs import BaseModelOutput, BaseModelOutputWithPo
|
||||
from transformers.modeling_utils import PreTrainedModel
|
||||
from transformers.models.whisper.modeling_whisper import ACT2FN
|
||||
|
||||
from vllm_omni.model_executor.models.minicpmo_4_5.encoder_cuda_graph import EncoderCudaGraph
|
||||
|
||||
try:
|
||||
from transformers.models.whisper.modeling_whisper import WHISPER_ATTENTION_CLASSES
|
||||
except ImportError:
|
||||
@@ -1582,7 +1580,6 @@ class SiglipVisionTransformer(SiglipPreTrainedModel):
|
||||
self.encoder = SiglipEncoder(config)
|
||||
self.post_layernorm = nn.LayerNorm(embed_dim, eps=config.layer_norm_eps)
|
||||
self._use_flash_attention_2 = config._attn_implementation == "flash_attention_2"
|
||||
self._encoder_graph: EncoderCudaGraph | None = None
|
||||
|
||||
# Initialize weights and apply final processing
|
||||
self.post_init()
|
||||
@@ -1590,18 +1587,6 @@ class SiglipVisionTransformer(SiglipPreTrainedModel):
|
||||
def get_input_embeddings(self) -> nn.Module:
|
||||
return self.embeddings.patch_embedding
|
||||
|
||||
def _encode_last_hidden_state(
|
||||
self, hidden_states: torch.Tensor, attention_mask: torch.Tensor | None
|
||||
) -> torch.Tensor:
|
||||
output = self.encoder(
|
||||
inputs_embeds=hidden_states,
|
||||
attention_mask=attention_mask,
|
||||
output_attentions=False,
|
||||
output_hidden_states=False,
|
||||
return_dict=True,
|
||||
)
|
||||
return self.post_layernorm(output.last_hidden_state)
|
||||
|
||||
@add_start_docstrings_to_model_forward(SIGLIP_VISION_INPUTS_DOCSTRING)
|
||||
@replace_return_docstrings(output_type=BaseModelOutputWithPooling, config_class=SiglipVisionConfig)
|
||||
def forward(
|
||||
@@ -1651,21 +1636,6 @@ class SiglipVisionTransformer(SiglipPreTrainedModel):
|
||||
else patch_attention_mask
|
||||
)
|
||||
|
||||
# Position/mask construction above performs host decisions. Capture
|
||||
# only the transformer stack; padded FlashAttention unpadding also
|
||||
# reads dynamic lengths on the host and remains eager.
|
||||
if (
|
||||
self._encoder_graph is not None
|
||||
and not self.training
|
||||
and not output_attentions
|
||||
and not output_hidden_states
|
||||
and not (self._use_flash_attention_2 and attention_mask is not None)
|
||||
):
|
||||
last_hidden_state = self._encoder_graph(hidden_states, attention_mask)
|
||||
if not return_dict:
|
||||
return (last_hidden_state, None)
|
||||
return BaseModelOutputWithPooling(last_hidden_state=last_hidden_state)
|
||||
|
||||
encoder_outputs = self.encoder(
|
||||
inputs_embeds=hidden_states,
|
||||
attention_mask=attention_mask,
|
||||
@@ -3945,18 +3915,6 @@ class MiniCPMO45OmniLLMForConditionalGeneration(nn.Module, SupportsMultiModal, S
|
||||
|
||||
self.config = config
|
||||
self.multimodal_config = multimodal_config
|
||||
# Model-local opt-out for A/B measurements; --enforce-eager always wins.
|
||||
encoder_graphs = (
|
||||
bool(getattr(config, "encoder_cuda_graph", True)) and not vllm_config.model_config.enforce_eager
|
||||
)
|
||||
# Per-encoder limits, configurable via --hf-overrides. No eviction:
|
||||
# increasing the cap trades retained GPU memory for shape coverage.
|
||||
encoder_graph_options = {
|
||||
"max_graphs": getattr(config, "encoder_cuda_graph_max_graphs", 4),
|
||||
"min_capture_calls": getattr(config, "encoder_cuda_graph_min_capture_calls", 2),
|
||||
"min_free_bytes": getattr(config, "encoder_cuda_graph_min_free_bytes", 1 << 30),
|
||||
"share_pools": getattr(config, "encoder_cuda_graph_share_pools", True),
|
||||
}
|
||||
|
||||
# Initialize image processor
|
||||
self.image_processor = MiniCPMVImageProcessor(
|
||||
@@ -3976,10 +3934,6 @@ class MiniCPMO45OmniLLMForConditionalGeneration(nn.Module, SupportsMultiModal, S
|
||||
config.vision_config._attn_implementation = "eager"
|
||||
|
||||
self.vpm = SiglipVisionTransformer(config.vision_config)
|
||||
if encoder_graphs:
|
||||
self.vpm._encoder_graph = EncoderCudaGraph(
|
||||
self.vpm._encode_last_hidden_state, vllm_config, **encoder_graph_options
|
||||
)
|
||||
# Drop last layer if configured
|
||||
if config.drop_vision_last_layer:
|
||||
self.vpm.encoder.layers = self.vpm.encoder.layers[:-1]
|
||||
@@ -4047,11 +4001,6 @@ class MiniCPMO45OmniLLMForConditionalGeneration(nn.Module, SupportsMultiModal, S
|
||||
self.audio_encoder_layer = None
|
||||
self.audio_past_key_values = None
|
||||
|
||||
self._audio_encoder_graph = (
|
||||
EncoderCudaGraph(self._encode_audio_features, vllm_config, **encoder_graph_options)
|
||||
if encoder_graphs
|
||||
else None
|
||||
)
|
||||
self.mm_token_ids = set[int]()
|
||||
self.make_empty_intermediate_tensors = self.llm.make_empty_intermediate_tensors
|
||||
|
||||
@@ -4475,36 +4424,6 @@ class MiniCPMO45OmniLLMForConditionalGeneration(nn.Module, SupportsMultiModal, S
|
||||
audio_attention_mask_ = torch.logical_or(audio_attention_mask_, torch.logical_not(chunk_mask))
|
||||
|
||||
audio_attention_mask[audio_attention_mask_] = float("-inf")
|
||||
graph = getattr(self, "_audio_encoder_graph", None)
|
||||
if (
|
||||
graph is not None
|
||||
and not self.training
|
||||
and self.audio_encoder_layer == -1
|
||||
# Whisper's FP16 overflow guard reads tensor booleans on the host.
|
||||
and self.apm.conv1.weight.dtype != torch.float16
|
||||
):
|
||||
audio_embeds = graph(wavforms, audio_attention_mask)
|
||||
else:
|
||||
audio_embeds = self._encode_audio_features(wavforms, audio_attention_mask)
|
||||
|
||||
_, feature_lens_after_pooling = self._get_feat_extract_output_lengths(audio_feature_lens)
|
||||
|
||||
num_audio_tokens = feature_lens_after_pooling
|
||||
|
||||
final_audio_embeds = list[torch.Tensor]()
|
||||
idx = 0
|
||||
for i in range(len(audio_feature_lens_raw)):
|
||||
target_audio_embeds_lst = list[torch.Tensor]()
|
||||
for _ in range(len(audio_feature_lens_raw[i])):
|
||||
target_audio_embeds_lst.append(audio_embeds[idx, : num_audio_tokens[idx], :])
|
||||
idx += 1
|
||||
|
||||
final_audio_embeds.append(torch.cat(target_audio_embeds_lst))
|
||||
|
||||
return final_audio_embeds
|
||||
|
||||
def _encode_audio_features(self, wavforms: torch.Tensor, audio_attention_mask: torch.Tensor) -> torch.Tensor:
|
||||
"""Stateless audio encoder, projection and pooling (no streaming KV)."""
|
||||
selects_final_layer = self.audio_encoder_layer == -1
|
||||
audio_outputs = self.apm(
|
||||
wavforms,
|
||||
@@ -4524,7 +4443,21 @@ class MiniCPMO45OmniLLMForConditionalGeneration(nn.Module, SupportsMultiModal, S
|
||||
audio_embeds = self.audio_avg_pooler(audio_embeds)
|
||||
audio_embeds = audio_embeds.transpose(1, 2)
|
||||
|
||||
return audio_embeds
|
||||
_, feature_lens_after_pooling = self._get_feat_extract_output_lengths(audio_feature_lens)
|
||||
|
||||
num_audio_tokens = feature_lens_after_pooling
|
||||
|
||||
final_audio_embeds = list[torch.Tensor]()
|
||||
idx = 0
|
||||
for i in range(len(audio_feature_lens_raw)):
|
||||
target_audio_embeds_lst = list[torch.Tensor]()
|
||||
for _ in range(len(audio_feature_lens_raw[i])):
|
||||
target_audio_embeds_lst.append(audio_embeds[idx, : num_audio_tokens[idx], :])
|
||||
idx += 1
|
||||
|
||||
final_audio_embeds.append(torch.cat(target_audio_embeds_lst))
|
||||
|
||||
return final_audio_embeds
|
||||
|
||||
def get_audio_embedding_streaming(
|
||||
self,
|
||||
|
||||
Reference in new issue
Block a user