Convert ink_queue implementation to std::atomic - #13170
Conversation
e507f16 to
caf1333
Compare
caf1333 to
a106ab4
Compare
There was a problem hiding this comment.
Pull request overview
This PR refactors the ink_queue freelist / atomic list implementation to use std::atomic-based state (including a revised head_p representation) and adds unit tests/benchmarks to validate and measure the behavior. The goal is to eliminate UB from type punning and improve correctness around alignment and concurrency.
Changes:
- Refactor
head_pto an integral type and introducememcpy-based view/load/store helpers for pointer+version packing. - Update freelist/atomiclist operations to use
std::atomic(and add mutex-based mutual exclusion for pop paths). - Add Catch2 unit tests/benchmarks for freelist and atomic list behavior, and update build configuration accordingly.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
include/tscore/ink_queue.h |
Refactors head_p, adds atomic/mutex members to list types, and updates pointer/version access macros. |
src/tscore/ink_queue.cc |
Migrates freelist/atomiclist logic to std::atomic + new packing helpers; adds alignment checks and mutexes. |
src/tscore/unit_tests/test_ink_queue.cc |
New Catch2 unit tests and benchmarks for freelist/atomic list behavior. |
src/tscore/CMakeLists.txt |
Adds the new unit test and links atomic. |
src/proxy/logging/LogObject.cc |
Adapts CAS usage / version typing to the new head_p API. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (1)
cmake/Check128BitCas.cmake:45
CHECK_PROGRAMas written will not compile as C++:std::atomic<__int128>::compare_exchange_strongtakesexpectedby reference anddesiredas the second parameter, but here both arguments are rvalues and reversed. Also this file includesCheckCSourceCompilesbut callscheck_cxx_source_compiles, and the fallback usescheck_c_source_compileseven though the program is C++ (<atomic>). Please switch toinclude(CheckCXXSourceCompiles)and use a valid C++ compare-exchange snippet (with a mutableexpected) for both checks (including the-mcx16probe).
set(CHECK_PROGRAM
"
#include <atomic>
int main()
{
std::atomic<__int128> x{0};
return x.compare_exchange_strong(10, 0);
}
"
)
include(CheckCSourceCompiles)
check_cxx_source_compiles("${CHECK_PROGRAM}" TS_HAS_128BIT_CAS)
if(NOT TS_HAS_128BIT_CAS)
unset(TS_HAS_128BIT_CAS CACHE)
set(CMAKE_REQUIRED_FLAGS "-Werror -mcx16")
check_c_source_compiles("${CHECK_PROGRAM}" TS_HAS_128BIT_CAS)
set(NEED_MCX16 ${TS_HAS_128BIT_CAS})
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 8 comments.
Comments suppressed due to low confidence (2)
include/tscore/ink_queue.h:250
- INK_ATOMICLIST_EMPTY still passes the std::atomic<head_p> member directly into FREELIST_POINTER/TO_PTR. Since std::atomic is not implicitly convertible to head_p, this will not compile (and call sites like ProtectedQueue rely on this macro). Load the atomic (e.g., use .head.load(...)) before applying FREELIST_POINTER/TO_PTR, and update both NT/non-NT macro branches accordingly.
#if !defined(INK_QUEUE_NT)
#define INK_ATOMICLIST_EMPTY(_x) (!(TO_PTR(FREELIST_POINTER((_x.head)))))
#else
/* ink_queue_nt.c doesn't do the FROM/TO pointer swizzling */
#define INK_ATOMICLIST_EMPTY(_x) (!((FREELIST_POINTER((_x.head)))))
#endif
cmake/Check128BitCas.cmake:46
- Check128BitCas.cmake calls check_cxx_source_compiles but only includes CheckCSourceCompiles, which does not define that macro in standard CMake. Also, the fallback path still uses check_c_source_compiles even though CHECK_PROGRAM is now C++ (includes , std::atomic), so the -mcx16 probe will always fail. Include CheckCXXSourceCompiles and use check_cxx_source_compiles for both probes.
include(CheckCSourceCompiles)
check_cxx_source_compiles("${CHECK_PROGRAM}" TS_HAS_128BIT_CAS)
if(NOT TS_HAS_128BIT_CAS)
unset(TS_HAS_128BIT_CAS CACHE)
set(CMAKE_REQUIRED_FLAGS "-Werror -mcx16")
check_c_source_compiles("${CHECK_PROGRAM}" TS_HAS_128BIT_CAS)
set(NEED_MCX16 ${TS_HAS_128BIT_CAS})
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
include/tscore/ink_queue.h:256
INK_ATOMICLIST_EMPTYreadsInkAtomicList::headwithout an explicit.load(). This currently relies onstd::atomic's implicit conversion (which is not available for all specializations / libstdc++ implementations, notably for non-standard integral types like__int128), and it prevents choosing a cheaper memory order for an emptiness check. Use an explicit atomic load instead.
#if !defined(INK_QUEUE_NT)
#define INK_ATOMICLIST_EMPTY(_x) (!(TO_PTR(FREELIST_POINTER((_x.head)))))
#else
/* ink_queue_nt.c doesn't do the FROM/TO pointer swizzling */
#define INK_ATOMICLIST_EMPTY(_x) (!((FREELIST_POINTER((_x.head)))))
#endif
db619f4 to
1f4edc9
Compare
|
Rebased on 0847dc6 |
This change was authored with Claude Opus 4.7 and Claude Sonnet 4.6.
* Use `to_voidp_p` consistently
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
src/tscore/ink_queue.cc:199
ink_freelist_init()currently overwritesf->alignmentwithats_hugepage_size()when hugepages are enabled (lines 200-203). Butf->alignmentis also treated as the per-item alignment elsewhere (e.g. the newfreelist_free()/freelist_bulkfree()release asserts andis_next_ptr_aligned()). For hugepage-backed freelists that pack many small items into one hugepage allocation (e.g. IOBufferData blocks), only the first item in a chunk can be hugepage-aligned, so this makes the new alignment checks fail for most items and can abort in release builds.
// It is never useful to have alignment requirement looser than a page size
// so clip it. This makes the item alignment checks in the actual allocator simpler.
f->alignment = alignment;
f->use_hugepages = ats_hugepage_enabled() && use_hugepages;
f->hugepages_failure = 0;
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* Mutually exclude `ink_atomiclist_popall` and `ink_atomiclist_pop`
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
src/tscore/ink_queue.cc:210
- In hugepage mode (
use_hugepages == true),f->alignmentis currently overwritten withats_hugepage_size(). That makes the new release-assert alignment checks infreelist_free()/freelist_bulkfree()require every individual cell to be hugepage-aligned, which is impossible for typical freelists (e.g., IOBuffer freelists allocate many cells per hugepage-backed chunk). This will tripink_release_assert(is_addr_aligned(item, f->alignment))during chunk seeding / normal frees when hugepages are enabled.
f->alignment should remain the per-item alignment (clipped to at most ats_pagesize()), while hugepage size should only affect the chunk allocation alignment in freelist_new().
// It is never useful to have alignment requirement looser than a page size
// so clip it. This makes the item alignment checks in the actual allocator simpler.
f->alignment = alignment;
f->use_hugepages = ats_hugepage_enabled() && use_hugepages;
f->hugepages_failure = 0;
if (f->use_hugepages) {
// for hugepages, always make the allocation alignment on a hugepage boundary
f->alignment = ats_hugepage_size();
f->type_size = type_size;
} else {
if (f->alignment > ats_pagesize()) {
f->alignment = ats_pagesize();
}
// Make sure we align *all* the objects in the allocation, not just the first one
f->type_size = INK_ALIGN(type_size, f->alignment);
}
|
Apologies for stepping on your toes with #13571. My change is relatively constrained, mostly to get RISC-V support working; I was going to think about doing exactly what you do in this PR next. |
| set(TS_NEEDS_MCX16_FOR_128BIT_ATOMIC | ||
| ${NEED_MCX16} | ||
| CACHE BOOL "Whether -mcx16 is needed to compile 128bit atomics" | ||
| ) |
There was a problem hiding this comment.
Note: -mcx16 will not assume that cmpxchg16b is available, which is important for if we ever replace __sync_val_compare_and_swap with something more modern: https://gcc.gnu.org/bugzilla/show_bug.cgi?id=80878
There was a problem hiding this comment.
Thanks for this — the direction is right, and several pieces here are genuinely valuable: the std::atomic/memcpy/placement-new conversion removes real UB, the alignment assertions are sound, and the .load() conversions fix part of #13572. I ran your branch through a fairly deep review (stress tests on both head configurations, a memory-order audit, and benchmarks on the configurations your published numbers could not cover). The implementation itself held up well under stress; the problems I found are in the configuration gate and in what it silently changes. Details below, roughly in severity order.
1. The TS_HAS_128BIT_ATOMIC probe fails on every GCC build (blocker)
The probe in Check128BitAtomic.cmake compiles and links std::atomic<__int128> without -latomic. GCC never inlines 16-byte atomics, even with -mcx16 — it always emits __atomic_compare_exchange_16 library calls (GCC PR80878) — so the probe's link step fails on GCC regardless of flags. Only clang with -mcx16 passes. Verified on gcc 11.5 / clang 21:
g++ plain : undefined reference to `__atomic_compare_exchange_16'
g++ -mcx16 : undefined reference to `__atomic_compare_exchange_16'
clang++ plain : undefined reference to `__atomic_compare_exchange_16'
clang++ -mcx16 : LINKS
Building this branch confirms it: the gcc build gets #define TS_HAS_128BIT_ATOMIC 0, the clang build gets 1. I suspect this is also why your own "after" benchmark ran with a 64-bit head_p — it is not a hardware limitation, it is this probe.
Consequence: TS_128BIT_QUEUE is 0 on Linux x86-64 and aarch64 GCC builds — essentially every distro/production build — which silently drop from the 128-bit head (64-bit version field) to the packed head (15-bit version on x86-64, 11-bit on aarch64), with behavior now diverging by compiler.
2. The packed head's narrow version field breaks LogObject's reference counting (high, cascades from 1)
LogObject::_checkout_write increments the head version once per checkout, and on buffer swap credits m_references += FREELIST_VERSION(old_h) - 1 — the version field is a cumulative checkout count per buffer lifetime, not just an ABA tag. A default 9 MB log buffer with 100–200-byte entries absorbs roughly 45–90k checkouts before it fills. That exceeds a 15-bit version (32,768) and dwarfs an 11-bit one (2,048). A wrapped version undercounts m_references, so the flush thread can free a LogBuffer while writers still hold references.
This hazard already exists on master for the packed-tier platforms (ppc64/mips64), where nobody runs logging-heavy ATS. Because of finding 1, this PR would promote it to mainstream Linux gcc builds on x86-64 and aarch64.
3. With the head width held constant, the locks are a significant regression
Ed. note: This benchmark was run with a Debug release build and is invalid; see next comment below for a valid benchmarking run.
The PR's before/after comparison changes two variables at once (128-bit lock-free head → 64-bit packed head + locks). I benchmarked tools/benchmark/benchmark_FreeList with the head width held constant (clang builds, where TS_128BIT_QUEUE=1 on both sides), plus the gcc pairing. Ice Lake, 8 vCPUs, --benchmark-samples 30, dev preset:
| Configuration | 1 thread | 2 threads | 6 threads |
|---|---|---|---|
| master, clang (128-bit head, lock-free) | 231.5 ms | 661.7 ms | 2.157 s |
| this PR, clang (128-bit head + pop locks) | 365.9 ms (+58%) | 901.0 ms (+36%) | 3.323 s (+54%) |
master, gcc (128-bit head, __sync, lock-free) |
245.8 ms | 759.7 ms | 2.719 s |
| this PR, gcc (packed-64 head + pop locks) | 247.0 ms (+0.5%) | 756.6 ms (−0.4%) | 3.048 s (+12%) |
With the same 128-bit head, the locked version is 36–58% slower at every thread count on this host. Even the packed-64 configuration is neutral at 1–2 threads and ~12% slower at 6. The "significant performance improvement as number of threads increases" in the description appears to be the 128-bit→64-bit head change (finding 1), not the locks. One hypothesis for why the hybrid regime does poorly: locked pops and lock-free CAS pushes contend for the same head cache line, so pushers extend the poppers' critical sections.
4. ink_atomiclist_remove is the one mutation left unlocked (medium)
Pops and popall now serialize on l->m, but ink_atomiclist_remove still CASes the head lock-free, so a remove racing a locked pop can recreate exactly the use-after-free window this PR closes for #11640. Today this is safe only by the legacy discipline (remove callers must be the sole popper) — which the current callers happen to satisfy (NetHandler removes from the enable lists only on the owning net thread). Since remove is rare, taking the lock there too is cheap defense and makes the #11640 fix unconditional.
Smaller notes
- Chunk allocation in
freelist_newnow happens while holding the pop mutex, so a slow hugepage allocation (which can stall in compaction for tens of ms) blocks all poppers of that freelist. In exchange it eliminates the old racing-duplicate-chunk behavior — probably a fine trade, but worth stating in the description. alignment = std::lcm(alignment, alignof(InkFreeList))conflates the alignment of theInkFreeListbookkeeping struct with the alignment (and hence paddedtype_size) of user objects: an 8-byte-aligned small-object freelist now gets 16-byte object stride. Allocating the struct withalignof(InkFreeList)separately from the user alignment would avoid the bloat.new (item) void *{}value-initializes (a dead nullptr store) before the real next-pointer store;new (item) void *avoids it. Infreelist_bulkfreethe placement-new also re-runs on every CAS retry.
What held up under testing
For completeness, the things I tried to break and could not: a 6-thread conservation/double-reachability stress test (each popped item is claimed via an atomic flag, so an item reachable from two places is detected) passes repeatedly on both head configurations of this branch; the memory orders are correctly paired (pop CAS acquire/acquire, free CAS release/relaxed, push seq-cst) with the one DEBUG-only dummy_forced_read race you already document; all structs that gained a std::mutex are new-allocated so constructors run; and a full tree build passes with gcc. The core implementation is solid — the issues are all in the gate and its consequences.
Suggested fix, and relation to #13571
#13571 (just opened for the riscv64 build break, #13555) adds exactly the missing piece: a probe that retries 128-bit atomics with -latomic and links it when needed (TS_HAS_128BIT_CAS_LIBATOMIC / TS_NEEDS_LIBATOMIC_FOR_CAS). If TS_128BIT_QUEUE accepted that tier, then:
- gcc/x86-64 keeps a 128-bit head: libatomic's ifunc resolves to
cmpxchg16bat runtime, so it stays lock-free with a small call overhead (~2 ns/op in my microbenchmarks) instead of silently downgrading to 15-bit versions — findings 1 and 2 disappear. - riscv64 gets a correct lock-based head, so this PR would also fix #13555.
- Your design already tolerates lock-based heads on the pop side — the mutex is there anyway.
Happy to coordinate: if #13571 lands first, this PR rebases onto it and folds TS_HAS_128BIT_CAS_LIBATOMIC into the TS_128BIT_QUEUE gate, then re-run the benchmark with head width held constant to evaluate the locks on their own merits (or make the pop lock conditional on the platform lacking lock-free atomics).
|
Following up on my earlier review with a correction, a root-cause analysis of the regression, and a prototype that removes it. Correction to my benchmark tableMy earlier numbers were built with the
So in Release, the locks genuinely win under heavy contention — your instinct there was correct and my earlier claim of a contended regression was an artifact. The real cost is on the uncontended fast path: +29% per op at 1 thread on the 128-bit tier. Why this explains the end-to-end regressionI tested three mechanisms in isolation (microbenchmark with a versioned 128-bit Treiber stack):
In production, ProxyAllocator thread caches absorb most freelist traffic and ProtectedQueue operations rarely collide, so the fleet mostly runs the uncontended path — paying the +29% tax on every op while rarely collecting the contended win. That is consistent with the 5-10% end-to-end regression you measured internally. Prototype: same UB fix, no locksThe #11640 bug is a data race on the speculative next-pointer read — the version-tagged CAS has always correctly rejected stale values; the algorithm does not need mutual exclusion. The prototype keeps everything else in this PR (memcpy views, placement-new lifetime handling, alignment asserts, LSan annotations) and:
Relaxed slot accesses compile to plain loads/stores; the release/acquire edge stays on the head CAS (slot store sequenced-before the release CAS; popper's acquire on the head synchronizes-with it). Two details worth noting because they are easy to miss: the value-initializing placement-news ( Branch: phongn@988a139a57 (stacked on this PR's head, cherry-pick friendly) Validation: passes a 6-thread conservation/double-reachability stress test repeatedly on both head configurations, in both Debug and Release; performance is exactly master at 1 thread (56.7 ms) and ≈ master at 6 threads on the 128-bit tier. Honest residuals: the Debug-only races remain ( Given your internal 5-10% end-to-end regression was likely the uncontended mutex tax, the prototype should recover it — it would be great to see it through your internal benchmarking. Also worth noting from the table: gcc's packed-64 lock-free configuration (30 ms) is by far the fastest, so once the LogObject version-width hazard from my earlier review is addressed, deliberately selecting the packed head could be a legitimate follow-up optimization rather than a probe accident. |
The PR title is fairly self-explanatory, but the design choices here deserve explicit mention.
TS_HAS_128BIT_ATOMICso that 128-bit head objects depends on both__syncand__atomicIn our build pipeline, OSX and FreeBSD support this.
This has been done to eliminate type punning, which was done all over the implementation, and is UB. The pointer and version are now always set through the macros
FREELIST_POINTER,FREELIST_VERSION, andSET_FREELIST_POINTER_VERSION, which use a separate {pointer, version} struct type andmemcpyon platforms where this is appropriate (see preprocessor defs for the list).This is necessary so that the head of the list will be an atomic integer type instead of an atomic class type to be sure it can use 128bit atomic hardware instructions on platforms that support them.
void*alignment requirementsThis is a minor bug in master.
This particular change was made to fully fix #11640 - there is a minor data race without it in that the second pointer from the list head can be overwritten by an allocator's placement new before it is read without synchronization in
freelist_new(a similar argument applies toatomiclist_pop;atomiclist_popallis unaffected) by another thread, which is going to subsequently find out the list head is stale and retry. Thus, the garbage pointer is not dereferenced, but this is still UB. Benchmarking suggests this has not caused any performance regression (see below). In fact, I wonder if the addition of the lock helped performance under contention.I have been thinking about other approaches here. One approach is to add an atomic flag to the list head that is set by any thread popping from the list. A thread that has successfully popped can spin on that flag to wait for the completion of any other threads still reading the memory it is about to return. My intuition is that this will be better, but I don't know without benchmarking, and it's a lot more complex than the lock.
Fixes #7398
Fixes #11640 in release mode only (dummy_forced_read calls still race)
Partially, #13572
Previous Work
See #7382. This PR is only a step in the direction of #7382; it retains a lot of the old code structure along with most of its design flaws. If this change is accepted, it should thereafter be possible to apply other design improvements from #7382, such as the fleshed out versioned pointer type, with greater confidence.
A Few Comments About Assertions
This PR adds a hoard of assertions that check alignment requirements. Most of them are debug assertions - the alignment check on the pointer passed to
freelist_pushis a release assert for now, because it would almost certainly indicate a major issue if it triggered. According to the comment from @bryancall, this assertion was in fact failing before (it was previously a debug assert, and he commented it out). I am hopeful that that issue is now resolved.Performance Implications (from tools/benchmark/benchmark_FreeList)
This change represents a significant performance improvement as number of threads increases. The following benchmarks are from tools/benchmark/benchmark_FreeList. The first one is from the old code without this patch. The second one is a benchmark of the code with this patch, but the size of the
head_ppointer is different because my system does not support 128bit atomics in hardware.Before (without this PR) (
-DCMAKE_BUILD_TYPE=Release, 128 bithead_p)After (with this PR) (
-DCMAKE_BUILD_TYPE=Release, 64 bithead_p)