【问题标题】:Reusing a float buffer for doubles without undefined behaviour在没有未定义行为的情况下重用双精度浮点缓冲区
【发布时间】:2018-12-19 17:25:17
【问题描述】:

在一个特定的 C++ 函数中,我碰巧有一个指向浮点数的大缓冲区的指针,我想暂时使用它来存储一半的双精度数。有没有一种方法可以将此缓冲区用作存储双打的暂存空间,这也是标准允许的(即,不是未定义的行为)?

总之,我想要这样:

void f(float* buffer)
{
  double* d = reinterpret_cast<double*>(buffer);
  // make use of d
  d[i] = 1.;
  // done using d as scratch, start filling the buffer
  buffer[j] = 1.;
}

据我所知,没有简单的方法可以做到这一点:如果我理解正确,像这样的 reinterpret_cast&lt;double*&gt; 由于类型别名而导致未定义的行为,并且使用 memcpy 或 float/double union 不是在不复制数据和分配额外空间的情况下是可能的,这违背了目的并且在我的情况下恰好是昂贵的(并且在 C++ 中不允许使用联合进行类型双关)。

可以假设浮动缓冲区已正确对齐以将其用于双精度。

【问题讨论】:

  • 评论不用于扩展讨论;这个对话是moved to chat
  • 当你开始用浮点数填充缓冲区时,你是否需要每个双元素保持有效直到它被覆盖?或者可以一次性丢弃整个双打的暂存缓冲区吗?

标签: c++ strict-aliasing type-punning


【解决方案1】:

我认为下面的代码是一种有效的方法(实际上只是一个关于这个想法的小例子):

#include <memory>

void f(float* buffer, std::size_t buffer_size_in_bytes)
{
    double* d = new (buffer)double[buffer_size_in_bytes / sizeof(double)];

    // we have started the lifetime of the doubles.
    // "d" is a new pointer pointing to the first double object in the array.        
    // now you can use "d" as a double buffer for your calculations
    // you are not allowed to access any object through the "buffer" pointer anymore since the floats are "destroyed"       
    d[0] = 1.;
    // do some work here on/with the doubles...


    // conceptually we need to destory the doubles here... but they are trivially destructable

    // now we need to start the lifetime of the floats again
    new (buffer) float[10];  


    // here we are unsure about wether we need to update the "buffer" pointer to 
    // the one returned by the placement new of the floats
    // if it is nessessary, we could return the new float pointer or take the input pointer
    // by reference and update it directly in the function
}

int main()
{
    float* floats = new float[10];
    f(floats, sizeof(float) * 10);
    return 0;
}

重要的是你只使用你从placement new 收到的指针。并且重要的是放置新的花车。即使是无操作构造,也需要重新开始浮点数的生命周期。

忘记 cmets 中的 std::launderreinterpret_cast。新的安置将为您完成这项工作。

编辑:确保在 main 中创建缓冲区时正确对齐。

更新:

我只是想提供有关 cmets 中讨论的内容的最新信息。

  1. 首先提到的是我们可能需要将最初创建的浮点指针更新为重新放置新的浮点返回的指针(问题是最初的浮点指针是否仍然可以用于访问浮点,因为浮点数现在是“新”浮点数,由额外的新表达式获得)。

为此,我们可以 a) 通过引用传递浮点指针并更新它,或者 b) 从函数返回新获得的浮点指针:

一)

void f(float*& buffer, std::size_t buffer_size_in_bytes)
{
    double* d = new (buffer)double[buffer_size_in_bytes / sizeof(double)];    
    // do some work here on/with the doubles...
    buffer = new (buffer) float[10];  
}

b)

float* f(float* buffer, std::size_t buffer_size_in_bytes)
{
    /* same as inital example... */
    return new (buffer) float[10];  
}

int main()
{
    float* floats = new float[10];
    floats = f(floats, sizeof(float) * 10);
    return 0;
}
  1. 接下来要提到的更重要的是,placement-new 允许有内存开销。因此允许实现在返回的数组前面放置一些元数据。如果发生这种情况,那么天真地计算有多少双打会适合我们的记忆显然是错误的。问题是,我们不知道实现将预先为特定调用获取多少字节。但这对于调整我们知道将适合剩余存储空间的双打数量是必要的。 这里 (https://stackoverflow.com/a/8721932/3783662) 是另一个 SO 帖子,其中 Howard Hinnant 提供了一个测试 sn-p。我使用在线编译器对此进行了测试,发现对于微不足道的可破坏类型(例如双精度数),开销为 0。对于更复杂的类型(例如 std::string),开销为 8 个字节。但这可能因您的平台/编译器而异。使用 Howard 的 sn-p 预先对其进行测试。

  2. 1234563但最后——当我们访问值时——我们需要使用正确的类型以避免违反严格的别名规则。简单来说:只有在指针给定的位置确实存在指针类型的对象时才允许访问对象。那么如何让物体栩栩如生? 标准说:

https://timsong-cpp.github.io/cppwp/intro.object#1

“当隐式更改联合的活动成员或创建临时对象时,对象由定义、new 表达式创建。”

还有一个看起来很有趣的额外部门:

https://timsong-cpp.github.io/cppwp/basic.life#1:

“如果一个对象是类或聚合类型,并且它或其子对象之一由普通默认构造函数以外的构造函数初始化,则称该对象具有非空初始化。类型 T 的对象的生命周期开始时间:

  • 获得了适合类型 T 的具有正确对齐和大小的存储,并且
  • 如果对象有非空初始化,则其初始化完成”

所以现在我们可能会争辩说,因为替身是微不足道的,我们是否需要采取一些行动来使微不足道的对象变得栩栩如生并改变实际的生活对象?我说是的,因为我们最初获得了浮点数的存储空间,并且通过双指针访问存储空间会违反严格的别名。所以我们需要告诉编译器实际类型已经改变。整个最后一点 3 是相当有争议的讨论。你可以形成你自己的意见。您现在掌握了所有信息。

【讨论】:

  • 您至少需要从f 传回new 的结果float * 指针。或者使用洗钱。
  • 我认为在这种情况下“创建回状态”是不可能的。但也许我错了。也许值得一个新的 SO 问题 :)
  • 这种方法的另一个问题是new[] 会产生开销。这就是我推荐 reinterpret_cast+launder 方法的原因:调用非数组放置 new N 次,然后使用 launder 获取指向 double * 的“干净”指针。
  • @AndréOffringa 也许唯一的可移植解决方案是将新的每个元素分开放置(就像std::vector 那样)。您可以创建一个类并使用下标操作来使用它,就像使用 std::vector 或数组一样。但最终它不会是一个普通的 double[...] 数组。该解决方案看起来类似于 geza 的解决方案,并带有一些样板代码。当我有更多时间时,我会更新我的答案。
【解决方案2】:

您可以通过两种方式实现这一目标。

第一:

void set(float *buffer, size_t index, double value) {
    memcpy(reinterpret_cast<char*>(buffer)+sizeof(double)*index, &value, sizeof(double));
}
double get(const float *buffer, size_t index) {
    double v;
    memcpy(&v, reinterpret_cast<const char*>(buffer)+sizeof(double)*index, sizeof(double));
    return v;
}
void f(float *buffer) {
    // here, use set and get functions
}

第二:你需要分配一个“无类型”的char[]缓冲区,而不是float *,并使用placement new在里面放置浮点数或双精度数:

template <typename T>
void setType(char *buffer, size_t size) {
    for (size_t i=0; i<size/sizeof(T); i++) {
        new(buffer+i*sizeof(T)) T;
    }
}
// use it like this: setType<float>(buffer, sizeOfBuffer);

然后使用这个访问器:

template <typename T>
T &get(char *buffer, size_t index) {
    return *std::launder(reinterpret_cast<T *>(buffer+index*sizeof(T)));
}
// use it like this: get<float>(buffer, index) = 33.3f;

第三种方法可能类似于 phön 的答案(请参阅该答案下的我的 cmets),不幸的是,由于this problem,我无法做出正确的解决方案。

【讨论】:

  • 现在......请查看我对原始答案所做的编辑。这是一个滑的,这个。
  • 我们可以修复this problem,但我们没有进一步前进,因为我们仍在为指向不同类型的指针设置别名。我们需要std::pun!! :)
  • @PaulSanders 如果您实际创建对象并开始它们的生命周期,那么就不存在别名问题。这就是你开始一生的原因
  • @phön 你为什么这么说?当然有。别名指针与对象生命周期无关。更多噪音。
  • @AndréOffringa:不幸的是,我认为这不可能以严格标准的方式实现。但据我所知,目前我认为使用 phön 的解决方案是可以的。检查您的编译器是否增加了放置new[] 的开销,几乎可以肯定它不会。因此,如果我必须解决您的问题,我会选择这种方式。我已经添加了我的答案,只是有一个严格标准的符合方式。但是,我开始认为我的第一种方式有缺陷,并且它有 UB,所以我会检查一下。
【解决方案3】:

这是一种不那么可怕的替代方法。

你说,

...如果没有...分配额外的空间,就不可能实现浮点/双精度联合,这违背了目的并且在我的情况下恰好是昂贵的...

所以只需让每个联合对象包含两个浮点数而不是一个。

static_assert(sizeof(double) == sizeof(float)*2, "Assuming exactly two floats fit in a double.");
union double_or_floats
{
    double d;
    float f[2];
};

void f(double_or_floats* buffer)
{
    // Use buffer of doubles as scratch space.
    buffer[0].d = 1.0;
    // Done with the scratch space.  Start filling the buffer with floats.
    buffer[0].f[0] = 1.0f;
    buffer[0].f[1] = 2.0f;
}

当然,这会使索引变得更加复杂,并且必须修改调用代码。但它没有开销,而且更明显是正确的。

【讨论】:

    【解决方案4】:

    tl;dr 除非你告诉编译器你要在命令行上使用,否则不要给指针起别名。


    执行此操作的最简单方法可能是找出哪些编译器开关禁用了严格别名并将其用于有问题的源文件。

    需要,嗯?


    再想一想。尽管有很多关于安置新的东西,但这是唯一安全的方法。

    为什么?

    好吧,如果你有两个不同类型的指针指向同一个地址,那么你已经为那个地址加上了别名,你很有可能欺骗编译器。 你如何为这些指针赋值并不重要。编译器不会记住这一点。

    所以这是唯一安全的方法,这就是我们需要std::pun的原因。

    【讨论】:

    • 如果那个编译器没有那个开关怎么办?如果我想将此功能用作内联函数怎么办?我应该关闭整个项目的严格别名吗?对于某些情况,此答案可能是解决方案,但在其他情况下则不然。这是错误的:“唯一安全的方式”。我的回答也告诉了其他安全的方法。符合标准,适用于所有情况。
    • @geza 如果,如果,如果。很有可能,OP 可以使用这个技巧。
    • @geza 哦,是的,您认为在循环中调用placement new 是一个好主意(我现在看到这是正确的做法)。我将推翻我的投票,我在某些事情上错了。 ......啊,爆炸吧,我不能。编辑您的帖子(任何事情都可以),然后 ping 我。那我就可以了。
    • @geza TU,你最终会加分:)
    • @geza 不,没关系,至少在某种程度上你没有错。但不是我支持你,是这样。如果你投了反对票,然后又投了赞成票,不知道为什么。
    【解决方案5】:

    这个问题在可移植的 C++ 中无法解决。

    C++ 在指针别名方面是严格的。有点矛盾的是,这允许它在很多平台上编译(例如,double 数字可能与float 数字存储在不同的位置)。

    不用说,如果您正在努力实现可移植代码,那么您需要重新编码您拥有的代码。第二件最好的事情是务实,接受它可以在我遇到的任何桌面系统上运行;甚至可能是编译器名称/架构上的static_assert

    【讨论】:

    • 有趣的帖子here。而且我认为大 3 具有必要的命令行开关,因此,出于所有实际目的,问题是可以解决的。
    猜你喜欢
    • 2018-12-06
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2023-03-18
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多