Skip to content

Use std::atomic, and delete the hand-written atomics - #81

Merged
d-torrance merged 4 commits into
Macaulay2:masterfrom
d-torrance:rm-custom-atomics
Sep 2, 2026
Merged

d-torrance merged 4 commits into
Macaulay2:masterfrom
d-torrance:rm-custom-atomics

Conversation

@d-torrance

Copy link
Copy Markdown
Member

src/mathicgb/Atomic.hpp implemented atomic load and store by hand — compiler
barriers via __asm__ __volatile__ ("" ::: "memory"), CPU fences via
__sync_synchronize(), and a CAS loop on __sync_bool_compare_and_swap.
stdinc.h defined MATHICGB_USE_CUSTOM_ATOMIC_X86_X64 in both the _MSC_VER
and __GNUC__ branches, so on x86 and x64 — every ordinary Linux build — that,
and not std::atomic, is what MonomialMap and FixedSizeMonomialMap used.

Its own comment gave the premise to test:

it is surprising that any compiler ships with a std::atomic that is worse than
this - but that is very much the case.

Written in 2013 against GCC 4.x. It is no longer true, and the measurement is
not close.

What the compiler actually emits

Both implementations, same translation unit, GCC 13.3 at -O2, x86-64:

order custom std::atomic
load relaxed / acquire / seq_cst movq movq
store relaxed / release movq movq
store seq_cst movq; lock cmpxchgq; jne xchgq

Identical for every operation on the hot path, and on the one operation where
they differ the hand-written version is the worse of the two: a
compare-and-swap with a retry branch where std::atomic emits a single locked
exchange. So this is not a wash — it removes a slower implementation.

That seq_cst store is used once, at MonomialMap.hpp:188, on the map-growth
path. Traversal and insertion are relaxed, consume and release, which compile
the same either way — which is why timing cannot see the change: interleaved
runs of hyclic8-101-trimmed and cyclic7 at one and four threads, nine reps
each, land between −0.5% and +4.4% against 3–7% run-to-run deviation. Where the
emitted code is the same there is nothing to time.

The commits

  1. 1503063 — delete the hand-written implementation. A pure deletion, 329
    lines, no additions.
  2. ac7225e — remove MATHICGB_USE_FAKE_ATOMIC. It was added in c1105ec
    (2012-11-06) alongside the custom atomics, to measure their overhead. With
    those gone the whole difference between it and std::atomic is one
    instruction. It is also a quiet trap: nothing defines the macro, and a build
    that does passes 246/246 while running the suite multithreaded under TBB with
    no synchronization at all.
  3. 84989af — remove Atomic.hpp itself and use std::atomic directly.
  4. 3487b2c — update doc/description.txt, which described the deleted
    file and posed a project this branch answers.

On removing the wrapper, and why the comments changed

After the first two commits Atomic<T> only forwarded to a std::atomic
member, so the third commit removes it. It differed from std::atomic in two
ways, and one of them is load-bearing: Atomic() value-initialized, where
std::atomic's default constructor is trivial in C++17 and leaves the value
indeterminate. That is exactly why FixedSizeMonomialMap nulls its buckets by
hand.

Measured rather than assumed, by deleting the nulling from both constructors:

with Atomic<T> with plain std::atomic<T>
unit tests 246/246 pass SIGSEGV

valgrind on the second: Conditional jump or move depends on uninitialised value(s) in F4MatrixBuilder2::Builder::findOrCreateColumn, under TBB —
garbage bucket pointers walked as a hash chain. So both constructors now say why
the nulling is required. The existing comment is kept as it was, minus one claim
that is no longer true: new std::atomic<void*>[64]() into deliberately dirtied
memory comes back fully null on GCC 13.3. Switching to that form would mean
changing make_unique_array, which PolyHashTable.cpp and mathicgb.cpp also
use, so the explicit nulling stays.

The alignment assertion went with the wrapper. It was load-bearing when the
member was a raw T and atomicity depended on alignment; a std::atomic aligns
itself, and alignof(std::atomic<void*>) is 8.

Verification

cmake Release and Debug both pass 246/246, autotools make check and make distcheck pass, and valgrind reports zero errors on an F4 run. Single-threaded
output is byte-identical to the old code on cyclic5, cyclic7,
hyclic8-101-trimmed and hilbertkunz1.

Multithreaded output cannot be compared that way, which is worth recording: the
same binary produces a different .gb on five consecutive cyclic7 runs, and
identical output at -threadCount 1. A first pass at this comparison looked
like a discrepancy between the two builds and was not one.

This is also the first branch to run under the 16-cell matrix from PR #80, which
is more than routine here: Atomic.hpp compiled differently on x86 than
anywhere else, so the macOS and arm64 cells are the interesting ones.

🤖 Generated with Claude Code

d-torrance and others added 4 commits September 1, 2026 14:40
Atomic.hpp carried a hand-written implementation of atomic load and store
-- compiler barriers via __asm__ __volatile__ ("" ::: "memory"), CPU
fences via __sync_synchronize(), and a CAS loop on
__sync_bool_compare_and_swap.  stdinc.h defined
MATHICGB_USE_CUSTOM_ATOMIC_X86_X64 in both the _MSC_VER and __GNUC__
branches, so on x86 and x64 that, and not std::atomic, is what
MonomialMap and FixedSizeMonomialMap have been using.

Its own comment gave the premise: "it is surprising that any compiler
ships with a std::atomic that is worse than this - but that is very much
the case."  That was written in 2013 against GCC 4.x and it is no longer
true.  Compiling both implementations at -O2 with GCC 13.3 on x86-64
and comparing what comes out:

  order                custom                       std::atomic
  ------------------------------------------------------------------
  load relaxed         movq                         movq
  load acquire         movq                         movq
  load seq_cst         movq                         movq
  store relaxed        movq                         movq
  store release        movq                         movq
  store seq_cst        movq; lock cmpxchgq; jne     xchgq

Identical for every operation on the hot path, and on the one operation
where they differ the hand-written version is the worse of the two: a
compare-and-swap with a retry branch where std::atomic emits a single
locked exchange.  So this is not a wash.  It removes a slower
implementation.

That store is used once, at MonomialMap.hpp:188, on the map-growth path.
Everything in the traversal and insertion paths -- relaxed, consume and
release -- compiles to the same instructions either way, which is why
timing cannot see the change: interleaved runs of hyclic8-101-trimmed and
cyclic7 at one and four threads, nine reps each, land between -0.5% and
+4.4% with run-to-run deviation of 3-7%.  Where the emitted code is the
same there is nothing to time.

Also gone with it: the AtomicInternal barrier helpers and seqCstStore,
which existed only to serve the custom class, and the class comment's
claim about MSVC 2012, which cannot apply to a project that has required
C++17 since PR #60 and whose VS2012 files were removed in PR #78.

MATHICGB_USE_FAKE_ATOMIC is untouched and still compiles.  It selects a
no-synchronization implementation for measuring the overhead of the
atomic operations, which is a separate facility from the one removed
here.

Verified: cmake Release and Debug both pass 246/246, autotools make check
and make distcheck pass, a build with -DMATHICGB_USE_FAKE_ATOMIC still
compiles, and single-threaded output is byte-identical to the old code on
cyclic5, cyclic7, hyclic8-101-trimmed and hilbertkunz1.  Multithreaded
output cannot be compared this way: the same binary produces a different
.gb on five consecutive cyclic7 runs, and identical output at
-threadCount 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FakeAtomic is a no-op atomic: load returns the member, store assigns it,
with no atomicity, no ordering and no constraint on the compiler.  It
arrived in c1105ec, on 2012-11-06, in the same commit that added the
hand-written atomics, and that commit says what it was for -- to
"determine the overhead of the ordering constraints ... IF using the
custom Atomic and not std::atomic".  It was an instrument for measuring
the implementation the previous commit deleted.

There is no longer anything for it to measure.  Compiling the same
translation unit with GCC 13.3 at -O2, the whole difference between
FakeAtomic and std::atomic is one instruction -- the sequentially
consistent store, movq against xchgq.  Every load, and the relaxed and
release stores, are byte identical.  That store happens once per map
growth, at MonomialMap.hpp:188.

It is also a trap.  Nothing defines the macro: there is no configure
option, no cmake option and no CI cell, only a hand-written -D.  Build
that way and the unit tests pass, 246 of 246, which is worse than
failing: the suite runs multithreaded under TBB, so those runs contain
real data races that happen not to manifest on x86-64, and a green suite
invites the conclusion that the configuration is supported.

With FakeAtomic gone, ChooseAtomic has one implementation to choose
from, so it goes too and Atomic holds a std::atomic<T> directly.
Atomic.hpp is 53 lines, from 398 before this pair of commits.  The class
still earns its place: it asserts alignment and it withholds operator=
and operator T() so that loads and stores have to be written out.

A build that still passes -DMATHICGB_USE_FAKE_ATOMIC is not an error;
the macro is simply ignored.

Verified: cmake Release and Debug both pass 246/246.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With the hand-written implementation and the fake-atomic switch gone,
Atomic<T> was a wrapper that forwarded load and store to a std::atomic
member. Its own comment said it was equivalent to std::atomic<T>, and
after those two commits that is nearly true.

Nearly, because it differed in two ways. It withheld operator= and
operator T(), which std::atomic provides and which do sequentially
consistent loads and stores silently -- and a seq_cst store is the one
operation with a cost here, xchgq against a plain movq. And its
constructor value-initialized, where std::atomic's default constructor
is trivial in C++17 and leaves the value indeterminate.

The second difference is the one that mattered, and it is the reason
FixedSizeMonomialMap nulls its buckets by hand. Removing the wrapper
without saying so would leave that nulling looking like belt and
braces. Measured, with the nulling taken out of the two constructors:
with the wrapper the unit tests still pass 246/246, because its
constructor zeroed the array; with plain std::atomic the same build
segfaults, and valgrind reports the buckets being walked as a hash
chain in F4MatrixBuilder2::findOrCreateColumn under TBB.

The first constructor already explained this, and now does so about the
code as written rather than by analogy, since the member really is a
std::atomic. It is left as it was, except that its claim that new
int[x]() "is supposed to zero initialize but this apparently does not
work on GCC" is dropped: new std::atomic<void*>[64]() into deliberately
dirtied memory comes back fully null on GCC 13.3, and a comment that now
has to be relied on should not carry a false aside. Using that
value-initializing form here would mean changing make_unique_array,
which PolyHashTable and mathicgb.cpp also use, so the explicit nulling
stays. The second constructor allocates its buckets the same way and now
points at the first.

The alignment assertion goes with the wrapper. It was load-bearing when
the member was a raw T and atomicity depended on alignment; a
std::atomic aligns itself, and alignof(std::atomic<void*>) is 8.

Verified: cmake Release and Debug pass 246/246, autotools make
distcheck passes, and valgrind reports no errors on an F4 run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The file list still described Atomic.hpp as a MathicGB alternative to
std::atomic, necessary "because the std::atomic implementations that
shipped with GCC and MSVC were so slow that they were just completely
unusable", and documented MATHICGB_USE_FAKE_ATOMIC. None of that
survives this branch.

The section also posed this:

  Project (medium-effort, easy-difficulty): Figure out if GCC and MSVC
  really do ship a usable-speed std::atomic now and, if so, which
  versions are good and which are bad. Then let Atomic be implemented
  in terms of std::atomic on those good versions while retaining the
  fast custom implementation for the bad versions.

The first commit on this branch answers the first half for GCC 13.3:
usable-speed, and in fact better than the custom implementation on the
one operation where the two differ. The second half is moot -- there
are no bad versions left to retain a custom implementation for, and it
is gone.

Elsewhere the description of F4MatrixBuilder2's hash table said it is
implemented using "std::atomic (well, actually mgb::Atomic, but it's
the same thing)". Now it is std::atomic, so the aside goes and the
paragraph is rewrapped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@d-torrance
d-torrance merged commit eb2581c into Macaulay2:master Sep 2, 2026
17 checks passed
d-torrance added a commit that referenced this pull request Sep 18, 2026
MATHICGB_STREAM_CHECK asserted and then threw, so in a Debug build the
assert fired first and the twenty checks in the public streaming
interface aborted where the throw would have reported the problem.

What they report is caller error -- calling idealBegin() twice,
appending more terms than were promised -- not a bug in the library, so
throwing is the whole of the right answer and the assert is simply in
the way.

Nothing had ever caught this. Every test that drives an ideal stream
follows the protocol, so no check had fired, and before PR #80 no CI
cell built this code with assertions at all. The new test constructs a
StreamStateChecker directly and walks it into three violations, which
is the first time any of these twenty checks has been exercised.

The message drops "Assert expression:" for "Failed check:", there being
no assert left to name.

This also clears the 19 clang warnings that PR #81's CI turned up in the
macOS debug cells. They came from the assert(("message", condition))
idiom, which puts a comma operator with a no-effect left operand into
every expansion; deleting the assert removes the idiom. clang 18.1.3
reports 19 warnings in this file before the change and 0 after, and GCC
never warned.

One thing deliberately left out: StreamStateChecker's constructor
catches its own check without rethrowing, so with the assert gone a
composite modulus double-frees instead of aborting. That path is
unreachable through the public interface -- the modulus comes from a
GroebnerConfiguration that has already rejected a composite one -- and
the real fix is to stop owning the Pimpl by raw pointer. Left for its
own item, which is why no composite-modulus test appears here.

Verified: 251/251 in cmake Release and Debug, no build warnings from
GCC 13.3, and the new test passes in both configurations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant