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:

  1. Function parameters: Signifies that the caller's dependency chain enters the function via the specified argument.
  2. 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., dmb or lwsync). 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.