Skip to content

Commit a85ebf4

Browse files
authored
Merge pull request #57 from matheusvnm/benchmark
feat: add benchmark suite for performance measurement
2 parents 71068fe + 4be181c commit a85ebf4

10 files changed

Lines changed: 754 additions & 117 deletions

File tree

benchmarks/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""FastPubSub benchmark suite for measuring framework performance."""

benchmarks/bench.py

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
"""Core benchmark framework for FastPubSub performance measurement.
2+
3+
This module provides the infrastructure to measure Events Per Second (EPS)
4+
for different test cases, comparing FastPubSub performance against baseline.
5+
6+
Usage:
7+
python -m benchmarks.bench --case basic --duration 60
8+
python -m benchmarks.bench --case raw_pubsub --duration 60
9+
python -m benchmarks.bench --all --duration 60
10+
"""
11+
12+
import argparse
13+
import asyncio
14+
import csv
15+
import platform
16+
import sys
17+
import time
18+
from collections.abc import AsyncGenerator
19+
from contextlib import AbstractAsyncContextManager
20+
from dataclasses import dataclass
21+
from datetime import UTC, datetime
22+
from pathlib import Path
23+
from typing import Any, Protocol
24+
25+
import psutil
26+
27+
from fastpubsub.__about__ import __version__
28+
29+
30+
class BenchmarkCase(Protocol):
31+
"""Protocol defining the interface for all benchmark test cases.
32+
33+
All benchmark cases must implement:
34+
- EVENTS_PROCESSED: Counter for processed messages
35+
- case_name: Identifier for the test case
36+
- description: Human-readable description
37+
- start(): Async context manager that runs the benchmark
38+
"""
39+
40+
EVENTS_PROCESSED: int
41+
case_name: str
42+
description: str
43+
44+
def start(self) -> AbstractAsyncContextManager[float]:
45+
"""Start the benchmark and yield the start time.
46+
47+
Returns:
48+
Async context manager yielding the start timestamp.
49+
"""
50+
...
51+
52+
53+
@dataclass
54+
class MeasureResult:
55+
"""Container for benchmark measurement results.
56+
57+
Attributes:
58+
total_events: Total number of events processed.
59+
elapsed_time: Time elapsed in seconds.
60+
"""
61+
62+
total_events: int
63+
elapsed_time: float
64+
65+
@property
66+
def eps(self) -> float:
67+
"""Calculate Events Per Second.
68+
69+
Returns:
70+
float: Events per second (total_events / elapsed_time).
71+
"""
72+
if self.elapsed_time == 0:
73+
return 0.0
74+
return self.total_events / self.elapsed_time
75+
76+
77+
async def measure(case: BenchmarkCase, measure_time: int) -> AsyncGenerator[MeasureResult, None]:
78+
"""Run benchmark and yield results every second.
79+
80+
Args:
81+
case: The benchmark case to run.
82+
measure_time: Duration to run the benchmark in seconds.
83+
84+
Yields:
85+
MeasureResult: Current measurement snapshot every second.
86+
"""
87+
async with case.start() as start_time:
88+
while (elapsed_time := (time.time() - start_time)) < measure_time:
89+
yield MeasureResult(case.EVENTS_PROCESSED, elapsed_time)
90+
await asyncio.sleep(1.0)
91+
92+
yield MeasureResult(case.EVENTS_PROCESSED, time.time() - start_time)
93+
94+
95+
async def run_benchmark(case: BenchmarkCase, measure_time: int) -> MeasureResult:
96+
"""Execute a benchmark with real-time progress display.
97+
98+
Args:
99+
case: The benchmark case to run.
100+
measure_time: Duration to run the benchmark in seconds.
101+
102+
Returns:
103+
MeasureResult: Final measurement results.
104+
"""
105+
result = MeasureResult(0, 0.0)
106+
107+
async for result in measure(case, measure_time):
108+
# Clear line and print progress
109+
sys.stdout.write(
110+
f"\r[{case.case_name}] Events: {result.total_events:,}, "
111+
f"Time: {result.elapsed_time:.1f}s ({(measure_time - result.elapsed_time):.1f}s left), "
112+
f"EPS: {result.eps:,.2f} "
113+
)
114+
sys.stdout.flush()
115+
116+
# Print newline after progress
117+
print()
118+
return result
119+
120+
121+
def save_results(
122+
case: BenchmarkCase,
123+
result: MeasureResult,
124+
results_file: Path,
125+
) -> None:
126+
"""Save benchmark results to CSV file.
127+
128+
Args:
129+
case: The benchmark case that was run.
130+
result: The measurement results.
131+
results_file: Path to the CSV file.
132+
"""
133+
file_exists = results_file.exists()
134+
mem = psutil.virtual_memory()
135+
136+
with results_file.open("a", newline="") as csvfile:
137+
writer = csv.writer(csvfile, delimiter=";")
138+
139+
# Write header if file is new
140+
if not file_exists:
141+
writer.writerow(
142+
[
143+
"FastPubSub Version",
144+
"Case",
145+
"Total Events",
146+
"Elapsed Time",
147+
"EPS",
148+
"Timestamp",
149+
"Python Version",
150+
"Description",
151+
"Host Memory",
152+
]
153+
)
154+
155+
writer.writerow(
156+
[
157+
__version__,
158+
case.case_name,
159+
result.total_events,
160+
f"{result.elapsed_time:.2f}",
161+
f"{result.eps:.2f}",
162+
datetime.now(tz=UTC).isoformat(),
163+
platform.python_version(),
164+
case.description,
165+
f"{mem.total / (1024**3):.2f} GB",
166+
]
167+
)
168+
169+
170+
def print_results(cases_results: list[tuple[BenchmarkCase, MeasureResult]]) -> None:
171+
"""Print formatted benchmark results table.
172+
173+
Args:
174+
cases_results: List of (case, result) tuples.
175+
"""
176+
print("\n" + "=" * 60)
177+
print("BENCHMARK RESULTS")
178+
print("=" * 60)
179+
print(f"{'Case':<15} | {'Events':>12} | {'EPS':>12} | Description")
180+
print("-" * 60)
181+
182+
for case, result in cases_results:
183+
print(
184+
f"{case.case_name:<15} | {result.total_events:>12,} | "
185+
f"{result.eps:>12,.2f} | {case.description}"
186+
)
187+
188+
print("-" * 60)
189+
190+
# Calculate overhead if we have both cases
191+
if len(cases_results) == 2:
192+
raw_result = next((r for c, r in cases_results if c.case_name == "raw_pubsub"), None)
193+
basic_result = next((r for c, r in cases_results if c.case_name == "basic"), None)
194+
195+
if raw_result and basic_result and raw_result.eps > 0:
196+
overhead = ((raw_result.eps - basic_result.eps) / raw_result.eps) * 100
197+
print(f"FastPubSub overhead: {overhead:.1f}%")
198+
print("=" * 60)
199+
200+
201+
async def main() -> None:
202+
"""Main entry point for the benchmark CLI."""
203+
parser = argparse.ArgumentParser(
204+
description="FastPubSub Benchmark Suite",
205+
formatter_class=argparse.RawDescriptionHelpFormatter,
206+
epilog="""
207+
Examples:
208+
python -m benchmarks.bench --case basic --duration 60
209+
python -m benchmarks.bench --case raw_pubsub --duration 60
210+
python -m benchmarks.bench --all --duration 60
211+
""",
212+
)
213+
parser.add_argument(
214+
"--case",
215+
choices=["basic", "raw_pubsub"],
216+
default="basic",
217+
help="Benchmark case to run (default: basic)",
218+
)
219+
parser.add_argument(
220+
"--duration",
221+
type=int,
222+
default=60,
223+
help="Duration in seconds (default: 60)",
224+
)
225+
parser.add_argument(
226+
"--all",
227+
action="store_true",
228+
help="Run all benchmark cases",
229+
)
230+
231+
args = parser.parse_args()
232+
233+
# Import cases here to avoid circular imports
234+
from benchmarks.cases.basic import BasicTestCase
235+
from benchmarks.cases.raw_pubsub import RawPubSubTestCase
236+
237+
results_file = Path(__file__).resolve().parent / "results" / "benches.csv"
238+
cases_results: list[tuple[Any, MeasureResult]] = []
239+
240+
cases: list[Any]
241+
if args.all:
242+
cases = [RawPubSubTestCase(), BasicTestCase()]
243+
elif args.case == "basic":
244+
cases = [BasicTestCase()]
245+
else:
246+
cases = [RawPubSubTestCase()]
247+
248+
print(f"\nFastPubSub Benchmark Suite v{__version__}")
249+
print(f"Duration: {args.duration}s per case")
250+
print(f"Python: {platform.python_version()}")
251+
print("-" * 60)
252+
253+
for case in cases:
254+
print(f"\nStarting benchmark: {case.case_name}")
255+
print(f"Description: {case.description}")
256+
print()
257+
258+
result = await run_benchmark(case, args.duration)
259+
cases_results.append((case, result))
260+
261+
# Save results immediately after each case
262+
save_results(case, result, results_file)
263+
264+
# Print summary table
265+
print_results(cases_results)
266+
print(f"\nResults saved to: {results_file}")
267+
268+
269+
if __name__ == "__main__":
270+
asyncio.run(main())

benchmarks/cases/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Benchmark test cases."""
2+
3+
from benchmarks.cases.basic import BasicTestCase
4+
from benchmarks.cases.raw_pubsub import RawPubSubTestCase
5+
6+
__all__ = ["BasicTestCase", "RawPubSubTestCase"]

benchmarks/cases/basic.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""Basic benchmark case using FastPubSub framework.
2+
3+
This case measures FastPubSub's performance by creating an echo loop:
4+
1. Subscribe to a topic
5+
2. When a message is received, publish it back to the same topic
6+
3. Count each message processed
7+
8+
This creates an infinite loop of messages, allowing us to measure
9+
Events Per Second (EPS) for the FastPubSub framework.
10+
"""
11+
12+
import asyncio
13+
import logging
14+
import time
15+
from collections.abc import AsyncIterator
16+
from contextlib import asynccontextmanager
17+
18+
from fastpubsub import Message, PubSubBroker
19+
20+
# Disable logging for accurate benchmark timing
21+
logging.getLogger("fastpubsub").setLevel(logging.CRITICAL)
22+
logging.getLogger("google").setLevel(logging.CRITICAL)
23+
24+
# Benchmark configuration
25+
PROJECT_ID = "fastpubsub-benchmark"
26+
TOPIC_NAME = "bench-topic"
27+
SUBSCRIPTION_NAME = "bench-subscription"
28+
29+
# Test message payload (consistent with FastStream benchmarks)
30+
TEST_MESSAGE = {
31+
"name": "John",
32+
"age": 39,
33+
"fullname": "LongString" * 8,
34+
"children": [{"name": "Mike", "age": 8, "fullname": "LongString" * 8}],
35+
}
36+
37+
38+
class BasicTestCase:
39+
"""Benchmark case for FastPubSub Message processing.
40+
41+
This measures the performance of FastPubSub's message handling
42+
without any additional processing or validation.
43+
"""
44+
45+
case_name = "basic"
46+
description = "FastPubSub Message processing"
47+
48+
def __init__(self) -> None:
49+
"""Initialize the benchmark case."""
50+
self.EVENTS_PROCESSED = 0
51+
self._broker: PubSubBroker | None = None
52+
self._shutdown_event: asyncio.Event | None = None
53+
54+
def _setup_broker(self) -> PubSubBroker:
55+
"""Create and configure the broker with echo subscriber.
56+
57+
Returns:
58+
PubSubBroker: Configured broker instance.
59+
"""
60+
# Create broker with logging disabled for accurate timing
61+
broker = PubSubBroker(project_id=PROJECT_ID)
62+
63+
# Get publisher for echo responses
64+
publisher = broker.publisher(TOPIC_NAME)
65+
66+
# Reference to self for closure
67+
test_case = self
68+
69+
@broker.subscriber(
70+
alias="benchmark",
71+
topic_name=TOPIC_NAME,
72+
subscription_name=SUBSCRIPTION_NAME,
73+
autocreate=True,
74+
max_messages=1000, # Flow control
75+
ack_deadline_seconds=60,
76+
)
77+
async def handle(message: Message) -> None:
78+
"""Handle incoming message and echo it back."""
79+
test_case.EVENTS_PROCESSED += 1
80+
# Echo message back to create infinite loop
81+
await publisher.publish(message.data)
82+
83+
return broker
84+
85+
@asynccontextmanager
86+
async def start(self) -> AsyncIterator[float]:
87+
"""Start the benchmark.
88+
89+
Sets up the broker, starts message processing, and publishes
90+
the initial message to start the echo loop.
91+
92+
Yields:
93+
float: Timestamp when the benchmark started.
94+
"""
95+
self.EVENTS_PROCESSED = 0
96+
self._shutdown_event = asyncio.Event()
97+
self._broker = self._setup_broker()
98+
99+
try:
100+
# Start the broker (creates subscriptions and starts pulling)
101+
await self._broker.start()
102+
103+
# Record start time
104+
start_time = time.time()
105+
106+
# Publish initial message to start the echo loop
107+
publisher = self._broker.publisher(TOPIC_NAME)
108+
await publisher.publish(TEST_MESSAGE)
109+
110+
yield start_time
111+
112+
finally:
113+
# Shutdown the broker
114+
if self._broker:
115+
self._broker.shutdown()

0 commit comments

Comments
 (0)