Files
wehub-resource-sync 85742ab165
CPU Test / Lint - next (push) Waiting to run
Dashboard / Chromatic (push) Waiting to run
CPU Test / Lint - fast (push) Waiting to run
CPU Test / Build documentation (push) Waiting to run
CPU Test / Test (Store, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (Utilities, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (Weave, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (AgentOps, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (LLM proxy, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (Others, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (Store, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (Utilities, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (Weave, stable, Python 3.11) (push) Waiting to run
CPU Test / Test (AgentOps, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (LLM proxy, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (Others, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (Store, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (Utilities, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (Weave, stable, Python 3.12) (push) Waiting to run
CPU Test / Test (AgentOps, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (LLM proxy, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (Others, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (Store, latest, Python 3.13) (push) Waiting to run
CPU Test / Lint - slow (push) Waiting to run
CPU Test / Lint - JavaScript (push) Waiting to run
CPU Test / Test (AgentOps, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (LLM proxy, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (Others, legacy, Python 3.10) (push) Waiting to run
CPU Test / Test (Utilities, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (Weave, latest, Python 3.13) (push) Waiting to run
CPU Test / Test (JavaScript) (push) Waiting to run
Deploy Documentation / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:44:17 +08:00

508 lines
17 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
"""Collection-level contention benchmarks for Agent Lightning."""
from __future__ import annotations
import argparse
import asyncio
import json
import math
import multiprocessing as mp
import random
import threading
import time
import uuid
from contextlib import asynccontextmanager
from dataclasses import asdict, dataclass
from multiprocessing.process import BaseProcess
from pathlib import Path
from queue import Empty, Queue
from typing import Any, AsyncContextManager, Callable, Dict, List, Mapping, Sequence
from pymongo import AsyncMongoClient
from rich.console import Console
from rich.table import Table
from agentlightning.store.collection.base import LightningCollections
from agentlightning.store.collection.memory import InMemoryLightningCollections
from agentlightning.store.collection.mongo import MongoClientPool, MongoLightningCollections
from agentlightning.types import Rollout, RolloutConfig
console = Console()
DEFAULT_TOTAL_TASKS = 100_000
DEFAULT_CONCURRENCY = 1_024
DEFAULT_TASK_PREFIX = "collection-bench"
MONGO_DEFAULT_DB = "agentlightning_collection_bench"
@dataclass
class WorkerResult:
durations: List[float]
failures: int
@dataclass
class BenchmarkResult:
backend: str
name: str
total_tasks: int
concurrency: int
successes: int
failures: int
duration: float
throughput: float
avg_latency: float
p50_latency: float
p95_latency: float
p99_latency: float
min_latency: float
max_latency: float
success_rate: float
ops_per_worker: float
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Benchmark LightningStore collections without the store server.")
parser.add_argument("benchmark", choices=("insert", "dequeue"), help="Benchmarks to run.")
parser.add_argument("--backend", choices=("memory", "mongo"), default="memory", help="Collection backend to test.")
parser.add_argument("--total-tasks", type=int, default=DEFAULT_TOTAL_TASKS, help="Total operations to run.")
parser.add_argument("--concurrency", type=int, default=DEFAULT_CONCURRENCY, help="Number of concurrent workers.")
parser.add_argument("--task-prefix", default=DEFAULT_TASK_PREFIX, help="Base prefix for generated workload IDs.")
parser.add_argument("--summary-file", help="Optional newline-delimited JSON summary output.")
parser.add_argument(
"--mongo-uri", default="mongodb://localhost:27017/?replicaSet=rs0", help="Mongo connection URI."
)
parser.add_argument("--mongo-database", default=MONGO_DEFAULT_DB, help="Mongo database for benchmark artifacts.")
return parser.parse_args(argv)
def _percentile(values: Sequence[float], percentile: float) -> float:
if not values:
return 0.0
if len(values) == 1:
return values[0]
rank = (len(values) - 1) * percentile
lower = math.floor(rank)
upper = math.ceil(rank)
if lower == upper:
return values[int(rank)]
return values[lower] * (upper - rank) + values[upper] * (rank - lower)
def _aggregate_results(
*,
backend: str,
name: str,
results: Sequence[WorkerResult],
concurrency: int,
total_tasks: int,
duration: float,
) -> BenchmarkResult:
successes = sum(len(result.durations) for result in results)
failures = sum(result.failures for result in results)
latencies = [lat for result in results for lat in result.durations]
throughput = successes / duration if duration > 0 else 0.0
avg_latency = (sum(latencies) / len(latencies)) if latencies else 0.0
sorted_latencies = sorted(latencies)
return BenchmarkResult(
backend=backend,
name=name,
total_tasks=total_tasks,
concurrency=concurrency,
successes=successes,
failures=failures,
duration=duration,
throughput=throughput,
avg_latency=avg_latency,
p50_latency=_percentile(sorted_latencies, 0.50),
p95_latency=_percentile(sorted_latencies, 0.95),
p99_latency=_percentile(sorted_latencies, 0.99),
min_latency=sorted_latencies[0] if sorted_latencies else 0.0,
max_latency=sorted_latencies[-1] if sorted_latencies else 0.0,
success_rate=(successes / (successes + failures)) if (successes + failures) else 0.0,
ops_per_worker=(successes / concurrency) if concurrency else 0.0,
)
def _render_results(results: Sequence[BenchmarkResult]) -> None:
if not results:
console.print("[yellow]No benchmark results to display.[/yellow]")
return
table = Table(title="Collection Benchmarks", show_lines=False)
table.add_column("Backend")
table.add_column("Benchmark")
table.add_column("Successes", justify="right")
table.add_column("Failures", justify="right")
table.add_column("Throughput (req/s)", justify="right")
table.add_column("Avg Latency (ms)", justify="right")
table.add_column("P95 (ms)", justify="right")
table.add_column("P99 (ms)", justify="right")
table.add_column("Success Rate", justify="right")
for result in results:
table.add_row(
result.backend,
result.name,
f"{result.successes:,}",
f"{result.failures:,}",
f"{result.throughput:,.2f}",
f"{result.avg_latency * 1e3:,.2f}",
f"{result.p95_latency * 1e3:,.2f}",
f"{result.p99_latency * 1e3:,.2f}",
f"{result.success_rate * 100:,.2f}%",
)
console.print(table)
def _write_summary(results: Sequence[BenchmarkResult], file_path: Path) -> None:
file_path.parent.mkdir(parents=True, exist_ok=True)
with file_path.open("a", encoding="utf-8") as handle:
for result in results:
handle.write(json.dumps(result.to_dict()) + "\n")
def _make_rollout(worker_index: int, sequence: int, task_prefix: str) -> Rollout:
rollout_id = f"{task_prefix}-ro-{worker_index}-{sequence}-{uuid.uuid4().hex}"
current_time = time.time()
return Rollout(
rollout_id=rollout_id,
input={"task": rollout_id},
start_time=current_time,
end_time=None,
mode="train",
resources_id=None,
status="queuing",
config=RolloutConfig(),
metadata={},
)
async def _preload_queue(collections: LightningCollections, total_tasks: int, task_prefix: str) -> None:
batch: List[str] = []
for idx in range(total_tasks):
batch.append(f"{task_prefix}-queue-{idx}")
if len(batch) >= 512:
async with collections.atomic(mode="rw", labels=["rollout_queue"]) as collections_atomic:
await collections_atomic.rollout_queue.enqueue(batch)
batch.clear()
if batch:
async with collections.atomic(mode="rw", labels=["rollout_queue"]) as collections_atomic:
await collections_atomic.rollout_queue.enqueue(batch)
async def _reset_mongo_database(uri: str, database: str) -> None:
client = AsyncMongoClient[Mapping[str, Any]](uri)
try:
await client.drop_database(database)
finally:
await client.close()
class BaseBenchmark:
"""Shared control flow for collection benchmarks across backends."""
def __init__(
self, *, backend: str, total_tasks: int, concurrency: int, task_prefix: str, name: str, kind: str
) -> None:
self.backend = backend
self.total_tasks = total_tasks
self.concurrency = concurrency
self.task_prefix = task_prefix
self.name = name
self.kind = kind
def run(self) -> BenchmarkResult:
asyncio.run(self.setup())
start = time.perf_counter()
results = self.spawn_workers(worker_fn=self.worker_entrypoint)
duration = time.perf_counter() - start
return _aggregate_results(
backend=self.backend,
name=self.name,
results=results,
concurrency=self.concurrency,
total_tasks=self.total_tasks,
duration=duration,
)
def spawn_workers(
self,
worker_fn: Callable[[int, Any, Any], WorkerResult],
) -> List[WorkerResult]:
raise NotImplementedError()
def worker_entrypoint(self, worker_index: int, task_queue: Any, start_barrier: Any) -> WorkerResult:
start_barrier.wait()
console.print(f"Worker {worker_index} starting")
async def _runner() -> WorkerResult:
async with self.worker_context() as collections:
if self.kind == "insert":
return await insert_worker_async(
collections,
worker_index=worker_index,
task_queue=task_queue,
task_prefix=self.task_prefix,
)
if self.kind == "dequeue":
return await dequeue_worker_async(
collections,
worker_index=worker_index,
task_queue=task_queue,
)
raise ValueError(f"Unknown benchmark kind: {self.kind}")
return asyncio.run(_runner())
def worker_context(self, *args: Any, **kwargs: Any) -> AsyncContextManager[LightningCollections]:
"""Provide the execution context for the benchmark workers."""
raise NotImplementedError()
async def setup(self) -> None:
"""Prepare backend-specific state before running workers."""
if self.kind == "dequeue":
async with self.worker_context() as collections:
await _preload_queue(collections, self.total_tasks, self.task_prefix)
class MemoryBenchmark(BaseBenchmark):
def __init__(
self,
*,
total_tasks: int,
concurrency: int,
task_prefix: str,
kind: str,
) -> None:
super().__init__(
total_tasks=total_tasks,
concurrency=concurrency,
task_prefix=task_prefix,
name=f"collection-{kind}",
backend="memory",
kind=kind,
)
self.collections = InMemoryLightningCollections(lock_type="thread")
def spawn_workers(
self,
worker_fn: Callable[[int, Any, Any], WorkerResult],
) -> List[WorkerResult]:
task_queue: Queue[int] = Queue()
for task_id in range(self.total_tasks):
task_queue.put(task_id)
start_barrier = threading.Barrier(self.concurrency)
results: List[WorkerResult | None] = [None] * self.concurrency
def _thread_target(worker_index: int) -> None:
results[worker_index] = worker_fn(worker_index, task_queue, start_barrier)
threads: List[threading.Thread] = []
for worker_index in range(self.concurrency):
thread = threading.Thread(target=_thread_target, args=(worker_index,))
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
return [result for result in results if result is not None]
@asynccontextmanager
async def worker_context(self, *args: Any, **kwargs: Any):
yield self.collections
class MongoBenchmark(BaseBenchmark):
def __init__(
self,
*,
total_tasks: int,
concurrency: int,
task_prefix: str,
kind: str,
mongo_uri: str,
mongo_database: str,
) -> None:
super().__init__(
total_tasks=total_tasks,
concurrency=concurrency,
task_prefix=task_prefix,
name=f"collection-{kind}",
backend="mongo",
kind=kind,
)
self.mongo_uri = mongo_uri
self.mongo_database = mongo_database
self.partition_id = f"partition-{uuid.uuid4().hex}"
async def setup(self) -> None:
await _reset_mongo_database(self.mongo_uri, self.mongo_database)
return await super().setup()
@asynccontextmanager
async def worker_context(self):
pool = MongoClientPool[Mapping[str, Any]](mongo_uri=self.mongo_uri)
collections = MongoLightningCollections(
client_pool=pool,
database_name=self.mongo_database,
partition_id=self.partition_id,
tracker=None,
)
try:
yield collections
finally:
await pool.close()
def spawn_workers(
self,
worker_fn: Callable[[int, Any, Any], WorkerResult],
) -> List[WorkerResult]:
ctx = mp.get_context("fork")
task_queue = ctx.Queue()
for task_id in range(self.total_tasks):
task_queue.put(task_id)
start_barrier = ctx.Barrier(self.concurrency)
result_queue = ctx.Queue()
processes: List[BaseProcess] = []
for worker_index in range(self.concurrency):
process = ctx.Process(
target=_process_worker_target,
args=(self, worker_index, task_queue, start_barrier, result_queue),
)
process.start()
processes.append(process)
collected: List[WorkerResult] = []
errors: List[Exception] = []
for _ in range(self.concurrency):
item = result_queue.get()
if isinstance(item, Exception):
errors.append(item)
else:
collected.append(item)
for process in processes:
process.join()
if errors:
raise RuntimeError("One or more worker processes failed") from errors[0]
return collected
def _process_worker_target(
benchmark: BaseBenchmark,
worker_index: int,
task_queue: Any,
start_barrier: Any,
result_queue: Any,
) -> None:
try:
result = benchmark.worker_entrypoint(worker_index, task_queue, start_barrier)
except Exception as exc:
result_queue.put(exc)
raise
else:
result_queue.put(result)
async def insert_worker_async(
collections: LightningCollections,
*,
worker_index: int,
task_queue: Any,
task_prefix: str,
) -> WorkerResult:
durations: List[float] = []
failures = 0
while True:
try:
sequence = task_queue.get_nowait()
except Empty:
break
rollout = _make_rollout(worker_index, sequence, task_prefix)
req_start = time.perf_counter()
try:
async with collections.atomic(mode="rw", labels=["rollouts"]) as collections_atomic:
if random.uniform(0, 1) < 0.01:
console.print("Inserting rollout:", rollout.rollout_id)
await collections_atomic.rollouts.insert([rollout])
durations.append(time.perf_counter() - req_start)
except Exception:
failures += 1
return WorkerResult(durations=durations, failures=failures)
async def dequeue_worker_async(
collections: LightningCollections,
*,
worker_index: int,
task_queue: Any,
) -> WorkerResult:
del worker_index # unused but kept for symmetry
durations: List[float] = []
failures = 0
while True:
try:
task_queue.get_nowait()
except Empty:
break
req_start = time.perf_counter()
try:
async with collections.atomic(mode="rw", labels=["rollout_queue"]) as collections_atomic:
items = await collections_atomic.rollout_queue.dequeue(limit=1)
if items and random.uniform(0, 1) < 0.01:
console.print("Dequeued items:", items[0])
except Exception:
failures += 1
continue
if not items:
break
durations.append(time.perf_counter() - req_start)
return WorkerResult(durations=durations, failures=failures)
def run_benchmark(args: argparse.Namespace, benchmark_kind: str) -> BenchmarkResult:
params = {
"total_tasks": args.total_tasks,
"concurrency": args.concurrency,
"task_prefix": args.task_prefix,
}
if args.backend == "memory":
return MemoryBenchmark(kind=benchmark_kind, **params).run()
mongo_params = {
**params,
"mongo_uri": args.mongo_uri,
"mongo_database": args.mongo_database,
}
return MongoBenchmark(kind=benchmark_kind, **mongo_params).run()
def main(argv: Sequence[str] | None = None) -> None:
args = parse_args(argv)
if args.total_tasks <= 0:
raise ValueError("total-tasks must be positive")
if args.concurrency <= 0:
raise ValueError("concurrency must be positive")
results: List[BenchmarkResult] = []
results.append(run_benchmark(args, args.benchmark))
_render_results(results)
if args.summary_file:
_write_summary(results, Path(args.summary_file))
if __name__ == "__main__": # pragma: no cover - manual execution
main()