背景
这完全是一个工具链问题,跟 Arm 内存模型没关系。
正式的 Android C++ 开发 需要引入 Android NDK 工具链,而现在测试弱序内存模型其实只需要一个满足 ABI 兼容的 Linux 二进制文件,并不涉及任何 Android 生态。
那我能不能扔掉这个庞大的 NDK?里面可是塞满了大量的 llvm-prebuilts!二进制体积高达 2 GB!
总之我们不需要这些「工程」产物,只要能跑并且配置方便即可。
Zig is a general-purpose programming language and toolchain for maintaining robust, optimal and reusable software.
其中一个极其简单的途径是 Zig。我们不使用 Zig 语言本身,而是使用它自带的 zig c++ 工具链。
配置
需要说明,这一部分只是为了方便我个人跨主机使用。配置方式并不唯一,可以自行参考 Zig 文档和其他相关资料。
# 根据需求改动版本号
VER=0.14.0
curl -L https://ziglang.org/download/$VER/zig-linux-x86_64-$VER.tar.xz -o zig.tar.xz
sudo mkdir -p /opt/zig && sudo tar -xJf zig.tar.xz -C /opt/zig --strip-components=1
echo 'export PATH=/opt/zig:$PATH' | sudo tee /etc/profile.d/zig.sh
source /etc/profile.d/zig.sh
zig version
假设你未曾使用过 Zig,并且使用的 Linux 系统,还是某个 U 字头的主流发行版,但是你又不希望通过强推的 snap 包管理来安装 Zig。你可以尝试以上的命令。
Windows 平台可以看官方的安装指引,同上方链接。
我目前使用的版本,tar.xz 体积约为 50 MB,解压后的二进制总共为 300 MB。不算是🐖。
# 只用 Windows 的话不用管这些
# 纯血 Linux 的话也不用管
# ~/.bashrc 里面添加下述别名
alias adb='adb.exe'
# 然后刷新
source ~/.bashrc
# 理论上你可以使用 adb 了(手机要配置开发者模式这些,就不用说了吧)
caturra@bluepuni:~$ adb devices
* daemon not running; starting now at tcp:5037
* daemon started successfully
List of devices attached
10AE310TDR002UT device
又假设你使用的其实是 WSL,并且 Windows 已经配置好 adb 了(下载二进制+配好环境变量)。我们知道 WSL 虽然可以直接使用 adb on Linux,但是和大多数网络应用一样,存在端口转发的问题。一个最省事的解决方案是 alias 使用 exe 文件,这是 WSL 特有的跨系统操作。
# 输入:memorder.cpp
# 输出:memorder.aarch64
zig c++ -std=c++20 -O3 -static -target aarch64-linux-musl memorder.cpp -lpthread -o memorder.aarch64
再次假设你已经有了一个 memorder.cpp 文件,我们通过 zig c++
即可构建 Android 兼容的(不考虑 Android API)二进制文件。简单来说就是 Zig 工具链帮你处理掉了配置 sysroot 这些杂务。
adb push memorder.aarch64 /data/local/tmp/
adb shell chmod +x /data/local/tmp/memorder.aarch64
adb shell /data/local/tmp/memorder.aarch64
最后在 adb shell 执行二进制文件即可得到测试结果。
测试
我们根据此前 perfbook/高级同步 提供的 litmus test,来测试 Arm 的弱序内存模型。
#include <iostream>
#include <thread>
#include <atomic>
#include <ranges>
#include <functional>
#include <cassert>
#include <pthread.h>
#include <sched.h>
////////////////////////////////////////////////////////////////////////////////////////////
constinit auto condition_counter = 0;
constexpr auto condition_info = R"(Condition (1:r2=2 /\ 1:r3=0))";
constexpr auto nr_herd7_thread = 2;
// 需要独占 cacheline,这很重要!
alignas(64) std::atomic<int> x0;
alignas(64) std::atomic<int> x1;
alignas(64) int r2;
alignas(64) int r3;
void reset() {
x0.store(0, std::memory_order::relaxed);
x1.store(0, std::memory_order::relaxed);
r2 = 0;
r3 = 0;
}
void check() {
if(r2 == 2 && r3 == 0) {
condition_counter++;
}
}
void p0() {
x0.store(2, std::memory_order::relaxed);
std::atomic_thread_fence(std::memory_order::release);
x1.store(2, std::memory_order::relaxed);
}
void p1() {
r2 = x1.load(std::memory_order::relaxed);
r3 = x0.load(std::memory_order::relaxed);
}
////////////////////////////////////////////////////////////////////////////////////////////
constexpr int iterations = 1e7;
constexpr auto loop_maybe_buggy = std::views::iota(0, iterations);
constexpr auto loop = loop_maybe_buggy | std::views::drop(1);
std::atomic<int> start_epoch = 0;
std::atomic<int> end_epoch = 0;
auto pin_this_thread(unsigned core_id) {
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(core_id, &cpuset);
pthread_t t = pthread_self();
int rc = pthread_setaffinity_np(t, sizeof(cpu_set_t), &cpuset);
assert(!rc && "affinity");
}
auto spinwait(auto &what, auto when) {
while(what.load(std::memory_order::acquire) != when) {
std::this_thread::yield();
}
}
std::thread make_parallel(unsigned core_id, auto F, auto ...args) {
return std::thread([=] {
pin_this_thread(core_id);
for(auto e : loop) {
spinwait(start_epoch, e);
std::invoke(F, args...);
// 提供 release sequence,所有 Px 线程都会与 main 线程建立同步关系
end_epoch.fetch_add(1, std::memory_order::release);
}
});
}
int main() {
std::cout << "Running litmus test for " << iterations << " iterations..." << std::endl;
// 手机用的 8g3,其中 [2,6] 均为大核 ID,7 为超大核
make_parallel(2, p0).detach();
make_parallel(7, p1).detach();
pin_this_thread(6);
for(auto e : loop) {
reset();
end_epoch.store(0, std::memory_order::release);
start_epoch.store(e, std::memory_order::release);
spinwait(end_epoch, nr_herd7_thread);
check();
}
std::cout << "Litmus test finished." << std::endl;
std::cout << condition_info << " was met "
<< condition_counter << " times." << std::endl;
return 0;
}
这是 perfbook 的内存访问乱序测试。框架看着复杂是为了避免 std::thread
过慢的启动以至于无法观测,以及尽可能贴近 herd7 的写法。我们关注抽象的线程 p0 和 p1 即可,简单来说就是一个线程在执行 store-store,另一个线程则执行 load-load。
caturra@bluepuni:~/tmp/arm-test$ while true; do adb shell /data/local/tmp/memorder.aarch64; sleep 1s; done
Running litmus test for 10000000 iterations...
Litmus test finished.
Condition (1:r2=2 /\ 1:r3=0) was met 11 times.
Running litmus test for 10000000 iterations...
Litmus test finished.
Condition (1:r2=2 /\ 1:r3=0) was met 4 times.
Running litmus test for 10000000 iterations...
Litmus test finished.
Condition (1:r2=2 /\ 1:r3=0) was met 5 times.
Running litmus test for 10000000 iterations...
Litmus test finished.
Condition (1:r2=2 /\ 1:r3=0) was met 6 times.
Running litmus test for 10000000 iterations...
Litmus test finished.
Condition (1:r2=2 /\ 1:r3=0) was met 2 times.
总之,一个极为简化的理解是,x86 TSO 保证 store-store 和 load-load 均有序,因此不会触发 met,而 Arm 是真弱序模型。
void check() {
if(r2 == 2 && r3 == 0) {
condition_counter++;
}
}
void p0() {
x0.store(2, std::memory_order::relaxed);
std::atomic_thread_fence(std::memory_order::release);
x1.store(2, std::memory_order::relaxed);
}
void p1() {
r2 = x1.load(std::memory_order::relaxed);
std::atomic_thread_fence(std::memory_order::acquire); // ⭐
r3 = x0.load(std::memory_order::relaxed);
}
# 手机快要算冒烟了
...
Running litmus test for 10000000 iterations...
Litmus test finished.
Condition (1:r2=2 /\ 1:r3=0) was met 0 times.
Running litmus test for 10000000 iterations...
Litmus test finished.
Condition (1:r2=2 /\ 1:r3=0) was met 0 times.
Running litmus test for 10000000 iterations...
Litmus test finished.
Condition (1:r2=2 /\ 1:r3=0) was met 0 times.
进一步添加 fence 则可以阻止对应的 load-load 乱序行为,这也是 perfbook 里面的 case 2 示例。
读者要是感兴趣,也可以照搬其他的示例(比如更复杂的传播测试)。框架是易于移植测试的。
NOTES:
- 即使是 Arm 体系结构,在低压测试场景也是较难复现的,我推荐手机在运行一些大型负载应用的时候测试:比如原神。没开玩笑,开启游戏确实更容易抓到现场。
- 前面描述 x86 正确性的前提在于编译器没有重排序,语言标准是没有相关承诺的,但是可以反汇编确认编译器行为。一般这种做法称为编译器行为学。
- 同样是编译器行为学,atomic fence 在这里无法被 TSAN 识别而会产生误报,老问题了。
总结
本文基于 zig c++ 工具链提供了极易上手的、可复现的弱序内存模型测试方案。
至少可以总结:
- Android NDK 不是必需品,Zig 也不只是编程语言。
- 不要为了测试而去租一台云服务器(不论 vcpu 的影响),手机就可以了,免费且触手可及。
- 也不要为了一件小事而去养🐖。
后日谈
参与研(bug)发(fix)的手机机型,以及我的手(手指脱皮了,凹点姿势)
最后,文章的灵感其实来自于 Preshing 的 iPhone 4S 弱序测试。但是我非常后悔在 2022 年买了一台(此处省略 N 句吐槽)iPhone 14 Pro。还是安卓手机好!