背景

这完全是一个工具链问题,跟 Arm 内存模型没关系。

正式的 Android C++ 开发 需要引入 Android NDK 工具链,而现在测试弱序内存模型其实只需要一个满足 ABI 兼容的 Linux 二进制文件,并不涉及任何 Android 生态。

那我能不能扔掉这个庞大的 NDK?里面可是塞满了大量的 llvm-prebuilts!二进制体积高达 2 GB!

Android NDK 里面到底有什么东西?为什么体积会像🐖一样大?
说得好!这个比喻非常形象,Android NDK 的体积确实经常让人“虎躯一震”。它动不动就几个GB的大小,让很多开发者又爱又恨。下面我们来“解剖”一下这头“^(* ̄(oo) ̄)^”,看看它肚子里到底有什么“货”,以及为什么这么“胖”。 ------- Android NDK 里面到底有什么: - 编译/链接工具链(多平台预置) - Clang/LLD(AOSP 定制)、目标驱动、ar/strip 等 - 启动文件/连接片段:crt1.o、crti.o、crtn.o、linker scripts - bionic sysroot(Android 自家的 C 运行时) - 统一头文件(bionic、POSIX、Android NDK APIs) - 按 ABI 和 API level 切分的库目录 - aarch64/armeabi-v7a/x86/x86_64 × android-21/23/24/…(每个级别可能各有一份) - C++ 运行时与依赖 - libc++(shared/static)、libc++abi、libunwind、compiler-rt builtins - Sanitizers/分析/性能库(体积大头) - ASan/TSan/MSan/UBSan/HWASan、profile/coverage 等(各 ABI × 各 API level × shared/static) - Android 原生 API 的“可链接桩”和支持库 - libandroid、liblog、AAudio/OpenSLES、EGL/GLES/Vulkan loader、Media NDK、camera/sensor、AHardwareBuffer、AChoreographer、native_app_glue 等等 - 调试与集成工具 - lldb/gdbserver、ndk-stack、ndk-which、CMake toolchain file、ndk-build 脚本 - 多份变体与符号 - 各 ABI 的静态/动态版、带/不带调试符号、不同 API level 的兼容桩 ------- 为什么它这么大: - 维度组合爆炸:4 个 ABI × 多个 API level × 静态/动态 × sanitizer 变体 × 调试符号 - 自带完整 bionic/sysroot 和平台桩,保证“你编出来的 .so/.exe 在对应 minSdk 的设备上必然可链接可运行” - 自带调试器/分析器/工具和跨平台(Linux/macOS/Windows)主机预置 - 目标是“Android 一等公民”:要遵守 ABI、API level、调试、性能、兼容性,而不仅是能在内核上跑起来 *EOF*

总之我们不需要这些「工程」产物,只要能跑并且配置方便即可。

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 示例。

另一个 MP 测试示例
#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 (r1==0 after seeing flag==1))";
constexpr auto nr_herd7_thread = 2;

alignas(64) std::atomic<int> data;
alignas(64) std::atomic<int> flag;
alignas(64) int r1;

void reset() {
    data.store(0, std::memory_order::relaxed);
    flag.store(0, std::memory_order::relaxed);
    r1 = -1;
}

// Arm 会检测出来
void check() {
    assert(flag.load(std::memory_order::relaxed) == 1);
    if(r1 == 0) {
        condition_counter++;
    }
}

void p0() {
    data.store(42, std::memory_order::relaxed);
    flag.store(1, std::memory_order::relaxed);
}

void p1() {
    while(flag.load(std::memory_order::relaxed) == 0);
    r1 = data.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) {
    return;
    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;
}

读者要是感兴趣,也可以照搬其他的示例(比如更复杂的传播测试)。框架是易于移植测试的。

NOTES:

  • 即使是 Arm 体系结构,在低压测试场景也是较难复现的,我推荐手机在运行一些大型负载应用的时候测试:比如原神。没开玩笑,开启游戏确实更容易抓到现场。
  • 前面描述 x86 正确性的前提在于编译器没有重排序,语言标准是没有相关承诺的,但是可以反汇编确认编译器行为。一般这种做法称为编译器行为学。
  • 同样是编译器行为学,atomic fence 在这里无法被 TSAN 识别而会产生误报,老问题了。

总结

本文基于 zig c++ 工具链提供了极易上手的、可复现的弱序内存模型测试方案。

至少可以总结:

  • Android NDK 不是必需品,Zig 也不只是编程语言。
  • 不要为了测试而去租一台云服务器(不论 vcpu 的影响),手机就可以了,免费且触手可及。
  • 也不要为了一件小事而去养🐖。

后日谈

j1s 参与研(bug)发(fix)的手机机型,以及我的手(手指脱皮了,凹点姿势)

最后,文章的灵感其实来自于 Preshing 的 iPhone 4S 弱序测试。但是我非常后悔在 2022 年买了一台(此处省略 N 句吐槽)iPhone 14 Pro。还是安卓手机好!