Repository navigation
fix(mmap): make get_conversation thread-safe, prefault pages, drop executor hop #1245
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
ce2dec9
fix(dataset/mmap): make get_conversation thread-safe, prefault pages,…
ajcasagrande 4678074
test(dataset/mmap): annotate test helper signatures
ajcasagrande f8e24ea
test(dataset/mmap): stop backing store so mmap files are unlinked
ajcasagrande 562fdfa
docs(dataset/mmap): note major faults still possible when MMAP_PREFAU…
ajcasagrande e466182
test(dataset/mmap): stop store even if client construction fails
ajcasagrande 31ae145
Merge branch 'main' into ajc/mmap-threadsafe-prefault
ajcasagrande 898d6ac
Merge branch 'main' into ajc/mmap-threadsafe-prefault
ajcasagrande File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
172 changes: 172 additions & 0 deletions
172
tests/unit/dataset/test_mmap_conversation_concurrency.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,172 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| """Concurrency and resource-lifetime tests for MemoryMapDatasetClient. | ||
|
|
||
| ``get_conversation`` used to read through the mmap's shared file position | ||
| (``seek()`` then ``read()``). Any two readers sharing one client could | ||
| interleave those calls and serve each other's bytes, surfacing either as a | ||
| spurious ``MemoryMapSerializationError`` on a random conversation or, when the | ||
| foreign slice happened to parse, as silently wrong data. | ||
|
|
||
| The reads are now position-free slices, so no reader depends on the shared | ||
| position. | ||
| """ | ||
|
|
||
| import mmap | ||
| from collections.abc import AsyncIterator | ||
| from concurrent.futures import ThreadPoolExecutor | ||
| from contextlib import asynccontextmanager | ||
| from pathlib import Path | ||
| from typing import Any | ||
|
|
||
| import pytest | ||
|
|
||
| from aiperf.common.enums import MemoryMapFormat | ||
| from aiperf.common.models import Conversation, Text, Turn | ||
| from aiperf.dataset.memory_map_utils import ( | ||
| MemoryMapDatasetBackingStore, | ||
| MemoryMapDatasetClient, | ||
| ) | ||
|
|
||
| # Wildly varying sizes so a cross-read lands mid-record and is caught either as | ||
| # a decode error or as a mismatched session_id. | ||
| _CONVERSATION_COUNT = 24 | ||
| _THREADS = 8 | ||
| _LOOKUPS_PER_THREAD = 150 | ||
|
|
||
|
|
||
| def _make_conversation(index: int) -> Conversation: | ||
| """Conversation whose size grows with ``index`` and whose text encodes it.""" | ||
| text = f"conv-{index}:" + ("x" * (64 * (index + 1))) | ||
| return Conversation( | ||
| session_id=f"conv-{index}", | ||
| turns=[Turn(role="user", texts=[Text(contents=[text])])], | ||
| ) | ||
|
|
||
|
|
||
| @asynccontextmanager | ||
| async def _open_client( | ||
| tmp_path: Path, benchmark_id: str, count: int | ||
| ) -> AsyncIterator[MemoryMapDatasetClient]: | ||
| """Write ``count`` conversations and open a client over them. | ||
|
|
||
| The backing store is stopped on exit, so its ``@on_stop`` cleanup unlinks | ||
| the mmap files instead of leaking them for the run's lifetime. The client | ||
| is closed first: the store's cleanup deletes the files it maps. | ||
| """ | ||
| store = MemoryMapDatasetBackingStore( | ||
| benchmark_id=benchmark_id, format=MemoryMapFormat.CONVERSATION | ||
| ) | ||
| await store.initialize() | ||
| for i in range(count): | ||
| await store.add_conversation(f"conv-{i}", _make_conversation(i)) | ||
| await store.finalize() | ||
|
|
||
| metadata = store.get_client_metadata() | ||
| client: MemoryMapDatasetClient | None = None | ||
| try: | ||
| # Constructed inside the guard: if opening the mmap raises, the store | ||
| # still gets stopped and its files still get unlinked. | ||
| client = MemoryMapDatasetClient( | ||
| metadata.data_file_path, | ||
| metadata.index_file_path, | ||
| ) | ||
| yield client | ||
| finally: | ||
| if client is not None: | ||
| client.close() | ||
| await store.stop() | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_get_conversation_concurrent_readers_returns_own_bytes( | ||
| tmp_path: Path, monkeypatch: pytest.MonkeyPatch | ||
| ) -> None: | ||
| """Concurrent get_conversation() calls must not read each other's bytes.""" | ||
| monkeypatch.setenv("AIPERF_DATASET_MMAP_BASE_PATH", str(tmp_path)) | ||
| async with _open_client( | ||
| tmp_path, "test_conv_concurrency", _CONVERSATION_COUNT | ||
| ) as client: | ||
|
|
||
| def _read_many(thread_index: int) -> None: | ||
| for n in range(_LOOKUPS_PER_THREAD): | ||
| # Stagger the starting id per thread so the interleaving covers | ||
| # many (offset, size) pairs rather than repeatedly hitting one | ||
| # record. | ||
| i = (thread_index + n) % _CONVERSATION_COUNT | ||
| conv = client.get_conversation(f"conv-{i}") | ||
| assert conv.session_id == f"conv-{i}" | ||
|
|
||
| with ThreadPoolExecutor(max_workers=_THREADS) as pool: | ||
| # list() resolves every future so exceptions propagate. | ||
| list(pool.map(_read_many, range(_THREADS))) | ||
|
|
||
|
|
||
| class _InterleavingMmap: | ||
| """mmap wrapper that moves the shared position between seek() and read(). | ||
|
|
||
| This is the race, made deterministic. Merely leaving a stale position | ||
| behind proves nothing: a ``seek()`` + ``read()`` reader overwrites it on | ||
| the way in and still returns the right record. The bug needs a competing | ||
| seek to land *after* this reader seeks and *before* it reads. | ||
|
|
||
| A position-free reader goes through ``__getitem__`` and is unaffected. | ||
| """ | ||
|
|
||
| def __init__(self, real: mmap.mmap, foreign_offset: int) -> None: | ||
| self._real = real | ||
| self._foreign_offset = foreign_offset | ||
|
|
||
| def seek(self, pos: int) -> None: | ||
| self._real.seek(pos) | ||
|
|
||
| def read(self, size: int) -> bytes: | ||
| # The competing reader lands here, between our seek and our read. | ||
| self._real.seek(self._foreign_offset) | ||
| return self._real.read(size) | ||
|
|
||
| def __getitem__(self, item: slice | int) -> bytes | int: | ||
| return self._real[item] | ||
|
|
||
| def __len__(self) -> int: | ||
| return len(self._real) | ||
|
|
||
| def __getattr__(self, name: str) -> Any: | ||
| return getattr(self._real, name) | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_get_conversation_ignores_competing_seek_between_seek_and_read( | ||
| tmp_path: Path, monkeypatch: pytest.MonkeyPatch | ||
| ) -> None: | ||
| """An interleaved foreign seek must not change what get_conversation reads. | ||
|
|
||
| Fails against a ``seek()`` + ``read()`` implementation, which serves the | ||
| foreign record's bytes. | ||
| """ | ||
| monkeypatch.setenv("AIPERF_DATASET_MMAP_BASE_PATH", str(tmp_path)) | ||
| async with _open_client(tmp_path, "test_conv_position", 3) as client: | ||
| foreign = client.index.offsets["conv-2"].offset | ||
| client.data_mmap = _InterleavingMmap(client.data_mmap, foreign) | ||
|
|
||
| conversation = client.get_conversation("conv-0") | ||
|
|
||
| assert conversation.session_id == "conv-0" | ||
| assert conversation.turns[0].texts[0].contents[0].startswith("conv-0:") | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| @pytest.mark.parametrize("prefault", [True, False]) # fmt: skip | ||
| async def test_get_conversation_matches_written_data_under_prefault_setting( | ||
| tmp_path: Path, monkeypatch: pytest.MonkeyPatch, prefault: bool | ||
| ) -> None: | ||
| """Reads are identical whether or not pages were prefaulted at open.""" | ||
| monkeypatch.setenv("AIPERF_DATASET_MMAP_BASE_PATH", str(tmp_path)) | ||
| monkeypatch.setattr( | ||
| "aiperf.common.environment.Environment.DATASET.MMAP_PREFAULT", prefault | ||
| ) | ||
| async with _open_client(tmp_path, f"test_conv_prefault_{prefault}", 5) as client: | ||
| for i in range(5): | ||
| conv = client.get_conversation(f"conv-{i}") | ||
| assert conv.session_id == f"conv-{i}" | ||
| assert conv.turns[0].texts[0].contents[0].startswith(f"conv-{i}:") |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.