chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
@@ -0,0 +1,688 @@
import asyncio
import inspect
import logging
import random
import string
import time
from functools import partial
from typing import Any, Callable, Coroutine, Dict, List, Optional, Tuple
import aiohttp
import aiohttp.client_exceptions
import grpc
import numpy as np
import pandas as pd
from starlette.responses import StreamingResponse
from tqdm import tqdm
import ray
from ray import serve
from ray._common.test_utils import SignalActor as _SignalActor
from ray.serve._private.common import DeploymentStatus
from ray.serve.generated import serve_pb2, serve_pb2_grpc
from ray.serve.handle import DeploymentHandle
async def run_latency_benchmark(
f: Callable, num_requests: int, *, num_warmup_requests: int = 100
) -> pd.Series:
if inspect.iscoroutinefunction(f):
to_call = f
else:
async def to_call():
f()
latencies = []
for i in tqdm(range(num_requests + num_warmup_requests)):
start = time.perf_counter()
await to_call()
end = time.perf_counter()
# Don't include warm-up requests.
if i >= num_warmup_requests:
latencies.append(1000 * (end - start))
return pd.Series(latencies)
async def run_throughput_benchmark(
fn: Callable[[], List[float]],
multiplier: int = 1,
num_trials: int = 10,
trial_runtime: float = 1,
) -> Tuple[float, float, pd.Series]:
"""Benchmarks throughput of a function.
Args:
fn: The function to benchmark. If this returns anything, it must
return a list of latencies.
multiplier: The number of requests or tokens (or whatever unit
is appropriate for this throughput benchmark) that is
completed in one call to `fn`.
num_trials: The number of trials to run.
trial_runtime: How long each trial should run for. During the
duration of one trial, `fn` will be repeatedly called.
Returns:
A tuple ``(mean, stddev, latencies)`` summarizing per-trial throughput
across ``num_trials`` runs.
"""
# Warmup
start = time.time()
while time.time() - start < 0.1:
await fn()
# Benchmark
stats = []
latencies = []
for _ in tqdm(range(num_trials)):
start = time.perf_counter()
count = 0
while time.perf_counter() - start < trial_runtime:
res = await fn()
if res:
latencies.extend(res)
count += 1
end = time.perf_counter()
stats.append(multiplier * count / (end - start))
return round(np.mean(stats), 2), round(np.std(stats), 2), pd.Series(latencies)
async def do_single_http_batch(
*,
batch_size: int = 100,
url: str = "http://localhost:8000",
stream: bool = False,
) -> List[float]:
"""Sends a batch of http requests and returns e2e latencies."""
# By default, aiohttp limits the number of client connections to 100.
# We need to use TCPConnector to configure the limit if batch size
# is greater than 100.
connector = aiohttp.TCPConnector(limit=batch_size)
async with aiohttp.ClientSession(
connector=connector, raise_for_status=True
) as session:
async def do_query():
start = time.perf_counter()
try:
async with session.get(url) as r:
if stream:
async for chunk, _ in r.content.iter_chunks():
pass
else:
# Read the response to ensure it's consumed
await r.read()
except aiohttp.client_exceptions.ClientConnectionError:
pass
end = time.perf_counter()
return 1000 * (end - start)
return await asyncio.gather(*[do_query() for _ in range(batch_size)])
async def do_single_grpc_batch(
*, batch_size: int = 100, target: str = "localhost:9000"
):
channel = grpc.aio.insecure_channel(target)
stub = serve_pb2_grpc.RayServeBenchmarkServiceStub(channel)
payload = serve_pb2.StringData(data="")
async def do_query():
start = time.perf_counter()
await stub.grpc_call(payload)
end = time.perf_counter()
return 1000 * (end - start)
return await asyncio.gather(*[do_query() for _ in range(batch_size)])
async def collect_profile_events(coro: Coroutine):
"""Collects profiling events using Viztracer"""
from viztracer import VizTracer
tracer = VizTracer()
tracer.start()
await coro
tracer.stop()
tracer.save()
def generate_payload(size: int = 100, chars=string.ascii_uppercase + string.digits):
return "".join(random.choice(chars) for _ in range(size))
class Blackhole:
def sink(self, o):
pass
@serve.deployment
class Noop:
def __init__(self):
logging.getLogger("ray.serve").setLevel(logging.WARNING)
def __call__(self, *args, **kwargs):
return b""
@serve.deployment
class ModelComp:
def __init__(self, child):
logging.getLogger("ray.serve").setLevel(logging.WARNING)
self._child = child
async def __call__(self, *args, **kwargs):
return await self._child.remote()
@serve.deployment
class GrpcDeployment:
def __init__(self):
logging.getLogger("ray.serve").setLevel(logging.WARNING)
async def grpc_call(self, user_message):
return serve_pb2.ModelOutput(output=9)
async def call_with_string(self, user_message):
return serve_pb2.ModelOutput(output=9)
@serve.deployment
class GrpcModelComp:
def __init__(self, child):
logging.getLogger("ray.serve").setLevel(logging.WARNING)
self._child = child
async def grpc_call(self, user_message):
await self._child.remote()
return serve_pb2.ModelOutput(output=9)
async def call_with_string(self, user_message):
await self._child.remote()
return serve_pb2.ModelOutput(output=9)
@serve.deployment
class Streamer:
def __init__(self, tokens_per_request: int, inter_token_delay_ms: int = 10):
logging.getLogger("ray.serve").setLevel(logging.WARNING)
self._tokens_per_request = tokens_per_request
self._inter_token_delay_s = inter_token_delay_ms / 1000
async def stream(self):
for _ in range(self._tokens_per_request):
await asyncio.sleep(self._inter_token_delay_s)
yield b"hi"
async def __call__(self):
return StreamingResponse(self.stream())
@serve.deployment
class IntermediateRouter:
def __init__(self, handle: DeploymentHandle):
logging.getLogger("ray.serve").setLevel(logging.WARNING)
self._handle = handle.options(stream=True)
async def stream(self):
async for token in self._handle.stream.remote():
yield token
def __call__(self):
return StreamingResponse(self.stream())
@serve.deployment
class Benchmarker:
def __init__(
self,
handle: DeploymentHandle,
stream: bool = False,
):
logging.getLogger("ray.serve").setLevel(logging.WARNING)
self._handle = handle.options(stream=stream)
self._stream = stream
async def do_single_request(self, payload: Any = None) -> float:
"""Completes a single unary request. Returns e2e latency in ms."""
start = time.perf_counter()
if payload is None:
await self._handle.remote()
else:
await self._handle.remote(payload)
end = time.perf_counter()
return 1000 * (end - start)
async def do_single_choose_dispatch(self, payload: Any = None) -> float:
"""Completes a single unary request via choose_replica + dispatch.
Returns e2e latency in ms. With SingletonThreadRouter this involves
two run_coroutine_threadsafe round-trips (one for __aenter__, one
for _dispatch_to_marked_selection) vs. one for ``remote``.
"""
start = time.perf_counter()
if payload is None:
async with self._handle.choose_replica() as sel:
await self._handle.dispatch(sel)
else:
async with self._handle.choose_replica(payload) as sel:
await self._handle.dispatch(sel, payload)
end = time.perf_counter()
return 1000 * (end - start)
async def _do_single_stream(self) -> float:
"""Consumes a single streaming request. Returns e2e latency in ms."""
start = time.perf_counter()
async for r in self._handle.stream.remote():
pass
end = time.perf_counter()
return 1000 * (end - start)
async def _do_single_batch(self, batch_size: int) -> List[float]:
if self._stream:
return await asyncio.gather(
*[self._do_single_stream() for _ in range(batch_size)]
)
else:
return await asyncio.gather(
*[self.do_single_request() for _ in range(batch_size)]
)
async def run_latency_benchmark(
self,
*,
num_requests: int,
payload: Any = None,
mode: str = "remote",
) -> pd.Series:
if mode == "remote":
async def f():
await self.do_single_request(payload)
elif mode == "choose_dispatch":
async def f():
await self.do_single_choose_dispatch(payload)
else:
raise ValueError(f"Unknown mode {mode!r}")
return await run_latency_benchmark(f, num_requests=num_requests)
async def run_throughput_benchmark(
self,
*,
batch_size: int,
num_trials: int,
trial_runtime: float,
tokens_per_request: Optional[float] = None,
) -> Tuple[float, float]:
if self._stream:
assert tokens_per_request
multiplier = tokens_per_request * batch_size
else:
multiplier = batch_size
return await run_throughput_benchmark(
fn=partial(
self._do_single_batch,
batch_size=batch_size,
),
multiplier=multiplier,
num_trials=num_trials,
trial_runtime=trial_runtime,
)
# =============================================================================
# Controller Benchmark
# =============================================================================
# See https://github.com/ray-project/ray/issues/60680 for more details.
CONTROLLER_BENCH_CONFIG = {
"checkpoints": [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 3072, 4096],
"marination_period_s": 180,
"sample_interval_s": 5,
}
_CONTROLLER_AUTOSCALING_CONFIG = {
"min_replicas": 1,
"max_replicas": 4096,
"target_ongoing_requests": 1,
"upscale_delay_s": 1,
}
_CONTROLLER_WAITER_TIMEOUT_S = 1200
# SignalActor from ray._common.test_utils; use high max_concurrency for many
# concurrent waiters (up to 4096 in controller benchmark).
_SignalActorForController = _SignalActor.options(max_concurrency=100000)
@serve.deployment(
graceful_shutdown_timeout_s=1,
ray_actor_options={"num_cpus": 0.2},
max_ongoing_requests=100000,
autoscaling_config={
"min_replicas": 5,
"max_replicas": 10,
"target_ongoing_requests": 100000,
"upscale_delay_s": 1,
},
)
class ControllerBenchHelloWorld:
def __init__(self, signal_actor):
self.signal = signal_actor
async def __call__(self):
await self.signal.wait.remote()
return "hello"
@serve.deployment(
autoscaling_config=_CONTROLLER_AUTOSCALING_CONFIG,
max_ongoing_requests=2,
graceful_shutdown_timeout_s=1,
ray_actor_options={"num_cpus": 0.4},
)
class ControllerBenchMetricsGenerator:
"""Autoscaling deployment that generates handle metrics to stress the controller."""
def __init__(self, hello_world: DeploymentHandle):
self.hello_world = hello_world
async def __call__(self):
return await self.hello_world.remote()
def _controller_get_active_nodes() -> int:
"""Get number of active nodes in the cluster."""
return len([n for n in ray.nodes() if n.get("Alive", False)])
async def _controller_get_replica_count(
deployment_name: str = "ControllerBenchMetricsGenerator",
) -> int:
"""Get current number of running replicas for the specified deployment."""
status = serve.status()
for app in status.applications.values():
for name, deployment in app.deployments.items():
if name == deployment_name:
return deployment.replica_states.get("RUNNING", 0)
return 0
async def _controller_get_health_metrics() -> Dict[str, Any]:
"""Get controller health metrics. Fails the run if unavailable."""
client = serve.context._global_client
if client is None:
raise RuntimeError(
"Serve is not connected. get_health_metrics requires an active Serve "
"controller. Ensure Serve is started before running the controller benchmark."
)
controller = client._controller
if not hasattr(controller, "get_health_metrics"):
raise RuntimeError(
"Controller does not have get_health_metrics. This API is required for "
"the controller benchmark. Please use a Ray version that supports "
"controller health metrics."
)
return await controller.get_health_metrics.remote()
def _controller_extract_metrics_row(
health_metrics: Dict[str, Any],
checkpoint: int,
sample: int,
target_replicas: int,
actual_replicas: int,
num_nodes: int,
autoscale_duration_s: float,
) -> Dict[str, Any]:
"""Extract a flat row from health metrics with all available fields."""
def get_stat(d: dict, key: str, stat: str, default=0):
return d.get(key, {}).get(stat, default)
return {
"checkpoint": checkpoint,
"sample": sample,
"target_replicas": target_replicas,
"actual_replicas": actual_replicas,
"num_nodes": num_nodes,
"autoscale_duration_s": round(autoscale_duration_s, 3),
"loop_duration_mean_s": get_stat(health_metrics, "loop_duration_s", "mean"),
"loop_duration_std_s": get_stat(health_metrics, "loop_duration_s", "std"),
"loops_per_second": health_metrics.get("loops_per_second", 0),
"event_loop_delay_s": health_metrics.get("event_loop_delay_s", 0),
"num_asyncio_tasks": health_metrics.get("num_asyncio_tasks", 0),
"deployment_state_update_mean_s": get_stat(
health_metrics, "deployment_state_update_duration_s", "mean"
),
"application_state_update_mean_s": get_stat(
health_metrics, "application_state_update_duration_s", "mean"
),
"proxy_state_update_mean_s": get_stat(
health_metrics, "proxy_state_update_duration_s", "mean"
),
"proxy_state_update_std_s": get_stat(
health_metrics, "proxy_state_update_duration_s", "std"
),
"node_update_mean_s": get_stat(
health_metrics, "node_update_duration_s", "mean"
),
"node_update_std_s": get_stat(health_metrics, "node_update_duration_s", "std"),
"node_update_min_s": get_stat(health_metrics, "node_update_duration_s", "min"),
"handle_metrics_delay_mean_ms": get_stat(
health_metrics, "handle_metrics_delay_ms", "mean"
),
"replica_metrics_delay_mean_ms": get_stat(
health_metrics, "replica_metrics_delay_ms", "mean"
),
"process_memory_mb": health_metrics.get("process_memory_mb", 0),
}
async def _controller_wait_for_replicas_up(target: int, timeout: float = 300) -> float:
start = time.time()
while time.time() - start < timeout:
actual = await _controller_get_replica_count()
if actual >= target:
return time.time() - start
if int(time.time() - start) % 10 == 0:
logging.info(f"Waiting for {target} replicas... {actual}/{target}")
await asyncio.sleep(0.5)
actual = await _controller_get_replica_count()
raise RuntimeError(
f"Timeout: Only {actual}/{target} replicas after {timeout}s. Ending experiment."
)
async def _controller_wait_for_waiters(
signal_actor, expected: int, timeout: float = 300
) -> float:
start = time.time()
while time.time() - start < timeout:
num_waiters = await signal_actor.cur_num_waiters.remote()
if num_waiters >= expected:
return time.time() - start
await asyncio.sleep(0.5)
if int(time.time() - start) % 10 == 0:
logging.info(f"Waiting for {expected} waiters... {num_waiters}/{expected}")
num_waiters = await signal_actor.cur_num_waiters.remote()
raise RuntimeError(
f"Timeout: Only {num_waiters}/{expected} requests reached replicas after "
f"{timeout}s. Ending experiment."
)
async def _controller_wait_for_deployment_healthy(
deployment_name: str = "ControllerBenchMetricsGenerator",
app_name: str = "default",
timeout: float = 60,
) -> None:
"""Wait for the deployment to enter HEALTHY status via serve.status()."""
start = time.time()
while time.time() - start < timeout:
status = serve.status()
app = status.applications.get(app_name)
dep_status = None
if app and deployment_name in app.deployments:
dep = app.deployments[deployment_name]
dep_status = dep.status
if dep_status == DeploymentStatus.HEALTHY:
return
if dep_status == DeploymentStatus.UNHEALTHY:
raise RuntimeError(
f"Deployment {deployment_name} is UNHEALTHY: {getattr(dep, 'message', '')}"
)
if int(time.time() - start) % 10 == 0:
logging.info(
f"Waiting for {deployment_name} to be healthy, current: {dep_status}."
)
await asyncio.sleep(0.5)
raise RuntimeError(
f"Deployment {deployment_name} did not become HEALTHY after {timeout}s."
)
_BATCH_SIZE = 64
async def _controller_run_checkpoint(
handle: DeploymentHandle,
signal_actor,
checkpoint: int,
target_replicas: int,
marination_period_s: int,
sample_interval_s: int,
) -> List[Dict[str, Any]]:
"""Run a single checkpoint and collect metrics."""
start_time = time.time()
num_requests = int(target_replicas)
pending_requests: List[Any] = []
pending_requests.extend([handle.remote() for _ in range(num_requests)])
logging.info(f"Waiting for {num_requests} requests to be up...")
await _controller_wait_for_waiters(
signal_actor, len(pending_requests), timeout=_CONTROLLER_WAITER_TIMEOUT_S
)
logging.info(f"Waiting for {target_replicas} replicas to be up...")
# TODO: This is a hack to allow for some tolerance in the number of replicas.
# This is because the controller may not scale exactly to the target number of replicas.
# This is a bug in the controller autoscaling metrics aggregation logic, needs
# to be investigated further.
# This has the potential to introduce noise in the results from this benchmark.
replica_tolerance = 0.8
await _controller_wait_for_replicas_up(
int(target_replicas * replica_tolerance), timeout=_CONTROLLER_WAITER_TIMEOUT_S
)
logging.info(f"All {target_replicas} replicas are up.")
logging.info("Waiting for deployment to be healthy...")
await _controller_wait_for_deployment_healthy(timeout=_CONTROLLER_WAITER_TIMEOUT_S)
logging.info("Deployment is healthy.")
logging.info(f"Waiting for {marination_period_s} seconds to collect metrics...")
autoscale_duration_s = time.time() - start_time
samples = []
num_samples = marination_period_s // sample_interval_s
for sample_idx in range(num_samples):
health_metrics = await _controller_get_health_metrics()
actual_replicas = await _controller_get_replica_count()
num_nodes = _controller_get_active_nodes()
row = _controller_extract_metrics_row(
health_metrics=health_metrics,
checkpoint=checkpoint,
sample=sample_idx,
target_replicas=target_replicas,
actual_replicas=actual_replicas,
num_nodes=num_nodes,
autoscale_duration_s=autoscale_duration_s,
)
samples.append(row)
if sample_idx < num_samples - 1:
await asyncio.sleep(sample_interval_s)
await signal_actor.send.remote(clear=True)
try:
await asyncio.wait_for(
asyncio.gather(*pending_requests, return_exceptions=True),
timeout=30.0,
)
except asyncio.TimeoutError:
pass
return samples
async def run_controller_benchmark(
config: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
"""
Run the controller health metrics benchmark and return raw samples.
Uses MetricsGenerator (autoscaling) -> HelloWorld (fixed) -> SignalActor
to stress the controller as replicas scale. Fails if get_health_metrics
is unavailable.
Args:
config: Optional benchmark config (checkpoints, marination_period_s,
sample_interval_s). Uses CONTROLLER_BENCH_CONFIG if None.
Returns:
List of sample dicts (one per marination sample). Each sample has
target_replicas, autoscale_duration_s, loop_duration_mean_s,
loops_per_second, event_loop_delay_s, num_asyncio_tasks, etc.
Caller converts to perf_metrics via convert_controller_samples_to_perf_metrics.
"""
cfg = config or CONTROLLER_BENCH_CONFIG
checkpoints = cfg["checkpoints"]
marination_period_s = cfg["marination_period_s"]
sample_interval_s = cfg["sample_interval_s"]
if not ray.is_initialized():
ray.init()
signal_actor = _SignalActorForController.remote()
all_samples: List[Dict[str, Any]] = []
try:
for checkpoint_idx, target_replicas in enumerate(checkpoints):
hello_world = ControllerBenchHelloWorld.bind(signal_actor)
app = ControllerBenchMetricsGenerator.bind(hello_world)
handle = serve.run(app, name="default", route_prefix=None)
samples = await _controller_run_checkpoint(
handle=handle,
signal_actor=signal_actor,
checkpoint=checkpoint_idx,
target_replicas=target_replicas,
marination_period_s=marination_period_s,
sample_interval_s=sample_interval_s,
)
all_samples.extend(samples)
serve.shutdown()
finally:
serve.shutdown()
return all_samples
@@ -0,0 +1,39 @@
import time
import click
from ray import serve
from ray.serve._private.benchmarks.common import Benchmarker, Noop
from ray.serve.handle import DeploymentHandle
@click.command(help="Benchmark no-op DeploymentHandle latency.")
@click.option("--num-replicas", type=int, default=1)
@click.option("--num-requests", type=int, default=100)
@click.option(
"--mode",
type=click.Choice(["remote", "choose_dispatch"]),
default="remote",
help="Which call pattern to benchmark.",
)
def main(num_replicas: int, num_requests: int, mode: str):
h: DeploymentHandle = serve.run(
Benchmarker.bind(Noop.options(num_replicas=num_replicas).bind())
)
latencies = h.run_latency_benchmark.remote(
num_requests=num_requests, mode=mode
).result()
# Let the logs flush to avoid interwoven output.
time.sleep(1)
print(
f"Latency (ms) for noop DeploymentHandle requests via {mode!r} "
f"(num_replicas={num_replicas},num_requests={num_requests}):"
)
print(latencies.describe(percentiles=[0.5, 0.9, 0.95, 0.99]))
if __name__ == "__main__":
main()
@@ -0,0 +1,62 @@
import click
from ray import serve
from ray.serve._private.benchmarks.common import Benchmarker, Hello
from ray.serve.handle import DeploymentHandle
@click.command(help="Benchmark deployment handle throughput.")
@click.option(
"--batch-size",
type=int,
default=100,
help="Number of requests to send to downstream deployment in each trial.",
)
@click.option(
"--num-replicas",
type=int,
default=1,
help="Number of replicas in the downstream deployment.",
)
@click.option(
"--num-trials",
type=int,
default=5,
help="Number of trials of the benchmark to run.",
)
@click.option(
"--trial-runtime",
type=int,
default=1,
help="Duration to run each trial of the benchmark for (seconds).",
)
def main(
batch_size: int,
num_replicas: int,
num_trials: int,
trial_runtime: float,
):
app = Benchmarker.bind(
Hello.options(
num_replicas=num_replicas, ray_actor_options={"num_cpus": 0}
).bind(),
)
h: DeploymentHandle = serve.run(app)
mean, stddev = h.run_throughput_benchmark.remote(
batch_size=batch_size,
num_trials=num_trials,
trial_runtime=trial_runtime,
).result()
print(
"DeploymentHandle throughput {}: {} +- {} requests/s".format(
f"(num_replicas={num_replicas}, batch_size={batch_size})",
mean,
stddev,
)
)
if __name__ == "__main__":
main()
@@ -0,0 +1,32 @@
import asyncio
import click
import pandas as pd
import requests
from ray import serve
from ray.serve._private.benchmarks.common import Noop, run_latency_benchmark
@click.command(help="Benchmark no-op HTTP latency.")
@click.option("--num-replicas", type=int, default=1)
@click.option("--num-requests", type=int, default=100)
def main(num_replicas: int, num_requests: int):
serve.run(Noop.options(num_replicas=num_replicas).bind())
latencies: pd.Series = asyncio.new_event_loop().run_until_complete(
run_latency_benchmark(
lambda: requests.get("http://localhost:8000"),
num_requests=num_requests,
)
)
print(
"Latency (ms) for noop HTTP requests "
f"(num_replicas={num_replicas},num_requests={num_requests}):"
)
print(latencies.describe(percentiles=[0.5, 0.9, 0.95, 0.99]))
if __name__ == "__main__":
main()
@@ -0,0 +1,374 @@
import argparse
import logging
import time
from dataclasses import asdict, dataclass
from typing import Any, Dict, List, NamedTuple
from ray.serve._private.utils import generate_request_id
logger = logging.getLogger(__file__)
logging.basicConfig(level=logging.INFO)
MASTER_PORT = 5557
@dataclass
class LocustStage:
duration_s: int
users: int
spawn_rate: float
@dataclass
class PerformanceStats:
p50_latency: float
p90_latency: float
p99_latency: float
rps: float
@dataclass
class LocustTestResults:
history: List[Dict]
total_requests: int
num_failures: int
avg_latency: float
p50_latency: float
p90_latency: float
p99_latency: float
avg_rps: float
stats_in_stages: List[PerformanceStats]
@dataclass
class FailedRequest:
request_id: str
status_code: int
exception: str
response_time_ms: float
start_time_s: float
class LocustClient:
def __init__(
self,
host_url: str,
token: str,
data: Dict[str, Any] = None,
):
from locust import FastHttpUser, constant, events, task
from locust.contrib.fasthttp import FastResponse
self.errors = []
self.stats_in_stages: List[PerformanceStats] = []
class EndpointUser(FastHttpUser):
wait_time = constant(0)
failed_requests = []
host = host_url
@task
def test(self):
request_id = generate_request_id()
headers = (
{"Authorization": f"Bearer {token}", "X-Request-ID": request_id}
if token
else None
)
start = time.perf_counter()
with self.client.get(
"", headers=headers, json=data, catch_response=True
) as r:
# locust<=2.18 FastHttp truncates response_time to whole ms;
# re-measure so the 0.1ms buckets see sub-ms differences.
r.request_meta["response_time"] = (
time.perf_counter() - start
) * 1000
r.request_meta["context"]["request_id"] = request_id
@events.request.add_listener
def on_request(
response: FastResponse,
exception,
context,
start_time: float,
response_time: float,
**kwargs,
):
if exception and response.status_code != 0:
request_id = context["request_id"]
print(
f"Request '{request_id}' failed with exception:\n"
f"{exception}\n{response.text}"
)
if response.status_code != 0:
response.encoding = "utf-8"
err = FailedRequest(
request_id=request_id,
status_code=response.status_code,
exception=response.text,
response_time_ms=response_time,
start_time_s=start_time,
)
self.errors.append(err)
print(
f"Request '{request_id}' failed with exception:\n"
f"{exception}\n{response.text}"
)
self.user_class = EndpointUser
class ResponseTimeSnapshot(NamedTuple):
# Cumulative {rounded_response_time: count} histogram + request count.
response_times: Dict[float, int]
num_requests: int
def _fine_bucket_response_time(response_time):
"""0.1ms resolution below 100ms (vs locust's 1ms floor), coarser above."""
if response_time < 100:
return round(response_time, 1)
elif response_time < 1000:
return round(response_time)
else:
return int(round(response_time, -1))
def _install_fine_response_time_bucketing():
"""Swap in the finer bucketer; must run in every response-logging process.
Released locust (through at least 2.41) inlines the rounding in
StatsEntry._log_response_time, so patching requires overriding the whole
method. Unreleased locust factors it into stats.bucket_response_time."""
import locust.stats
if hasattr(locust.stats, "bucket_response_time"):
locust.stats.bucket_response_time = _fine_bucket_response_time
return
def _log_response_time(self, response_time):
# Copy of locust 2.x StatsEntry._log_response_time with the inline
# rounding replaced by the fine bucketer.
if response_time is None:
self.num_none_requests += 1
return
self.total_response_time += response_time
if self.min_response_time is None:
self.min_response_time = response_time
self.min_response_time = min(self.min_response_time, response_time)
self.max_response_time = max(self.max_response_time, response_time)
rounded_response_time = _fine_bucket_response_time(response_time)
# setdefault keeps this compatible with both the plain dict (<=2.18)
# and defaultdict (>=2.33) versions of response_times.
self.response_times.setdefault(rounded_response_time, 0)
self.response_times[rounded_response_time] += 1
locust.stats.StatsEntry._log_response_time = _log_response_time
def on_stage_finished(master_runner, stats_in_stages, stage_duration_s, prev_snapshot):
"""Per-stage stats by differencing cumulative snapshots; returns the
snapshot to seed the next stage. Percentiles use locust's own
calculate_response_time_percentile so they match its end-of-test report."""
from locust.stats import (
calculate_response_time_percentile,
diff_response_time_dicts,
)
stats_entry = master_runner.stats.entries.get(("", "GET"))
snapshot = ResponseTimeSnapshot(
dict(stats_entry.response_times), stats_entry.num_requests
)
stage_hist = diff_response_time_dicts(
snapshot.response_times, prev_snapshot.response_times
)
stage_requests = snapshot.num_requests - prev_snapshot.num_requests
stats_in_stages.append(
PerformanceStats(
p50_latency=calculate_response_time_percentile(
stage_hist, stage_requests, 0.5
),
p90_latency=calculate_response_time_percentile(
stage_hist, stage_requests, 0.9
),
p99_latency=calculate_response_time_percentile(
stage_hist, stage_requests, 0.99
),
rps=stage_requests / stage_duration_s if stage_duration_s else 0.0,
)
)
return snapshot
def run_locust_worker(
master_address: str, host_url: str, token: str, data: Dict[str, Any]
):
import locust
from locust.env import Environment
from locust.log import setup_logging
setup_logging("INFO")
# Workers log response times, so the finer bucketer must be installed here.
_install_fine_response_time_bucketing()
client = LocustClient(host_url=host_url, token=token, data=data)
env = Environment(user_classes=[client.user_class], events=locust.events)
runner = env.create_worker_runner(
master_host=master_address, master_port=MASTER_PORT
)
runner.greenlet.join()
if client.errors:
raise RuntimeError(f"There were {len(client.errors)} errors: {client.errors}")
def run_locust_master(
host_url: str,
token: str,
expected_num_workers: int,
stages: List[LocustStage],
wait_for_workers_timeout_s: float,
):
import gevent
import locust
from locust import LoadTestShape
from locust.env import Environment
from locust.stats import (
get_error_report_summary,
get_percentile_stats_summary,
get_stats_summary,
stats_history,
stats_printer,
)
_install_fine_response_time_bucketing()
client = LocustClient(host_url, token)
class StagesShape(LoadTestShape):
curr_stage_ix = 0
# Cumulative response-time snapshot at the start of the current stage;
# on_stage_finished diffs against it to get per-stage stats.
prev_snapshot = ResponseTimeSnapshot({}, 0)
def tick(cls):
run_time = cls.get_run_time()
prefix_time = 0
for i, stage in enumerate(stages):
prefix_time += stage.duration_s
if run_time < prefix_time:
if i != cls.curr_stage_ix:
cls.prev_snapshot = on_stage_finished(
master_runner,
client.stats_in_stages,
stages[cls.curr_stage_ix].duration_s,
cls.prev_snapshot,
)
cls.curr_stage_ix = i
current_stage = stages[cls.curr_stage_ix]
return current_stage.users, current_stage.spawn_rate
# End of stage test
cls.prev_snapshot = on_stage_finished(
master_runner,
client.stats_in_stages,
stages[cls.curr_stage_ix].duration_s,
cls.prev_snapshot,
)
master_env = Environment(
user_classes=[client.user_class],
shape_class=StagesShape(),
events=locust.events,
)
master_runner = master_env.create_master_runner("*", MASTER_PORT)
start = time.time()
while len(master_runner.clients.ready) < expected_num_workers:
if time.time() - start > wait_for_workers_timeout_s:
raise RuntimeError(
f"Timed out waiting for {expected_num_workers} workers to "
"connect to Locust master."
)
print(
f"Waiting for workers to be ready, "
f"{len(master_runner.clients.ready)} "
f"of {expected_num_workers} ready."
)
time.sleep(1)
# Periodically output current stats (each entry is aggregated
# stats over the past 10 seconds, by default)
gevent.spawn(stats_printer(master_env.stats))
gevent.spawn(stats_history, master_runner)
# Start test & wait for the shape test to finish
master_runner.start_shape()
master_runner.shape_greenlet.join()
# Send quit signal to all locust workers
master_runner.quit()
# Print stats
for line in get_stats_summary(master_runner.stats, current=False):
print(line)
# Print percentile stats
for line in get_percentile_stats_summary(master_runner.stats):
print(line)
# Print error report
if master_runner.stats.errors:
for line in get_error_report_summary(master_runner.stats):
print(line)
stats_entry_key = ("", "GET")
stats_entry = master_runner.stats.entries.get(stats_entry_key)
results = LocustTestResults(
history=master_runner.stats.history,
total_requests=master_runner.stats.num_requests,
num_failures=master_runner.stats.num_failures,
avg_latency=stats_entry.avg_response_time,
p50_latency=stats_entry.get_response_time_percentile(0.5),
p90_latency=stats_entry.get_response_time_percentile(0.9),
p99_latency=stats_entry.get_response_time_percentile(0.99),
avg_rps=stats_entry.total_rps,
stats_in_stages=client.stats_in_stages,
)
return asdict(results)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--worker-type", type=str, required=True)
parser.add_argument("--host-url", type=str, required=True)
parser.add_argument("--token", type=str, required=True)
parser.add_argument("--master-address", type=str, required=False)
parser.add_argument("--expected-num-workers", type=int, required=False)
parser.add_argument("--stages", type=str, required=False)
parser.add_argument("--wait-for-workers-timeout-s", type=float, required=False)
args = parser.parse_args()
host_url = args.host_url
token = args.token
if args.worker_type == "master":
results = run_locust_master(
host_url,
token,
args.expected_num_workers,
args.stages,
args.wait_for_workers_timeout_s,
)
else:
results = run_locust_worker(args.master_address, host_url, token, args.data)
print(results)
if __name__ == "__main__":
main()
@@ -0,0 +1,182 @@
# Runs several scenarios with varying max batch size, max concurrent queries,
# number of replicas, and with intermediate serve handles (to simulate ensemble
# models) either on or off.
import asyncio
import logging
from pprint import pprint
from typing import Dict, Union
import aiohttp
from starlette.requests import Request
import ray
from ray import serve
from ray.serve._private.benchmarks.common import run_throughput_benchmark
from ray.serve.handle import DeploymentHandle
NUM_CLIENTS = 8
CALLS_PER_BATCH = 100
async def fetch(session, data):
async with session.get("http://localhost:8000/", data=data) as response:
response = await response.text()
assert response == "ok", response
@ray.remote
class Client:
def ready(self):
return "ok"
async def do_queries(self, num, data):
async with aiohttp.ClientSession() as session:
for _ in range(num):
await fetch(session, data)
def build_app(
intermediate_handles: bool,
num_replicas: int,
max_batch_size: int,
max_ongoing_requests: int,
):
@serve.deployment(max_ongoing_requests=1000)
class Upstream:
def __init__(self, handle: DeploymentHandle):
self._handle = handle
# Turn off access log.
logging.getLogger("ray.serve").setLevel(logging.WARNING)
async def __call__(self, req: Request):
return await self._handle.remote(await req.body())
@serve.deployment(
num_replicas=num_replicas,
max_ongoing_requests=max_ongoing_requests,
)
class Downstream:
def __init__(self):
# Turn off access log.
logging.getLogger("ray.serve").setLevel(logging.WARNING)
@serve.batch(max_batch_size=max_batch_size)
async def batch(self, reqs):
return [b"ok"] * len(reqs)
async def __call__(self, req: Union[bytes, Request]):
if max_batch_size > 1:
return await self.batch(req)
else:
return b"ok"
if intermediate_handles:
return Upstream.bind(Downstream.bind())
else:
return Downstream.bind()
async def trial(
intermediate_handles: bool,
num_replicas: int,
max_batch_size: int,
max_ongoing_requests: int,
data_size: str,
) -> Dict[str, float]:
results = {}
trial_key_base = (
f"replica:{num_replicas}/batch_size:{max_batch_size}/"
f"concurrent_queries:{max_ongoing_requests}/"
f"data_size:{data_size}/intermediate_handle:{intermediate_handles}"
)
print(
f"intermediate_handles={intermediate_handles},"
f"num_replicas={num_replicas},"
f"max_batch_size={max_batch_size},"
f"max_ongoing_requests={max_ongoing_requests},"
f"data_size={data_size}"
)
app = build_app(
intermediate_handles, num_replicas, max_batch_size, max_ongoing_requests
)
serve.run(app)
if data_size == "small":
data = None
elif data_size == "large":
data = b"a" * 1024 * 1024
else:
raise ValueError("data_size should be 'small' or 'large'.")
async with aiohttp.ClientSession() as session:
async def single_client():
for _ in range(CALLS_PER_BATCH):
await fetch(session, data)
single_client_avg_tps, single_client_std_tps = await run_throughput_benchmark(
single_client,
multiplier=CALLS_PER_BATCH,
)
print(
"\t{} {} +- {} requests/s".format(
"single client {} data".format(data_size),
single_client_avg_tps,
single_client_std_tps,
)
)
key = f"num_client:1/{trial_key_base}"
results[key] = single_client_avg_tps
clients = [Client.remote() for _ in range(NUM_CLIENTS)]
ray.get([client.ready.remote() for client in clients])
async def many_clients():
ray.get([a.do_queries.remote(CALLS_PER_BATCH, data) for a in clients])
multi_client_avg_tps, _ = await run_throughput_benchmark(
many_clients,
multiplier=CALLS_PER_BATCH * len(clients),
)
results[f"num_client:{len(clients)}/{trial_key_base}"] = multi_client_avg_tps
return results
async def main():
results = {}
for intermediate_handles in [False, True]:
for num_replicas in [1, 8]:
for max_batch_size, max_ongoing_requests in [
(1, 1),
(1, 10000),
(10000, 10000),
]:
# TODO(edoakes): large data causes broken pipe errors.
for data_size in ["small"]:
results.update(
await trial(
intermediate_handles,
num_replicas,
max_batch_size,
max_ongoing_requests,
data_size,
)
)
print("Results from all conditions:")
pprint(results)
return results
if __name__ == "__main__":
ray.init()
serve.start()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(main())
@@ -0,0 +1,294 @@
# Runs some request ping to compare HTTP and gRPC performances in TPS and latency.
# Note: this takes around 1 hour to run.
import asyncio
import json
import logging
import time
from random import random
from typing import Callable, Dict
import aiohttp
import numpy as np
import pandas as pd
from grpc import aio
from starlette.requests import Request
import ray
from ray import serve
from ray.serve._private.common import RequestProtocol
from ray.serve.config import gRPCOptions
from ray.serve.generated import serve_pb2, serve_pb2_grpc
from ray.serve.handle import DeploymentHandle
CALLS_PER_BATCH = 100
DELTA = 10**-7
async def get_query_tps(name: str, fn: Callable, multiplier: int = CALLS_PER_BATCH):
"""Get query TPS.
Run the function for 0.5 seconds 10 times to calculate how many requests can
be completed. And use those stats to calculate the mean and std of TPS.
"""
# warmup
start = time.time()
while time.time() - start < 0.1:
await fn()
# real run
stats = []
for _ in range(10):
count = 0
start = time.time()
while time.time() - start < 0.5:
await fn()
count += 1
end = time.time()
stats.append(multiplier * count / (end - start))
tps_mean = round(np.mean(stats), 2)
tps_std = round(np.std(stats), 2)
print(f"\t{name} {tps_mean} +- {tps_std} requests/s")
return tps_mean, tps_std
async def get_query_latencies(name: str, fn: Callable):
"""Get query latencies.
Take all the latencies from the function and calculate the mean and std.
"""
many_client_results = np.asarray(await fn())
many_client_results.flatten()
latency_ms_mean = round(np.mean(many_client_results) * 1000, 2)
latency_ms_std = round(np.std(many_client_results) * 1000, 2)
print(f"\t{name} {latency_ms_mean} +- {latency_ms_std} ms")
return latency_ms_mean, latency_ms_std
async def fetch_http(session, data):
data_json = {"nums": data}
response = await session.get("http://localhost:8000/", json=data_json)
response_text = await response.read()
float(response_text.decode())
async def fetch_grpc(stub, data):
result = await stub.grpc_call(serve_pb2.RawData(nums=data))
_ = result.output
@ray.remote
class HTTPClient:
def ready(self):
return "ok"
async def do_queries(self, num, data):
async with aiohttp.ClientSession() as session:
for _ in range(num):
await fetch_http(session, data)
async def time_queries(self, num, data):
stats = []
async with aiohttp.ClientSession() as session:
for _ in range(num):
start = time.time()
await fetch_http(session, data)
end = time.time()
stats.append(end - start)
return stats
@ray.remote
class gRPCClient:
def __init__(self):
channel = aio.insecure_channel("localhost:9000")
self.stub = serve_pb2_grpc.RayServeBenchmarkServiceStub(channel)
def ready(self):
return "ok"
async def do_queries(self, num, data):
for _ in range(num):
await fetch_grpc(self.stub, data)
async def time_queries(self, num, data):
stats = []
for _ in range(num):
start = time.time()
await fetch_grpc(self.stub, data)
end = time.time()
stats.append(end - start)
return stats
def build_app(
num_replicas: int,
max_ongoing_requests: int,
data_size: int,
):
@serve.deployment(max_ongoing_requests=1000)
class DataPreprocessing:
def __init__(self, handle: DeploymentHandle):
self._handle = handle
# Turn off access log.
logging.getLogger("ray.serve").setLevel(logging.WARNING)
def normalize(self, raw: np.ndarray) -> np.ndarray:
return (raw - np.min(raw)) / (np.max(raw) - np.min(raw) + DELTA)
async def __call__(self, req: Request):
"""HTTP entrypoint.
It parses the request, normalize the data, and send to model for inference.
"""
body = json.loads(await req.body())
raw = np.asarray(body["nums"])
processed = self.normalize(raw)
return await self._handle.remote(processed)
async def grpc_call(self, raq_data):
"""gRPC entrypoint.
It parses the request, normalize the data, and send to model for inference.
"""
raw = np.asarray(raq_data.nums)
processed = self.normalize(raw)
output = await self._handle.remote(processed)
return serve_pb2.ModelOutput(output=output)
async def call_with_string(self, raq_data):
"""gRPC entrypoint."""
return serve_pb2.ModelOutput(output=0)
@serve.deployment(
num_replicas=num_replicas,
max_ongoing_requests=max_ongoing_requests,
)
class ModelInference:
def __init__(self):
# Turn off access log.
logging.getLogger("ray.serve").setLevel(logging.WARNING)
self._model = np.random.randn(data_size, data_size)
async def __call__(self, processed: np.ndarray) -> float:
# Run a dot product with a random matrix to simulate a model inference.
model_output = np.dot(processed, self._model)
return sum(model_output)
return DataPreprocessing.bind(ModelInference.bind())
async def trial(
num_replicas: int,
max_ongoing_requests: int,
data_size: int,
num_clients: int,
proxy: RequestProtocol,
) -> Dict[str, float]:
# Generate input data as array of random floats.
data = [random() for _ in range(data_size)]
# Build and deploy the app.
app = build_app(
num_replicas=num_replicas,
max_ongoing_requests=max_ongoing_requests,
data_size=data_size,
)
serve.run(app)
# Start clients.
if proxy == RequestProtocol.GRPC:
clients = [gRPCClient.remote() for _ in range(num_clients)]
elif proxy == RequestProtocol.HTTP:
clients = [HTTPClient.remote() for _ in range(num_clients)]
ray.get([client.ready.remote() for client in clients])
async def client_time_queries():
return ray.get([a.time_queries.remote(CALLS_PER_BATCH, data) for a in clients])
async def client_do_queries():
ray.get([a.do_queries.remote(CALLS_PER_BATCH, data) for a in clients])
trial_key_base = (
f"proxy:{proxy}/"
f"num_client:{num_clients}/"
f"replica:{num_replicas}/"
f"concurrent_queries:{max_ongoing_requests}/"
f"data_size:{data_size}"
)
tps_mean, tps_sdt = await get_query_tps(
trial_key_base,
client_do_queries,
)
latency_ms_mean, latency_ms_std = await get_query_latencies(
trial_key_base,
client_time_queries,
)
results = {
"proxy": proxy.value,
"num_client": num_clients,
"replica": num_replicas,
"concurrent_queries": max_ongoing_requests,
"data_size": data_size,
"tps_mean": tps_mean,
"tps_sdt": tps_sdt,
"latency_ms_mean": latency_ms_mean,
"latency_ms_std": latency_ms_std,
}
return results
async def main():
start_time = time.time()
results = []
for num_replicas in [1, 8]:
for max_ongoing_requests in [1, 10_000]:
for data_size in [1, 100, 10_000]:
for num_clients in [1, 8]:
for proxy in [RequestProtocol.GRPC, RequestProtocol.HTTP]:
results.append(
await trial(
num_replicas=num_replicas,
max_ongoing_requests=max_ongoing_requests,
data_size=data_size,
num_clients=num_clients,
proxy=proxy,
)
)
print(f"Total time: {time.time() - start_time}s")
print("results", results)
df = pd.DataFrame.from_dict(results)
df = df.sort_values(
by=["proxy", "num_client", "replica", "concurrent_queries", "data_size"]
)
print("Results from all conditions:")
# Print the results in with tab separated so we can copy into google sheets.
for i in range(len(df.index)):
row = list(df.iloc[i])
print("\t".join(map(str, row)))
if __name__ == "__main__":
ray.init()
grpc_port = 9000
grpc_servicer_functions = [
"ray.serve.generated.serve_pb2_grpc."
"add_RayServeBenchmarkServiceServicer_to_server",
]
serve.start(
grpc_options=gRPCOptions(
port=grpc_port,
grpc_servicer_functions=grpc_servicer_functions,
)
)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(main())
@@ -0,0 +1,29 @@
from dataclasses import dataclass
from typing import List, Optional
from pydantic import BaseModel
#
# NOTE: PLEASE READ CAREFULLY BEFORE CHANGING
#
# Payloads in this module are purposefully extracted from benchmark file to force
# Ray's cloudpickle behavior when it does NOT serialize the class definition itself
# along with its payload (instead relying on it being imported)
#
class PayloadPydantic(BaseModel):
text: Optional[str] = None
floats: Optional[List[float]] = None
ints: Optional[List[int]] = None
ts: Optional[float] = None
reason: Optional[str] = None
@dataclass
class PayloadDataclass:
text: Optional[str] = None
floats: Optional[List[float]] = None
ints: Optional[List[int]] = None
ts: Optional[float] = None
reason: Optional[str] = None
@@ -0,0 +1,60 @@
import grpc
from ray.serve._private.benchmarks.streaming._grpc import (
test_server_pb2,
test_server_pb2_grpc,
)
async def _async_list(async_iterator):
items = []
async for item in async_iterator:
items.append(item)
return items
class TestGRPCServer(test_server_pb2_grpc.GRPCTestServerServicer):
def __init__(self, tokens_per_request):
self._tokens_per_request = tokens_per_request
async def Unary(self, request, context):
if request.request_data == "error":
await context.abort(
code=grpc.StatusCode.INTERNAL,
details="unary rpc error",
)
return test_server_pb2.Response(response_data="OK")
async def ClientStreaming(self, request_iterator, context):
data = await _async_list(request_iterator)
if data and data[0].request_data == "error":
await context.abort(
code=grpc.StatusCode.INTERNAL,
details="client streaming rpc error",
)
return test_server_pb2.Response(response_data="OK")
async def ServerStreaming(self, request, context):
if request.request_data == "error":
await context.abort(
code=grpc.StatusCode.INTERNAL,
details="OK",
)
for i in range(self._tokens_per_request):
yield test_server_pb2.Response(response_data="OK")
async def BidiStreaming(self, request_iterator, context):
data = await _async_list(request_iterator)
if data and data[0].request_data == "error":
await context.abort(
code=grpc.StatusCode.INTERNAL,
details="bidi-streaming rpc error",
)
for i in range(self._tokens_per_request):
yield test_server_pb2.Response(response_data="OK")
@@ -0,0 +1,16 @@
syntax = "proto3";
message Request {
string request_data = 2;
}
message Response {
string response_data = 2;
}
service GRPCTestServer {
rpc Unary(Request) returns (Response);
rpc ClientStreaming(stream Request) returns (Response);
rpc ServerStreaming(Request) returns (stream Response);
rpc BidiStreaming(stream Request) returns (stream Response);
}
@@ -0,0 +1,216 @@
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
from ray.serve._private.benchmarks.streaming._grpc import (
test_server_pb2 as backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2,
)
class GRPCTestServerStub(object):
"""Missing associated documentation comment in .proto file."""
def __init__(self, channel: grpc.Channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.Unary = channel.unary_unary(
"/GRPCTestServer/Unary",
request_serializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.SerializeToString,
response_deserializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.FromString,
)
self.ClientStreaming = channel.stream_unary(
"/GRPCTestServer/ClientStreaming",
request_serializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.SerializeToString,
response_deserializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.FromString,
)
self.ServerStreaming = channel.unary_stream(
"/GRPCTestServer/ServerStreaming",
request_serializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.SerializeToString,
response_deserializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.FromString,
)
self.BidiStreaming = channel.stream_stream(
"/GRPCTestServer/BidiStreaming",
request_serializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.SerializeToString,
response_deserializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.FromString,
)
class GRPCTestServerServicer(object):
"""Missing associated documentation comment in .proto file."""
def Unary(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def ClientStreaming(self, request_iterator, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def ServerStreaming(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def BidiStreaming(self, request_iterator, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details("Method not implemented!")
raise NotImplementedError("Method not implemented!")
def add_GRPCTestServerServicer_to_server(servicer, server):
rpc_method_handlers = {
"Unary": grpc.unary_unary_rpc_method_handler(
servicer.Unary,
request_deserializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.FromString,
response_serializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.SerializeToString,
),
"ClientStreaming": grpc.stream_unary_rpc_method_handler(
servicer.ClientStreaming,
request_deserializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.FromString,
response_serializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.SerializeToString,
),
"ServerStreaming": grpc.unary_stream_rpc_method_handler(
servicer.ServerStreaming,
request_deserializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.FromString,
response_serializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.SerializeToString,
),
"BidiStreaming": grpc.stream_stream_rpc_method_handler(
servicer.BidiStreaming,
request_deserializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.FromString,
response_serializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
"GRPCTestServer", rpc_method_handlers
)
server.add_generic_rpc_handlers((generic_handler,))
# This class is part of an EXPERIMENTAL API.
class GRPCTestServer(object):
"""Missing associated documentation comment in .proto file."""
@staticmethod
def Unary(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_unary(
request,
target,
"/GRPCTestServer/Unary",
backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.SerializeToString,
backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def ClientStreaming(
request_iterator,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.stream_unary(
request_iterator,
target,
"/GRPCTestServer/ClientStreaming",
backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.SerializeToString,
backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def ServerStreaming(
request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.unary_stream(
request,
target,
"/GRPCTestServer/ServerStreaming",
backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.SerializeToString,
backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@staticmethod
def BidiStreaming(
request_iterator,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None,
):
return grpc.experimental.stream_stream(
request_iterator,
target,
"/GRPCTestServer/BidiStreaming",
backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.SerializeToString,
backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
)
@@ -0,0 +1,123 @@
import abc
import asyncio
import enum
import logging
import time
from typing import Tuple, Union
import numpy as np
from ray.actor import ActorHandle
from ray.runtime_env import RuntimeEnv
from ray.serve._private.benchmarks.common import Blackhole, run_throughput_benchmark
from ray.serve._private.benchmarks.serialization.common import PayloadPydantic
from ray.serve.handle import DeploymentHandle
GRPC_DEBUG_RUNTIME_ENV = RuntimeEnv(
env_vars={"GRPC_TRACE": "http", "GRPC_VERBOSITY": "debug"},
)
class IOMode(enum.Enum):
SYNC = "SYNC"
ASYNC = "ASYNC"
class Endpoint:
def __init__(self, tokens_per_request: int):
self._tokens_per_request = tokens_per_request
# Switch off logging to minimize its impact
logging.getLogger("ray").setLevel(logging.WARNING)
logging.getLogger("ray.serve").setLevel(logging.WARNING)
def stream(self):
payload = PayloadPydantic(
text="Test output",
floats=[float(f) for f in range(1, 100)],
ints=list(range(1, 100)),
ts=time.time(),
reason="Success!",
)
for i in range(self._tokens_per_request):
yield payload
async def aio_stream(self):
payload = PayloadPydantic(
text="Test output",
floats=[float(f) for f in range(1, 100)],
ints=list(range(1, 100)),
ts=time.time(),
reason="Success!",
)
for i in range(self._tokens_per_request):
yield payload
class Caller(Blackhole):
def __init__(
self,
downstream: Union[ActorHandle, DeploymentHandle],
*,
mode: IOMode,
tokens_per_request: int,
batch_size: int,
num_trials: int,
trial_runtime: float,
):
self._h = downstream
self._mode = mode
self._tokens_per_request = tokens_per_request
self._batch_size = batch_size
self._num_trials = num_trials
self._trial_runtime = trial_runtime
self._durations = []
# Switch off logging to minimize its impact
logging.getLogger("ray").setLevel(logging.WARNING)
logging.getLogger("ray.serve").setLevel(logging.WARNING)
def _get_remote_method(self):
if self._mode == IOMode.SYNC:
return self._h.stream
elif self._mode == IOMode.ASYNC:
return self._h.aio_stream
else:
raise NotImplementedError(f"Streaming mode not supported ({self._mode})")
@abc.abstractmethod
async def _consume_single_stream(self):
pass
async def _do_single_batch(self):
durations = await asyncio.gather(
*[
self._execute(self._consume_single_stream)
for _ in range(self._batch_size)
]
)
self._durations.extend(durations)
async def _execute(self, fn):
start = time.monotonic()
await fn()
dur_s = time.monotonic() - start
return dur_s * 1000 # ms
async def run_benchmark(self) -> Tuple[float, float]:
coro = run_throughput_benchmark(
fn=self._do_single_batch,
multiplier=self._batch_size * self._tokens_per_request,
num_trials=self._num_trials,
trial_runtime=self._trial_runtime,
)
# total_runtime = await collect_profile_events(coro)
total_runtime = await coro
p50, p75, p99 = np.percentile(self._durations, [50, 75, 99])
print(f"Individual request quantiles:\n\tP50={p50}\n\tP75={p75}\n\tP99={p99}")
return total_runtime
@@ -0,0 +1,95 @@
import click
import ray
from ray.serve._private.benchmarks.streaming.common import Caller, Endpoint, IOMode
# @ray.remote(runtime_env=GRPC_DEBUG_RUNTIME_ENV)
@ray.remote
class EndpointActor(Endpoint):
pass
# @ray.remote(runtime_env=GRPC_DEBUG_RUNTIME_ENV)
@ray.remote
class CallerActor(Caller):
async def _consume_single_stream(self):
method = self._get_remote_method()
async for ref in method.options(num_returns="streaming").remote():
r = ray.get(ref)
# self.sink(str(r, 'utf-8'))
self.sink(r)
@click.command(help="Benchmark streaming deployment handle throughput.")
@click.option(
"--tokens-per-request",
type=int,
default=1000,
help="Number of tokens (per request) to stream from downstream deployment",
)
@click.option(
"--batch-size",
type=int,
default=10,
help="Number of requests to send to downstream deployment in each batch.",
)
@click.option(
"--num-replicas",
type=int,
default=1,
help="Number of replicas in the downstream deployment.",
)
@click.option(
"--num-trials",
type=int,
default=5,
help="Number of trials of the benchmark to run.",
)
@click.option(
"--trial-runtime",
type=int,
default=5,
help="Duration to run each trial of the benchmark for (seconds).",
)
@click.option(
"--io-mode",
type=str,
default="async",
help="Controls mode of the streaming generation (either 'sync' or 'async')",
)
def main(
tokens_per_request: int,
batch_size: int,
num_replicas: int,
num_trials: int,
trial_runtime: float,
io_mode: str,
):
h = CallerActor.remote(
EndpointActor.remote(
tokens_per_request=tokens_per_request,
),
mode=IOMode(io_mode.upper()),
tokens_per_request=tokens_per_request,
batch_size=batch_size,
num_trials=num_trials,
trial_runtime=trial_runtime,
)
mean, stddev = ray.get(h.run_benchmark.remote())
print(
"Core Actors streaming throughput ({}) {}: {} +- {} tokens/s".format(
io_mode.upper(),
f"(num_replicas={num_replicas}, "
f"tokens_per_request={tokens_per_request}, "
f"batch_size={batch_size})",
mean,
stddev,
)
)
if __name__ == "__main__":
main()
@@ -0,0 +1,218 @@
import asyncio
import logging
import time
from concurrent import futures
from tempfile import TemporaryDirectory
import click
import grpc
import ray
from ray.serve._private.benchmarks.streaming._grpc import (
test_server_pb2,
test_server_pb2_grpc,
)
from ray.serve._private.benchmarks.streaming._grpc.grpc_server import TestGRPCServer
from ray.serve._private.benchmarks.streaming.common import Caller, IOMode
# @ray.remote(runtime_env=GRPC_DEBUG_RUNTIME_ENV)
@ray.remote
class EndpointActor:
async def __init__(self, tokens_per_request, socket_type, tempdir):
# Switch off logging to minimize its impact
logging.getLogger("ray").setLevel(logging.WARNING)
logging.getLogger("ray.serve").setLevel(logging.WARNING)
self.server = await self.start_server(tokens_per_request, socket_type, tempdir)
print("gRPC server started!")
@staticmethod
async def start_server(tokens_per_request, socket_type, tempdir):
server = grpc.aio.server(futures.ThreadPoolExecutor(max_workers=1))
addr, server_creds, _ = _gen_addr_creds(socket_type, tempdir)
server.add_secure_port(addr, server_creds)
await server.start()
test_server_pb2_grpc.add_GRPCTestServerServicer_to_server(
TestGRPCServer(tokens_per_request), server
)
return server
# @ray.remote(runtime_env=GRPC_DEBUG_RUNTIME_ENV)
@ray.remote
class GrpcCallerActor(Caller):
def __init__(
self,
tempdir,
socket_type,
*,
mode: IOMode,
tokens_per_request: int,
batch_size: int,
num_trials: int,
trial_runtime: float,
):
super().__init__(
self.create_downstream(socket_type, tempdir),
mode=mode,
tokens_per_request=tokens_per_request,
batch_size=batch_size,
num_trials=num_trials,
trial_runtime=trial_runtime,
)
@staticmethod
def create_downstream(socket_type, tempdir):
addr, _, channel_creds = _gen_addr_creds(socket_type, tempdir)
channel = grpc.aio.secure_channel(
addr, credentials=channel_creds, interceptors=[]
)
return test_server_pb2_grpc.GRPCTestServerStub(channel)
async def _consume_single_stream(self):
try:
async for r in self._h.ServerStreaming(test_server_pb2.Request()):
self.sink(r)
except Exception as e:
print(str(e))
def _gen_addr_creds(socket_type, tempdir):
if socket_type == "uds":
addr = f"unix://{tempdir}/server.sock"
server_creds = grpc.local_server_credentials(grpc.LocalConnectionType.UDS)
channel_creds = grpc.local_channel_credentials(grpc.LocalConnectionType.UDS)
elif socket_type == "local_tcp":
addr = "127.0.0.1:5432"
server_creds = grpc.local_server_credentials(grpc.LocalConnectionType.LOCAL_TCP)
channel_creds = grpc.local_channel_credentials(
grpc.LocalConnectionType.LOCAL_TCP
)
else:
raise NotImplementedError(f"Not supported socket type ({socket_type})")
return addr, server_creds, channel_creds
async def run_grpc_benchmark(
batch_size,
io_mode,
socket_type,
num_replicas,
num_trials,
tokens_per_request,
trial_runtime,
):
with TemporaryDirectory() as tempdir:
_ = EndpointActor.remote(
tokens_per_request=tokens_per_request,
socket_type=socket_type,
tempdir=tempdir,
)
ca = GrpcCallerActor.remote(
tempdir,
socket_type,
mode=IOMode(io_mode.upper()),
tokens_per_request=tokens_per_request,
batch_size=batch_size,
num_trials=num_trials,
trial_runtime=trial_runtime,
)
# TODO make starting server a method (to make synchronization explicit)
time.sleep(5)
mean, stddev = await ca.run_benchmark.remote()
print(
"gRPC streaming throughput ({}) {}: {} +- {} tokens/s".format(
io_mode.upper(),
f"(num_replicas={num_replicas}, "
f"tokens_per_request={tokens_per_request}, "
f"batch_size={batch_size})",
mean,
stddev,
)
)
@click.command(help="Benchmark streaming deployment handle throughput.")
@click.option(
"--tokens-per-request",
type=int,
default=1000,
help="Number of tokens (per request) to stream from downstream deployment",
)
@click.option(
"--batch-size",
type=int,
default=10,
help="Number of requests to send to downstream deployment in each batch.",
)
@click.option(
"--num-replicas",
type=int,
default=1,
help="Number of replicas in the downstream deployment.",
)
@click.option(
"--num-trials",
type=int,
default=5,
help="Number of trials of the benchmark to run.",
)
@click.option(
"--trial-runtime",
type=int,
default=5,
help="Duration to run each trial of the benchmark for (seconds).",
)
@click.option(
"--io-mode",
type=str,
default="async",
help="Controls mode of the streaming generation (either 'sync' or 'async')",
)
@click.option(
"--socket-type",
type=str,
default="local_tcp",
help="Controls type of socket used as underlying transport (either 'uds' or "
"'local_tcp')",
)
def main(
tokens_per_request: int,
batch_size: int,
num_replicas: int,
num_trials: int,
trial_runtime: float,
io_mode: str,
socket_type: grpc.LocalConnectionType,
):
"""Reference benchmark for vanilla Python (w/ C-based core) gRPC implementation"""
asyncio.run(
run_grpc_benchmark(
batch_size,
io_mode,
socket_type,
num_replicas,
num_trials,
tokens_per_request,
trial_runtime,
)
)
if __name__ == "__main__":
main()
@@ -0,0 +1,94 @@
import click
from ray import serve
from ray.serve._private.benchmarks.streaming.common import Caller, Endpoint, IOMode
@serve.deployment(ray_actor_options={"num_cpus": 0})
class EndpointDeployment(Endpoint):
pass
@serve.deployment
class CallerDeployment(Caller):
async def _consume_single_stream(self):
method = self._get_remote_method().options(
stream=True,
)
async for r in method.remote():
# Blackhole the response
# self.sink(str(r, 'utf-8'))
self.sink(r)
@click.command(help="Benchmark streaming deployment handle throughput.")
@click.option(
"--tokens-per-request",
type=int,
default=1000,
help="Number of requests to send to downstream deployment in each trial.",
)
@click.option(
"--batch-size",
type=int,
default=10,
help="Number of requests to send to downstream deployment in each trial.",
)
@click.option(
"--num-replicas",
type=int,
default=1,
help="Number of replicas in the downstream deployment.",
)
@click.option(
"--num-trials",
type=int,
default=5,
help="Number of trials of the benchmark to run.",
)
@click.option(
"--trial-runtime",
type=int,
default=1,
help="Duration to run each trial of the benchmark for (seconds).",
)
@click.option(
"--io-mode",
type=str,
default="async",
help="Controls mode of the streaming generation (either 'sync' or 'async')",
)
def main(
tokens_per_request: int,
batch_size: int,
num_replicas: int,
num_trials: int,
trial_runtime: float,
io_mode: str,
):
app = CallerDeployment.bind(
EndpointDeployment.options(num_replicas=num_replicas).bind(tokens_per_request),
mode=IOMode(io_mode.upper()),
tokens_per_request=tokens_per_request,
batch_size=batch_size,
num_trials=num_trials,
trial_runtime=trial_runtime,
)
h = serve.run(app)
mean, stddev = h.run_benchmark.remote().result()
print(
"DeploymentHandle streaming throughput ({}) {}: {} +- {} tokens/s".format(
io_mode.upper(),
f"(num_replicas={num_replicas}, "
f"tokens_per_request={tokens_per_request}, "
f"batch_size={batch_size})",
mean,
stddev,
)
)
if __name__ == "__main__":
main()
@@ -0,0 +1,140 @@
import asyncio
import logging
from typing import Tuple
import aiohttp
import click
from starlette.responses import StreamingResponse
from ray import serve
from ray.serve._private.benchmarks.common import run_throughput_benchmark
from ray.serve.handle import DeploymentHandle
@serve.deployment(ray_actor_options={"num_cpus": 0})
class Downstream:
def __init__(self, tokens_per_request: int):
logging.getLogger("ray.serve").setLevel(logging.WARNING)
self._tokens_per_request = tokens_per_request
async def stream(self):
for i in range(self._tokens_per_request):
yield "hi"
def __call__(self, *args):
return StreamingResponse(self.stream())
@serve.deployment(ray_actor_options={"num_cpus": 0})
class Intermediate:
def __init__(self, downstream: DeploymentHandle):
logging.getLogger("ray.serve").setLevel(logging.WARNING)
self._h = downstream.options(stream=True)
async def stream(self):
async for token in self._h.stream.remote():
yield token
def __call__(self, *args):
return StreamingResponse(self.stream())
async def _consume_single_stream():
async with aiohttp.ClientSession(raise_for_status=True) as session:
async with session.get("http://localhost:8000") as r:
async for line in r.content:
pass
async def run_benchmark(
tokens_per_request: int,
batch_size: int,
num_trials: int,
trial_runtime: float,
) -> Tuple[float, float]:
async def _do_single_batch():
await asyncio.gather(*[_consume_single_stream() for _ in range(batch_size)])
return await run_throughput_benchmark(
fn=_do_single_batch,
multiplier=batch_size * tokens_per_request,
num_trials=num_trials,
trial_runtime=trial_runtime,
)
@click.command(help="Benchmark streaming HTTP throughput.")
@click.option(
"--tokens-per-request",
type=int,
default=1000,
help="Number of requests to send to downstream deployment in each trial.",
)
@click.option(
"--batch-size",
type=int,
default=10,
help="Number of requests to send to downstream deployment in each trial.",
)
@click.option(
"--num-replicas",
type=int,
default=1,
help="Number of replicas in the downstream deployment.",
)
@click.option(
"--num-trials",
type=int,
default=5,
help="Number of trials of the benchmark to run.",
)
@click.option(
"--trial-runtime",
type=int,
default=1,
help="Duration to run each trial of the benchmark for (seconds).",
)
@click.option(
"--use-intermediate-deployment",
is_flag=True,
default=False,
help="Whether to run an intermediate deployment proxying the requests.",
)
def main(
tokens_per_request: int,
batch_size: int,
num_replicas: int,
num_trials: int,
trial_runtime: float,
use_intermediate_deployment: bool,
):
app = Downstream.options(num_replicas=num_replicas).bind(tokens_per_request)
if use_intermediate_deployment:
app = Intermediate.bind(app)
serve.run(app)
mean, stddev = asyncio.new_event_loop().run_until_complete(
run_benchmark(
tokens_per_request,
batch_size,
num_trials,
trial_runtime,
)
)
print(
"HTTP streaming throughput {}: {} +- {} tokens/s".format(
f"(num_replicas={num_replicas}, "
f"tokens_per_request={tokens_per_request}, "
f"batch_size={batch_size}, "
f"use_intermediate_deployment={use_intermediate_deployment})",
mean,
stddev,
)
)
if __name__ == "__main__":
main()