Skip to content

fix(dataset): make MemoryMapDatasetClient.get_conversation thread-safe - #34

Open
qiaoxj07 wants to merge 1 commit into
SemiAnalysisAI:mainfrom
qiaoxj07:fix/mmap-get-conversation-thread-safe
Open

fix(dataset): make MemoryMapDatasetClient.get_conversation thread-safe#34
qiaoxj07 wants to merge 1 commit into
SemiAnalysisAI:mainfrom
qiaoxj07:fix/mmap-get-conversation-thread-safe

Conversation

@qiaoxj07

@qiaoxj07 qiaoxj07 commented Aug 3, 2026

Copy link
Copy Markdown

Problem

MemoryMapDatasetClient.get_conversation reads its record through the mmap's shared file position:

# src/aiperf/dataset/memory_map_utils.py
self.data_mmap.seek(offset_info.offset)
conv_bytes = self.data_mmap.read(offset_info.size)

MemoryMapDatasetClientStore.get_conversation dispatches this onto a thread pool (run_in_executor), so several threads share one mmap object and 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 bogus

MemoryMapSerializationError: Failed to decode conversation data:
  Invalid JSON: trailing characters at line 1 column N
  (or: EOF while parsing a string at line 1 column N)

on a random conversation — and, when the foreign slice happens to parse, as the wrong conversation being served with no error at all.

The two sibling readers in the same class (get_payload_bytes, get_payload_turn) already use position-free slicing; only get_conversation did not.

Fix

Use the same position-free slice.

Evidence this is a read-time race, not data corruption

From a 46-run benchmark sweep that all shared one mmap cache entry:

  • 4 runs hit the error, each on a different conversation. A bad record would fail identically in every run.
  • The on-disk files are perfect. cmp on the 6.4 GB dataset.dat between the cache source and the per-run copy → byte-identical; index.dat md5 identical; sizes identical.
  • Each named conversation decodes fine when read standalone from the same file with the same index.
  • The error 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; another had index size 18,094,940 but its JSON closed at column 1,154,734, i.e. it landed inside a different, shorter record.

Same bytes, same index, different victim each time.

Reproduction

A standalone stdlib script — one mmap, 8 threads, 16,000 reads of 64 KB–1 MB records, sys.setswitchinterval(1e-6) to widen the GIL handoff window:

seek()+read()  wrong reads: 143 / 16000
slice          wrong reads:   0 / 16000

At the default 5 ms switch interval a short synthetic loop does not hit the window (320,000 fast reads in 0.7 s → 0 hits), so I am not claiming a default-settings repro. The production workload widens the window considerably: records up to 18 MB, page faults against a 6.4 GB file (the method's own docstring notes "mmap reads can block on page faults"), and an asyncio loop competing for the GIL.

Tests

Adds tests/unit/dataset/test_mmap_conversation_concurrency.py:

  • test_get_conversation_is_thread_safe — 8 threads × 150 lookups over 24 conversations of varying sizes; every result must match the requested id.
  • test_get_conversation_ignores_shared_mmap_position — a stale mmap position must not change what is read.

Note on verification

I could not execute the test suite locally (no environment with the project's dependencies on this machine), so CI should be the gate on these tests. The one-line production change is a direct substitution of an API the same class already uses twice, and the race itself is demonstrated by the standalone script above.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

Try out this PR

Quick install:

pip install --upgrade --force-reinstall git+https://github.com/ai-dynamo/aiperf.git@0d1ef21f988df20fbd8ab52ed8843c479441b07a

Recommended 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@0d1ef21f988df20fbd8ab52ed8843c479441b07a

Last updated for commit: 0d1ef21Browse code

`get_conversation` read its record with

    self.data_mmap.seek(offset_info.offset)
    conv_bytes = self.data_mmap.read(offset_info.size)

but an `mmap` object carries a single shared file position, and
`MemoryMapDatasetClientStore.get_conversation` dispatches this onto a thread
pool via `run_in_executor`. Concurrent callers therefore interleave as

    thread A: seek(offset_a)
    thread B: seek(offset_b)
    thread A: read(size_a)     -> bytes from offset_b, length size_a

and thread A silently receives another conversation's bytes. The symptom is a
`MemoryMapSerializationError` ("Invalid JSON: trailing characters" or "EOF
while parsing a string") on a random conversation, or -- when the foreign
slice happens to parse -- the wrong conversation being served.

Switch to position-free slicing, which is what the two sibling readers in the
same class (`get_payload_bytes`, `get_payload_turn`) already do.

Evidence from a 46-run benchmark sweep sharing one cache entry: 4 runs hit the
error, each on a *different* conversation, while `cmp` showed the on-disk
dataset.dat and index.dat byte-identical to the cache source and every named
conversation decoded fine when read standalone. Same bytes, same index,
different victim each time -- i.e. a read-time race, not data corruption.

A standalone stdlib reproduction (one mmap, 8 threads, 16k reads of 64KB-1MB
records) with `sys.setswitchinterval(1e-6)` to widen the handoff window gives
143 wrong reads via seek()+read() and 0 via slicing. At the default 5ms switch
interval the window is narrow enough that a short synthetic loop does not hit
it; the production workload widens it with 18MB records, page faults on a
6.4GB file, and an asyncio loop competing for the GIL.

Adds tests/unit/dataset/test_mmap_conversation_concurrency.py covering the
concurrent-read invariant and position independence.
@qiaoxj07
qiaoxj07 force-pushed the fix/mmap-get-conversation-thread-safe branch from 375e3bb to 0d1ef21 Compare August 3, 2026 14:05
@qiaoxj07

qiaoxj07 commented Aug 3, 2026

Copy link
Copy Markdown
Author

Reproduced on the real dataset, with the shipped package

Ran an A/B directly inside a production container against a live 6.4 GB dataset.dat (9,843 conversations), importing the installed aiperf@818c3a5a2 — same class, same index, same _deserialize_conversation, the only variable being the read implementation. Read-only; nothing in site-packages was touched.

dataset: 9843 conversations
probing 32 of them, record sizes 1,266 .. 3,781,642 bytes

=== forced GIL switch interval (1e-06s), 8 threads ===
  seek   reads=3200  wrong_id=0  decode_err=34   (1.5s)
  slice  reads=3200  wrong_id=0  decode_err=0    (1.5s)

=== default GIL switch interval (0.005s), 8 threads ===
  seek   reads=3200  wrong_id=0  decode_err=0    (1.4s)
  slice  reads=3200  wrong_id=0  decode_err=0    (1.4s)

34 of 3,200 reads (1.06%) failed on the shipped seek()+read() path; 0 on the slice path. Every failure was a MemoryMapSerializationError — the exact exception class seen in the field — rather than a wrong-but-valid conversation, which is what you would expect: a cross-read almost always lands mid-record and yields invalid JSON.

The default-interval arm shows 0/3,200 for both. That is not evidence against the race: in production the observed rate is roughly one failure per ~500k conversation reads, so 3,200 reads is three orders of magnitude short of the sample needed. I did not run the ~500k-read version because the only nodes with a live mmap were running benchmarks whose measurements it would perturb.

@ajcasagrande ajcasagrande left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can confirm this is a valid fix.

Its also even more valid to combine this with removing the run_in_executor call in the first place. it was added under false pretenses.

ajcasagrande added a commit to ai-dynamo/aiperf that referenced this pull request Aug 4, 2026
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants