Skip to content

Commit e6ce6af

Browse files
Merge pull request #1618 from kieranhejmadi01/arm-cpp-mem-model
Cpp-Memory-Model-on-Arm-Learning-Path
2 parents 86edb7f + afaa6d7 commit e6ce6af

File tree

7 files changed

+401
-0
lines changed

7 files changed

+401
-0
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
title: Introduction to Memory Models
3+
weight: 2
4+
5+
### FIXED, DO NOT MODIFY
6+
layout: learningpathall
7+
---
8+
9+
## What is a memory model?
10+
11+
A language’s memory model defines how operations on shared data interleave at runtime, providing rules on what reorderings are allowed by compilers and hardware. In C++, the memory model specifies how threads interact with shared variables, ensuring consistent behavior across different compilers and architectures. A developer can think of memory ordering in 4 broad categories.
12+
13+
- **Source Code Order** The exact sequence in which you write statements. This is the most intuitive view because it directly reflects how code appears to you.
14+
15+
```output
16+
int x = 5; // A
17+
int z = x * 5 // B
18+
int y = 42 // C
19+
```
20+
21+
- **Program Order**: The logical sequence recognized by the compiler, which may rearrange or optimize instructions under certain constraints for an output binary (i.e. program) that takes fewer cycles. Although the statements may appear in a particular order in your source, the compiler could restructure them if it deems it safe. For example the pseudo assembly below has reordered the source line instructions above.
22+
23+
```output
24+
LDR R1 #5 // A
25+
LDR R2 #42 // C
26+
MULT R3, #R1, #5 // B
27+
```
28+
29+
- **Execution Order**: How instructions are actually issued and executed by the hardware. Modern CPUs often employ techniques to improve instruction-level parallelism such as out-of-order execution and speculation for performance. For instance, on an ARM-based system, you might see instructions issued in different order during runtime. The subtle difference between program order and execution order is that program order refers to the sequence seen in the binary whereas execution is the order in which those instructions are actually issued and retired. Even though the instructions are listed in one order, the CPU might reorder their micro-operations as long as it respects dependencies.
30+
31+
- **Hardware Perceived Order**: The perspective observed by other devices or the rest of the system, which can differ if the hardware buffers writes or merges memory operations. Crucially, the hardware-perceived order can vary between CPU architectures, for example between x86 and AArch64 - this should be considered when porting applications. An abstract diagram from the academic paper is shown below [Maranget et. al, 2012]. A write operation in one of the 5 threads in the pentagon below may propagate to the other threads in any order.
32+
33+
![abstract_model](./Abstract_model.png)
34+
35+
## High-level difference between Arm Memory Model and x86 Memory Model
36+
37+
The memory models of ARM and x86 architectures differ in terms of ordering guarantees and required synchronization. x86 processors implement a relatively strong memory model, commonly referred to as Total Store Order (TSO). Under TSO, loads and stores appear to execute in program order, with only limited reordering permitted. This strong ordering means that software running on x86 generally relies on fewer memory barrier instructions, making it easier to reason about concurrency.
38+
39+
In contrast, ARM’s memory model is more relaxed, allowing greater reordering of memory operations to optimize performance and energy efficiency. This relaxed model provides less intuitive ordering guarantees, meaning that loads and stores may be observed out of order by other processors. This means that source code needs to correctly follow the language standard to ensure reliable behaviour.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
---
2+
title: C++ Memory Model and Atomics
3+
weight: 3
4+
5+
### FIXED, DO NOT MODIFY
6+
layout: learningpathall
7+
---
8+
9+
## C++ Memory Model for Single Threads
10+
11+
12+
For a long time, writing C++ programs on single-core systems was relatively straightforward. The compiler could reorder instructions however it wished, so long as the program’s observable behavior remained unchanged. This optimization freedom is commonly referred to as the “as-if” rule. Essentially, compilers can optimize away or move instructions around as if the code had not changed, provided they do not affect inputs, outputs, or volatile accesses.
13+
14+
That single-threaded world was simpler: you wrote code, the compiler made it faster (by reordering or eliding instructions if safe), and everyone benefited. But then multi-core processors and multi-threaded applications became the norm. Suddenly, reordering instructions was not merely about performance—it could change the meaning of programs with threads reading and writing shared data simultaneously.
15+
16+
### Expanding Memory Model for Multiple Threads
17+
18+
When multi threading gained traction, compilers and CPUs need more precise rules about what reordering is allowed. This is where the formalized C++ memory model, introduced in C++11, steps in. Prior to C++11, concurrency in C++ was partially specified and relied on platform-specific behavior. Now, the language standard includes well-defined semantics ensuring that developers writing concurrent code can rely on a set of guaranteed rules.
19+
20+
Under the new model, if a piece of data is shared between threads without proper synchronization, you can no longer assume it behaves like single-threaded code. Instead, operations on this shared data may be reordered unless you explicitly prevent it using atomic operations or other synchronization primitives such as mutexes. To ensure correctness, C++ provides an array of memory orders (such as `std::memory_order_relaxed`, `std::memory_order_acquire`, `std::memory_order_release`, etc.) that govern how loads and stores can be observed in a multi-threaded environment. Details can be found on the C++ reference manual.
21+
22+
## C++ Atomic Memory Ordering
23+
24+
In C++, `std::memory_order` atomic operations allow developers to specify how memory accesses, including regular, non-atomic memory accesses are ordered among atomic operation. Choosing the right memory order is crucial for balancing performance and correctness. Assume we have 2 atomic integers with initial values of 0:
25+
26+
```cpp
27+
std::atomic<int> x{0};
28+
std::atomic<int> y{0};
29+
```
30+
31+
Below are a few of C++’s atomic memory orders, along with a short code snippet illustrating what might or might not be reordered.
32+
33+
- `memory_order_relaxed`
34+
35+
Relaxed operations do not impose ordering constraints beyond atomicity. They can be freely reordered with respect to other operations. This provides maximum performance but can lead to visibility issues if used incorrectly.
36+
37+
```cpp
38+
// Thread A:
39+
r1 = y.load(std::memory_order_relaxed); // A
40+
x.store(r1, std::memory_order_relaxed); // B
41+
42+
// Thread B:
43+
r2 = x.load(std::memory_order_relaxed); // C
44+
y.store(42, std::memory_order_relaxed); // D
45+
// These two stores could appear in any order relative to each other.
46+
```
47+
48+
In the pseudo code snippet above, it's possible for operation B to precede operation C, or the mirror possibility of D executing before A.
49+
50+
- `memory_order_acquire` and `memory_order_release`
51+
52+
Acquire and release are used to synchronise atomic variables. In the example below, thread A writes to memory (allocating the string and setting data) and then uses a release-store to publish these updates. Thread B repeatedly performs an acquire-load until it sees the updated pointer. The acquire ensures that once Thread B sees a non-null pointer, all writes made by Thread A (including the update to data) become visible, synchronizing the two threads.
53+
54+
```cpp
55+
// Thread A
56+
p = new "Hello";
57+
data = 42;
58+
atomic_store(ptr, p, memory_order_release); // Release: publish writes (p, data)
59+
60+
// Thread B
61+
while (atomic_load(ptr, memory_order_acquire) is null) { } // Acquire: wait until p is available
62+
// Now, *p == "Hello" and data == 42 (synchronized with Thread A)
63+
64+
```
65+
66+
Sequential consistency, `memory_order_seq_cst` is the strongest order and the default ordering if nothing is specified. There are several other memory ordering possibilities, for information on all possible memory ordering possibilities in the C++11 standard and their nuances, please refer to the [C++ reference](https://en.cppreference.com/w/cpp/atomic/memory_order).
67+
68+
69+
70+
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
---
2+
title: Example of Race Condition
3+
weight: 4
4+
5+
### FIXED, DO NOT MODIFY
6+
layout: learningpathall
7+
---
8+
9+
## Example of a Race Condition when porting from x86 to AArch64
10+
11+
Due to the differences in the hardware perceived ordering as explained in the earlier sections, source code written for x86 may behave differently when ported to Arm. To demonstrate this we will create a trivial example and run it both on an x86 and Arm cloud instance.
12+
13+
Start an Arm-based cloud instance, in this example I am using `t4g.xlarge` AWS instance running Ubuntu 22.04 LTS. If you are new to using cloud-based virtual machines, please see our [getting started guide](https://learn.arm.com/learning-paths/servers-and-cloud-computing/intro/).
14+
15+
First confirm you are using a Arm-based instance with the following command.
16+
17+
```bash
18+
uname -m
19+
```
20+
You should see the following output.
21+
22+
```output
23+
aarch64
24+
```
25+
26+
Next, we will install the prerequisitve packages.
27+
28+
```bash
29+
sudo apt update
30+
sudo apt install g++ clang
31+
```
32+
33+
Copy and paste the following code snippet into a file named `relaxed_memory_model.cpp`.
34+
35+
```cpp
36+
#include <iostream>
37+
#include <atomic>
38+
#include <thread>
39+
#include <cassert>
40+
#include <chrono>
41+
42+
struct Node {
43+
int x;
44+
};
45+
std::atomic<Node*> node{nullptr};
46+
47+
void threadA() {
48+
auto n = new Node();
49+
n->x = 42;
50+
node.store(n, std::memory_order_relaxed);
51+
}
52+
53+
void threadB() {
54+
Node* n = nullptr;
55+
while ((n = node.load(std::memory_order_relaxed)) == nullptr) {
56+
std::this_thread::sleep_for(std::chrono::nanoseconds(50)); // Small sleep to improve scheduling
57+
}
58+
if (n->x != 42) {
59+
std::cerr << "Race condition detected: n->x = " << n->x << std::endl;
60+
std::terminate();
61+
}
62+
}
63+
64+
void runTest() {
65+
for (int i = 0; i < 100000; ++i) { // Run many iterations but eventually time out
66+
node.store(nullptr, std::memory_order_relaxed);
67+
std::thread t1(threadA);
68+
std::thread t2(threadB);
69+
std::thread t3(threadA);
70+
std::thread t4(threadA);
71+
t1.join();
72+
t2.join();
73+
t3.join();
74+
t4.join();
75+
delete node.load();
76+
}
77+
}
78+
79+
int main() {
80+
runTest();
81+
std::cout << "No Race Condition Occurred in this run" << std::endl;
82+
return 0;
83+
}
84+
```
85+
86+
The code snippet above is a trivial example of a data race condition. Thread A creates a node variable and assigns it the number 42. On the otherhand, thread B checks than the variable assigned to the Node is equal to 42. Both functions use the `memory_order_relaxed` model, which allows the possibility for thread B to read an unintialised variable before it has been assigned the value 42 in thread A.
87+
88+
```bash
89+
g++ relaxed_memory_ordering.cpp -o relaxed_memory_ordering -O3
90+
```
91+
92+
```output
93+
./relaxed_memory_ordering
94+
...
95+
~ 5-30 second wait
96+
...
97+
Race condition detected: n->x = 42
98+
terminate called without an active exception
99+
Aborted (core dumped)
100+
```
101+
102+
It is worth noting that this is only a probability of a race condition. Our contrived example is designed to trigger frequently. Unfortunately, in production workloads there may be a more subtle probability that may surface in production or under specific workloads. This is the reason race conditions are difficult to spot.
103+
104+
### Behaviour on x86 instance
105+
106+
Due to the more strong memory model associated with x86 processors, programs that do not adhere to the C++ standard may give programmers a false sense of security. To demonstrate this I connected to an AWS `t2.2xlarge` instance that uses the x86 architecture.
107+
108+
Running the following command I can observe the underlying hardware is a Intel Xeon E5-2686 Processor
109+
110+
```bash
111+
lscpu | grep -i "Model"
112+
```
113+
114+
```output
115+
Model name: Intel(R) Xeon(R) CPU E5-2686 v4 @ 2.30GHz
116+
Model: 79
117+
```
118+
Follow the instructions above and recompiling leads to no race conditions on this x86-based machine.
119+
120+
```output
121+
./relaxed_memory_ordering
122+
No race condition occurred in this run
123+
```
124+
125+
126+
## Using correct memory ordering of Atomics
127+
128+
As the example above shows, not adhering to the C++ standard can lead to a false sensitivity when running on x86 platforms. To fix the race condition when porting we need to use the correct memory ordering for each thread. The following snippet of C++ updates `threadA` to use the `memory_order_release`, `threadB` to use `memory_order_acquire` and the `runTest` fuction to use `memory_order_release` on the Node object.
129+
130+
Save the adjusted code snippet below into a file named `correct_memory_ordering.cpp`.
131+
132+
```cpp
133+
#include <iostream>
134+
#include <atomic>
135+
#include <thread>
136+
#include <cassert>
137+
#include <chrono>
138+
139+
struct Node {
140+
int x;
141+
};
142+
std::atomic<Node*> node{nullptr};
143+
144+
void threadA() {
145+
auto n = new Node();
146+
n->x = 42;
147+
node.store(n, std::memory_order_release);
148+
}
149+
150+
void threadB() {
151+
Node* n = nullptr;
152+
while ((n = node.load(std::memory_order_acquire)) == nullptr) {
153+
std::this_thread::sleep_for(std::chrono::nanoseconds(50)); // Small sleep to improve scheduling
154+
}
155+
if (n->x != 42) {
156+
std::cerr << "Race condition detected: n->x = " << n->x << std::endl;
157+
std::terminate();
158+
}
159+
}
160+
161+
void runTest() {
162+
for (int i = 0; i < 100000; ++i) { // Run many iterations but eventually time out
163+
node.store(nullptr, std::memory_order_release);
164+
std::thread t1(threadA);
165+
std::thread t2(threadB);
166+
std::thread t3(threadA);
167+
std::thread t4(threadA);
168+
t1.join();
169+
t2.join();
170+
t3.join();
171+
t4.join();
172+
delete node.load();
173+
}
174+
}
175+
176+
int main() {
177+
runTest();
178+
std::cout << "No Race Condition Occurred in this run" << std::endl;
179+
return 0;
180+
}
181+
182+
```
183+
184+
Compiling with the following command and run on an Aarch64 based machine.
185+
186+
```bash
187+
g++ correct_memory_ordering.cpp -o correct_memory_ordering -O3
188+
```
189+
190+
```output
191+
./correct_memory_ordering
192+
No Race Condition Occurred in this run
193+
```
194+
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
title: Detecting Race Conditions
3+
weight: 5
4+
5+
### FIXED, DO NOT MODIFY
6+
layout: learningpathall
7+
---
8+
9+
## How to detect infrequent race conditions?
10+
11+
Threadsantizer, commonly referred to as `TSan` is a concurrency bug detection tool that identifies data races in multi-threaded programs. By instrumenting code at compile time, TSan dynamically tracks memory operations, monitoring lock usage and detecting inconsistencies in thread synchronization. When it finds a potential data race, it reports detailed information to aid debugging. TSan’s overhead can be significant, but it provides valuable insights into concurrency issues often missed by static analysis.
12+
13+
TSan is available through both recent `clang` and `gcc` compilers. Using the `clang++` compiler in this example, compiling the correct_memory_ordering example with the following command and running the output binary.
14+
15+
```bash
16+
clang++ relaxed_memory_ordering.cpp -fsanitize=thread -fPIE -pie -g
17+
```
18+
19+
20+
21+
```output
22+
==================
23+
WARNING: ThreadSanitizer: data race (pid=2892958)
24+
Read of size 4 at 0xfffff42007b0 by thread T2:
25+
...
26+
...
27+
...
28+
SUMMARY: ThreadSanitizer: data race /home/ubuntu/src/relaxed_memory_ordering.cpp:23:12 in threadB()
29+
==================
30+
31+
```
32+
33+
The summary output highlights a potential data race in the `threadB` function corresponding to the source code expression `n->x != 42`.
34+
35+
## Limitations of TSan
36+
37+
Thread Sanitizer (TSan) is powerful for detecting data races but has notable drawbacks. First, it only identifies concurrency issues at runtime, meaning any problematic code that isn’t exercised during testing goes unnoticed. Additionally, if race conditions exist in third-party binaries or libraries, TSan can’t instrument or fix them without access to their source code. Another major limitation is performance overhead: TSan can slow programs by 2 to 20x and requires extra memory, making it challenging for large-scale or real-time systems.
38+
39+
For further information please refer to the [Google documentation](https://github.com/google/sanitizers/wiki/threadsanitizercppmanual).
45.8 KB
Loading
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
title: Learn about the C++ Memory Model when Porting to Arm
3+
4+
minutes_to_complete: 45
5+
6+
who_is_this_for: Intermediate C++ developers who are looking to port and optimise their application from x86 to AArch64.
7+
8+
learning_objectives:
9+
- Learn about the C++ memory model
10+
- Learn about the differences between the Arm and x86 memory model
11+
- Learn best practices for writing C++ on Arm to avoid race conditions
12+
13+
prerequisites:
14+
- Access to an x86 and AArch64 cloud instance
15+
- Intermediate understanding of C++
16+
17+
author_primary: Kieran Hejmadi
18+
19+
### Tags
20+
skilllevels: Introductory
21+
subjects: Performance and Architecture
22+
armips:
23+
- Neoverse
24+
tools_software_languages:
25+
- C++
26+
- ThreadSantizer (TSan)
27+
operatingsystems:
28+
- Linux
29+
30+
31+
### FIXED, DO NOT MODIFY
32+
# ================================================================================
33+
weight: 1 # _index.md always has weight of 1 to order correctly
34+
layout: "learningpathall" # All files under learning paths have this same wrapper
35+
learning_path_main_page: "yes" # This should be surfaced when looking for related content. Only set for _index.md of learning path content.
36+
---

0 commit comments

Comments
 (0)