Skip to content

Add compare-and-swap on foreign memory: %CAS-MEM-UINT32 and -UINT64 - #1841

Open
dg1sbg wants to merge 1 commit into
clasp-developers:mainfrom
dg1sbg:feat/foreign-cas
Open

Add compare-and-swap on foreign memory: %CAS-MEM-UINT32 and -UINT64#1841
dg1sbg wants to merge 1 commit into
clasp-developers:mainfrom
dg1sbg:feat/foreign-cas

Conversation

@dg1sbg

@dg1sbg dg1sbg commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Fixes #1835.

MP:CAS signals MP:NOT-ATOMIC for every FFI accessor, and the specialised-array
workaround does not exist either (CORE:ACAS is transformed only for element type T
and rank 1, cleavir/primop.lisp:673), so there is currently no way to compare-and-swap
a word of foreign memory on Clasp.

This adds CLASP-FFI:%CAS-MEM-UINT32 and CLASP-FFI:%CAS-MEM-UINT64.

Why typed functions rather than an MP:CAS place

As noted in the issue, MP:CAS matches with EQ while the FFI accessors box their
results, so a uint64 above the fixnum range would compare two distinct bignums and
never swap; and %MEM-REF dispatches on a runtime type keyword, while choosing a swap
width needs the C type at compile time. A typed function sidesteps both. MP:CAS support
for the constant-type case can be layered on top later — I am happy to follow up with it
if you would prefer that shape.

Deliberately a CL_DEFUN rather than a new cc-blir:cas producer: the bytecode VM has no
atomic opcode, so a compiler-only route would leave EVAL, the REPL and every
--build-mode=bytecode build on a different path.

Contract

Returns the prior word, so the swap happened iff that value is = to the expected one
— the same contract MP:CAS has, and the same as SBCL's
(sb-ext:cas (sb-sys:sap-ref-32 sap off) old new). It falls out of
__atomic_compare_exchange_n, which overwrites expected with the word it saw.

An address that is not aligned for its width is refused with an error rather than swapped
non-atomically, and a static_assert fails the build if either width would need a
libatomic lock. Compiler builtins rather than std::atomic_ref because Darwin builds
-stdlib=libc++, which only gained atomic_ref in LLVM 19.

Tests

Six, in regression-tests/mp.lisp: semantics for both widths, 4 threads × 10000
CAS-increments per width, a neighbouring-word check that catches a wrong swap width, and
the unaligned refusal.

Verified on macOS arm64, boehmprecise, native: full suite 2013 successes against a
2007 baseline taken on this same base immediately before the change — +6 is exactly
the new tests, with the same 5 expected failures and no unexpected failures.

Cross-process check outside the suite, 4 processes × 50000 increments of one word of POSIX
shared memory behind a start barrier: 200000/200000 with the CAS, 114669/200000
with a plain read-modify-write control.

There is no way to atomically swap a word of foreign memory on Clasp. Every
route through MP:CAS signals MP:NOT-ATOMIC, because a place is CAS-able only if
its accessor carries an ATOMIC-EXPANDER sysprop (lsp/atomics.lisp:8,23) and no
FFI accessor has one. The obvious workaround does not exist either: CORE:ACAS is
transformed only for element type T and rank 1 (cleavir/primop.lisp:673), so
moving a shared counter into a (simple-array (unsigned-byte 32) (*)) does not
help. That leaves lock-free code whose state lives in mmap'ed or malloc'ed
memory -- shared-memory rings, cross-process counters, allocator metadata --
with no Clasp implementation, where SBCL has
(sb-ext:cas (sb-sys:sap-ref-32 sap off) old new).

This adds two typed functions rather than an MP:CAS place, which is the cheaper
and more honest increment. MP:CAS matches the old value with EQ while
CLASP-FFI:%MEM-REF-UINT64 boxes its result, so a value above the fixnum range
would compare two distinct bignums and never swap, whereas the hardware compares
raw words. A typed function sidesteps boxing entirely and needs no compile-time
knowledge of the C type, which the runtime-keyword-dispatching %MEM-REF cannot
supply. MP:CAS support for the constant-type case can be layered on top later.

The functions return the prior word, so the swap happened iff that value is =
to EXPECTED -- the same contract MP:CAS has, and the same as SBCL's. It falls
out of __atomic_compare_exchange_n, which overwrites EXPECTED with the word it
actually saw.

An address not aligned for its width is refused with an error rather than
swapped anyway. An unaligned atomic is not lock-free, and silently non-atomic is
the exact failure mode of the CORE:ACAS C++ fallback. A static_assert fails the
build if either width would need a libatomic lock. Compiler builtins rather than
std::atomic_ref because Darwin builds -stdlib=libc++, which only gained
atomic_ref in LLVM 19.

Six tests: semantics for both widths, four threads times 10000 CAS-increments
per width, a neighbouring-word check that catches a wrong swap width, and the
unaligned refusal. Measured across processes as well, 4 processes times 50000
increments of one word of POSIX shared memory behind a start barrier: exact with
the CAS, 114669 of 200000 with a plain read-modify-write control.

Fixes clasp-developers#1835
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.

No compare-and-swap on foreign memory: MP:CAS signals NOT-ATOMIC for every FFI accessor

1 participant