Skip to content

Commit 5f44f9d

Browse files
authored
Merge pull request #59 from matheusvnm/pubsub_conn_improvements
perf: efficient graceful shutdown, pubsubclient factory for pubsub connection pooling, and benchmark improvements
2 parents a85ebf4 + 4580d26 commit 5f44f9d

27 files changed

Lines changed: 1678 additions & 408 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,4 @@ cython_debug/
144144
.poetry/
145145
.python-version/
146146
.secrets
147+
*.DS_Store

benchmarks/bench.py

Lines changed: 62 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,6 @@
22
33
This module provides the infrastructure to measure Events Per Second (EPS)
44
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
105
"""
116

127
import argparse
@@ -20,24 +15,31 @@
2015
from dataclasses import dataclass
2116
from datetime import UTC, datetime
2217
from pathlib import Path
23-
from typing import Any, Protocol
18+
from typing import Any, Protocol, cast
2419

2520
import psutil
2621

22+
from benchmarks.cases import BaselinePubSubTestCase, BasicTestCase
2723
from fastpubsub.__about__ import __version__
2824

25+
BENCH_CASE_IMPLEMENTATIONS = {
26+
"all": [BaselinePubSubTestCase, BasicTestCase],
27+
"baseline": [BaselinePubSubTestCase],
28+
"basic": [BasicTestCase],
29+
}
30+
2931

3032
class BenchmarkCase(Protocol):
3133
"""Protocol defining the interface for all benchmark test cases.
3234
3335
All benchmark cases must implement:
34-
- EVENTS_PROCESSED: Counter for processed messages
36+
- num_msgs: The number of messages to be sent
3537
- case_name: Identifier for the test case
3638
- description: Human-readable description
37-
- start(): Async context manager that runs the benchmark
39+
- all the methods included in this protocol
3840
"""
3941

40-
EVENTS_PROCESSED: int
42+
num_msgs: int
4143
case_name: str
4244
description: str
4345

@@ -49,6 +51,13 @@ def start(self) -> AbstractAsyncContextManager[float]:
4951
"""
5052
...
5153

54+
def get_total_processed_msgs(self) -> int:
55+
"""Get the sum of processed messages.
56+
57+
Returns:
58+
The total number of processed messages.
59+
"""
60+
5261

5362
@dataclass
5463
class MeasureResult:
@@ -86,10 +95,10 @@ async def measure(case: BenchmarkCase, measure_time: int) -> AsyncGenerator[Meas
8695
"""
8796
async with case.start() as start_time:
8897
while (elapsed_time := (time.time() - start_time)) < measure_time:
89-
yield MeasureResult(case.EVENTS_PROCESSED, elapsed_time)
98+
yield MeasureResult(-1, elapsed_time)
9099
await asyncio.sleep(1.0)
91100

92-
yield MeasureResult(case.EVENTS_PROCESSED, time.time() - start_time)
101+
yield MeasureResult(case.get_total_processed_msgs(), time.time() - start_time)
93102

94103

95104
async def run_benchmark(case: BenchmarkCase, measure_time: int) -> MeasureResult:
@@ -107,13 +116,20 @@ async def run_benchmark(case: BenchmarkCase, measure_time: int) -> MeasureResult
107116
async for result in measure(case, measure_time):
108117
# Clear line and print progress
109118
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} "
119+
f"\r[{case.case_name}] Time: {result.elapsed_time:.1f}s "
120+
f"({(measure_time - result.elapsed_time):.1f}s left)"
113121
)
114122
sys.stdout.flush()
115123

116-
# Print newline after progress
124+
# Prints the final result and the final line
125+
sys.stdout.write(
126+
f"\r[{case.case_name}] Events: {result.total_events:,}, "
127+
f"Time: {result.elapsed_time:.1f}s "
128+
"({(measure_time - result.elapsed_time):.1f}s left), "
129+
f"EPS: {result.eps:,.2f} "
130+
)
131+
sys.stdout.flush()
132+
117133
print()
118134
return result
119135

@@ -149,6 +165,7 @@ def save_results(
149165
"Python Version",
150166
"Description",
151167
"Host Memory",
168+
"Number of Initial Messages",
152169
]
153170
)
154171

@@ -163,6 +180,7 @@ def save_results(
163180
platform.python_version(),
164181
case.description,
165182
f"{mem.total / (1024**3):.2f} GB",
183+
case.num_msgs,
166184
]
167185
)
168186

@@ -187,15 +205,18 @@ def print_results(cases_results: list[tuple[BenchmarkCase, MeasureResult]]) -> N
187205

188206
print("-" * 60)
189207

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)
208+
# Calculate overhead for each test case against baseline.
209+
baseline_result = next((r for c, r in cases_results if c.case_name == "baseline"), None)
210+
if not baseline_result or baseline_result.eps <= 0:
211+
return
194212

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)
213+
for c, r in cases_results:
214+
if c.case_name == "baseline":
215+
continue
216+
217+
overhead = ((baseline_result.eps - r.eps) / baseline_result.eps) * 100
218+
print(f"FastPubSub ({c.case_name}) overhead: {overhead:.1f}%")
219+
print("=" * 60)
199220

200221

201222
async def main() -> None:
@@ -205,46 +226,42 @@ async def main() -> None:
205226
formatter_class=argparse.RawDescriptionHelpFormatter,
206227
epilog="""
207228
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
229+
python -m benchmarks.bench --case basic --duration 60 --num-msgs 10
230+
python -m benchmarks.bench --case baseline --duration 60 --num-msgs 10
231+
python -m benchmarks.bench --case all --duration 60 --num-msgs 10
211232
""",
212233
)
213234
parser.add_argument(
214235
"--case",
215-
choices=["basic", "raw_pubsub"],
216-
default="basic",
217-
help="Benchmark case to run (default: basic)",
236+
choices=list(BENCH_CASE_IMPLEMENTATIONS.keys()),
237+
default="all",
238+
help="Benchmark case to run (default: all)",
239+
)
240+
parser.add_argument(
241+
"--num-msgs",
242+
default=100,
243+
help="The number of messages to process (default: 100)",
218244
)
219245
parser.add_argument(
220246
"--duration",
221247
type=int,
222248
default=60,
223249
help="Duration in seconds (default: 60)",
224250
)
225-
parser.add_argument(
226-
"--all",
227-
action="store_true",
228-
help="Run all benchmark cases",
229-
)
230251

231252
args = parser.parse_args()
232253

233-
# Import cases here to avoid circular imports
234-
from benchmarks.cases.basic import BasicTestCase
235-
from benchmarks.cases.raw_pubsub import RawPubSubTestCase
254+
cases_cls: list[Any] = cast(list[Any], BENCH_CASE_IMPLEMENTATIONS.get(args.case, []))
255+
if not cases_cls:
256+
raise ValueError(f"No benchmark found for --case {args.case}")
257+
258+
cases = []
259+
for case_cls in cases_cls:
260+
cases.append(case_cls(int(args.num_msgs)))
236261

237262
results_file = Path(__file__).resolve().parent / "results" / "benches.csv"
238263
cases_results: list[tuple[Any, MeasureResult]] = []
239264

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-
248265
print(f"\nFastPubSub Benchmark Suite v{__version__}")
249266
print(f"Duration: {args.duration}s per case")
250267
print(f"Python: {platform.python_version()}")

benchmarks/cases/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Benchmark test cases."""
22

3+
from benchmarks.cases.baseline import BaselinePubSubTestCase
34
from benchmarks.cases.basic import BasicTestCase
4-
from benchmarks.cases.raw_pubsub import RawPubSubTestCase
55

6-
__all__ = ["BasicTestCase", "RawPubSubTestCase"]
6+
__all__ = ["BasicTestCase", "BaselinePubSubTestCase"]
Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Raw google-cloud-pubsub benchmark case (baseline).
1+
"""Baseline google-cloud-pubsub benchmark case.
22
33
This case measures the performance of using the google-cloud-pubsub
44
library directly, without FastPubSub. It serves as a baseline to
@@ -13,6 +13,7 @@
1313
import asyncio
1414
import json
1515
import logging
16+
import queue
1617
import time
1718
from collections.abc import AsyncIterator
1819
from concurrent.futures import Future
@@ -29,8 +30,8 @@
2930

3031
# Benchmark configuration (same as basic case)
3132
PROJECT_ID = "fastpubsub-benchmark"
32-
TOPIC_NAME = "bench-raw-topic"
33-
SUBSCRIPTION_NAME = "bench-raw-subscription"
33+
TOPIC_NAME = "bench-baseline-topic"
34+
SUBSCRIPTION_NAME = "bench-baseline-subscription"
3435

3536
# Test message payload (consistent with FastStream benchmarks)
3637
TEST_MESSAGE = {
@@ -41,19 +42,20 @@
4142
}
4243

4344

44-
class RawPubSubTestCase:
45+
class BaselinePubSubTestCase:
4546
"""Baseline benchmark using pure google-cloud-pubsub library.
4647
47-
This measures the raw performance of the google-cloud-pubsub
48+
This measures the baseline performance of the google-cloud-pubsub
4849
library without any FastPubSub overhead.
4950
"""
5051

51-
case_name = "raw_pubsub"
52+
case_name = "baseline"
5253
description = "Pure google-cloud-pubsub (baseline)"
5354

54-
def __init__(self) -> None:
55+
def __init__(self, num_msgs: int) -> None:
5556
"""Initialize the benchmark case."""
56-
self.EVENTS_PROCESSED = 0
57+
self.num_msgs = num_msgs
58+
self._EVENTS_QUEUE: queue.Queue[int] = queue.Queue()
5759
self._subscriber_client: SubscriberClient | None = None
5860
self._publisher_client: PublisherClient | None = None
5961
self._streaming_pull_future: StreamingPullFuture | None = None
@@ -96,12 +98,12 @@ def _on_message(self, message: PubSubMessage) -> None:
9698
Args:
9799
message: The received PubSub message.
98100
"""
99-
self.EVENTS_PROCESSED += 1
101+
self._EVENTS_QUEUE.put_nowait(1)
100102

101103
# Acknowledge the message just like FastPubSub
102104
message.ack_with_response()
103105

104-
# Echo message back to create infinite loop
106+
# Echo message back to create infinite loop (Do not create topic)
105107
topic_path = PublisherClient.topic_path(PROJECT_ID, TOPIC_NAME)
106108
future: Future[str] = self._publisher_client.publish( # type: ignore[union-attr]
107109
topic=topic_path,
@@ -121,7 +123,6 @@ async def start(self) -> AsyncIterator[float]:
121123
Yields:
122124
float: Timestamp when the benchmark started.
123125
"""
124-
self.EVENTS_PROCESSED = 0
125126

126127
# Create clients
127128
self._publisher_client = PublisherClient()
@@ -145,13 +146,15 @@ async def start(self) -> AsyncIterator[float]:
145146
# Record start time
146147
start_time = time.time()
147148

148-
# Publish initial message to start the echo loop
149-
initial_message = json.dumps(TEST_MESSAGE).encode()
150-
future: Future[str] = self._publisher_client.publish(
151-
topic=topic_path,
152-
data=initial_message,
153-
)
154-
future.result(timeout=10) # Wait for initial publish
149+
# Publish initial messages to start the echo loop
150+
151+
for _ in range(self.num_msgs):
152+
initial_message = json.dumps(TEST_MESSAGE).encode()
153+
future: Future[str] = self._publisher_client.publish(
154+
topic=topic_path,
155+
data=initial_message,
156+
)
157+
future.result(timeout=10) # Wait for initial publish
155158

156159
yield start_time
157160

@@ -168,3 +171,18 @@ async def start(self) -> AsyncIterator[float]:
168171

169172
if self._publisher_client:
170173
self._publisher_client = None
174+
175+
def get_total_processed_msgs(self) -> int:
176+
"""Get the sum of processed messages.
177+
178+
Returns:
179+
The total number of processed messages.
180+
"""
181+
182+
processed_messages = 0
183+
while True:
184+
try:
185+
processed_messages += self._EVENTS_QUEUE.get_nowait()
186+
except queue.Empty:
187+
break
188+
return processed_messages

0 commit comments

Comments
 (0)