fix(dataset): make MemoryMapDatasetClient.get_conversation thread-safe - #34
fix(dataset): make MemoryMapDatasetClient.get_conversation thread-safe#34qiaoxj07 wants to merge 1 commit into
Conversation
Try out this PRQuick install: pip install --upgrade --force-reinstall git+https://github.com/ai-dynamo/aiperf.git@0d1ef21f988df20fbd8ab52ed8843c479441b07aRecommended 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@0d1ef21f988df20fbd8ab52ed8843c479441b07aLast updated for commit: |
`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.
375e3bb to
0d1ef21
Compare
Reproduced on the real dataset, with the shipped packageRan an A/B directly inside a production container against a live 6.4 GB 34 of 3,200 reads (1.06%) failed on the shipped 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
left a comment
There was a problem hiding this comment.
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.
… 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>
Problem
MemoryMapDatasetClient.get_conversationreads its record through the mmap's shared file position:MemoryMapDatasetClientStore.get_conversationdispatches this onto a thread pool (run_in_executor), so several threads share onemmapobject and interleave:Thread A silently gets another conversation's bytes. It surfaces as a bogus
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; onlyget_conversationdid 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:
cmpon the 6.4 GBdataset.datbetween the cache source and the per-run copy → byte-identical;index.datmd5 identical; sizes identical.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: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.