Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ what exists is one vertical slice and it is not finished:
| Lifting CIR calls into the dialect | works on real compiler output, with or without a hidden result argument; tested end to end from C++ |
| Queue ownership analysis: SPSC / MPSC / SPMC / MPMC | prints a verdict; sound for direct calls, UNKNOWN wherever a function's address escapes; thread lifetime and escaping queues not modelled |
| Lowering back to CIR | faithful round trip, sret included; an op missing what it needs is a diagnostic |
| Specialised implementations | an unbounded SPSC queue, substituted for a global `wtc::queue<T>` proven SPSC; 3.6x throughput on one producer and one consumer (`benchmarks/`) |
| Specialised implementations | an unbounded SPSC queue, substituted for a global `wtc::queue<T>` proven SPSC; 2.4 to 3x throughput and a much lower tail latency (`benchmarks/RESULTS.md`) |

`docs/audit.md` lists the known defects in full, fixed and open. The analysis
verdict is now safe to substitute on for the cases it claims; what it does not
Expand Down
113 changes: 80 additions & 33 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,36 +14,83 @@ for, and the hand-written specialised implementation from `runtime/`. The
second one is the ceiling: the compiler cannot beat code a human wrote for
this exact case, it can only reach it without the human.

## What is here

- `queue_bench.cpp`: one producer and one consumer move 5M ints through a
global `wtc::queue<int>`. The consumer checks the count, the sum and the
order, and the run fails if any is wrong. Nothing in it names the
substitute: the shim makes `wtc::runtime::spsc_queue<int>` available.
- `spsc_direct.cpp`: the same workload written by hand against
`runtime/spsc_queue.hpp`, with its allocation-free pop. This is the ceiling.
- `run.sh`: builds `queue_bench.cpp` twice from the same CIR, once as
written and once with `--select-queue-impl` in the pipeline, plus
`spsc_direct`, through the pinned toolchain down to a native binary, and
reports the median of `RUNS` runs of each. Set `LLVM_BUILD` to the pinned
toolchain (default `/opt/llvm`, as in the image) and `WTC_BUILD` to this
project's build tree.

Only throughput is measured so far, not the tail, and runs are not pinned.

## Measured

At the pin (`1ebf89d8c5e6`), 2026-10-18, on an Apple M1 Pro (8 cores,
16 GB), macOS, threads not pinned, five runs each, median:

| variant | items/s | vs baseline |
| --- | ---: | ---: |
| baseline: `wtc::queue<int>` | 2.07M | 1.0x |
| optimized: substituted by `--select-queue-impl` | 7.44M | 3.6x |
| direct: `spsc_queue` by hand | 43.1M | 20.9x |

The gap between optimized and direct is almost all one allocation per item.
The substitute has to keep `wtc::queue<T>::try_pop`'s interface, which
returns a `std::shared_ptr<T>`, so every pop builds one; the hand-written
consumer uses `try_pop_optional` and allocates nothing. Closing it means
changing what the dialect's pop returns, not the queue.
## Running it

Not in CI: shared runners are too noisy for timings to mean anything. Run it
by hand, on a quiet machine, against the pinned toolchain:

```sh
LLVM_BUILD=<pinned toolchain> WTC_BUILD=<this project's build tree> benchmarks/run.sh
```

It takes about two minutes at the default `RUNS=5` on an M1 Pro and
overwrites [`RESULTS.md`](RESULTS.md): medians, latency percentiles, the
spread over runs, and the commit, toolchain and machine they came from. To
keep a result, commit `RESULTS.md` on its own after the run, so the commit
it names is the one that was measured. `RESULTS` writes the report
elsewhere; `BENCH_CPUS` pins every run to those CPUs with `taskset` on
Linux.

## How it works

Each workload in `workloads/` is one source built in up to three variants:

- **baseline**: `wtc::queue<T>`, lifted and lowered back unchanged, so it
goes through the same pipeline as optimized and differs only in the
substitution;
- **optimized**: the same, with `--select-queue-impl`;
- **direct**: `wtc::runtime::spsc_queue<T>` used by hand, with its
allocation-free pop (`-DWTC_BENCH_DIRECT`). The ceiling.

`run.sh` holds, for each workload, whether the optimized build must be
substituted, and fails if it was not, or was when it must not be. A number
in the results therefore always measures what its column says. Every
workload also checks what it received, and fails rather than reports if
anything was lost, duplicated or reordered. Variants run interleaved so
that drift over a session affects them alike.

The queue in each workload is a global touched only by `push` and
`BENCH_POP` at the call sites (`bench.hpp`). Passing it to a helper would be
a use `--select-queue-impl` cannot retarget, and the pass would rightly
refuse to substitute.

| Workload | What it measures |
| --- | --- |
| `spsc_throughput` | one producer, one consumer, 5M ints, flat out |
| `spsc_latency` | push-to-pop latency at a paced 1M items/s: p50, p99, p99.9, max |
| `spsc_payload` | throughput with a 64-byte record as the element type |
| `spsc_same_thread` | one thread pushes a 200k batch and pops it, 10 times: raw cost, no contention, and a case a bounded ring would deadlock on |
| `mpsc_control` | two producers: must not be substituted; measures the noise floor |

## Reading the results

`mpsc_control` is the yardstick. Its two builds run the same queue code;
the only difference is that the optimized one has had the shim's anchor
erased, and with it the unused substitute's code. On the M1 Pro they
measured 6, 13 and 18 percent apart in three sessions, and in all three the
optimized build was the faster, whichever ran first. That is a systematic
difference between two binaries, most likely their layout, rather than
noise, but the conclusion is the same: a difference of that size between
any two columns is not evidence that the queue made it. `RESULTS.md` states
the figure for its own session.

Tail latency is noisier still. On the M1 Pro, one run in five can be
disturbed by something else on the machine, moving every variant at once,
and even undisturbed runs put the substituted queue's p99 anywhere from
about 2 to 25 µs. The medians are robust to one bad run; `RESULTS.md` also
lists each run's p99, so it can be seen rather than assumed.

The gap between optimized and direct is mostly one allocation per item. The
substitute keeps `wtc::queue<T>::try_pop`'s interface, which returns a
`std::shared_ptr<T>`, so every pop builds one; the hand-written consumer
uses `try_pop_optional` and allocates nothing. Closing it means changing
what the dialect's pop returns, not the queue.

## Not measured yet

- Pinned runs on Linux. On macOS threads cannot be pinned, so latency
includes whatever the scheduler does.
- Latency under bursts or saturation; `spsc_latency` is one steady rate.
- Anything with more than one queue, or with queues that are not globals,
since those are not substituted.
- Memory: the substitute allocates in blocks, the original per item.
72 changes: 72 additions & 0 deletions benchmarks/RESULTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Benchmark results

Generated by `benchmarks/run.sh` on 2026-10-18 15:52 UTC. Rerun it to update this file rather than editing it. How the suite works, and what it does not measure yet, is in `benchmarks/README.md`.

## Environment

| | |
| --- | --- |
| Commit | `cf0c29aec0f3` on `bench/suite` |
| LLVM pin | `1ebf89d8c5e6` |
| CPU | Apple M1 Pro, 8 cores |
| Memory | 16 GiB |
| OS | macOS 26.5.2 (arm64) |
| Runs | 5 per variant, interleaved; medians reported |
| Threads | not pinned |

## Throughput

Median items per second. The ratios are against baseline, the same program with `wtc::queue<T>`.

| Workload | Optimized build | baseline | optimized | direct | optimized ÷ baseline | direct ÷ baseline |
| --- | --- | ---: | ---: | ---: | ---: | ---: |
| `spsc_throughput` | substituted | 2.65M | 8.02M | 43.58M | 3.02x | 16.44x |
| `spsc_payload` | substituted | 2.65M | 7.41M | 28.82M | 2.80x | 10.88x |
| `spsc_same_thread` | substituted | 3.13M | 7.58M | 38.62M | 2.42x | 12.35x |
| `mpsc_control` | kept | 2.03M | 2.40M | — | 1.18x | — |

**Noise.** `mpsc_control` is not substituted, so its baseline and optimized builds run the same queue code, and they measured 18% apart. A difference of that size or less between any two columns here is not evidence of anything.

## Latency

Time from push to pop, per item: the median over runs of each run's percentile. The worst case is the median of each run's maximum. These workloads are paced (see Workloads), so they are not in the throughput tables.

| Workload | Variant | p50 | p99 | p99.9 | max |
| --- | --- | ---: | ---: | ---: | ---: |
| `spsc_latency` | baseline | 2.62 µs | 992.04 µs | 1.50 ms | 1.81 ms |
| `spsc_latency` | optimized | 208 ns | 4.38 µs | 133.04 µs | 415.42 µs |
| `spsc_latency` | direct | 166 ns | 1.46 µs | 87.79 µs | 352.83 µs |

Tail latency varies far more from run to run than throughput does, and one disturbed run moves every variant at once. The p99 of each run:

| Workload | Variant | run 1 | run 2 | run 3 | run 4 | run 5 |
| --- | --- | ---: | ---: | ---: | ---: | ---: |
| `spsc_latency` | baseline | 992.04 µs | 1.60 ms | 565.04 µs | 14.94 ms | 621.25 µs |
| `spsc_latency` | optimized | 5.25 µs | 23.04 µs | 3.96 µs | 1.79 µs | 4.38 µs |
| `spsc_latency` | direct | 2.42 µs | 32.46 µs | 917 ns | 708 ns | 1.46 µs |

## Spread

Throughput over all runs, slowest to fastest, items per second.

| Workload | Variant | min | median | max |
| --- | --- | ---: | ---: | ---: |
| `spsc_throughput` | baseline | 2.60M | 2.65M | 2.68M |
| `spsc_throughput` | optimized | 7.89M | 8.02M | 8.07M |
| `spsc_throughput` | direct | 39.12M | 43.58M | 45.28M |
| `spsc_payload` | baseline | 2.40M | 2.65M | 2.73M |
| `spsc_payload` | optimized | 6.89M | 7.41M | 7.47M |
| `spsc_payload` | direct | 26.18M | 28.82M | 31.78M |
| `spsc_same_thread` | baseline | 3.07M | 3.13M | 3.14M |
| `spsc_same_thread` | optimized | 7.17M | 7.58M | 7.70M |
| `spsc_same_thread` | direct | 32.22M | 38.62M | 39.11M |
| `mpsc_control` | baseline | 1.83M | 2.03M | 2.11M |
| `mpsc_control` | optimized | 2.25M | 2.40M | 2.42M |

## Workloads

- **`spsc_throughput`**: One producer, one consumer, as fast as they can go: kItems ints through one queue. The consumer spins on an empty queue. Measures throughput under saturation; latency here would only measure how far behind the consumer falls, so none is reported.
- **`spsc_latency`**: One producer, one consumer, at a fixed rate well below either queue's capacity: one item every kIntervalNs. Each item is the time it was pushed, so the consumer can measure how long it waited in the queue. Reports the latency distribution, the number the project's motivation rests on: p50, p99, p99.9 and the worst case.
- **`spsc_payload`**: spsc_throughput with a 64-byte record instead of an int, so each push and pop copies a cache line. Shows whether the substitution's advantage holds when the payload, not the synchronisation, starts to dominate, and that it applies to a user-defined element type at all.
- **`spsc_same_thread`**: One thread is both the producer and the consumer: it pushes a batch, then pops it all, kRounds times. The verdict is still SPSC (one thread pushes, one thread pops; they happen to be the same), and this is the case a bounded ring cannot take: with a batch larger than the ring, a push that waited for room would wait forever. The substitute is unbounded, so it must complete. With no second thread there is no contention at all, so this measures the queues' raw cost per operation.
- **`mpsc_control`**: The control: two producers, one consumer. The verdict is MPSC, so --select-queue-impl must leave the queue alone, and run.sh fails if it does not. Baseline and optimized should then be the same program and measure the same, which bounds how much of the other workloads' difference is noise. There is no direct variant: spsc_queue is not safe with two producers.
81 changes: 81 additions & 0 deletions benchmarks/bench.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#pragma once

// Shared by every workload in benchmarks/workloads/.
//
// Each workload is one source compiled two ways. Without WTC_BENCH_DIRECT
// it uses wtc::queue<T>, and run.sh builds it as written (baseline) and
// through --select-queue-impl (optimized). With WTC_BENCH_DIRECT it uses
// wtc::runtime::spsc_queue<T> directly and pops without allocating: the
// hand-written ceiling.
//
// The queue is a global, touched only by push and by BENCH_POP at the call
// sites. That is not style: --select-queue-impl substitutes a global only
// when it can retarget every use, and passing the queue to a helper is a
// use it cannot. A workload written with helpers would silently measure
// nothing.
//
// Every workload checks what it received and exits non-zero if anything
// was lost, duplicated or reordered: a fast wrong queue is not a result.
// It then prints one RESULT line, which run.sh collects.

#include <algorithm>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <vector>

#ifdef WTC_BENCH_DIRECT
#include "../runtime/spsc_queue.hpp"
template <typename T>
using bench_queue = wtc::runtime::spsc_queue<T>;
#define BENCH_POP(q) (q).try_pop_optional()
#else
#include "queue.hpp"
template <typename T>
using bench_queue = wtc::queue<T>;
#define BENCH_POP(q) (q).try_pop()
#endif

namespace bench {

inline std::int64_t now_ns() {
return std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
}

[[noreturn]] inline void fail(const char *workload, const char *what, long long detail) {
std::fprintf(stderr, "FAIL %s: %s (%lld)\n", workload, what, detail);
std::exit(1);
}

// The value at quantile q of sorted, by the nearest-rank method.
inline std::int64_t quantile(const std::vector<std::int64_t> &sorted, double q) {
if (sorted.empty())
return 0;
std::size_t rank = static_cast<std::size_t>(q * static_cast<double>(sorted.size()));
if (rank >= sorted.size())
rank = sorted.size() - 1;
return sorted[rank];
}

// One line, key=value pairs, for run.sh to collect. Latency fields appear
// only for workloads that measure latency.
inline void report(const char *workload, long items, std::int64_t elapsed_ns,
std::vector<std::int64_t> *latencies = nullptr) {
double seconds = static_cast<double>(elapsed_ns) / 1e9;
std::printf("RESULT workload=%s items=%ld elapsed_ns=%lld items_per_sec=%.0f",
workload, items, static_cast<long long>(elapsed_ns),
static_cast<double>(items) / seconds);
if (latencies) {
std::sort(latencies->begin(), latencies->end());
std::printf(" p50_ns=%lld p99_ns=%lld p999_ns=%lld max_ns=%lld",
static_cast<long long>(quantile(*latencies, 0.50)),
static_cast<long long>(quantile(*latencies, 0.99)),
static_cast<long long>(quantile(*latencies, 0.999)),
static_cast<long long>(latencies->back()));
}
std::printf("\n");
}

}
66 changes: 0 additions & 66 deletions benchmarks/queue_bench.cpp

This file was deleted.

Loading
Loading