C++ carries_dependency and Release-Consume Memory
This article examines how the [[carries_dependency]]
attribute optimizes release-consume memory ordering paths in C++. It
covers the mechanics of std::memory_order_consume, the
performance problem that occurs when dependency chains cross function
boundaries, and how [[carries_dependency]] informs the
compiler to preserve dependency tracking across these boundaries instead
of emitting costly memory barrier instructions.
The Problem: Release-Consume and Function Boundaries
In C++ concurrency, std::memory_order_consume is
designed as a lighter-weight alternative to
std::memory_order_acquire. While an acquire operation
synchronizes all prior writes from the releasing thread, a consume
operation only synchronizes writes that carry a data dependency to the
loaded atomic variable.
On weakly-ordered architectures like ARM and PowerPC, hardware
naturally maintains data dependencies through register operations. A CPU
will not reorder a load that depends on the value of a previous load.
Consequently, an atomic read using memory_order_consume can
theoretically be executed without generating an explicit hardware memory
fence instruction, yielding higher performance than
memory_order_acquire.
However, the C++ compiler must formally trace this dependency chain from the atomic load to all dependent operations. When a dependent value is passed into a function or returned from one, separate compilation units and ABI boundaries break the compiler's visibility. Without explicit instruction, the compiler cannot guarantee that the function will maintain the dependency chain, nor can the caller guarantee it for return values. To avoid data races, the compiler is forced to conservatively insert an acquire fence, negating the performance benefits of consume ordering.
How
[[carries_dependency]] Solves It
The [[carries_dependency]] attribute resolves this
visibility issue by acting as an optimization hint and contract between
callers and callees. It can be applied to:
- Function parameters: Signifies that the caller's dependency chain enters the function via the specified argument.
- Function return values: Signifies that the dependency chain exits the function and propagates back to the caller.
By explicitly annotating these interfaces, the developer tells the compiler that the data dependency is preserved across the function call.
#include <atomic>
struct Config {
int threshold;
int timeout;
};
std::atomic<Config*> global_config{nullptr};
// Informs the compiler that 'cfg' carries a dependency from the caller
void process_config([[carries_dependency]] Config* cfg) {
if (cfg) {
// Safe to read cfg->threshold without an explicit acquire fence
int t = cfg->threshold;
// ...
}
}
// Informs the compiler that the returned pointer carries a dependency
[[carries_dependency]] Config* fetch_config() {
return global_config.load(std::memory_order_consume);
}
void run() {
Config* cfg = fetch_config();
process_config(cfg);
}Compiler Optimization Mechanisms
When [[carries_dependency]] is applied, the compiler
optimizes code generation in two primary ways:
- Eliminating Fence Instructions: On architectures
with weak memory models (such as ARM or PowerPC), the compiler avoids
emitting hardware memory barriers (e.g.,
dmborlwsync). Instead, it relies on the CPU’s native dependency tracking by ensuring operations use the value loaded into specific registers. - Preserving Register Allocations: The compiler arranges code generation to preserve the hardware dependency chain. For example, if a pointer is loaded from an atomic variable, the compiler ensures that subsequent reads use an address calculation derived from the register holding that pointer, rather than reloading or speculating values.
Practical Considerations
On strongly-ordered architectures like x86 and x86-64, loads are not
reordered with subsequent loads at the hardware level, making acquire
and consume operations functionally identical with zero-cost fences. The
optimizations unlocked by [[carries_dependency]] are
primarily realized on weakly-ordered architectures where explicit memory
barriers introduce measurable overhead.
It is worth noting that because tracking dependency chains across
standard library functions and complex expressions is exceptionally
difficult, many major C++ compilers temporarily treat
memory_order_consume identically to
memory_order_acquire. Nevertheless, using
[[carries_dependency]] correctly documents dependency
semantics and enables proper hardware-level dependency ordering in
toolchains that fully support the consume model.