尝试使用“快速”pimpl时,memcpy未被优化排除

4

我需要使用一个非常庞大且复杂的头文件类(类似于 boost::multiprecision::cpp_bin_float<76>,下文称之为BHP),我想通过类似 pimpl 的实现来隐藏它,以减少在相当庞大的项目中的编译时间(将 Boost 类替换为std::complex<double> 可以将编译时间缩短约50%)。

然而,我想避免动态内存分配。因此,像这样的东西似乎很自然(暂时忽略可以使用 aligned_storagealignas 避免的对齐问题):

struct Hidden {
  char data[sz];

  Hidden& punned(Hidden const& other);
};

Hidden::punned可以在单个翻译单位中定义,将data转换为BHP*,对其进行操作,并且不会污染所有其他翻译单位的170k LOC头文件。可能的实现方式如下:

Hidden& Hidden::punned(Hidden const& other) {
  *(BHP*)(data) += *(BHP*)(other.data);
  return *this;
}

当然,这是未定义的行为,因为我们通过char类型的指针访问BHP类型的对象,违反了严格别名规则。正确的做法是:
Hidden& Hidden::proper(Hidden const& other) {
  BHP tmp; std::memcpy(&tmp, data, sz);
  BHP tmp2; std::memcpy(&tmp2, other.data, sz);
  tmp += tmp2;
  std::memcpy(data, &tmp, sz);
  return *this;
}

现在看起来很明显这些memcpy调用可以被优化掉。不幸的是,这不是情况,它们仍然存在并且使proper()punned() 大得多。

我想知道正确的方法是a)直接将数据存储在Hidden对象中和b)避免不必要的复制以重新解释它,并且c)避免违反严格的对齐规则以及d)不需要携带指向存储区域的额外指针。

这里有一个godbolt链接;请注意,我测试过的所有编译器(GCC 4.9 - trunk,Clang 3.9、4.0和5.0以及Intel 18)都没有“优化掉” memcpy。一些版本的GCC(例如5.3)也直接抱怨违反了严格别名规则,但并非所有版本都是如此。我还插入了一个Direct类,它了解BHP,因此可以直接调用它,但我想避免这种情况。

最小工作示例:

#include <cstring>

constexpr std::size_t sz = 64;

struct Base {
  char foo[sz];
  Base& operator+=(Base const& other) { foo[0] += other.foo[0]; return *this; }
};
typedef Base BHP;

// or:
//#include <boost/multiprecision/cpp_bin_float.hpp>
//typedef boost::multiprecision::number<boost::multiprecision::cpp_bin_float<76> > BHP;

struct Hidden {
  char data[sz];

  Hidden& proper(Hidden const& other);
  Hidden& punned(Hidden const& other);
};

Hidden& Hidden::proper(Hidden const& other) {
  BHP tmp; std::memcpy(&tmp, data, sz);
  BHP tmp2; std::memcpy(&tmp2, other.data, sz);
  tmp += tmp2;
  std::memcpy(data, &tmp, sz);
  return *this;
}

Hidden& Hidden::punned(Hidden const& other) {
  *(BHP*)(data) += *(BHP*)(other.data);
  return *this;
}

struct Direct {
  BHP member;
  Direct& direct(Direct const& other);
};

Direct& Direct::direct(Direct const& other) {
  member += other.member;
  return *this;
}

struct Pointer {
  char storage[sz];
  BHP* data;

  Pointer& also_ok(Pointer const& other);
};

Pointer& Pointer::also_ok(Pointer const& other) {
  *data += *other.data;
  return *this;
}

2
当然,这是未定义的行为,因为我们通过char类型的指针访问BHP类型的对象,从而违反了严格别名规则。不,将其他指针类型转换为char*并通过后者重新解释是免除别名的。它必须这样做,才能实现memcpy()(没有编译器技巧)和使用它进行“位转换”的过程。但是,反之则不成立。 - underscore_d
@underscore_d 我不明白你所说的“通过后者重新解释”的意思。 - Joe
2
@Joe 解除引用 char* 强制转换并使用它来读取对象的表示形式,即将对象重新解释为字节。这是允许的。将一些仅仅是一组字节的东西强制转换为其他类型,而它不是以该类型构造的,则不行。换句话说,从更改类型的强制转换获得的指针只有在那里真正存在该类型的对象(或兼容的对象)时才能被解除引用;我们不能只创建一个 char 数组,填充数据并告诉编译器:“这里:有一个 SomeClass”。但是我们可以说:“显示组成此 SomeClasschar”。 - underscore_d
@underscore_d,这就是我想说的(我们有一个char*,我们将其转换为其他类型并通过此访问其后面的对象),因此我感到困惑。 - Claudius
1
你的项目中是否可能使用显式模板初始化 - Martin Ueding
显示剩余5条评论
1个回答

1

当然,这是未定义的行为,因为我们通过char类型的指针访问BHP类型的对象。

实际上并非如此。通过char*访问是可以的,前提是那里确实有一个BHP对象。也就是说,只要两边都是:

new (data) BHP(...);

那么这是完全可以的:

然后这是完全可以的:

*(BHP*)(data) += *(BHP*)(other.data);

请确保您的字符数组也是 alignas(BHP)

请注意,有时候 gcc 不喜欢重新解释 char[],因此您可以选择使用类似 std::aligned_storage_t 的东西。


好的,谢谢!我被GCC警告搞糊涂了(在Godbold上触发了GCC 5.4,我也曾经让7.2发出过这个警告,但我不确定是何时或如何)。但是,是的,我总是保留一个按照你所说的构建的BHP - Claudius

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接