C++ 从 libc++ 的 std::expected 内存布局实现讲一下空基类优化

Posted by Ciel's Paperplane on September 17, 2024

图片

本文前置知识见 C++ 生命周期与内存对齐与小缓冲区优化

0. std::expected 的内存布局行为

首先 std::expected<T, E> 是 C++23 的一个类,它内部可以存储一个表示 T 类型的预期值或是表示 E 类型的非预期值。因为任意时刻只需要一种值可用即可,所以自然可以把这两个值包成一个 union,或是用 size 和 alignment 都满足两者的缓冲区来存储它。除此以外,还需要一个 bool 值来指示当前存储的是 T 还是 E 值。

所以对 std::expected<uint64_t, uint8_t>sizeof 会得到 16,因为能同时容纳下 uint64_tuint8_t 的缓冲区大小和对齐都至少应为 8 字节,然后指示状态的 bool 值占 1 字节,被 8 字节对齐后只能多了 7 字节的尾填充。

以上行为在 libc++ 和 libstdc++ 都一致,但是 libc++ 的实现实际上更有追求一点,以下截自 libc++ std::expected 源码的 __expected_base 部分:

// This class implements the storage used by `std::expected`. We have a few
// goals for this storage:
// 1. Whenever the underlying {_Tp | _Unex} combination has free bytes in its
//    tail padding, we should reuse it to store the bool discriminator of the
//    expected, so as to save space.
// 2. Whenever the `expected<_Tp, _Unex>` as a whole has free bytes in its tail
//    padding, we should allow an object following the expected to be stored in
//    its tail padding.
// 3. However, we never want a user object (say `X`) that would follow an
//    `expected<_Tp, _Unex>` to be stored in the padding bytes of the
//    underlying {_Tp | _Unex} union, if any. That is because we use
//    `construct_at` on that union, which would end up overwriting the `X`
//    member if it is stored in the tail padding of the union.

简单来说就是:缓冲区的尾填充可以存放自己的 bool,但不能存放别人的对象;而 std::expected 整体如果有无关缓冲区的尾填充,那可以存放别人的对象。

我们用以下这些例子来解释这段注释:

struct T1 {
    alignas(8) uint8_t buffer[6]{};
};
struct T2 {
    alignas(8) uint8_t buffer[7]{};
};
struct T3 {
    alignas(8) uint8_t buffer[8]{};
};

static_assert(sizeof(std::expected<T1, uint8_t>) == 8);
static_assert(sizeof(std::expected<T2, uint8_t>) == 8);
static_assert(sizeof(std::expected<T3, uint8_t>) == 16);

struct U1 : std::expected<T1, uint8_t> {
    uint8_t c{};
};

struct U2 : std::expected<T2, uint8_t> {
    uint8_t c[8]{};
};

struct U3 : std::expected<T3, uint8_t> {
    uint8_t c[7]{};
};

static_assert(sizeof(U1) == 16);
static_assert(sizeof(U2) == 16);
static_assert(sizeof(U3) == 16);

T1 和 T2 分别有 2 和 1 字节的尾填充,这用来存放了 bool 值,所以 std::expected 中存储 T1 和 T2 的 sizeof 都为 8 字节。(libstdc++ 没有这个优化)

T3 开始就讲过了,带上了 7 字节的尾填充最后 sizeof 为 16 字节。

U1 继承了存储 T1 的 std::expected,按理说还有 1 字节尾填充,但是 uint8_t c 并不能存放至此,而是要到下一个 8 字节开头,所以最后 sizeof 为 16。

U2 直接就是存放在下一段 8 字节,所以这里 uint8_t 为 1 - 8 个时 sizeof 都是 16 字节。

U3 的 uint8_t[7] 数组利用了存放 T3 的 std::expected 的 7 字节尾填充,sizeof 不用增加。

这些行为我们下面慢慢解释。

1. 空基类优化简介

首先我们都知道 C++ 的空类在正常情况下也要占据一字节空间,原因是 C++ 要求每个对象都要有其独立的地址。毕竟如果没有独立地址,那一个空类的数组的起始与终止位置都是同一个地址,压根不可能正常使用相关的算法(比如说 STL 算法库里大多都要接受一对迭代器作为函数形参,结束条件就是两个迭代器相等,那如果每个对象都同一个地址自然就还没开始就结束了)。

但是 C++ 又有一个空基类优化的技巧,这允许一个类通过继承空类的方式将那 1 字节抹去。此时那个空基类与派生类有同一个起始地址。此外 C++20 的 [[no_unique_address]] 也有同样的效果,不过更好用了,一是看起来比继承更直观,二是没有继承时空基类不能为 final 的限制。

struct Empty {
    void print() { std::cout << this << '\n'; }
};

struct T1 {
    uint32_t i{};
    Empty e; // 占据 1 字节并多了 3 字节尾填充
};
struct T2 : Empty {
    uint32_t i{};
};
struct T3 {
    uint32_t i{};
    [[no_unique_address]] Empty e;
};

static_assert(sizeof(T1) == 8);
static_assert(sizeof(T2) == 4);
static_assert(sizeof(T3) == 4);

int main() {
    T2 t2;
    std::cout << &t2 << '\n'; // 0000004181cff97c
    t2.print();               // 0000004181cff97c

    T3 t3;
    std::cout << &t3 << '\n'; // 0000004181cff978
    t3.e.print();             // 0000004181cff978
}

但是空基类优化不允许同一个类型的成员变量被应用优化超过一次,原因上面也提到了,还是因为同类的对象需要不同的地址来区分。

struct Empty {
    void print() { std::cout << this << '\n'; }
};

struct T4 {
    int i{};
    [[no_unique_address]] Empty e1;
    [[no_unique_address]] Empty e2;
    [[no_unique_address]] Empty e3;
};

static_assert(sizeof(T4) == 8);

int main() {
    T4 t4;
    std::cout << &t4 << '\n'; // 00000009e1d1fa78
    t4.e1.print();            // 00000009e1d1fa78
    t4.e2.print();            // 00000009e1d1fa7c
    t4.e3.print();            // 00000009e1d1fa7d
}

2. 利用内存对齐尾填充字节

本节所探讨的行为标准并不是来自 C++ 标准而是来自 LLVM 与 GNU 共同遵守的 Itanium ABI,这表示 MSVC 并不一定可以复现相关行为。

(1) 利用空基类优化重用尾填充

不仅是空类可以利用空基类优化,之前我们说过内存对齐的尾填充也可以用空基类优化的手段来将派生类成员变量存放至此,即这个例子:

// Derived can reuse Base's tail padding.
struct Base {
    alignas(8) unsigned char buf[1]{};
};

struct Derived : Base {
    int i{};
};

static_assert(sizeof(Base)    == 8);
static_assert(sizeof(Derived) == 8);

(2) POD 的尾填充被忽略

但是上例有个非常重要的地方当初我没提,就是 buf[1] 后的 {} 是非常重要的存在,如果去掉了它,尾对齐就直接不能用了,sizeof(Derived) 升高成 16 字节。

struct Base {
    alignas(8) unsigned char buf[1];
};

struct Derived : Base {
    int i{};
};

static_assert(sizeof(Base)    == 8);
static_assert(sizeof(Derived) == 16);

去掉 {} 与否的区别是什么呢,其实是 {} 代表了对数组进行了初始化,也就是默认构造函数变成了用户定义的了,导致 std::is_trivially_default_constructible_v<Base> 会变为 false,更进一步也就是 std::is_trivial_v<Base> 会变为 false,更进一步也就是 std::is_pod<Base> 会变为 false

POD 类型大概可以认为是在 C 里存在的所有类型。

避免利用 POD 类型的尾填充主要是因为我们需要兼容 C 的优化,例如 std::memcpy

(3) 对象表示与值表示

一个 T 类型的对象表示为其占据的 sizeof(T)unsigned char 对象。而值表示则是其中参与表示 T 的值的所有位的集合。也就是值表示是对象表示的子集。

struct S {
    char c;  // 1 字节值
             // 3 字节填充
    float f; // 4 字节值
 
    bool operator==(const S& other) const noexcept {
        return c == other.c && f == other.f;
    }
};

在此例中 S 的值表示为 char cfloat f 的所占的 5 个字节,而对象表示则是全部的 8 个字节。判断相等只需要值表示相等即可,也就是通过各种手段修改 3 字节填充的数据都不影响值表示。而对象表示是值表示的超集,这就意味着只需要复制对象表示就足以产生一个值表示相等的对象。那这就是 std::memcpy 的用武之地。

在 C++ 中大多数类的拷贝都需要调用拷贝构造函数,但是 POD 类型并不需要,而是简单的赋值即可,更普遍地会用 std::memcpy 来进行底层字节的拷贝,这样的速度非常快。

但是如果我们利用了 POD 基类的尾填充,在此时就会发生灾难性的后果:

struct B {
    int i;
    char c;
};
struct C : B {
    short s;
};

static_assert(sizeof(C) == 12);

int main() {
    C c1 { 1, 2, 3 };
    B& b1 = c1;
    C c2 { 4, 5, 6 };
    B& b2 = c2;

    std::cout << c1.s << '\n'; // 3

    b1 = b2;
    
    std::cout << c1.s << '\n'; // 3

    static_assert(sizeof(b2) == 8);
    std::memcpy(&b1, &b2, sizeof(b2));

    std::cout << c1.s << '\n'; // 3
}

本例中我们分别使用了 =std::memcpy 来用值表示与对象表示的形式将 b2 赋值给 b1,得益于 C 中的 short s 没有位于 B 的尾填充中,std::memcpy 的使用没有问题。而如果它这么干了,那 sizeof(C)sizeof(B) 就会同为 8 字节,那么在 std::memcpy 时就会将 b2 的尾填充一并复制过去而覆盖了 c1.s 的数据。

实际上 C++ 中对于批量复制的操作经常会根据类型 T 是否满足 std::is_trivially_copyable 来尝试使用 std::memcpy 来提升性能。但实际上从上例我们可以看出来,一个类型光满足 std::is_trivially_copyable 是不够的,某些场景下必须要满足 std::is_pod 才行。

(4) 早期 gcc 和 clang 的 bug

我们将上例的 B 拆分出了一个基类 A,此时变化点在于,B 本身由于多了一个继承已经不是 POD 类型了。由于不是 POD 类型所以 C.s 选择坐落在了 B 的尾填充内。这样的话 std::memcpy 已经是不可以使用的了。

struct A {
    int i;
};
struct B : A {
    char c;
};
struct C : B {
    short s;
};

static_assert(sizeof(C) == 8);

int main() {
    C c1 { 1, 2, 3 };
    B& b1 = c1;
    C c2 { 4, 5, 6 };
    B& b2 = c2;

    std::cout << c1.s << '\n'; // 3

    b1 = b2;
    
    std::cout << c1.s << '\n'; // 3

    static_assert(sizeof(b2) == 8);
    std::copy_n(&b2, 1, &b1);
    // std::memcpy(&b1, &b2, sizeof(b2)); // not supposed to call

    std::cout << c1.s << '\n'; // 6 on earlier version of gcc and clang
}

但是早期版本的 gcc 和 clang 的 std::copy_n 由于判断 std::is_trivially_copyable_v<B> 成立就直接调用了 std::memcpy,导致 c1.s 被覆盖。

之后这个 bug 分别在下面的 commit 被修,正式版应该分别为 gcc12.4 和 clang17.0.1:

⚙ D151953 [libc++] Fix std::copy and std::move for ranges with potentially overlapping tail padding

108846 – std::copy, std::copy_n and std::copy_backward on potentially overlapping subobjects

(5) std::is_podstd::is_trivialstd::is_standard_layout 的区别

省流版:std::is_podstd::is_trivialstd::is_standard_layout 的交集。

平凡类型指的是标量(即所有基础类型)与“默认拷贝移动构造和拷贝移动赋值与析构函数全存在且平凡”的类及其它们的数组和 CV 限定版本。

平凡类型相比于 POD 类型增加了继承关系和访问权限。

而标准布局类型则不允许这种关系。

struct N { // neither trivial nor standard-layout
    int i;
    int j;
    virtual ~N();
};

struct T { // trivial but not standard-layout
    int i;
private:
    int j;
};

struct SL { // standard-layout but not trivial
    int i;
    int j;
    ~SL();
};

struct POD { // both trivial and standard-layout
    int i;
    int j;
};

虽然 std::is_pod 在 C++20 被废弃了,但是它的存在由此看来还是有价值的。



Ciel's Paperplane

作诗中...