From 2ff736392286c2df239a7504d7b55ba6cc73b7ec Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 25 Jul 2026 02:41:52 +1000 Subject: [PATCH 1/3] test(perf): extend peak-RSS suite to backend-inclusive File read paths (LAB-350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The memory suite measured only the serializer layer with the read input pre-allocated, so backend read-side copies (File os.read + header slice) were structurally invisible — a regression reintroducing a full-payload read-side copy passed the suite (cachekit-py#169). Add three backend-inclusive guards running the read END-TO-END through FileBackend -> StandardCacheHandler -> CacheOperationHandler: - mmap fast path (tracemalloc): ~1.1x logical on the Python heap; the os.read fallback regression measures ~1.9x (fault-injected) and trips the 1.6x bound. - non-mmap default-serializer path (tracemalloc): pins the measured ~5x cost (os.read + slice + bytes() coercion of the zero-copy view + envelope decode + output) so one MORE full-payload copy fails; reducing the 5x is follow-up work per #169's scope. - end-to-end peak RSS (subprocess): ~2x payload (mapped pages + df); catches full-payload copies tracemalloc cannot see (Arrow pool, Rust, view coercion — fault-injected at 3.0x vs the 2.6x bound). Subprocess peak-RSS now reads VmHWM from /proc/self/status instead of ru_maxrss: ru_maxrss survives fork+exec on Linux, so a child spawned from a fat pytest process inherits the parent's watermark and real regressions hide under it (observed: 2GB inherited base, 0.00x measured cost). Applied to the existing ByteStorage store guard too, which had the same silent false-pass fragility. --- tests/performance/README.md | 17 +- tests/performance/test_large_object_memory.py | 250 +++++++++++++++++- 2 files changed, 255 insertions(+), 12 deletions(-) diff --git a/tests/performance/README.md b/tests/performance/README.md index 3ebdb79..5322425 100644 --- a/tests/performance/README.md +++ b/tests/performance/README.md @@ -88,7 +88,22 @@ Tests per-request wrapper creation overhead: **Key Insight**: Per-request pattern adds negligible overhead (<0.15% of network latency). -### 7. `stats_utils.py` - **Statistical Utilities** +### 7. `test_large_object_memory.py` - **Memory Regression Guards** (CI-gated: `performance and slow`) + +Deterministic peak-memory bounds for large-object caching, at two scopes (#169): + +- **Serializer-only** (`test_store_path...` / `test_load_path...`): serializer layer in + isolation via tracemalloc. Store ~2x logical, load ~1.1x, wire ~1x. +- **Backend-inclusive** (`test_file_backend_*`): the read path end-to-end through the File + backend (backend read → unwrap → deserialize). The mmap fast path allocates ~1.1x on the + Python heap and costs ~2x peak RSS (mapped pages + df); the non-mmap default-serializer + path currently costs ~5x on the heap. Subprocess RSS uses `VmHWM`, not `ru_maxrss` + (which survives fork+exec and inherits the parent's watermark — false passes). + +**Key Insight**: The headline low-read-memory claim maps to the backend-inclusive numbers; +serializer-only numbers can stay green while a backend read path regrows a full-payload copy. + +### 8. `stats_utils.py` - **Statistical Utilities** Provides rigorous performance measurement tools: diff --git a/tests/performance/test_large_object_memory.py b/tests/performance/test_large_object_memory.py index 19dc480..748a90d 100644 --- a/tests/performance/test_large_object_memory.py +++ b/tests/performance/test_large_object_memory.py @@ -9,15 +9,26 @@ - the read path's base64-decode + JSON-parse + full-body slice drove read peak to ~5.4x. Pre-fix these assertions fail; post-fix store peak is ~2x, read ~1.1x, wire ~1x. + +Two measurement scopes live here (cachekit-py#169): + +- serializer-only (`test_store_path...`/`test_load_path...`): the serializer layer in + isolation, with the read input allocated before measurement starts. +- backend-inclusive (`test_file_backend_*`): the read path END-TO-END through the File + backend (backend read -> unwrap -> deserialize), so a regression reintroducing a + full-payload copy in a BACKEND read path fails even while the serializer-only + numbers stay green. The headline low-read-memory claim maps to these. """ from __future__ import annotations import gc +import os import subprocess import sys import textwrap import tracemalloc +from pathlib import Path import numpy as np import pandas as pd @@ -29,6 +40,23 @@ _MB = 1024 * 1024 +# Subprocess peak-RSS reads use VmHWM from /proc/self/status, NOT resource.ru_maxrss: +# on Linux ru_maxrss lives in the signal struct and SURVIVES fork+exec, so a child +# spawned from a fat pytest process inherits the parent's watermark — the child's +# whole measurement then hides under the inherited peak and a real regression false- +# passes (observed: base 1997 MiB, read cost 0.00x). VmHWM is per-mm and starts fresh +# for the exec'd image. Linux-only, hence the skipif on the tests that embed this. +_VMHWM_SNIPPET = textwrap.dedent( + """ + def vmhwm_kib(): + with open("/proc/self/status") as f: + for line in f: + if line.startswith("VmHWM"): + return int(line.split()[1]) + raise RuntimeError("VmHWM not found in /proc/self/status") + """ +) + def _numeric_df(mb: int) -> pd.DataFrame: """Incompressible float64 frame ~mb MiB (worst case for compression).""" @@ -102,29 +130,28 @@ def test_full_roundtrip_through_cache_handler_is_correct_and_compact(): @pytest.mark.slow @pytest.mark.performance +@pytest.mark.skipif(sys.platform != "linux", reason="peak-RSS via /proc/self/status VmHWM is Linux-only") def test_byte_storage_store_has_no_full_payload_copy(): """Rust-side allocation guard for ByteStorage.store() (cachekit-core#45). - tracemalloc cannot see Rust allocations, so this invariant uses peak RSS in - a dedicated subprocess. Determinism comes from the payload: 512MB of a - repeating 8-byte pattern LZ4-compresses to ~2MB, so every Rust-side buffer - downstream of the input (compressed data, msgpack envelope, returned bytes) - is negligible and peak RSS ~= interpreter + payload (~1.1x). The eliminated - ``data.to_vec()`` full-payload copy (cachekit-core < 0.3.0) re-adds ~1.0x - payload and trips the 1.7x bound with margin on both sides. + tracemalloc cannot see Rust allocations, so this invariant uses peak RSS + (VmHWM) in a dedicated subprocess. Determinism comes from the payload: 512MB + of a repeating 8-byte pattern LZ4-compresses to ~2MB, so every Rust-side + buffer downstream of the input (compressed data, msgpack envelope, returned + bytes) is negligible and peak RSS ~= interpreter + payload (~1.1x). The + eliminated ``data.to_vec()`` full-payload copy (cachekit-core < 0.3.0) + re-adds ~1.0x payload and trips the 1.7x bound with margin on both sides. """ payload_mb = 512 - script = textwrap.dedent( + script = _VMHWM_SNIPPET + textwrap.dedent( f""" - import resource - from cachekit._rust_serializer import ByteStorage payload = b"cachekit" * ({payload_mb} * 1024 * 1024 // 8) envelope = ByteStorage(None).store(payload, None) # Sanity: compressible payload => tiny envelope, or the RSS bound is meaningless. assert len(envelope) < 32 * 1024 * 1024, f"envelope unexpectedly large: {{len(envelope)}}" - print(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) # KiB on Linux + print(vmhwm_kib()) """ ) result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True) # noqa: S603 (trusted: sys.executable + literal code) @@ -135,3 +162,204 @@ def test_byte_storage_store_has_no_full_payload_copy(): f"store() peak RSS {peak / payload_bytes:.2f}x payload — a full-payload copy is back " f"on the write path (expected ~1.1x without the to_vec copy, ~2.1x with it)" ) + + +# --------------------------------------------------------------------------- +# Backend-inclusive read paths (cachekit-py#169) +# +# Everything above measures the serializer layer with the read input already in +# memory, so backend read-side copies (File os.read + the file_data[14:] slice) +# are structurally invisible. These tests run the read END-TO-END through the +# real stack (FileBackend -> StandardCacheHandler -> CacheOperationHandler -> +# unwrap -> deserialize) and bound what the whole path allocates. +# --------------------------------------------------------------------------- + + +def _file_read_stack(cache_dir: Path, serializer_name: str): + """The real L2 read wiring the decorator uses, on a File backend. + + encryption=False is the explicit opt-out: it keeps supports_mmap_read() True for + "arrow" regardless of any CACHEKIT_MASTER_KEY leaking in from the environment. + """ + from cachekit.backends.file import FileBackend + from cachekit.backends.file.config import FileBackendConfig + from cachekit.cache_handler import CacheOperationHandler, CacheSerializationHandler, StandardCacheHandler + from cachekit.key_generator import CacheKeyGenerator + + backend = FileBackend(FileBackendConfig(cache_dir=cache_dir, max_size_mb=1024, max_value_mb=400)) + operation = CacheOperationHandler( + CacheSerializationHandler(serializer_name=serializer_name, encryption=False), + CacheKeyGenerator(), + cache_handler=StandardCacheHandler(backend), + ) + return backend, operation + + +@pytest.mark.slow +@pytest.mark.performance +@pytest.mark.skipif(os.name != "posix", reason="mmap read path is POSIX-only (#171); non-POSIX falls back to os.read") +def test_file_backend_read_python_allocations_bounded(tmp_path: Path) -> None: + """END-TO-END DataFrame read through the File backend (mmap fast path, #171). + + The whole pipeline — get_buffer mmap -> envelope unwrap -> Arrow deserialize -> + pandas — should allocate ~1.1x logical on the Python heap (just the output df; + the mapped payload and Arrow-pool buffers are not Python allocations). If any + stage rematerializes the payload as bytes — get_buffer regressing to os.read + (file bytes + payload slice, measures ~1.9x via fault injection), a bytes() + coercion of the zero-copy view — the peak clears the 1.6x bound and this fails. + """ + df = _numeric_df(50) + logical = _logical(df) + backend, operation = _file_read_stack(tmp_path / "cache", "arrow") + key = "perf:file-read:arrow" + backend.set(key, operation.serialization_handler.serialize_data(df, cache_key=key)) + + # Guard the measurement's precondition explicitly: if mmap doesn't apply here, + # the test would silently measure the fallback path and fail confusingly. + assert operation.serialization_handler.supports_mmap_read() + probe = backend.get_buffer(key) + assert probe is not None, "get_buffer returned None; end-to-end read would fall back to os.read" + probe.close() + + gc.collect() + tracemalloc.start() + hit = operation.get_cached_value(key) + peak = tracemalloc.get_traced_memory()[1] + tracemalloc.stop() + + assert hit is not None, "end-to-end File read missed (errors read as miss — check logs)" + pd.testing.assert_frame_equal(hit[1], df) + assert peak / logical < 1.6, ( + f"File-backend end-to-end read peak {peak / logical:.2f}x logical — a full-payload " + f"read-side copy is back in the backend path (expected ~1.1x zero-copy, ~2x+ with a copy)" + ) + + +@pytest.mark.slow +@pytest.mark.performance +def test_file_backend_bytes_read_python_allocations_bounded(tmp_path: Path) -> None: + """END-TO-END read through FileBackend.get() (default serializer, no mmap). + + This is the path most cached functions take (anything that isn't a plaintext + Arrow DataFrame). Measured cost today: ~5x payload on the Python heap — + FileBackend.get's two full-payload copies (os.read + the file_data[14:] slice, + the exact copies #169 calls out), StandardSerializer.deserialize's ``bytes(data)`` + re-coercion of the envelope's zero-copy memoryview (Rust retrieve needs bytes), + the decompressed msgpack document, and the unpacked output. The bound pins that: + one MORE full-payload copy (~6x) fails. Tightening below 5x means fixing those + copies (separate ticket per #169 — this test is the measurement). + """ + payload = np.random.default_rng(0).bytes(50 * _MB) # incompressible: envelope ~= payload size + backend, operation = _file_read_stack(tmp_path / "cache", "default") + key = "perf:file-read:bytes" + backend.set(key, operation.serialization_handler.serialize_data(payload, cache_key=key)) + + gc.collect() + tracemalloc.start() + hit = operation.get_cached_value(key) + peak = tracemalloc.get_traced_memory()[1] + tracemalloc.stop() + + assert hit is not None, "end-to-end File read missed (errors read as miss — check logs)" + assert hit[1] == payload + assert peak / len(payload) < 5.7, ( + f"File-backend bytes read peak {peak / len(payload):.2f}x payload — an additional full-payload " + f"read-side copy crept in (known cost ~5x: os.read + slice + bytes() coercion + decode + output)" + ) + + +@pytest.mark.slow +@pytest.mark.performance +@pytest.mark.skipif(sys.platform != "linux", reason="mmap read path is POSIX-only (#171); VmHWM measurement is Linux-only") +def test_file_backend_end_to_end_read_peak_rss_bounded(tmp_path: Path) -> None: + """Peak-RSS bound for a large DataFrame read end-to-end through the File backend. + + tracemalloc cannot see mmap page residency, Arrow-pool buffers, or Rust-side + allocations, so the headline low-read-RSS claim gets a real RSS measurement in a + dedicated subprocess (same pattern as the ByteStorage store test above). The + entry is written by a separate subprocess so neither payload creation nor the + write path pollutes the read process's high-water mark, and this pytest process + never holds payload-scale memory (which would skew later RSS-delta tests). + + Expected read cost above the post-import baseline: ~2x payload — the checksum + pass faults every mapped page in (1x, file-backed) and to_pandas materializes + the df (1x). Any ADDED full-payload buffer on that floor — an owned-bytes + coercion of the zero-copy view, an Arrow-pool or Rust-side copy (invisible to + tracemalloc) — measures ~3x and blows the 2.6x bound (verified by fault + injection). The one class this metric cannot see: mmap falling back to os.read + swaps file-backed pages for anonymous heap at the same ~2x total — that one is + caught by the tracemalloc test above (~1.1x -> ~1.9x). Payload is uncompressed + Arrow IPC of incompressible float64 (compression="none", the mmap-friendly + on-disk format), so on-disk size ~= logical size and the ratios are meaningful. + """ + payload_mb = 256 + cols = 20 + rows = payload_mb * _MB // (8 * cols) + cache_dir = str(tmp_path / "cache") + key = "perf:file-read:rss" + + backend_setup = textwrap.dedent( + f""" + from cachekit.backends.file import FileBackend + from cachekit.backends.file.config import FileBackendConfig + + backend = FileBackend(FileBackendConfig(cache_dir={cache_dir!r}, max_size_mb=1024, max_value_mb=400)) + """ + ) + + write_script = backend_setup + textwrap.dedent( + f""" + import numpy as np + import pandas as pd + + from cachekit.serializers.arrow_serializer import ArrowSerializer + from cachekit.serializers.wrapper import SerializationWrapper + + rng = np.random.default_rng(0) + df = pd.DataFrame({{f"c{{i}}": rng.standard_normal({rows}) for i in range({cols})}}) + data, meta = ArrowSerializer(compression="none").serialize(df) + backend.set({key!r}, SerializationWrapper.wrap(data, meta.to_dict(), "arrow")) + """ + ) + + read_script = ( + backend_setup + + _VMHWM_SNIPPET + + textwrap.dedent( + f""" + from cachekit.cache_handler import CacheOperationHandler, CacheSerializationHandler, StandardCacheHandler + from cachekit.key_generator import CacheKeyGenerator + + operation = CacheOperationHandler( + CacheSerializationHandler(serializer_name="arrow", encryption=False), + CacheKeyGenerator(), + cache_handler=StandardCacheHandler(backend), + ) + assert operation.serialization_handler.supports_mmap_read() + probe = backend.get_buffer({key!r}) + assert probe is not None, "get_buffer returned None; read would fall back to os.read" + probe.close() + + base = vmhwm_kib() + hit = operation.get_cached_value({key!r}) + peak = vmhwm_kib() + assert hit is not None, "end-to-end File read missed" + assert hit[1].shape == ({rows}, {cols}), f"wrong shape: {{hit[1].shape}}" + print(base, peak) + """ + ) + ) + + write = subprocess.run([sys.executable, "-c", write_script], capture_output=True, text=True) # noqa: S603 (trusted: sys.executable + literal code) + assert write.returncode == 0, f"write subprocess failed: {write.stderr}" + read = subprocess.run([sys.executable, "-c", read_script], capture_output=True, text=True) # noqa: S603 (trusted: sys.executable + literal code) + assert read.returncode == 0, f"read subprocess failed: {read.stderr}" + + base_kib, peak_kib = (int(v) for v in read.stdout.split()) + read_cost = (peak_kib - base_kib) * 1024 + payload_bytes = payload_mb * _MB + print(f"\n end-to-end File read RSS: base {base_kib / 1024:.0f} MiB, read cost {read_cost / payload_bytes:.2f}x payload") + assert read_cost < payload_bytes * 2.6, ( + f"end-to-end File read peak RSS {read_cost / payload_bytes:.2f}x payload above baseline — " + f"a full-payload read-side copy is back (expected ~2x: mapped pages + df; ~3x with a heap copy)" + ) From d15869d5d163cf202061952cf91510273ea7c003 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sun, 26 Jul 2026 00:35:32 +1000 Subject: [PATCH 2/3] test(perf): recalibrate mmap-path guard after expert-panel review (LAB-350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Panel finding (fault-verified): to_pandas(self_destruct, split_blocks) returns numpy VIEWS over Arrow-pool buffers, so the healthy mmap-path read allocates ~0.001x logical on the Python heap — not the ~1.1x the docstring claimed. Under the old 1.6x bound a reintroduced bytes() coercion of the zero-copy view (measures ~0.96x) silently PASSED. Tighten the bound to 0.5x: both regression classes now trip it (coercion ~1.0x, os.read fallback ~1.9x), and a future pyarrow that copies in to_pandas fails loud for conscious recalibration. Also from the panel: module docstring no longer claims the suite avoids process RSS (three tests are RSS-based since this PR); the two-scopes explanation lives once (section banner) with the module docstring pointing at it; README catalog entry no longer duplicates multipliers that live in docstrings; RSS-test baseline folded into the assert message instead of a capture-swallowed print. --- tests/performance/README.md | 13 ++---- tests/performance/test_large_object_memory.py | 46 +++++++++---------- 2 files changed, 28 insertions(+), 31 deletions(-) diff --git a/tests/performance/README.md b/tests/performance/README.md index 5322425..4d70267 100644 --- a/tests/performance/README.md +++ b/tests/performance/README.md @@ -91,14 +91,11 @@ Tests per-request wrapper creation overhead: ### 7. `test_large_object_memory.py` - **Memory Regression Guards** (CI-gated: `performance and slow`) Deterministic peak-memory bounds for large-object caching, at two scopes (#169): - -- **Serializer-only** (`test_store_path...` / `test_load_path...`): serializer layer in - isolation via tracemalloc. Store ~2x logical, load ~1.1x, wire ~1x. -- **Backend-inclusive** (`test_file_backend_*`): the read path end-to-end through the File - backend (backend read → unwrap → deserialize). The mmap fast path allocates ~1.1x on the - Python heap and costs ~2x peak RSS (mapped pages + df); the non-mmap default-serializer - path currently costs ~5x on the heap. Subprocess RSS uses `VmHWM`, not `ru_maxrss` - (which survives fork+exec and inherits the parent's watermark — false passes). +**serializer-only** (`test_store_path...` / `test_load_path...`, tracemalloc in isolation) +and **backend-inclusive** (`test_file_backend_*`, the read path end-to-end through the +File backend, tracemalloc + subprocess peak RSS). Bounds, measured multipliers, and the +metric-choice rationale (VmHWM vs `ru_maxrss`, tracemalloc/RSS blind spots) live in the +module and per-test docstrings — the single source of truth. **Key Insight**: The headline low-read-memory claim maps to the backend-inclusive numbers; serializer-only numbers can stay green while a backend read path regrows a full-payload copy. diff --git a/tests/performance/test_large_object_memory.py b/tests/performance/test_large_object_memory.py index 748a90d..66864ba 100644 --- a/tests/performance/test_large_object_memory.py +++ b/tests/performance/test_large_object_memory.py @@ -1,23 +1,19 @@ """Memory regression guards for large-object (Arrow/DataFrame) caching. These lock in the fixes that removed the base64+JSON wrapper inflation and the -Arrow serializer's copy chain. They assert DETERMINISTIC, environment-independent -metrics (Python-tracked peak via tracemalloc + on-wire size), not process RSS — so -they are stable in CI yet fail loudly if the regressions return: +Arrow serializer's copy chain, asserting DETERMINISTIC metrics only: tracemalloc +peaks and on-wire size in-process, and — for allocations tracemalloc cannot see +(Rust, Arrow pool, mmap residency) — peak RSS via VmHWM in dedicated subprocesses. - base64+JSON wrap drove store tracemalloc peak to ~5.7x logical and the wire to 1.33x. - the read path's base64-decode + JSON-parse + full-body slice drove read peak to ~5.4x. Pre-fix these assertions fail; post-fix store peak is ~2x, read ~1.1x, wire ~1x. -Two measurement scopes live here (cachekit-py#169): - -- serializer-only (`test_store_path...`/`test_load_path...`): the serializer layer in - isolation, with the read input allocated before measurement starts. -- backend-inclusive (`test_file_backend_*`): the read path END-TO-END through the File - backend (backend read -> unwrap -> deserialize), so a regression reintroducing a - full-payload copy in a BACKEND read path fails even while the serializer-only - numbers stay green. The headline low-read-memory claim maps to these. +Two measurement scopes live here — serializer-only (`test_store_path...` / +`test_load_path...`) and backend-inclusive (`test_file_backend_*`, cachekit-py#169); +see the "Backend-inclusive read paths" section banner below for the split and why +the headline low-read-memory claim maps to the backend-inclusive numbers. """ from __future__ import annotations @@ -202,11 +198,14 @@ def test_file_backend_read_python_allocations_bounded(tmp_path: Path) -> None: """END-TO-END DataFrame read through the File backend (mmap fast path, #171). The whole pipeline — get_buffer mmap -> envelope unwrap -> Arrow deserialize -> - pandas — should allocate ~1.1x logical on the Python heap (just the output df; - the mapped payload and Arrow-pool buffers are not Python allocations). If any - stage rematerializes the payload as bytes — get_buffer regressing to os.read - (file bytes + payload slice, measures ~1.9x via fault injection), a bytes() - coercion of the zero-copy view — the peak clears the 1.6x bound and this fails. + pandas — allocates ~0.0x logical on the Python heap: the mapped payload and the + Arrow-pool buffers are not Python allocations, and ``to_pandas(self_destruct, + split_blocks)`` hands back numpy views over pool buffers rather than copies, so + even the output df is invisible to tracemalloc. Any stage rematerializing the + payload as Python bytes trips the 0.5x bound (fault-injected: a bytes() coercion + of the zero-copy view measures ~1.0x; get_buffer regressing to os.read measures + ~1.9x). If a pyarrow upgrade makes to_pandas copy again, this fails LOUD at + ~1.0x — recalibrate consciously; the guard never silently widens. """ df = _numeric_df(50) logical = _logical(df) @@ -229,9 +228,10 @@ def test_file_backend_read_python_allocations_bounded(tmp_path: Path) -> None: assert hit is not None, "end-to-end File read missed (errors read as miss — check logs)" pd.testing.assert_frame_equal(hit[1], df) - assert peak / logical < 1.6, ( - f"File-backend end-to-end read peak {peak / logical:.2f}x logical — a full-payload " - f"read-side copy is back in the backend path (expected ~1.1x zero-copy, ~2x+ with a copy)" + assert peak / logical < 0.5, ( + f"File-backend end-to-end read peak {peak / logical:.2f}x logical — a full-payload copy is " + f"back in the read path (expected ~0.0x zero-copy; ~1x = bytes() coercion, ~2x = os.read " + f"fallback, ~1x with neither = pyarrow to_pandas stopped returning views — recalibrate)" ) @@ -288,7 +288,7 @@ def test_file_backend_end_to_end_read_peak_rss_bounded(tmp_path: Path) -> None: tracemalloc) — measures ~3x and blows the 2.6x bound (verified by fault injection). The one class this metric cannot see: mmap falling back to os.read swaps file-backed pages for anonymous heap at the same ~2x total — that one is - caught by the tracemalloc test above (~1.1x -> ~1.9x). Payload is uncompressed + caught by the tracemalloc test above (~0.0x -> ~1.9x). Payload is uncompressed Arrow IPC of incompressible float64 (compression="none", the mmap-friendly on-disk format), so on-disk size ~= logical size and the ratios are meaningful. """ @@ -358,8 +358,8 @@ def test_file_backend_end_to_end_read_peak_rss_bounded(tmp_path: Path) -> None: base_kib, peak_kib = (int(v) for v in read.stdout.split()) read_cost = (peak_kib - base_kib) * 1024 payload_bytes = payload_mb * _MB - print(f"\n end-to-end File read RSS: base {base_kib / 1024:.0f} MiB, read cost {read_cost / payload_bytes:.2f}x payload") assert read_cost < payload_bytes * 2.6, ( - f"end-to-end File read peak RSS {read_cost / payload_bytes:.2f}x payload above baseline — " - f"a full-payload read-side copy is back (expected ~2x: mapped pages + df; ~3x with a heap copy)" + f"end-to-end File read peak RSS {read_cost / payload_bytes:.2f}x payload above baseline " + f"({base_kib / 1024:.0f} MiB) — a full-payload read-side copy is back " + f"(expected ~2x: mapped pages + df; ~3x with a heap copy)" ) From 519167f8ae4669a1fb67e1c6b25696c654c252e7 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sun, 26 Jul 2026 11:51:11 +1000 Subject: [PATCH 3/3] test(perf): bound measurement subprocesses with a timeout (LAB-350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit: subprocess.run without timeout lets a wedged child hang the CI job to its ceiling. All three measurement subprocesses (ByteStorage store guard + the RSS write/read pair) now share a 300s timeout — generous for slow runners, and a hang fails the test loudly via TimeoutExpired instead of stalling the run. Co-authored-by: multica-agent --- tests/performance/test_large_object_memory.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/performance/test_large_object_memory.py b/tests/performance/test_large_object_memory.py index 66864ba..f0cbea4 100644 --- a/tests/performance/test_large_object_memory.py +++ b/tests/performance/test_large_object_memory.py @@ -36,6 +36,10 @@ _MB = 1024 * 1024 +# Measurement subprocesses finish in seconds; a wedged child must fail the test +# (TimeoutExpired), not hang the CI job to its ceiling. Generous for slow runners. +_SUBPROCESS_TIMEOUT_S = 300 + # Subprocess peak-RSS reads use VmHWM from /proc/self/status, NOT resource.ru_maxrss: # on Linux ru_maxrss lives in the signal struct and SURVIVES fork+exec, so a child # spawned from a fat pytest process inherits the parent's watermark — the child's @@ -150,7 +154,7 @@ def test_byte_storage_store_has_no_full_payload_copy(): print(vmhwm_kib()) """ ) - result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True) # noqa: S603 (trusted: sys.executable + literal code) + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=_SUBPROCESS_TIMEOUT_S) # noqa: S603 (trusted: sys.executable + literal code) assert result.returncode == 0, f"store subprocess failed: {result.stderr}" peak = int(result.stdout.strip()) * 1024 payload_bytes = payload_mb * 1024 * 1024 @@ -350,9 +354,9 @@ def test_file_backend_end_to_end_read_peak_rss_bounded(tmp_path: Path) -> None: ) ) - write = subprocess.run([sys.executable, "-c", write_script], capture_output=True, text=True) # noqa: S603 (trusted: sys.executable + literal code) + write = subprocess.run([sys.executable, "-c", write_script], capture_output=True, text=True, timeout=_SUBPROCESS_TIMEOUT_S) # noqa: S603 (trusted: sys.executable + literal code) assert write.returncode == 0, f"write subprocess failed: {write.stderr}" - read = subprocess.run([sys.executable, "-c", read_script], capture_output=True, text=True) # noqa: S603 (trusted: sys.executable + literal code) + read = subprocess.run([sys.executable, "-c", read_script], capture_output=True, text=True, timeout=_SUBPROCESS_TIMEOUT_S) # noqa: S603 (trusted: sys.executable + literal code) assert read.returncode == 0, f"read subprocess failed: {read.stderr}" base_kib, peak_kib = (int(v) for v in read.stdout.split())