332 lines
10 KiB
Python
332 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
LMCache Controller ZMQ Benchmark Tool - CLI Entry Point
|
|
|
|
This tool performs load testing on LMCache Controller using ZMQ interface
|
|
to measure message throughput, latency, and system performance.
|
|
|
|
Test operations:
|
|
- BatchedKVOperationMsg: admit/evict messages via PUSH socket
|
|
- RegisterMsg/DeRegisterMsg/HeartbeatMsg: worker lifecycle messages
|
|
"""
|
|
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
# Standard
|
|
from typing import Dict, List
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
import multiprocessing
|
|
import statistics
|
|
|
|
# First Party
|
|
from lmcache.logging import init_logger
|
|
from lmcache.tools.controller_benchmark.benchmark import (
|
|
BenchmarkResults,
|
|
OperationStats,
|
|
ZMQControllerBenchmark,
|
|
)
|
|
from lmcache.tools.controller_benchmark.config import ZMQBenchmarkConfig
|
|
|
|
logger = init_logger(__name__)
|
|
|
|
|
|
def run_single_process(config: ZMQBenchmarkConfig) -> BenchmarkResults:
|
|
"""Run benchmark in a single process and return results"""
|
|
benchmark = ZMQControllerBenchmark(config)
|
|
asyncio.run(benchmark.run_benchmark())
|
|
benchmark.print_results()
|
|
return benchmark.get_results()
|
|
|
|
|
|
def aggregate_results(
|
|
results_list: List[BenchmarkResults], operations: Dict[str, float]
|
|
) -> BenchmarkResults:
|
|
"""Aggregate results from multiple processes"""
|
|
aggregated = BenchmarkResults()
|
|
|
|
if not results_list:
|
|
return aggregated
|
|
|
|
# Sum up totals
|
|
aggregated.total_requests = sum(r.total_requests for r in results_list)
|
|
aggregated.total_messages = sum(r.total_messages for r in results_list)
|
|
aggregated.total_time = max(r.total_time for r in results_list)
|
|
aggregated.overall_rps = sum(r.overall_rps for r in results_list)
|
|
aggregated.overall_qps = sum(r.overall_qps for r in results_list)
|
|
|
|
# Aggregate memory usage
|
|
for r in results_list:
|
|
aggregated.memory_usage.extend(r.memory_usage)
|
|
|
|
# Aggregate per-operation stats
|
|
for op_name in operations.keys():
|
|
op_stats_list = [
|
|
r.operations[op_name] for r in results_list if op_name in r.operations
|
|
]
|
|
if op_stats_list:
|
|
# Sum QPS and RPS
|
|
total_qps = sum(s.qps for s in op_stats_list)
|
|
total_rps = sum(s.rps for s in op_stats_list)
|
|
|
|
# Average latencies (weighted by RPS would be more accurate,
|
|
# but simple average is acceptable)
|
|
min_latencies = [s.min_latency for s in op_stats_list if s.min_latency > 0]
|
|
max_latencies = [s.max_latency for s in op_stats_list if s.max_latency > 0]
|
|
p95_latencies = [s.p95_latency for s in op_stats_list if s.p95_latency > 0]
|
|
|
|
aggregated.operations[op_name] = OperationStats(
|
|
qps=total_qps,
|
|
rps=total_rps,
|
|
avg_latency=(
|
|
sum(s.avg_latency * s.rps for s in op_stats_list) / total_rps
|
|
if total_rps > 0
|
|
else 0.0
|
|
),
|
|
min_latency=min(min_latencies) if min_latencies else 0.0,
|
|
max_latency=max(max_latencies) if max_latencies else 0.0,
|
|
p95_latency=(
|
|
sum(s.p95_latency * s.rps for s in op_stats_list) / total_rps
|
|
if total_rps > 0 and p95_latencies
|
|
else 0.0
|
|
),
|
|
errors=sum(s.errors for s in op_stats_list),
|
|
)
|
|
|
|
return aggregated
|
|
|
|
|
|
def print_aggregated_results(
|
|
results: BenchmarkResults,
|
|
config: ZMQBenchmarkConfig,
|
|
num_processes: int,
|
|
):
|
|
"""Print aggregated benchmark results from all processes"""
|
|
print("\n" + "=" * 80)
|
|
print(
|
|
"LMCache Controller ZMQ Benchmark - AGGREGATED RESULTS (%d processes)"
|
|
% num_processes
|
|
)
|
|
print("=" * 80)
|
|
|
|
print("\nConfiguration:")
|
|
print(" Controller URL: %s" % config.controller_pull_url)
|
|
print(" Duration: %d seconds" % config.duration)
|
|
print(" Batch Size: %d" % config.batch_size)
|
|
print(" Operations: %s" % config.operations)
|
|
print(
|
|
" Instances per process: %d, Workers: %d, Locations: %d, Keys: %d"
|
|
% (
|
|
config.num_instances,
|
|
config.num_workers,
|
|
config.num_locations,
|
|
config.num_keys,
|
|
)
|
|
)
|
|
print(" Total Instances: %d" % (config.num_instances * num_processes))
|
|
|
|
print("\nAggregated Performance:")
|
|
print(" Total Requests: %d" % results.total_requests)
|
|
print(" Total Messages: %d" % results.total_messages)
|
|
print(" Total Time: %.2fs" % results.total_time)
|
|
print(" Overall RPS (Requests/sec): %.2f" % results.overall_rps)
|
|
print(" Overall QPS (Messages/sec): %.2f" % results.overall_qps)
|
|
|
|
print("\nPer-Operation Performance (Aggregated):")
|
|
for op_name in config.operations.keys():
|
|
if op_name in results.operations:
|
|
stats = results.operations[op_name]
|
|
print(" %s:" % op_name)
|
|
print(" RPS (Requests/sec): %.2f" % stats.rps)
|
|
print(" QPS (Messages/sec): %.2f" % stats.qps)
|
|
print(
|
|
" Latency - Avg: %.3fms, Min: %.3fms, Max: %.3fms, P95: %.3fms"
|
|
% (
|
|
stats.avg_latency * 1000,
|
|
stats.min_latency * 1000,
|
|
stats.max_latency * 1000,
|
|
stats.p95_latency * 1000,
|
|
)
|
|
)
|
|
print(" Errors: %d" % stats.errors)
|
|
|
|
print("\nSystem Metrics (All Processes):")
|
|
if results.memory_usage:
|
|
avg_memory = statistics.mean(results.memory_usage)
|
|
max_memory = max(results.memory_usage)
|
|
print(" Memory Usage - Avg: %.1f%%, Max: %.1f%%" % (avg_memory, max_memory))
|
|
|
|
print("=" * 80)
|
|
|
|
|
|
def main():
|
|
"""Main function with argument parsing"""
|
|
parser = argparse.ArgumentParser(
|
|
description="LMCache Controller ZMQ Benchmark Tool",
|
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--controller-host",
|
|
type=str,
|
|
default="127.0.0.1",
|
|
help="Controller host address",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--monitor-ports",
|
|
type=str,
|
|
default='{"pull":8100,"reply":8101}',
|
|
help='Monitor ports in JSON format, e.g. {"pull":8100,"reply":8101}',
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--duration",
|
|
type=int,
|
|
default=60,
|
|
help="Benchmark duration in seconds",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--batch-size",
|
|
type=int,
|
|
default=50,
|
|
help="Number of KV operations per batch message",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--operations",
|
|
type=str,
|
|
default="admit:70,evict:25,heartbeat:5",
|
|
help="Operation distribution (name:percentage comma-separated)",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--num-instances",
|
|
type=int,
|
|
default=10,
|
|
help="Number of instances to simulate per process",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--num-workers",
|
|
type=int,
|
|
default=1,
|
|
help="Number of workers per instance",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--num-locations",
|
|
type=int,
|
|
default=1,
|
|
help="Number of storage locations",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--num-keys",
|
|
type=int,
|
|
default=10000,
|
|
help="Number of unique keys",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--num-hashes",
|
|
type=int,
|
|
default=100,
|
|
help="Number of hashes for P2P lookup operations",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--num-processes",
|
|
type=int,
|
|
default=1,
|
|
help="Number of concurrent benchmark processes",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--no-register-first",
|
|
action="store_true",
|
|
help="Skip pre-registering workers before benchmark",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Parse monitor ports from JSON
|
|
try:
|
|
monitor_ports = json.loads(args.monitor_ports)
|
|
pull_port = monitor_ports.get("pull", 8100)
|
|
reply_port = monitor_ports.get("reply")
|
|
heartbeat_port = monitor_ports.get("heartbeat")
|
|
except json.JSONDecodeError as e:
|
|
logger.error("Failed to parse monitor-ports JSON: %s", e)
|
|
raise ValueError("Invalid monitor-ports format") from e
|
|
|
|
# Convert 0.0.0.0 to 127.0.0.1 for client connections
|
|
client_host = (
|
|
"127.0.0.1" if args.controller_host == "0.0.0.0" else args.controller_host
|
|
)
|
|
controller_pull_url = f"{client_host}:{pull_port}"
|
|
controller_reply_url = f"{client_host}:{reply_port}" if reply_port else None
|
|
controller_heartbeat_url = (
|
|
f"{client_host}:{heartbeat_port}" if heartbeat_port else None
|
|
)
|
|
|
|
# Parse operations
|
|
operations = {}
|
|
for op_str in args.operations.split(","):
|
|
if ":" in op_str:
|
|
name, percentage = op_str.split(":", 1)
|
|
operations[name.strip()] = float(percentage.strip())
|
|
|
|
num_processes = args.num_processes
|
|
|
|
# Create a base config dict
|
|
base_config_kwargs = {
|
|
"controller_pull_url": controller_pull_url,
|
|
"controller_reply_url": controller_reply_url,
|
|
"controller_heartbeat_url": controller_heartbeat_url,
|
|
"duration": args.duration,
|
|
"batch_size": args.batch_size,
|
|
"operations": operations,
|
|
"num_instances": args.num_instances,
|
|
"num_workers": args.num_workers,
|
|
"num_locations": args.num_locations,
|
|
"num_keys": args.num_keys,
|
|
"num_hashes": args.num_hashes,
|
|
"register_first": not args.no_register_first,
|
|
"num_processes": num_processes,
|
|
}
|
|
|
|
try:
|
|
if num_processes == 1:
|
|
# Single process mode
|
|
config = ZMQBenchmarkConfig(**base_config_kwargs, process_id=0)
|
|
run_single_process(config)
|
|
else:
|
|
# Multi-process mode
|
|
logger.info(
|
|
"Starting multi-process benchmark with %d processes", num_processes
|
|
)
|
|
configs = [
|
|
ZMQBenchmarkConfig(**base_config_kwargs, process_id=i)
|
|
for i in range(num_processes)
|
|
]
|
|
# Use multiprocessing pool to run benchmarks in parallel
|
|
with multiprocessing.Pool(processes=num_processes) as pool:
|
|
results_list = pool.map(run_single_process, configs)
|
|
|
|
# Aggregate and print combined results
|
|
aggregated = aggregate_results(results_list, operations)
|
|
print_aggregated_results(aggregated, configs[0], num_processes)
|
|
|
|
except KeyboardInterrupt:
|
|
print("\nBenchmark interrupted by user")
|
|
except Exception as e:
|
|
logger.error("Benchmark failed: %s", e)
|
|
raise e
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|