高效的类型双关,没有未定义的行为

Dra*_*-On 8 c++ gcc language-lawyer type-punning c++20

假设我正在开发一个名为 libModern 的库。该库使用称为 libLegacy 的遗留 C 库作为实现策略。libLegacy 的界面如下所示:

typedef uint32_t LegacyFlags;

struct LegacyFoo {
    uint32_t x;
    uint32_t y;
    LegacyFlags flags;
    // more data
};

struct LegacyBar {
    LegacyFoo foo;
    float a;
    // more data
};

void legacy_input(LegacyBar const* s); // Does something with s
void legacy_output(LegacyBar* s); // Stores data in s
Run Code Online (Sandbox Code Playgroud)

出于各种原因,libModern 不应该向用户公开 libLegacy 的类型,其中包括:

  • libLegacy 是一个不应泄露的实现细节。libModern 的未来版本可能会选择使用另一个库而不是 libLegacy。
  • libLegacy 使用难以使用、容易误用的类型,这些类型不应成为任何面向用户的 API 的一部分。

处理这种情况的教科书方法是 pimpl 习惯用法:libModern 将提供一个包装类型,该类型内部有一个指向遗留数据的指针。然而,这在这里是不可能的,因为 libModern 无法分配动态内存。一般来说,其目标不是增加大量开销。

因此,libModern 定义了自己的类型,这些类型与遗留类型布局兼容,但具有更好的接口。在此示例中,它使用强标志enum而不是普通uint32_t标志:

enum class ModernFlags : std::uint32_t
{
    first_flag = 0,
    second_flag = 1,
};

struct ModernFoo {
    std::uint32_t x;
    std::uint32_t y;
    ModernFlags flags;
    // More data
};

struct ModernBar {
    ModernFoo foo;
    float a;
    // more data
};
Run Code Online (Sandbox Code Playgroud)

现在的问题是:libModern 如何在传统类型和现代类型之间进行转换而不需要太多开销?我知道有3个选择:

  1. reinterpret_cast。这是未定义的行为,但实际上会产生完美的装配。我想避免这种情况,因为我不能依赖它明天仍然可以工作或依赖另一个编译器。
  2. std::memcpy。在简单的情况下,这会生成相同的最佳装配,但在任何重要的情况下,这都会增加显着的开销。
  3. C++20 的std::bit_cast. 在我的测试中,它最多生成与memcpy. 在某些情况下,情况更糟。

这是与 libLegacy 接口的 3 种方法的比较:

  1. 与接口legacy_input()
    1. 使用reinterpret_cast
      void input_ub(ModernBar const& s) noexcept {
          legacy_input(reinterpret_cast<LegacyBar const*>(&s));
      }
      
      Run Code Online (Sandbox Code Playgroud) 集会:
      input_ub(ModernBar const&):
              jmp     legacy_input
      
      Run Code Online (Sandbox Code Playgroud) 这是完美的代码生成器,但它调用了 UB。
    2. 使用memcpy
      input_ub(ModernBar const&):
              jmp     legacy_input
      
      Run Code Online (Sandbox Code Playgroud) 集会:
      input_memcpy(ModernBar const&):
              sub     rsp, 24
              movdqu  xmm0, XMMWORD PTR [rdi]
              mov     rdi, rsp
              movaps  XMMWORD PTR [rsp], xmm0
              call    legacy_input
              add     rsp, 24
              ret
      
      Run Code Online (Sandbox Code Playgroud) 明显更糟。
    3. 使用bit_cast
      void input_memcpy(ModernBar const& s) noexcept {
          LegacyBar ls;
          std::memcpy(&ls, &s, sizeof(ls));
          legacy_input(&ls);
      }
      
      Run Code Online (Sandbox Code Playgroud) 集会:
      input_bit_cast(ModernBar const&):
              sub     rsp, 40
              movdqu  xmm0, XMMWORD PTR [rdi]
              mov     rdi, rsp
              movaps  XMMWORD PTR [rsp+16], xmm0
              mov     rax, QWORD PTR [rsp+16]
              mov     QWORD PTR [rsp], rax
              mov     rax, QWORD PTR [rsp+24]
              mov     QWORD PTR [rsp+8], rax
              call    legacy_input
              add     rsp, 40
              ret
      
      Run Code Online (Sandbox Code Playgroud) 我不知道这里发生了什么。
  2. 与legacy_output()接口
    1. 使用reinterpret_cast
      input_memcpy(ModernBar const&):
              sub     rsp, 24
              movdqu  xmm0, XMMWORD PTR [rdi]
              mov     rdi, rsp
              movaps  XMMWORD PTR [rsp], xmm0
              call    legacy_input
              add     rsp, 24
              ret
      
      Run Code Online (Sandbox Code Playgroud) 集会:
      output_ub():
              sub     rsp, 56
              lea     rdi, [rsp+16]
              call    legacy_output
              mov     rax, QWORD PTR [rsp+16]
              mov     rdx, QWORD PTR [rsp+24]
              add     rsp, 56
              ret
      
      Run Code Online (Sandbox Code Playgroud)
    2. 使用memcpy
      void input_bit_cast(ModernBar const& s) noexcept {
          LegacyBar ls = std::bit_cast<LegacyBar>(s);
          legacy_input(&ls);
      }
      
      Run Code Online (Sandbox Code Playgroud) 集会:
      output_memcpy():
              sub     rsp, 56
              lea     rdi, [rsp+16]
              call    legacy_output
              mov     rax, QWORD PTR [rsp+16]
              mov     rdx, QWORD PTR [rsp+24]
              add     rsp, 56
              ret
      
      Run Code Online (Sandbox Code Playgroud)
    3. 使用bit_cast
      input_bit_cast(ModernBar const&):
              sub     rsp, 40
              movdqu  xmm0, XMMWORD PTR [rdi]
              mov     rdi, rsp
              movaps  XMMWORD PTR [rsp+16], xmm0
              mov     rax, QWORD PTR [rsp+16]
              mov     QWORD PTR [rsp], rax
              mov     rax, QWORD PTR [rsp+24]
              mov     QWORD PTR [rsp+8], rax
              call    legacy_input
              add     rsp, 40
              ret
      
      Run Code Online (Sandbox Code Playgroud) 集会:
      output_bit_cast():
              sub     rsp, 72
              lea     rdi, [rsp+16]
              call    legacy_output
              movdqa  xmm0, XMMWORD PTR [rsp+16]
              movaps  XMMWORD PTR [rsp+48], xmm0
              mov     rax, QWORD PTR [rsp+48]
              mov     QWORD PTR [rsp+32], rax
              mov     rax, QWORD PTR [rsp+56]
              mov     QWORD PTR [rsp+40], rax
              mov     rax, QWORD PTR [rsp+32]
              mov     rdx, QWORD PTR [rsp+40]
              add     rsp, 72
              ret
      
      Run Code Online (Sandbox Code Playgroud)

您可以在此处找到编译器资源管理器的完整示例。

我还注意到,代码生成根据结构的确切定义(即成员的顺序、数量和类型)而有很大差异。但 UB 版本的代码始终优于其他两个版本,或者至少与其他两个版本一样好。

现在我的问题是:

  1. 为什么代码生成变化如此之大?这让我想知道我是否错过了一些重要的事情。
  2. 我可以做些什么来引导编译器在不调用 UB 的情况下生成更好的代码吗?
  3. 是否有其他符合标准的方法可以生成更好的代码?

use*_*522 7

在编译器资源管理器链接中,Clang 为所有输出情况生成相同的代码。我不知道GCC在这种情况下遇到什么问题std::bit_cast

对于输入情况,三个函数不能生成相同的代码,因为它们具有不同的语义。

使用 时input_ub,对 的调用legacy_input可能会修改调用者的对象。其他两个版本不可能出现这种情况。因此,编译器无法优化副本,不知道legacy_input其行为如何。

如果您按值传递给输入函数,则所有三个版本至少会在编译器资源管理器链接中使用 Clang 生成相同的代码。

要重现原始代码生成的代码,input_ub您需要不断将调用者对象的地址传递给legacy_input.

如果legacy_input是一个extern C函数,那么我认为标准没有指定两种语言的对象模型应该如何在此调用中交互。因此,出于标记的目的language-lawyer,我将假设它legacy_input是一个普通的 C++ 函数。

直接传递地址的问题在于,同一地址处&s通常不存在可与该对象进行指针相互转换的对象。因此,如果尝试通过指针访问成员,那就是 UB。LegacyBarModernBarlegacy_inputLegacyBar

理论上,您可以在所需的地址创建一个 LegacyBar 对象,重用该ModernBar对象的对象表示形式。ModernBar但是,由于调用者可能会期望调用后仍然存在一个对象,因此您需要ModernBar通过相同的过程在存储中重新创建一个对象。

但不幸的是,并不总是允许您以这种方式重用存储。例如,如果传递的引用引用一个const完整的对象,则为 UB,并且还有其他要求。问题还在于调用者对旧对象的引用是否会引用新对象,这意味着这两个ModernBar对象是否可以透明地替换。情况也并非总是如此。

因此,总的来说,如果不对传递给函数的引用施加额外的约束,我认为您无法在没有未定义行为的情况下实现相同的代码生成。


归档时间:

查看次数:

669 次

最近记录:

2 年 前