From 2d3c70f5af59248fca1ab0bc244271110883d615 Mon Sep 17 00:00:00 2001 From: Arik Levinsky Date: Mon, 22 Jun 2026 08:42:14 -0600 Subject: [PATCH] fix(loadtest): measure parent-side wall in the multiprocess sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multi-process fan-out gathered each slice's samples but called summarize(..., wall_seconds=0.0), so the reported throughput was always 0.0 (count/wall collapses to 0.0 when wall is non-positive). Latencies, retries, oversell, and the oracle verdict were already meaningful; only throughput was degenerate. Time the parent-side wall around the ProcessPoolExecutor run — from dispatch until the last slice returns — and pass it to summarize, mirroring how the single-process drive() reports its wall. The parent-side span is the correct denominator for an aggregate throughput across overlapping workers; summing per-slice walls would double-count concurrent time. The comparative table's published numbers still come from the single-process run. Closes #96. Co-Authored-By: Claude Opus 4.8 (1M context) --- loadtest/multiprocess.py | 13 ++++++++++--- tests/integration/test_load_multiprocess.py | 3 +++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/loadtest/multiprocess.py b/loadtest/multiprocess.py index 1f72acb..565a326 100644 --- a/loadtest/multiprocess.py +++ b/loadtest/multiprocess.py @@ -12,6 +12,7 @@ import asyncio import random from concurrent.futures import ProcessPoolExecutor +from time import perf_counter from uuid import UUID from loadtest.harness import StrategyReport, violation_magnitude @@ -75,6 +76,13 @@ async def multiprocess_sweep( slices = [ids[p::processes] for p in range(processes)] loop = asyncio.get_running_loop() with ProcessPoolExecutor(max_workers=processes) as pool: + # Parent-side wall spans the whole parallel run — from dispatch until + # the last slice returns — so it is the correct denominator for an + # aggregate throughput across overlapping workers (summing per-slice + # walls would double-count concurrent time). The comparative table's + # published numbers still come from the single-process run; this is + # the multi-process path's own end-to-end throughput. + start = perf_counter() futures = [ loop.run_in_executor( pool, driven_slice, database_url, sl, f"mp-{p}", concurrency, seed + p @@ -82,10 +90,9 @@ async def multiprocess_sweep( for p, sl in enumerate(slices) ] results = await asyncio.gather(*futures) + wall = perf_counter() - start samples = [s for slice_samples in results for s in slice_samples] - # Wall here is approximate (post-hoc); the comparative table's throughput - # comes from the single-process run. summarize over the union for tallies. - metrics = summarize("guarded-mp", samples, wall_seconds=0.0) + metrics = summarize("guarded-mp", samples, wall_seconds=wall) oracle = await run_oracle(postgres_read_uow_factory(engine)) finally: await engine.dispose() diff --git a/tests/integration/test_load_multiprocess.py b/tests/integration/test_load_multiprocess.py index cd91965..dc46e3c 100644 --- a/tests/integration/test_load_multiprocess.py +++ b/tests/integration/test_load_multiprocess.py @@ -20,3 +20,6 @@ async def test_multiprocess_guarded_stays_clean(postgres_url: str) -> None: assert report.oversell == 0 assert report.metrics.count == 48 assert report.metrics.errors == 0 + # The multi-process path measures its own end-to-end wall, so it reports a + # real aggregate throughput rather than a degenerate 0.0 (issue #96). + assert report.metrics.throughput > 0.0