Skip to content

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

Description

@dg1sbg

Summary

There is no way to compare-and-swap a word of foreign memory on Clasp. Every route signals
MP:NOT-ATOMIC. SBCL and AllegroCL both offer one, so portable lock-free code that keeps its state
in mmaped or malloced memory — shared-memory rings, cross-process counters, allocator
metadata — has no Clasp implementation.

This is an enhancement request rather than a bug report, and it looks like unfinished work rather
than a deliberate exclusion: MP:CAS's own docstring says the place set "is planned to be
expanded", and, as detailed below, most of the compiler machinery already exists and is currently
unreachable.

Reproduction

(let* ((fd (clasp-ffi:%foreign-alloc 64))
       (addr (clasp-ffi:%foreign-data-address fd)))
  (clasp-ffi:%mem-set-uint32 (+ addr 8) 5)
  (format t "~&plain access works: ~a~%" (clasp-ffi:%mem-ref-uint32 (+ addr 8)))
  (mp:cas (clasp-ffi:%mem-ref-uint32 (+ addr 8)) 5 9))
plain access works: 5
NOT-ATOMIC: Don't know how to atomically access the place (%MEM-REF-UINT32 ...)

The same failure occurs for the generic accessor (mp:cas (clasp-ffi:%mem-ref fd :uint32 8) 5 9),
for mp:get-atomic-expansion on either form, and identically through CFFI's mem-ref. Plain
foreign reads and writes work fine; only the atomic route is missing.

The obvious workaround does not exist either: specialised arrays have no CAS, since only svref
(element type T) is a CAS-able place — src/lisp/kernel/cleavir/primop.lisp:672-674 asserts
(and (eql etype 't) (eql rank 1)) — so moving a shared counter into a
(simple-array (unsigned-byte 32) (*)) does not help.

Where it would go

The place registry is src/lisp/kernel/lsp/atomics.lisp: a place is CAS-able iff
(core:get-sysprop <accessor> 'atomic-expander) is non-nil, populated by define-atomic-expander
forms at :307-371, five more in src/lisp/kernel/clos/atomics.lisp, and one per structure
accessor at src/lisp/kernel/lsp/defstruct.lisp:362. Anything else reaches atomics.lisp:23 and
signals MP:NOT-ATOMIC (src/lisp/kernel/clos/conditions.lisp:748-753) — exactly the message
above. Note there are two copies to keep in sync: the kernel's and the cross-compiler's
src/cross-clasp/mp-atomics.lisp:324-441.

The machinery mostly exists already, and some of it is dead code

This is the part that made me think the request is small rather than large:

  • cmp:irc-cmpxchg (src/lisp/kernel/cleavir/cmpir.lisp:713-725) already emits LLVM cmpxchg,
    takes a raw pointer, an old value, a new value and a memory order, and is not Lisp-object
    specific.
  • cc-blir:cas (src/lisp/kernel/cleavir/blir.lisp:14-15) is a BIR instruction that CASes a raw
    address with a settable order, is fully translated to LLVM at
    src/lisp/kernel/cleavir/translate.lisp:1473-1479 — and nothing in the tree produces it.

    grep -rn 'cc-blir:cas' src/ returns only the class definition and its translator.
  • Its sibling cc-blir:memref2 already computes a byte-offset address from a pointer, which is the
    address arithmetic a foreign accessor needs.
  • cmp:irc-atomicrmw, with a complete operation table, likewise has zero callers.

One design point worth settling before any patch

clasp-ffi:%mem-ref dispatches on a runtime type keyword, while a CAS needs the C type at
compile time to choose a width. And MP:CAS's contract is EQ-matching on a Lisp object
(atomics.lisp:151-155), whereas the hardware compares raw 32- or 64-bit words — the FFI accessors
box their results (src/core/fli.cc:864-867 returns mk_fixnum_uint32(v)).

So the cheapest correct increment is probably not an MP:CAS place at all, but a typed
function — something like (core:cas-mem-uint32 address old new) returning the prior word or a
boolean — with MP:CAS support layered on top later for the constant-type case. That also sidesteps
the boxing question entirely. I would be glad to be told the preferred shape.

Relationship to #672

#672 is adjacent but does not cover this:
every place it enumerates is a Lisp place — svref, car, cdr, structure accessors,
symbol-value, symbol-plist, slot-value, instance access, arrays. Foreign memory, FFI,
mem-ref and pointers appear nowhere in its body or its comments. I am filing separately rather
than commenting there so the FFI case is not lost inside a five-year-old umbrella.

Minor, related: the MP:FENCE docstring is inaccurate

Not worth its own issue, but it is in the same area. src/core/mpPackage.cc:656 promises that
ORDER is "the same as accepted by ATOMIC, except that :relaxed does not make sense for fences
and will be rejected". :relaxed is rejected on no path: gen-fence's error
(src/lisp/kernel/cmp/codegen-special-form.lisp:43-45) is only reachable once a transform has
fired, and src/lisp/kernel/cleavir/bir-to-bmir.lisp:374-378 defines transforms for exactly four
orders — :sequentially-consistent, :acquire-release, :acquire, :release. :relaxed, and any
typo, falls through to the out-of-line function, which does (void)order and emits seq_cst.

To be fair to the design: the inlining path genuinely works — with cmp:*compile-native* bound to
T I measured dmb ishld for :acquire against dmb ish for the other three — and every fallback
is stronger than requested, never weaker, so this is a diagnostics and performance matter, not a
memory-model one. Only the docstring's claim is wrong.

Environment

  • clasp-boehmprecise-3.0.1-112-gc7faba5ec-non-cst, macOS arm64, Boehm precise GC
  • Verified against upstream/main @ 205f83feca442b694b9d1cb02ca4a573d37cc845
  • For comparison: SBCL (sb-ext:cas (sb-sys:sap-ref-32 sap off) old new) and AllegroCL
    (excl:atomic-conditional-setf (sys:memref-int addr off 0 :unsigned-long) new old) each compile
    to a single LOCK CMPXCHG

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions