Repository navigation
Conversation
The sanitizer runs so far built the source with the system compiler, which checks that compiler's code, not the code this pipeline generates. The pinned clang has the instrumentation but no sanitizer runtime, so wtc-cc --tsan instruments the object itself and links it with a compiler that has the runtime. Clang's front end marks every function sanitize_thread; compiling an .ll file does not, and without the mark only atomics are instrumented, so a run is silent whatever the program does. wtc-cc forces the mark on every function. The negative control holds the path to two races it must find: a plain one on a global, which fails without the mark, and a queue with two producers forced to the spsc ring.
The leg checked the spsc tag only. It now also runs the inline variants under ThreadSanitizer, the compiler's own builds of the pipeline and of the batched loop over each implementation, and the kept-element guard on the compiler's inline builds, which must stay silent. The output directory is made absolute, since the runtime tests write their logs from their build directory, and setarch is used only where it is permitted: a container refuses it, and the sanitizer of clang 18 runs there without it. The pipeline is no longer pinned to fixed processors, which a container restricted to others cannot have.
A record written from a constant naming an object -- a global's
initialiser, or `pair p{&a, &b}`, which the front end emits as a copy
out of a private constant global -- made the walk give up on every field
of the record: the constant writes values no store lists. The constant's
members are those values, so each pointer member is now listed with the
field's stores, and a load from the field is placed on them.
A constant whose layout is not the record's own (a union, a record the
front end re-emits for its padding) still gives the record up, and a
member that is not a whole global, a function's address or a view into
part of one, leaves the field unplaced.
On the corpus the layout line was scored on (memcached amalgamated, the
DPDK ring and stack tests, Phoenix, streamcluster) the output is byte
for byte unchanged.
The ownership analysis followed a queue handle only through a stack slot written once, and called every queue of the type unknown at the first handle loaded from a field: a pipeline that hands its stages their queues in a struct was never specialised. It now asks the walk for the handle's places, as the layout analysis does, after the walk is over, since a field is placed under every instance of each function that stores it. A handle that names one of several queues counts on each of them: a thread too many on a queue it does not touch costs a specialisation, one missing on the queue it does would let a race through. Such an operation names no queue, so the borrow analysis refuses its type, now saying so rather than "nothing pops it", and the declared-topology check takes it as a reason it cannot verify, never as proof of a broken promise. A queue that is itself a member of a larger object, an element of an array or a heap object is still unknown: it has no constructor call of its own for the specialisation to rewrite. The place is checked to be a whole queue, not merely at offset zero, since the first member of a struct is at the struct's own address.
The audit still said the operations carry no progress guarantee and no contract, which the operations' contract and progress interface have since made untrue. What is missing is a memory-ordering attribute.
Most programs keep a queue in a struct or allocate it, and the analysis called every such queue unknown: a verdict lived on the global or the slot that is the queue, and the specialisation rewrote the constructor call found there. A queue is now a place the walk names, a root and an offset, and one that is not the whole of a global or a slot keeps its verdict on the one constructor call that builds it -- the struct's constructor, or the call after the allocation -- found by resolving each constructor's object under every instance of its function. The walk now enters the functions that construct queues for that. A call that builds two queues, a struct's constructor run for two objects, passes one tag for both and is left alone; an element of an array of queues is still unknown. All the objects one allocation site makes are one queue, which is sound for counting, since each object's threads are among all of theirs; a declared spsc on such a queue is not taken as broken on the strength of threads that may each have their own object.
A loop that takes and lets go of one mutex on every iteration pays for both each time, and hands the mutex's line to whichever thread wants it in between. The shim's mutex now offers a held run: batch_begin takes the mutex, batch_step ends one section and every WTC_LOCK_BATCH_K (64) lets it go and takes it again, so another thread can have it, and batch_end lets it go. The dialect gets the three as operations, lifted from the shim's calls and lowered back to them; each blocks, as a lock does. The pushes' batching pass refuses a loop calling into any operation of the dialect, not only those it knew by name.
In a function marked WTC_BATCH, a loop that takes and lets go of one mutex on every iteration now holds it across the loop, letting it go every WTC_LOCK_BATCH_K sections and at the end. Every interleaving of the result is one the original allows -- other threads only happen not to take the mutex in between -- so no critical section computes anything new. What can change is progress. The code between the sections now runs with the mutex held, and if it waited for a thread that needs the mutex the two would wait for each other. So the rules are stricter than for pushes: one lock and one unlock of one invariant mutex, in one block, on every iteration; nothing that synchronises or throws; no external call but the allocator and functions that only touch the memory they are given; no continue, return or goto, and no break inside the section, which would leave it held in the original and let it go twice here.
W8 runs one or two threads through a loop that takes one mutex, updates a shared record, lets the mutex go and does work of its own, built as written and with the loop batched by the compiler. Holding the mutex longer makes the other thread wait longer, so beside the rate it reports how unevenly the threads finish; the record's sum must agree between the builds. The w8 leg runs both, alone and contended, with and without work between sections. The README now lists W7 and its flavour script as well.
The latency numbers for the queue are the gate's, which predate inline storage. The w2mvp leg measures push-to-pop latency at one element per microsecond and per ten for the general-purpose shim, its spsc and spsc_inline tags and the bare ring popped by borrowing.
W8's runs have a thread count and a work size where the queues have an element size, and the summary stopped at the first of them with a KeyError. Both now enter the key and the title, the finish spread is a column, a build called plain is a baseline, and a leg with neither field is summarised as before; W3wtc's titles now name the work it carried.
Three legs on four reserved cores of the lab server, five repetitions each: open-loop latency for the implementations the MVP substitutes, the two-producer control that prices the tag's third arm, and W8. The notes say what the numbers do not: the general-purpose queue near saturation at one element per microsecond, the verbatim original shim slower than in September for no reason found, and W8's work being about 15 ns, since the compiler folds its rounds eight at a time.
Two threads contending for one mutex in a loop run 5.5 times faster with the compiler holding it across 64 sections, 28 times with no work between them, and still finish within a few percent of each other. The latency of the inline queue and the cost of the tag's third arm go in beside them.
try_pop returns its shared_ptr through a hidden first pointer on x86-64, and reading that shape wrong is how the prototype crashed on its first real pop. The suite emits for the host, so on a host with another convention the shape went untested. lit now offers %cir_x86_64_cxx where the host's headers allow it (natively on x86-64 Linux, by target on macOS), and one test takes the prototype's program from x86-64 CIR through lifting, analysis, specialisation, lowering and translation. With the return slot ignored in lifting, it fails.
The borrow analysis allows a list of uses and refuses the rest, and two of the refusals the plan listed had no test: releasing the popped shared_ptr early with reset(), harmless on an element held inline but not on the list, and turning a pointer into the element into an integer, which can be turned back after the next pop. A third case went untested end to end: a consumer that only borrows an element whose destructor runs code, which keeps the queue plain spsc, since inline its destructor would run at the next pop.
The progress check knew throwing as calls into the C++ runtime, and ClangIR spells a throw as its own operations, so a function declared lock-free that throws passed as one that blocks nowhere. A throw now blocks: the runtime allocates the exception and unwinds the stack.
An indirect call whose targets the module knows is judged by them, and nothing tested it; when one of them could block, the chain named the call as "an indirect callee" and left the reader to find which. Each call in the chain now names the function it enters, with "through a pointer" where it was not called by name. The test also covers the two reasons the plan listed and the suite did not: a wait in the threading library and a sleep.
The plan for the progress check asked for this to be shown and not only known: a push to an unbounded queue may find its block full and allocate the next one, so a producer declared lock-free fails on every implementation the shim has. -DPRODUCER builds the demo that way, the test holds it to failing with that reason, and the MVP's account says what it would take: a bounded queue.
Three passes kept their own list of external functions they may call past: the walk, the progress check and the lock batching, and the lists had drifted (memchr in one, the math functions in two). The functions that read and write only the memory they are handed now live in Utils, and each pass adds what its own question allows: the walk, output and free, which write no object it tracks, but not a copy, which may write pointers; the lock batching, the allocator, which cannot wait for a mutex the program holds. The plan for the progress check asked for this. On the layout analysis's corpus the output is byte for byte unchanged.
The declared-topology check failed the build on two threads that touch a queue, as the plan said it should only for two that can both exist. A program that starts one thread function or the other on the two arms of an if has at most one producer, and the build stopped saying it had two. A pair is now proof only when the walk cannot tell the two threads apart as exclusive; otherwise the declaration gets a warning saying why it cannot be verified, since the count still takes them for two.
The -O2 build of the pipeline was run and its output checked, which shows the queue ran inline but not that the analysis proved the borrow itself on the slot -O2 produces. The build's remarks are now checked as the -O0 build's are.
A substitution is only worth its speed-up if the code the compiler generated does not race, and the suite checked that for the substitutions themselves (tsan_own_build.cpp) but not for the other programs it runs: the queue reached through a struct, a pointer parameter or a spawn loop, the borrower whose type cannot be held inline, the consumer that keeps its element, the -O2 inline build, the declared inline queue, the plain lock loop, the hand-written lock batch, the hand-written push batches on all three implementations and the compiler's choice, the progress example, and the program whose layout the alignment pass changes. Each now also runs the compiler's own build under ThreadSanitizer beside its ordinary run, under `%if tsan` so a host without the runtime still runs the rest of the test. Where the ordinary build has WTC_CHECK the sanitized one does not: the contract checks have their runs, and the build a user gets is the one that must be free of races. The alignment test goes around wtc-cc and instruments the transformed module itself; a race planted in a copy of it is reported, and the same module built without instrumentation runs silent. mutex_basic.cpp gets no such run: its program has one thread.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
The rest of the MVP plan on top of
main: a stronger correctness check, thereach the queue analysis lacked, and the second dialect type put to use.
ThreadSanitizer on the compiler's own output.
tools/wtc-cc --tsaninstruments what the pipeline generated (the pinned clang has the pass but
no runtime) and links it with a compiler that has one. Every function is
marked
sanitize_thread, without which only atomics are instrumented and arun is silent whatever it does.
correctness.shnow runs the inlinevariants and the compiler's own builds of W3wtc and W7 under it: 27 checks,
all passing. The negative control in the suite holds the path to a plain
race on a global and to a two-producer queue forced to the ring. Every
positive integration test that runs a program also runs the compiler's
build of it under ThreadSanitizer, where the runtime is found.
Queues reached through fields, in structs and on the heap.
it, as the layout analysis does; a handle that may name one of several
queues counts on each, so a thread too many costs a specialisation and
none goes missing.
pair p{&a, &b}isa copy out of a private constant). On the layout analysis's corpus the
output is byte for byte unchanged.
on the one constructor call that builds it. A shared constructor (two
objects of one struct) and arrays of queues stay general-purpose. All
objects of one allocation site are one queue, which is sound for
counting; a declared spsc on such a queue is not called broken on that
basis.
Critical sections held as one. In a
WTC_BATCHfunction, a loop thattakes and lets go of one mutex on every iteration holds it across the
loop, letting it go every 64 sections. The rules are stricter than for
pushes, because code between the sections now runs with the mutex held:
nothing in the loop may wait for another thread.
Measurements, four reserved cores, five repetitions each:
The tag's third arm costs the general-purpose queue nothing (0.93 M/s with
two producers and two consumers, as in the gate's legs).
What the plan's earlier stages still lacked, found by going through
them item by item:
%cir_x86_64_cxx), so the hidden returnslot is tested on any host; with the slot ignored, the test fails.
throw, which ClangIR spells as operationsrather than runtime calls: a lock-free function that throws passed.
the two arms of one
if, which cannot both exist.unbounded queue may allocate a block), now shown by the demo.
reset(), a pointerturned into an integer, an element that cannot be held inline), the
borrow proof at -O2, indirect calls with known targets in a progress
chain, and one shared list of external functions instead of three.
How it was checked
tests).
that tells a queue from the struct it starts, the shared-constructor
rule, the per-site summary in the declared check, the one-of rule in
the declared check, the
sanitize_threadmark, the return slot inlifting.
streamcluster) gives byte for byte the same output before and after
each change to the shared walk and lists.
hand-inline build.