I’ve reconstructed a minimalistic setup to reproduce it: a server.py deploys several chained Deployments and load.py sends requests to process.
Here is an exact environment:
ray[serve]==2.49.1
numpy>=1.24.0,<3
fastapi>=0.100.0,<0.139.2
httpx>=0.24.0
uvicorn>=0.23.0
starlette>=0.27.0,<1.0
pydantic>=2.0.0
protobuf>=3.20.3,<7
At this point I’ve also tried running it with Ray 2.56.1 and removing `_owner` - runs completely the same; the majority of requests are being processed successfully, while some catch error in the same spot. In the attached code it is this line - _ = result["a"]["mask"].retrieve().
Here is a server.py:
#!/usr/bin/env python3
"""Deploy the minimal ref-count repro Serve app. Press Ctrl+C to stop."""
from __future__ import annotations
import os
import time
import ray
from ray import serve
from deployments import bind_app, create_object_owner_actor
_ROOT = os.path.dirname(os.path.abspath(__file__))
os.environ.setdefault("RAY_record_ref_creation_sites", "1")
os.environ["PYTHONPATH"] = _ROOT + (
os.pathsep + os.environ["PYTHONPATH"] if os.environ.get("PYTHONPATH") else ""
)
ray.init(
num_cpus=12,
object_store_memory=800_000_000,
runtime_env={"working_dir": _ROOT, "env_vars": {"PYTHONPATH": _ROOT}},
_system_config={"local_fs_capacity_threshold": 0.99},
)
create_object_owner_actor()
serve.start(http_options={"host": "0.0.0.0", "port": 8000})
serve.run(bind_app(), name="ref_count_repro", route_prefix="/")
print("Server running at http://0.0.0.0:8000 POST /process")
try:
while True:
time.sleep(3600)
except KeyboardInterrupt:
serve.shutdown()
load.py:
#!/usr/bin/env python3
"""Concurrent load against the minimal ref-count repro server."""
from __future__ import annotations
import asyncio
import httpx
URL = "http://127.0.0.1:8000/process"
CONCURRENCY = 10
TOTAL = 500
TIMEOUT_S = 300.0
async def main() -> None:
sem = asyncio.Semaphore(CONCURRENCY)
async def one(client: httpx.AsyncClient, i: int) -> None:
async with sem:
r = await client.post(URL, timeout=TIMEOUT_S)
if "ReferenceCountingAssertionError" in r.text or r.status_code >= 500:
print(f"request={i} status={r.status_code} body={r.text}")
async with httpx.AsyncClient() as client:
await asyncio.gather(*(one(client, i) for i in range(TOTAL)))
if __name__ == "__main__":
asyncio.run(main())
and deployments.py
from __future__ import annotations
import asyncio
import time
from typing import Any
import numpy as np
import ray
from fastapi import FastAPI
from ray import serve
from ray.serve.handle import DeploymentHandle
_PATCHED = False
def _apply_serve_lock_patch() -> None:
global _PATCHED
if _PATCHED:
return
from ray.serve._private.replica_result import ActorReplicaResult
async def to_object_ref_async(self) -> object:
assert not self._is_streaming
while True:
if self._obj_ref is not None:
return self._obj_ref
acquired = self._object_ref_or_gen_sync_lock.acquire(blocking=False)
if acquired:
try:
if self._obj_ref is None:
self._obj_ref = await self._obj_ref_gen.__anext__()
return self._obj_ref
finally:
self._object_ref_or_gen_sync_lock.release()
else:
await asyncio.sleep(0)
ActorReplicaResult.to_object_ref_async = to_object_ref_async # type: ignore[method-assign]
_PATCHED = True
_apply_serve_lock_patch()
MAX_ONGOING = 10
INFERENCE_SLEEP_S = 0.05
ARRAY_SHAPE = (64, 128, 128)
_OPTS = {"max_ongoing_requests": MAX_ONGOING, "ray_actor_options": {"num_cpus": 0}}
_OWNER_NAME = "ObjectOwnerActor"
_OWNER_NAMESPACE = "ObjectOwner"
@ray.remote
class ObjectOwnerActor:
"""Detached actor that owns object refs via ray.put(..., _owner=actor)."""
def __init__(self) -> None:
self.alive = True
def ensure_alive(self) -> bool:
return self.alive
def create_object_owner_actor() -> None:
actor = ObjectOwnerActor.options(
name=_OWNER_NAME,
namespace=_OWNER_NAMESPACE,
lifetime="detached",
get_if_exists=True,
num_cpus=0,
).remote()
assert ray.get(actor.ensure_alive.remote())
class ArrayRef:
"""Wraps a numpy array owned by ObjectOwnerActor."""
def __init__(self, data: np.ndarray) -> None:
self.shape = data.shape
self.dtype = data.dtype
owner = ray.get_actor(name=_OWNER_NAME, namespace=_OWNER_NAMESPACE)
self.data_ref = [ray.put(data, _owner=owner)]
def retrieve(self) -> np.ndarray:
return ray.get(self.data_ref[0])
# ---------------------------------------------------------------------------
# Deployments
# ---------------------------------------------------------------------------
@serve.deployment
class Worker:
async def __call__(self, image: ArrayRef) -> ArrayRef:
return await worker_infer.remote(image)
@ray.remote
def worker_infer(image: ArrayRef) -> ArrayRef:
arr = image.retrieve()
time.sleep(INFERENCE_SLEEP_S)
return ArrayRef(arr.copy())
@serve.deployment
class BranchB:
def __init__(self, worker: DeploymentHandle) -> None:
self.worker = worker
async def __call__(self, image: ArrayRef) -> dict[str, Any]:
refs = [self.worker.remote(image=image) for _ in range(3)]
masks = await asyncio.gather(*refs)
return {"masks": list(masks)}
async def localize(self, result: dict[str, Any], shared: ArrayRef) -> dict[str, Any]:
_ = await localize_b.remote(shared)
return result
@ray.remote
def localize_b(shared: ArrayRef) -> ArrayRef:
arr = shared.retrieve()
return ArrayRef(arr.copy())
@serve.deployment
class BranchA:
async def __call__(self, image: ArrayRef) -> dict[str, Any]:
time.sleep(INFERENCE_SLEEP_S)
arr = image.retrieve()
return {"mask": ArrayRef(arr.copy())}
async def merge(
self,
a_result: dict[str, Any],
b_result: dict[str, Any],
) -> dict[str, Any]:
return await merge_ab.remote(a_result["mask"], b_result["masks"])
async def localize(self, result: dict[str, Any], shared: ArrayRef) -> dict[str, Any]:
_ = await localize_a.remote(result["mask"], shared)
return result
@ray.remote
def merge_ab(mask_a: ArrayRef, masks_b: list[ArrayRef]) -> dict[str, Any]:
arr = mask_a.retrieve()
for m in masks_b:
_ = m.retrieve()
return {"mask": ArrayRef(arr.copy())}
@ray.remote
def localize_a(mask: ArrayRef, shared: ArrayRef) -> ArrayRef:
arr = mask.retrieve()
_ = shared.retrieve()
return ArrayRef(arr.copy())
@serve.deployment
class Orchestrator:
def __init__(self, branch_a: DeploymentHandle, branch_b: DeploymentHandle) -> None:
self.branch_a = branch_a
self.branch_b = branch_b
async def __call__(self, image: ArrayRef) -> dict[str, Any]:
# Fire both branches; keep responses unresolved.
a_ref = self.branch_a.remote(image=image)
b_ref = self.branch_b.remote(image=image)
# Chain unresolved responses into merge.
a_ref = self.branch_a.merge.remote(a_result=a_ref, b_result=b_ref)
shared = await make_shared.remote(image=image)
# Fan out shared ArrayRef + still-unresolved branch refs into localize.
b_ref = self.branch_b.localize.remote(result=b_ref, shared=shared)
a_ref = self.branch_a.localize.remote(result=a_ref, shared=shared)
a_result = await a_ref
b_result = await b_ref
return {"a": a_result, "b": b_result}
@ray.remote
def make_shared(image: ArrayRef) -> ArrayRef:
arr = image.retrieve()
return ArrayRef(arr.copy())
app = FastAPI()
@serve.deployment
@serve.ingress(app)
class Ingress:
def __init__(self, orch: DeploymentHandle) -> None:
create_object_owner_actor()
self.orch = orch
@app.post("/process")
async def process(self) -> dict[str, str]:
data = np.random.randint(-1000, 400, size=ARRAY_SHAPE, dtype=np.int16)
image = ArrayRef(data)
result = await self.orch.remote(image=image)
# Extra retrieve at the ingress boundary.
_ = result["a"]["mask"].retrieve()
for mask in result["b"]["masks"]:
_ = mask.retrieve()
return {"status": "ok"}
def bind_app() -> Any:
worker = Worker.options(**_OPTS).bind()
branch_b = BranchB.options(**_OPTS).bind(worker=worker)
branch_a = BranchA.options(**_OPTS).bind()
orch = Orchestrator.options(**_OPTS).bind(branch_a=branch_a, branch_b=branch_b)
return Ingress.options(**_OPTS).bind(orch=orch)
How to run: run server.py and wait until all Deployments had been deployed. Then in a separate shell run load.py. It should return something like request=28 status=500 body=Internal Server Error in case of ReferenceCountingAssertionError
What I’ve also noticed - if I await both b_ref and a_ref before calling .localize methods, then this error doesn’t appear, like that:
shared = await make_shared.remote(image=image)
b_ref = await b_ref
a_ref = await a_ref
# Fan out shared ArrayRef + still-unresolved branch refs into localize.
b_ref = self.branch_b.localize.remote(result=b_ref, shared=shared)
a_ref = self.branch_a.localize.remote(result=a_ref, shared=shared)
Unfortunately, it only solves one case in my production code since this error appears in other Deployment as well, and I cannot figure out what and how to await there.
@Herald