ARM NEON scan helpers truncate 64-bit versions when USE_64_BIT=1, producing false conflicts #69

Closed
opened 2026-08-02 21:01:54 +00:00 by weaselbot · 0 comments
Member

Summary

When ConflictSet.cpp is compiled with -DUSE_64_BIT=1 on aarch64, the ARM NEON fast paths in checkMaxBetweenExclusiveImpl and scan16 treat InternalVersionT as a 32-bit value. They copy only the low 32 bits of each version into uint32x4_t lanes and compare them against a 32-bit readVersion. This yields wrong conflict/Commit decisions once any version exceeds 2^32 - 1.

This is a correctness bug in the aarch64 SIMD code, distinct from #68 (which is about reading uninitialized child slots).

Reproduction

On an aarch64 host with clang:

cmake -B build-64 -S . -DBUILD_TESTING=ON -DCMAKE_BUILD_TYPE=Release       -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++       -DCMAKE_CXX_FLAGS=-DUSE_64_BIT=1
cmake --build build-64 -j4
ctest --test-dir build-64 -R script_test_internal_version_zero --output-on-failure

Result:

1/2 Test #5730: script_test_internal_version_zero ............***Failed
AssertionError in test_conflict_set.py:28 (DebugConflictSet compares radix_tree vs skip_list)

Many corpus blackbox tests also abort, e.g.:

./build-64/driver corpus/eace36b916b0cde74b86ce8b89aaf9c0271e04b0

Output:

Expected commit, got conflict for read of [x8e...x00x01, x8e...x01) at version 6993204482

Root cause

ConflictSet.cpp has three ARM NEON blocks that load only 4 bytes per version:

  • scan16 with indices (used by Node48): lines 2109–2131

    uint32x4_t w4[4];
    memcpy(w4, vs, sizeof(w4));   // only 64 bytes; wrong when InternalVersionT is 8 bytes
    uint32_t rv;
    memcpy(&rv, &readVersion, sizeof(rv)); // only low 32 bits
    
  • scan16 without indices (used by Node256): lines 2176–2200

    • same uint32_t rv truncation.
  • checkMaxBetweenExclusiveImpl(Node16): lines 2324–2345

    • same truncation when comparing childMaxVersion against readVersion.

With USE_64_BIT=1, InternalVersionT stores an int64_t, so 8 bytes per element. The helpers copy at most 4 bytes per element and compare only the low 32 bits, which is incorrect for versions above 2^32.

Verification

Building the same configuration with the SIMD fallback makes the failures disappear:

cmake -B build-64-fallback -S . -DBUILD_TESTING=ON -DCMAKE_BUILD_TYPE=Release       -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++       -DCMAKE_CXX_FLAGS="-DUSE_64_BIT=1 -DUSE_SIMD_FALLBACK=ON"
cmake --build build-64-fallback -j4
ctest --test-dir build-64-fallback --output-on-failure -E valgrind

All non-valgrind tests pass.

Impact

Any aarch64 build using 64-bit versions (USE_64_BIT=1) can return spurious Conflict results (or miss real ones, depending on the high bits) once the version counter exceeds 2^32 - 1. This violates the documented result semantics and breaks the existing script_test_internal_version_zero regression test as well as many fuzz-corpus blackbox tests.

## Summary When `ConflictSet.cpp` is compiled with `-DUSE_64_BIT=1` on aarch64, the ARM NEON fast paths in `checkMaxBetweenExclusiveImpl` and `scan16` treat `InternalVersionT` as a 32-bit value. They copy only the low 32 bits of each version into `uint32x4_t` lanes and compare them against a 32-bit `readVersion`. This yields wrong conflict/Commit decisions once any version exceeds `2^32 - 1`. This is a correctness bug in the aarch64 SIMD code, distinct from #68 (which is about reading uninitialized child slots). ## Reproduction On an aarch64 host with clang: ```sh cmake -B build-64 -S . -DBUILD_TESTING=ON -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_CXX_FLAGS=-DUSE_64_BIT=1 cmake --build build-64 -j4 ctest --test-dir build-64 -R script_test_internal_version_zero --output-on-failure ``` Result: ``` 1/2 Test #5730: script_test_internal_version_zero ............***Failed AssertionError in test_conflict_set.py:28 (DebugConflictSet compares radix_tree vs skip_list) ``` Many corpus blackbox tests also abort, e.g.: ```sh ./build-64/driver corpus/eace36b916b0cde74b86ce8b89aaf9c0271e04b0 ``` Output: ``` Expected commit, got conflict for read of [x8e...x00x01, x8e...x01) at version 6993204482 ``` ## Root cause `ConflictSet.cpp` has three ARM NEON blocks that load only 4 bytes per version: - `scan16` with indices (used by `Node48`): lines 2109–2131 ```cpp uint32x4_t w4[4]; memcpy(w4, vs, sizeof(w4)); // only 64 bytes; wrong when InternalVersionT is 8 bytes uint32_t rv; memcpy(&rv, &readVersion, sizeof(rv)); // only low 32 bits ``` - `scan16` without indices (used by `Node256`): lines 2176–2200 - same `uint32_t rv` truncation. - `checkMaxBetweenExclusiveImpl(Node16)`: lines 2324–2345 - same truncation when comparing `childMaxVersion` against `readVersion`. With `USE_64_BIT=1`, `InternalVersionT` stores an `int64_t`, so 8 bytes per element. The helpers copy at most 4 bytes per element and compare only the low 32 bits, which is incorrect for versions above `2^32`. ## Verification Building the same configuration with the SIMD fallback makes the failures disappear: ```sh cmake -B build-64-fallback -S . -DBUILD_TESTING=ON -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_CXX_FLAGS="-DUSE_64_BIT=1 -DUSE_SIMD_FALLBACK=ON" cmake --build build-64-fallback -j4 ctest --test-dir build-64-fallback --output-on-failure -E valgrind ``` All non-valgrind tests pass. ## Impact Any aarch64 build using 64-bit versions (`USE_64_BIT=1`) can return spurious `Conflict` results (or miss real ones, depending on the high bits) once the version counter exceeds `2^32 - 1`. This violates the documented result semantics and breaks the existing `script_test_internal_version_zero` regression test as well as many fuzz-corpus blackbox tests.
weaselbot was assigned by andrew 2026-08-03 02:27:09 +00:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: weaselab/conflict-set#69