Skip to content

Fix BITCOUNT ... BIT throwing PlatformNotSupportedException on ARM64 - #2008

Open
hexonal wants to merge 1 commit into
microsoft:mainfrom
hexonal:fix-bitcount-bit-arm64
Open

Fix BITCOUNT ... BIT throwing PlatformNotSupportedException on ARM64#2008
hexonal wants to merge 1 commit into
microsoft:mainfrom
hexonal:fix-bitcount-bit-arm64

Conversation

@hexonal

@hexonal hexonal commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Follow-up to the portability gap I flagged at the end of #1971 and deliberately kept out of scope there.

Garnet publishes linux/arm64 images (docker-linux.yml:78) and builds linux-arm64 / linux-musl-arm64 / win-arm64 natives, so this reproduces on a shipped configuration.

The bug

BitIndexCount(byte, int, int) ends in Popcnt.X64.PopCount, an x86-only hardware intrinsic with no IsSupported guard. It is reachable only from the four-argument BITCOUNT key start end BIT form, so on any ARM64 host that command throws:

crit: Session[0] ProcessMessages threw an exception: System.PlatformNotSupportedException: Operation is not supported on this platform.
   at System.Runtime.Intrinsics.X86.Popcnt.X64.PopCount(UInt64 value)
   at Garnet.server.BitmapManager.BitIndexCount(Byte payload, Int32 startBitOffset, Int32 endBitOffset)
   at Garnet.server.BitmapManager.BitIndexCount(Byte* value, Int64 startOffset, Int64 endOffset)
   at Garnet.server.BitmapManager.BitCountDriver(...)
   ...
   at Garnet.server.RespServerSession.ProcessMessages(...)

Correcting my own earlier wording: in #1971 I described this as crashing the server process. It doesn't — I measured it properly this time. The exception unwinds out of ProcessMessages and terminates the RESP session: the issuing client gets no reply and a reset connection, while the server process stays up and other sessions are unaffected. I verified that directly — a second concurrent connection kept answering PING throughout, and a new connection opened fine afterwards.

Repro on macOS arm64, against an unmodified build of main:

SETBIT B 20 1        -> :0
BITCOUNT B           -> :1                    (plain form is fine)
BITCOUNT B 0 -1 BIT  -> connection closed, 0 bytes
PING                 -> Connection reset by peer

Redis returns 1 for that last command. Every BITCOUNT key start end BIT fails this way on ARM64, so the BIT-index form is unusable there.

The fix

One line: use BitOperations.PopCount, which is what __scalar_popc in this same file already does. #1034 ("Platform independent bitcount operation") made the scalar path portable; this call site was missed.

The counted value is (mask & payload) with both operands byte, so it is always in [0, 255] and the 32-bit overload returns the same count. I verified that exhaustively rather than by inspection: all 65536 (mask, payload) pairs and all 20736 reachable (payload, startBitOffset, endBitOffset) triples — including the real reverse() table — produce identical results under both expressions, zero mismatches.

On x86-64 with POPCNT this stays a single POPCNT instruction (32-bit operand rather than 64-bit); on ARM64 the JIT emits an AdvSimd CNT/ADDV sequence instead of throwing.

The SIMD helpers in this file also call Popcnt.X64, but their only call site is behind Avx2.IsSupported / Ssse3.IsSupported, both false on ARM64, so they are unreachable there and left alone. For what it's worth this was the only unguarded x86 intrinsic reachable on ARM64 anywhere in libs/ — the Bmi2 uses in GeoHash.cs, Sse42 in RespCommandHashLookup.cs (which already has an Arm.Crc32.Arm64 branch) and Sse.Prefetch0 in Tsavorite.cs are all properly guarded.

Relationship to #1994

@vazois#1994 fixed the mask arithmetic in BitIndexCount(byte*, long, long). This touches the final popcount in the BitIndexCount(byte, int, int) overload that code calls into; on ARM64 it throws before the corrected mask is ever counted. With both in, the BitmapBitCountBitBoundaryUnderflowTest you added in #1994 passes on ARM64 too.

Testing

No new test — GarnetBitmapTests already covers this. On ARM64, BitmapBitCountSimpleTest, BitmapBitCountBitBoundaryUnderflowTest and BitmapBitCountLongBitOffsetParsingTest fail without this change and pass with it. (#1034 likewise shipped without a new test.)

  • macOS arm64, .NET 10, Release: GarnetBitmapTests 355/358 → 358/358
  • Boundary checks against Redis semantics: 0 -1 BIT → 1, 16 23 BIT → 1, 0 0 BIT → 0, 0 19 BIT → 0, 20 20 BIT → 1, session stays alive
  • net8.0 builds clean (0 warnings, TreatWarningsAsErrors is repo-wide); its tests were not run — I only have the .NET 10 runtime here. Tests were run on net10.0.
  • dotnet format --verify-no-changes clean

This can't regress-test in CI: every test matrix is ubuntu-latest/windows-latest, both x64, so those three tests pass vacuously there with or without the fix. If you'd want coverage, an ubuntu-24.04-arm leg on the standalone test job would catch this class of bug — happy to send that as a separate PR rather than widen this one.

release/v1 has the identical line; happy to send the backport alongside #1998 if you want it.

Unrelated and pre-existing, mentioning it only because I noticed it while checking the guards: __simd_popcX128 is entered on Ssse3.IsSupported alone but its tail calls Popcnt.X64, and SSSE3 (Core 2, 2006) predates POPCNT (Nehalem, 2008) — so an SSSE3-without-POPCNT x64 CPU would hit the same exception there for payloads ≥ 128 bytes. Happy to file or fix that separately.

BitIndexCount(byte, int, int) ends in Popcnt.X64.PopCount, an x86-only
hardware intrinsic with no IsSupported guard. It is reachable only from
the four-argument BITCOUNT key start end BIT form, so on a host without
the x86 POPCNT instruction -- any ARM64 host -- that command throws
PlatformNotSupportedException out of ProcessMessages, which terminates
the RESP session: the client gets no reply and the connection is
dropped. The server process itself stays up and other sessions are
unaffected. The plain byte-offset forms are unaffected.

Use BitOperations.PopCount instead. That is what __scalar_popc in this
same file already does, since microsoft#1034 made the scalar path platform
independent; this call site was missed. The counted value is
(mask & payload) with both operands byte, so it is always in [0, 255]
and the 32-bit overload returns the same count -- verified exhaustively
over all 256 x 9 x 9 (payload, startBitOffset, endBitOffset) inputs. On
x86-64 with POPCNT this remains a single POPCNT instruction (32-bit
operand rather than 64-bit); on ARM64 the JIT emits an AdvSimd CNT/ADDV
sequence instead of throwing.

The SIMD helpers in this file also call Popcnt.X64, but their only call
site is behind Avx2.IsSupported / Ssse3.IsSupported checks that are both
false on ARM64, so they are unreachable there and left alone.

No new test: GarnetBitmapTests already covers this. On ARM64,
BitmapBitCountSimpleTest, BitmapBitCountBitBoundaryUnderflowTest and
BitmapBitCountLongBitOffsetParsingTest fail without this change and pass
with it (358/358 for the class). They cannot fail in CI because every
test matrix is ubuntu-latest/windows-latest, both x64.
Copilot AI review requested due to automatic review settings August 2, 2026 05:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes an ARM64 portability bug in Garnet’s BITCOUNT ... BIT path by removing an unguarded x86-only POPCNT intrinsic call and replacing it with a platform-independent popcount implementation.

Changes:

  • Replaced Popcnt.X64.PopCount(...) with BitOperations.PopCount(...) in BitmapManagerBitCount.BitIndexCount(byte, int, int) to avoid PlatformNotSupportedException on ARM64.
  • Keeps behavior equivalent for the masked-byte popcount while enabling correct execution on non-x86 platforms.

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