Simdutf Can Now Be Used Without libc++ or libc++abi

Mitchell Hashimoto

simdutf 现在已可不依赖 libc++ 或 libc++abi 使用

此 PR起,simdutf 已可在不依赖 libc++ 或 libc++abi 的情况下使用1

simdutf 是 libghostty-vt 中最后一个剩余的 libc++ 依赖2。在将 Ghostty 更新为使用这一新的 simdutf 构建后,我们得以从依赖中完全移除 libc++ 和 libc++abi。

不依赖 libc++ 的好处

不依赖 libc++ 可以让库更具可移植性(嵌入式、WebAssembly、独立运行环境),简化交叉编译(无需针对特定目标的 C++ 标准库)、减小二进制体积,并能简化静态链接。

作为一个通用的底层库,simdutf 应当尽可能可移植和灵活。如果下游使用者能够轻松使用 libc++,那很好!但如果不能,也不应该因此被阻碍而无法使用 simdutf,因为它本质上并不需要它。

libc++ 与 libc++abi

要让程序摆脱对 libc++ 的依赖,需要处理两个部分。

首先,libc++ 是 C++ 标准库,提供了诸如 std::vectorstd::string 等内容。如果你引入了 <vector>,甚至是像 <cstring> 这样的 libc C++ 兼容头文件,就等于依赖了 libc++。

更隐蔽的部分是 libc++abi,它提供了 C++ ABI,包括异常处理、虚函数表、RTTI 等。如果你使用了任何需要这些机制的 C++ 特性,那么即使完全没有引入任何 C++ 标准库头文件,你依然依赖于 libc++abi。

例如,下面的极简 C++ 程序就依赖于 libc++abi,因为它使用了函数局部静态变量,而这类变量需要由 C++ ABI 提供的线程安全初始化机制:

struct Implementation {
    int version;
};

const Implementation& get_impl() {
    static const Implementation impl{1};
    return impl;
}

int main() {
    return get_impl().version;
}

让 simdutf 摆脱对 libc++ 的依赖

我们先来谈 libc++(而非 ABI)。

simdutf 是一个大量使用最新、最前沿 C++ 特性的 C++ 库,虽然我与 Daniel Lemire(丹尼尔·勒米尔)并无私交,但他似乎非常喜欢将 C++ 特性发挥到极致。因此,simdutf 在很大程度上就是一个 C++ 项目。

为了让我的改动有机会被接受,我必须确保项目能够继续使用 C++ 特性,而不会因此带来麻烦。

STL 用法

我决定采用的方法是引入一个stl_compat.h 头文件,将所有 C++ 标准库类型集中管理。在常规的 libc++ 模式下,stl_compat.h 中的所有内容只是对相应 C++ 标准库类型的简单引入或最小化别名,不会带来任何运行时开销。

NO_LIBCXX 模式下,stl_compat.h 为 simdutf 所使用的 C++ 类型提供了自己的实现,但仅实现到足以与 simdutf 所需功能兼容的程度。例如,stl_compat.h 提供了自己的 std::pair 实现。

因此,整个 diff 中的改动大多如下所示:

-std::pair<const char *, char32_t *>
+internal::pair<const char *, char32_t *>
arm_convert_latin1_to_utf32(const char *buf, size_t len,
                            char32_t *utf32_output) {

ABI 兼容性

我的目标是尽可能保留 ABI 兼容性。部分公开 ABI 会暴露诸如 std::string 之类的 C++ 类型,在这些场景下,ABI 不得不被打破。除此之外则完全得以保留。

鉴于 SIMDUTF_NO_LIBCXX 是一项会对编译单元产生影响的新特性,我认为仅在启用该标志时打破 ABI 是可以接受的。在未启用 NO_LIBCXX 标志的现有情况下,ABI 得以完全保留,现有用户更新 simdutf 时不会遭遇 ABI 破坏。

令我惊喜的是,ABI 的破坏非常小,仅涉及少数诊断函数(例如获取当前实现名称的函数)以及用于处理其他 C++ 类型(例如文本编码的 std::string)的辅助函数。由于从定义上看,使用 SIMDUTF_NO_LIBCXX 的人本就不关心 libc++,这些 ABI 破坏更像是特性而非缺陷。

让 simdutf 摆脱对 libc++abi 的依赖

这项任务要复杂得多。

主要问题在于,libc++abi 依赖通常不会以明显的源码级引入形式出现。它们之所以会出现,是因为编译器会为看似普通的语言特性悄悄生成对 C++ ABI 运行时的调用。为了检测这些依赖,我不得不编写一个脚本来反编译目标文件,并查找诸如 __cxa_guard_acquire 之类的符号。

simdutf 中最大的问题来源是运行时分发层。原始代码大量依赖函数局部静态变量。在 C++ 中,这些局部变量由诸如 __cxa_guard_acquire__cxa_guard_release 之类的线程安全初始化辅助函数来保护,而这些函数由 C++ ABI 运行时提供。因此,即使代码中完全没有提及 libc++abi,编译后的目标文件仍然依赖于它。在 NO_LIBCXX 模式下的修复方法是改用翻译单元静态变量,而非函数局部静态变量。

#if SIMDUTF_IMPLEMENTATION_ICELAKE
  #ifdef SIMDUTF_NO_LIBCXX
static const icelake::implementation icelake_singleton{};
  #endif
static const icelake::implementation *get_icelake_singleton() {
  #ifdef SIMDUTF_NO_LIBCXX
  return &icelake_singleton;
  #else
  static const icelake::implementation icelake_singleton{};
  return &icelake_singleton;
  #endif
}
#endif

接下来,simdutf 将每个后端建模为抽象 implementation 接口的子类。这一设计可以保留,但抽象类的虚表仍会为那些不可能被调用的纯虚函数条目引用 __cxa_pure_virtual。在 SIMDUTF_NO_LIBCXX 模式下,我选择提供一个极小的本地垫片,并确保运行时实际上永远不会执行到它。我将其标记为弱符号,以便在存在 C++ ABI 时该符号可被其覆盖。

#ifdef SIMDUTF_NO_LIBCXX
// The abstract implementation vtable still carries pure-virtual slots even
// though correct dispatch never reaches them in this build mode. Provide the
// narrowest possible ABI shim so stricter no-libcxx objects do not require
// libc++abi just for this unreachable hook. Keep it weak so a toolchain's real
// libc++abi definition wins if one is linked in anyway.
extern "C" SIMDUTF_WEAK [[noreturn]] void __cxa_pure_virtual() noexcept {
  __builtin_trap();
}
#endif

最后,我编写了一个脚本,在使用 -fno-exceptions-fno-rtti 构建时对产物进行审计,检查是否不会出现诸如 __cxa_guard_*__gxx_personality__cxa_throwtypeinfo__dynamic_cast 之类的符号。该检查已被加入 simdutf 的 CI,以确保未来在 NO_LIBCXX 构建中不会意外重新引入对 libc++abi 的依赖。

验证

内部验证

simdutf 是一个对正确性和性能要求极高的库,因此我必须确保我的改动不会对二者产生影响。我修改了已有的测试和基准测试套件,使其能在 NO_LIBCXX 和常规模式下同时运行,并确保所有测试均通过、基准测试结果不受影响。

重要的是,我提交了确保 NO_LIBCXX 模式与现有测试和基准测试套件兼容所需的改动。这意味着未来对 simdutf 的任何改动都可以继续对两者进行验证。

外部验证:Ghostty

接下来,我将 Ghostty 更新为使用来自我分支的新版 simdutf,将我们的构建更新为使用 SIMDUTF_NO_LIBCXX,并添加了我们自己的测试套件来验证产物不再依赖 libc++ 或 libc++abi。

Ghostty 拥有一套完善的测试来验证我们的 UTF-8 解码行为(特别是对无效输入的处理)。Ghostty 还有一个内置的基准测试套件,用于在多种场景下测试我们的 UTF-8 吞吐量。我运行了所有 Ghostty 测试和基准测试,验证了它们全部通过,且如预期那样 UTF-8 性能未受影响。

拉取请求

让某样东西能运行和让它被合入是两回事。

作为一名维护者,我太清楚“这能用”和“这能被合入”之间的差距。我了解验证他人工作并确信今后能够维护它的挑战。我也知道提交一个大型 PR 却不清楚其动因的困难。我还了解近期 AI 生成的低质内容所带来的负担。

因此,我付出了我期望从一位明星贡献者那里看到的努力,并努力为 simdutf 的维护者成为那样的人。

首先,我审查了整个 diff(是的,全部约 3,000 行)。然后我又审查了一遍。我手动将整个 diff 重读了三四遍。基于那些即使在功能上没有问题、但我自己可能会提出评论的地方,我做了多处修改。

接着,我手写了一份详细的 PR 描述,解释了动机、方法、局限性和验证方式。我想确保维护者不仅了解细节,也能感受到我在细节上投入的思考。

最后,我披露了我确实使用了 AI 来辅助编写代码。但我明确表示,我已手动审查了所有内容,没有在撰写 PR 描述或评论时使用 AI,并且作为人类,我有能力也乐于为任何提议的改动进行辩护和修改。

颇具讽刺意味的是,整理完整的 diff 只花了我大约 2 小时,而额外的验证工作和 PR 准备却花了约 3 小时。出于对维护者在项目上所付出努力的尊重,我在与人协作的边界上花费的时间比在代码本身上的还要多,而这正是我们应该做的。

最终状态

simdutf PR 仍在审查中。初步反馈是积极的,我也愿意接受对我提出的任何修改要求。维护者也有可能最终不想合入它,这也没关系。

如果你想在不依赖 libc++ 或 libc++abi 的情况下使用 simdutf,目前可以使用我的分支。生成单文件合并版加头文件的构建说明同样适用。在构建 C++ 代码并引入头文件时,只需定义 SIMDUTF_NO_LIBCXX 即可获得无 libc++ 版本的库。

Ghostty PR 现已合入。因此,libghostty-vt 在 SIMD 构建中不再依赖 libc++ 或 libc++abi。

脚注

  1. libc++ 是 C++ 标准库(例如 std::vectorstd::string 等),而 libc++abi 是 C++ ABI 库(例如异常处理、RTTI 等)。

  2. 请注意,在禁用 SIMD 的情况下,libghostty-vt 始终是零依赖的,甚至不依赖 libc。它是一个完全独立的库。

原文由 Mitchell Hashimoto 发布

本文章由 muse-spark-1.2-contributor 进行翻译