Skip to content

Substitute spsc_queue for a global queue proven SPSC, and measure it - #7

Closed
ZweZeya wants to merge 4 commits into
feat/spsc-runtimefrom
feat/select-queue-impl-pinned
Closed

ZweZeya wants to merge 4 commits into
feat/spsc-runtimefrom
feat/select-queue-impl-pinned

Conversation

@ZweZeya

@ZweZeya ZweZeya commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #6 (feat/spsc-runtime).

What this changes

--select-queue-impl, from 253a2bc (feat/select-queue-impl), ported to the pin: for a cir.global whose verdict is SPSC it retargets the storage, construction, push/pop and destructor registration to the module's spsc_queue<T>. Three commits:

  1. The pass. Changes from 253a2bc, each found at the pin:
    • The destructor is retargeted through this global's __cxa_atexit registration. 253a2bc rewrote every reference to the original destructor, and two globals of one type share one, so substituting @q1 also registered spsc_queue's destructor for @q2.
    • Every use of the global is checked first; one the pass cannot retarget means no substitution, with a remark naming the use.
    • Alignment comes from cir.record_layouts (the record type's data layout ignores alignas; the substitute aligns to 64).
    • Pops with a result slot are substituted only when the substitute's try_pop takes one too.
    • Diagnostics are MLIR remarks.
  2. The shim makes the substitute available. queue<T>'s constructor odr-uses a [[gnu::used]] anchor that instantiates spsc_queue<T>, so no program has to name it; the pass erases the anchor and its @llvm.used entry.
  3. Benchmarks, with the result checked (count, sum, order) so a fast wrong queue fails rather than gets timed; run.sh is portable and reports the median.

Only globals are substituted.

How it was checked

29 of 29 pass, no warnings (SelectQueueImplementation/global-spsc.mlir covers the shared-destructor case; refusals.mlir the four ways a queue is left alone). On the prototype's example (q1 SPSC, q2 MPMC, same type) only q1 is substituted and q2's destructor is untouched; the result runs as a native binary, and for x86-64 lowers to LLVM IR whose substituted try_pop call carries sret and the new size.

Measured at the pin on an Apple M1 Pro, median of five, all runs checked:

variant items/s vs baseline
wtc::queue<int> 2.07M 1.0x
substituted 7.44M 3.6x
spsc_queue by hand 43.1M 20.9x

The remaining gap is one shared_ptr allocation per pop, kept to match try_pop's interface.

Checklist

  • CI is green
  • New behaviour has a test; a fixed bug has a test that failed before
  • No file moves or renames mixed in with substantive changes
  • The pinned LLVM revision is untouched, or this PR does nothing else

🤖 Generated with Claude Code

@ZweZeya
ZweZeya requested a review from apaznikov as a code owner October 18, 2026 05:15
@ZweZeya ZweZeya mentioned this pull request Oct 18, 2026
4 tasks done
ZweZeya and others added 3 commits October 18, 2026 13:19
--select-queue-impl, from 253a2bc (feat/select-queue-impl), ported to
the pin and to the analysis now being sound. For a cir.global whose
verdict is SPSC it retargets the storage, the construction, the push
and pop ops, and the destructor registration to the module's
wtc::runtime::spsc_queue<T> for the same T.

Changed on the way, each for a reason found at the pin:

- The destructor is retargeted through this global's own __cxa_atexit
  registration. The earlier version rewrote every reference to the
  original destructor, and two globals of one type share one, so
  substituting @q1 also registered spsc_queue's destructor for @q2,
  still a wtc::queue.
- Every use of the global is checked first. Its type changes, so a use
  the pass does not retarget -- the address passed elsewhere, an op that
  can also reach a non-SPSC queue -- would read the new storage as the
  old type. Such a global keeps its implementation, and a remark names
  the use.
- The substitute is found by its wtc_impl_spsc_* annotations, not its
  record name, and its arguments are read from the signature rather
  than the entry block, which a declaration does not have.
- Alignment comes from cir.record_layouts: the data layout of a CIR
  record type does not see alignas, and spsc_queue aligns to 64.
- Pops that write through a result slot are only substituted when the
  substitute's try_pop takes one too; lowering restores sret.
- Anchors (wtc_anchor) are erased afterwards, with their @llvm.used
  entries. Diagnostics are MLIR remarks, not stdout.

Verified: 28 of 28 pass. On the prototype's example (q1 SPSC, q2 MPMC,
one type) q1 is substituted and q2 is not, its destructor untouched; the
result compiles to a native binary that runs to completion, and for
x86-64 to LLVM IR whose substituted try_pop call carries sret and the
new size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A program had to instantiate wtc::spsc_queue<T, N> itself for the pass
to find it, so a human still named the specialisation the compiler was
meant to choose.

queue<T>'s constructor now odr-uses wtc::detail::alternatives<T>, which
instantiates wtc::runtime::spsc_queue<T>. Measured at the pin: it is
[[gnu::used]], putting it in @llvm.used, that makes ClangIR emit it and
everything it calls, at -O0 and -O1; the address taken in the
constructor leaves nothing behind. --select-queue-impl erases it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From 253a2bc, with three changes. queue_bench.cpp names no substitute,
since the shim now provides one. Its consumer checks the count, the sum
and the order of what it receives, and the run fails if any is wrong,
because a fast wrong queue is not a result. run.sh takes the pinned
toolchain from LLVM_BUILD (default /opt/llvm, as in the image), uses
xcrun only on Darwin, refuses to time an "optimized" build in which
nothing was substituted, and reports the median of RUNS runs.

mlir-translate still needs --allow-unregistered-dialect: cir-to-llvm
leaves CIR types in attributes even for plain clang output. wtc-opt no
longer does.

Measured on an Apple M1 Pro, median of five: baseline 2.07M items/s,
substituted 3.6x that, hand-written 20.9x. The remaining gap is one
shared_ptr allocation per pop, which the substitute keeps to match
try_pop's interface.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ZweZeya
ZweZeya force-pushed the feat/select-queue-impl-pinned branch from 74c71e6 to 57ea2a0 Compare October 18, 2026 05:20
run.sh confirmed the optimized build was substituted by matching
"cir.global external @g_queue". On Linux the global is
"cir.global external dso_local @g_queue", so the check would have called a
correct build unsubstituted. It now matches only the name and the type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@apaznikov

Copy link
Copy Markdown
Collaborator

Closing as superseded by #10. The substitution there rewrites the topology tag at the queue's constructor rather than retargeting the global to another type, so it applies to locals as well as globals and needs no anchor or destructor retargeting; on the pipeline benchmark the compiler's build is byte for byte the hand-tagged one. Your M1 numbers are a useful independent replication: 3.6× substituted and 20.9× by hand line up with the 3.07× and the 10 to 41× of the inline ring measured on x86 for the gate, and the gap you name, one shared_ptr allocation per pop, is exactly what the borrow analysis and the inline queue in #10 remove.

@apaznikov apaznikov closed this Oct 19, 2026
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