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
508 lines
17 KiB
Python
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()
|