Repository navigation
fix(mmap): make get_conversation thread-safe, prefault pages, drop executor hop - #1245
Conversation
Try out this PRQuick install: pip install --upgrade --force-reinstall git+https://github.com/ai-dynamo/aiperf.git@898d6acb0f9f284bf6cc5f9d086f064fe5f52e31Recommended with virtual environment (using uv): uv venv --python 3.12 && source .venv/bin/activate
uv pip install --upgrade --force-reinstall git+https://github.com/ai-dynamo/aiperf.git@898d6acb0f9f284bf6cc5f9d086f064fe5f52e31Last updated for commit: |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThe change adds the default-enabled ChangesMemory-mapped dataset reads
Estimated code review effort: 3 (Moderate) | ~20 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/aiperf/dataset/memory_map_utils.py`:
- Around line 422-424: Move mmap client construction, _prefault_data_mmap(), and
all direct mmap slicing performed by the async read methods into a dedicated
executor or worker. Update the affected async methods and initialization paths,
including the methods around the listed ranges, to await executor submissions
while preserving position-free reads and existing results. Ensure no mmap
page-faulting or other blocking file access runs on the event loop, regardless
of MMAP_PREFAULT or later OS eviction.
In `@tests/unit/dataset/test_mmap_conversation_concurrency.py`:
- Around line 43-45: Add type annotations throughout the targeted test helpers
and test functions: annotate tmp_path and monkeypatch in _build_client and the
affected tests, item and the return type in _InterleavingMmap.__getitem__, and
name and the return type in _InterleavingMmap.__getattr__. Ensure every function
parameter and return value in these symbols has an appropriate annotation
without changing behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: 5104bd35-511b-47c3-9607-60da5c48a297
📒 Files selected for processing (4)
docs/environment-variables.mdsrc/aiperf/common/environment.pysrc/aiperf/dataset/memory_map_utils.pytests/unit/dataset/test_mmap_conversation_concurrency.py
… drop executor hop
## The bug
`get_conversation` read its record through the mmap's *shared file position*:
self.data_mmap.seek(offset_info.offset)
conv_bytes = self.data_mmap.read(offset_info.size)
`MemoryMapDatasetClientStore` dispatched that onto the default executor, so N
threads shared one mmap object and could interleave:
thread A: seek(offset_a)
thread B: seek(offset_b)
thread A: read(size_a) -> bytes from offset_b, length size_a
Thread A silently gets another conversation's bytes. It surfaces as a spurious
MemoryMapSerializationError: Failed to decode conversation data:
Invalid JSON: trailing characters at line 1 column N
on a *random* conversation, or -- when the foreign slice happens to parse -- as
the wrong conversation served with no error at all.
Reported against a 6.4GB dataset (SemiAnalysisAI#34): four runs sharing
one mmap cache entry each failed on a *different* conversation while `cmp`
showed the on-disk files byte-identical, and each named conversation decoded
fine when read standalone. The reported offsets are consistent with reading the
right *length* from the wrong *place* -- one failure reported "EOF while parsing
at column 1801628" where the index size for that record is exactly 1,801,628.
The two sibling readers (`get_payload_bytes`, `get_payload_turn`) already used
position-free slicing; only `get_conversation` did not. Now it does.
## Three further changes in the same path
1. `madvise(MADV_WILLNEED)` plus a page walk at open, gated on the new
`Environment.DATASET.MMAP_PREFAULT` (default True). No read then takes a
major fault mid-benchmark, where the fault latency would land inside a
measured request. Workers share the kernel page cache, so N workers cost one
disk read rather than N serialized ones.
2. Drop `loop.run_in_executor` from all three client-store readers. The hop
existed to absorb blocking major faults, which (1) eliminates.
3. Flatten `index.offsets` into `dict[str, tuple[int, int]]` at load time,
dropping per-lookup Pydantic attribute access. Wire format unchanged;
`self.index` stays for callers iterating `index.conversation_ids`.
## Measurements
Executor hop, 64KB read, page-cache warm:
direct on loop 1.08us
run_in_executor 19.41us 18.0x, +18.33us per lookup
Event-loop tick lateness under continuous reads, warmed pool:
64KB records p50 p99 max
idle 0.081 0.230 0.338 ms
executor 0.059 0.410 0.639 ms
inline 0.008 0.045 0.128 ms
The executor was worse on every percentile at typical record sizes. At 8MB it
bought ~2x median at the cost of 2-4x p99 and max -- the wrong trade for a tool
whose output is latency percentiles.
Prefault cost at open, by dataset size:
16 MB 0.9 ms cold 0.4 ms warm
256 MB 12.6 ms cold 3.4 ms warm
1024 MB 40.0 ms cold 13.4 ms warm
seek()+read() vs slice is within noise (0.03us at 64KB, 6.3us at 8MB) -- both
are the same memcpy under the GIL. Neither mmap read path releases the GIL
(CPython Modules/mmapmodule.c:318 and :999 both call PyBytes_FromStringAndSize
with no Py_BEGIN_ALLOW_THREADS), which is why the executor could not protect the
loop from a fault in the first place.
End-to-end throughput is unchanged: ~43us saved per request against ~42ms
request latency is below run-to-run noise. This is a correctness fix plus CPU
and allocation hygiene, not a throughput win.
## Tests
`tests/unit/dataset/test_mmap_conversation_concurrency.py`:
- `_InterleavingMmap` injects a competing seek between the reader's seek and its
read, making the race deterministic. Mutation-verified: reintroducing
seek()+read() fails it with the reported production symptom (asked for
conv-0, received conv-2's bytes, "Invalid JSON: EOF while parsing a string").
Note that merely leaving a stale position behind does NOT catch this -- the
reader's own seek() overwrites it on the way in.
- 8 threads x 150 lookups over 24 records of varying size. Weaker: it does not
reproduce at the default switch interval and can only ever be suggestive.
- Reads agree with MMAP_PREFAULT on and off.
Signed-off-by: Anthony Casagrande <acasagrande@nvidia.com>
Co-authored-by: Xianjie Qiao <5410381+qiaoxj07@users.noreply.github.com>
28a7745 to
ce2dec9
Compare
Adds the missing type hints on _InterleavingMmap.__getitem__ / __getattr__ and on the tmp_path / monkeypatch fixture parameters, per the repo standard that every function parameter and return value is annotated. No behaviour change. Signed-off-by: Anthony Casagrande <acasagrande@nvidia.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
debermudez
left a comment
There was a problem hiding this comment.
Overall assessment: Fix is correct and minimal — mmap[a:b] is the right call and matches the sibling readers that were already safe. The _InterleavingMmap test is a nice deterministic reproducer, and the evidence chain in the PR description is model-quality bug forensics. Two issues worth discussing before merge.
Fix order:
- (Recommended)
_prefault_data_mmapblocks the async event loop during startup — see inline comment. - (Optional) Docstring for
get_conversationslightly overstates the no-fault guarantee whenMMAP_PREFAULT=False.
What's working well: position-free slice fix, _InterleavingMmap deterministic race test, concurrent stress test with varied sizes, graceful madvise fallback, index flattening.
_build_client dropped the MemoryMapDatasetBackingStore without stopping it, so its @on_stop cleanup never ran and the dataset.dat/index.dat files it created were left on disk after each test. Replace it with an _open_client asynccontextmanager that closes the client and then awaits store.stop(), which also removes the duplicated try/finally in each test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…LT is off The get_conversation docstring stated reads don't trigger major faults, but that only holds with MMAP_PREFAULT enabled. Record the disabled case and why those faults are left on the loop thread rather than moved to an executor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review addressedAddressed 3 of 5 review threads. Fixed:
Skipped (2 threads):
🤖 Generated with Claude Code |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
tests/unit/dataset/test_mmap_conversation_concurrency.py (2)
161-164: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winAssert that the setting controls prefaulting.
Both settings return the same conversations if prefaulting is skipped, always enabled, or inverted. Wrap
MemoryMapDatasetClient._prefault_data_mmap()with a counter and assert one call whenprefaultisTrueand zero calls when it isFalse.Based on PR objectives, this test must validate prefault behavior, not only read output.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/unit/dataset/test_mmap_conversation_concurrency.py` around lines 161 - 164, Update the test around _open_client in the prefault parametrization to spy on or wrap MemoryMapDatasetClient._prefault_data_mmap(), count invocations, and assert the count is one when prefault is True and zero when prefault is False, while retaining the existing conversation-read assertions.
96-98: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winOffload concurrent readers from the event loop.
Replace the blocking
list(pool.map(_read_many, range(_THREADS)))call withasyncio.to_thread()tasks awaited byasyncio.gather().🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/unit/dataset/test_mmap_conversation_concurrency.py` around lines 96 - 98, Update the concurrent-reader test around the ThreadPoolExecutor usage to run blocking reader work via asyncio.to_thread() and await the resulting tasks with asyncio.gather(). Remove the synchronous list(pool.map(_read_many, range(_THREADS))) execution while preserving all _THREADS reader invocations and exception propagation.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@tests/unit/dataset/test_mmap_conversation_concurrency.py`:
- Around line 66-74: Move MemoryMapDatasetClient construction inside the try
block so store cleanup always reaches the finally block when initialization
fails. Track whether client creation succeeded and call client.close() only for
a successfully constructed client, while always awaiting store.stop().
---
Nitpick comments:
In `@tests/unit/dataset/test_mmap_conversation_concurrency.py`:
- Around line 161-164: Update the test around _open_client in the prefault
parametrization to spy on or wrap MemoryMapDatasetClient._prefault_data_mmap(),
count invocations, and assert the count is one when prefault is True and zero
when prefault is False, while retaining the existing conversation-read
assertions.
- Around line 96-98: Update the concurrent-reader test around the
ThreadPoolExecutor usage to run blocking reader work via asyncio.to_thread() and
await the resulting tasks with asyncio.gather(). Remove the synchronous
list(pool.map(_read_many, range(_THREADS))) execution while preserving all
_THREADS reader invocations and exception propagation.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: cafb45bf-10c5-4ca6-9810-15550910c17b
📒 Files selected for processing (2)
src/aiperf/dataset/memory_map_utils.pytests/unit/dataset/test_mmap_conversation_concurrency.py
🚧 Files skipped from review as they are similar to previous changes (1)
- src/aiperf/dataset/memory_map_utils.py
MemoryMapDatasetClient(...) ran before the try block, so a failure opening the mmap skipped the finally and left store.stop() uncalled -- the same leak the guard was added to prevent. Construct inside the guard and close only a client that was actually built. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
debermudez
left a comment
There was a problem hiding this comment.
Re-reviewed. Both prior findings resolved:
_prefault_data_mmapevent-loop concern — verified your rebuttal directly against CPython 3.12mmapmodule.c: neithermmap_read_methodnormmap_subscriptrelease the GIL, so an executor hop wouldn't have helped. Closing this, no code change needed.- Docstring fix (562fdfa) — confirmed present.
Re-validated the fix itself: mutation-tested _InterleavingMmap (reverting to seek()+read() reproduces the exact reported symptom), and ran a real CLI benchmark against the mock server (200 conversations, wide ISL variance, 32-way concurrency) through the actual MemoryMapDatasetClientStore path — 0% errors, no decode errors.
Two CodeRabbit test nitpicks remain open (prefault-call-count assertion, ThreadPoolExecutor vs asyncio.to_thread in test) — both optional, non-blocking.
LGTM.
…1326 to release/0.13.0) Combined cherry-pick of: - #1325: feat: control-plane groundwork for k8s execution (9668416) - #1326: feat(k8s): AIPerf goes k8s-native — operator, CRDs, JobSet execution, and the aiperf kube CLI (f481e5d) Conflicts resolved against release/0.13.0 base (via prerequisite stack #1245→#1274→#1280→#1322→#1347). Signed-off-by: Anthony Casagrande <acasagrande@nvidia.com>
…1326 to release/0.13.0) Combined cherry-pick of: - #1325: feat: control-plane groundwork for k8s execution (9668416) - #1326: feat(k8s): AIPerf goes k8s-native — operator, CRDs, JobSet execution, and the aiperf kube CLI (f481e5d) Conflicts resolved against release/0.13.0 base (via prerequisite stack #1245→#1274→#1280→#1322→#1347). Also includes prerequisite stack cherry-picks: - fix(mmap): make get_conversation thread-safe, prefault pages, drop executor hop (#1245) - fix(dataset): restore random_pool batch-size support with --input-file (#1274) - fix(config): stop silently dropping CLI flags passed with --config (#1280) - fix(test-harness): implement num_prompt_special_tokens on FakeTokenizer (#1322) - feat: Support stateful responses API with previous_response_id chaining (#1347) Signed-off-by: Elias Bermudez <dbermudez@nvidia.com> Signed-off-by: Jacob Murry <jacobmurry@google.com> Co-authored-by: Xianjie Qiao <5410381+qiaoxj07@users.noreply.github.com> Co-authored-by: Elias Bermudez <dbermudez@nvidia.com> Co-authored-by: Jacob Murry <jacobmurry@google.com> Signed-off-by: Anthony Casagrande <acasagrande@nvidia.com>
…k (cherry-pick #1245+#1274+#1280+#1322+#1325+#1326+#1347 to release/0.13.0) (#1379) Signed-off-by: Elias Bermudez <dbermudez@nvidia.com> Signed-off-by: Jacob Murry <jacobmurry@google.com> Signed-off-by: Anthony Casagrande <acasagrande@nvidia.com> Co-authored-by: Xianjie Qiao <5410381+qiaoxj07@users.noreply.github.com> Co-authored-by: Elias Bermudez <dbermudez@nvidia.com> Co-authored-by: Jacob Murry <jacobmurry@google.com>
The bug
MemoryMapDatasetClient.get_conversationread its record through the mmap's shared file position:MemoryMapDatasetClientStore.get_conversationdispatched this onto the default executor, so N threads shared onemmapobject and could interleave:Thread A silently gets another conversation's bytes. It surfaces as a spurious
on a random conversation — or, when the foreign slice happens to parse, as the wrong conversation served with no error at all.
The two sibling readers (
get_payload_bytes,get_payload_turn) already used position-free slicing; onlyget_conversationdid not.Evidence it is a read-time race, not data corruption
Reported against a 6.4 GB dataset in SemiAnalysisAI/aiperf#34, from a 46-run sweep sharing one mmap cache entry:
cmpon the 6.4 GBdataset.datbetween cache source and per-run copy → byte-identical;index.datmd5 identical.Changes
get_conversation— the fix.madvise(MADV_WILLNEED)+ page walk at open, gated on the newAIPERF_DATASET_MMAP_PREFAULT(defaultTrue). No read then takes a major fault mid-benchmark, where the fault latency would land inside a measured request. Workers share the kernel page cache, so N workers cost one disk read rather than N serialized ones.run_in_executorfrom all three client-store readers. The hop existed to absorb blocking major faults, which (2) eliminates.index.offsetsintodict[str, tuple[int, int]]at load time, dropping per-lookup Pydantic attribute access. Wire format unchanged.Performance proof
The executor hop costs 18x
64 KB read, page-cache warm:
run_in_executor+18.33 µs per lookup, 18.0x. Paid per request/turn on the worker hot path.
It also made tail latency worse
Event-loop tick lateness under continuous reads, warmed pool:
Inline wins on every percentile at typical record sizes. At 8 MB the executor buys ~2x median at the cost of 2–4x p99 and max — the wrong trade for a tool whose output is latency percentiles.
Why the executor never protected the loop anyway
Neither mmap read path releases the GIL. From CPython
Modules/mmapmodule.c:mmap_read_method→PyBytes_FromStringAndSize(&self->data[self->pos], num_bytes)at :318 — noPy_BEGIN_ALLOW_THREADSmmap_subscriptslice →PyBytes_FromStringAndSize(self->data + start, slicelen)at :999 — samePy_BEGIN_ALLOW_THREADSin the module are dealloc/close (:149/:162/:200/:218) andfstatat construction (:1294)A major fault inside that memcpy stalls every Python thread regardless of which one runs it.
Prefault cost at open
~250 ms one-time for a 6.4 GB dataset. Set
AIPERF_DATASET_MMAP_PREFAULT=falseto trade tail-latency predictability for faster startup.seek()+read() vs slice is within noise
Both are the same memcpy. The fix is free — it is a correctness change, not a perf change.
End-to-end: no measurable throughput change
Large-payload replay, 32 concurrency, 640 requests, alternating branch/main to control for drift:
Overlapping distributions. ~43 µs saved per request against ~42 ms request latency is 0.1% — below noise.
Being explicit: this PR is a correctness fix plus CPU and allocation hygiene. It is not a throughput win, and I could not surface one against a mock server. The savings would matter where the client is CPU-bound (high request rates, many workers), which I have no way to demonstrate here.
Behavioral equivalence was verified separately: same seed, same config, branch vs main →
inputs.jsonbyte-identical (111,041 bytes,cmpclean) and every deterministic metric matching exactly, includingoutput_sequence_length.avgto full float precision.Tests
tests/unit/dataset/test_mmap_conversation_concurrency.py:test_get_conversation_ignores_competing_seek_between_seek_and_read—_InterleavingMmapinjects a competing seek between the reader's seek and its read, making the race deterministic. Mutation-verified: reintroducingseek()+read()fails it with the exact reported symptom (asked forconv-0, receivedconv-2's bytes,Invalid JSON: EOF while parsing a string at line 1 column 947).test_get_conversation_concurrent_readers_returns_own_bytes— 8 threads × 150 lookups over 24 records of varying size. Weaker: does not reproduce at the default switch interval, only ever suggestive.test_get_conversation_matches_written_data_under_prefault_setting— reads agree withMMAP_PREFAULTon and off.Verification
pytest tests/unit/— 17,700 passedpytest -m component_integration— 396 passedpre-commit— cleanMMAP_PREFAULT=falseNotes for reviewers
AIPERF_DATASET_MMAP_PREFAULTdefaults toTrue, so every run now pays the startup pass in the table above. It is the safety argument for removing the executor hop; disabling one without the other is not recommended._offsetsname now exists in bothMemoryMapDatasetBackingStore(writer,ConversationOffset) andMemoryMapDatasetClient(reader,tuple[int, int]). Different classes, different value types — mildly confusing when grepping.Fixes the issue reported in SemiAnalysisAI#34.
Summary by CodeRabbit
New Features
AIPERF_DATASET_MMAP_PREFAULTsetting, enabled by default, to preload mapped dataset data into the operating system’s page cache.Bug Fixes
Documentation