使用自定义的超分配分配器利用std::vector结束后的内存

Utilize memory past the end of a std::vector using a custom overallocating allocator

本文关键字:vector 结束 std 内存 分配器 自定义 分配      更新时间:2023-10-16

假设我有一个分配器my_allocator,它将在调用allocate(n)时始终为n+x(而不是n)元素分配内存。

我可以安全地假设[data()+n, data()+n+x)范围内的内存(对于std::vector<T, my_allocator<T>>)是可访问/有效的,可以进一步使用(即在基本情况下放置新的或simd加载/存储(只要没有重新分配)?

注意:我知道data()+n-1之后的所有内容都是未初始化的存储。用例将是基本类型的向量(无论如何都没有构造函数),使用自定义分配器来避免在向向量抛出simd内部函数时出现特殊的极端情况。

my_allocator应分配1.)正确对齐的存储空间,并具有2.)已使用寄存器大小的倍数。

让事情更清楚一点:

假设我有两个向量,我想把它们相加

std::vector<double, my_allocator<double>> a(n), b(n);
// fill them ...
auto c = a + b;
assert(c.size() == n);

如果从my_allocator获得的存储现在分配对齐的存储,并且如果sizeof(double)*(n+x)始终是使用的simd寄存器大小的倍数(因此是每个寄存器的值数量的倍数),我假设我可以做类似

的事情
for(size_t i=0u; i<(n+x); i+=y) 
{ // where y is the number of doubles per register and and divisor of (n+x)
    auto ma = _aligned_load(a.data() + i);
    auto mb = _aligned_load(b.data() + i);
    _aligned_store(c.data() + i,  _simd_add(ma, mb)); 
}

我不需要关心任何特殊情况,比如未对齐的加载或来自某些不能被y整除的n的积压。

但是向量仍然只包含n值,并且可以像n大小的向量一样处理

后退一下,如果您试图解决的问题是允许底层内存被SIMD intrinsic或展开循环有效地处理,或者两者都可以,那么您不一定需要分配超出已使用数量的内存,只是为了将分配大小"四舍五入"为向量宽度的倍数。

有多种方法可以处理这种情况,您提到了几种方法,例如特殊的引入和引出代码来处理开头和结尾部分。这里实际上有两个不同的问题——处理数据不是向量宽度的倍数,以及处理(可能)未对齐的起始地址。你的过度分配方法解决了第一个问题——但可能有更好的方法……

在实践中,大多数SIMD代码可以简单地读取超出处理区域末尾的内容。有些人可能会争辩说,这在技术上是UB,但是当使用SIMD内在特性时,您已经冒险超越了标准c++的界限。事实上,这种技术已经在标准库中得到了广泛的应用,因此它得到了编译器和库维护者的隐含认可。它也是处理SIMD代码的标准方法,因此您可以非常确定它不会突然中断。

使其工作的关键是观察到,如果您可以有效地读取甚至单个字节在某些位置N,那么任何a 自然对齐的读取任何大小1都不会触发故障。当然,您仍然需要忽略或以其他方式处理超出正式分配区域末端所读取的数据—但是无论如何您都需要使用"分配额外"方法这样做,对吗?根据算法的不同,您可以屏蔽无效数据,或者在SIMD部分完成后排除无效数据(即,如果您正在搜索一个字节,如果您在分配的区域之后找到一个字节,则与"未找到"相同)。

要做到这一点,你需要以对齐的方式阅读,但我认为这可能是你已经想要做的事情。您可以安排分配的内存首先对齐,或者在开始时进行重叠读取(即,首先进行一个未对齐的读取,然后所有读取与第一个对齐的读取重叠未对齐的部分对齐),或者使用与尾部相同的技巧在数组之前读取(与为什么这样做是安全的相同推理)。此外,有各种技巧可以请求对齐内存,而无需编写自己的分配器。

总的来说,我的建议是尽量避免使用编写自定义分配器。除非代码包含得非常紧凑,否则您可能会遇到各种陷阱,包括对内存分配方式做出错误假设的其他代码,以及Leon在他的回答中提到的各种其他陷阱。此外,使用自定义分配器会禁用标准容器算法所使用的一系列优化,除非您在任何地方都使用它,因为其中许多优化只适用于使用相同分配器的容器。

此外,当我实际实现自定义分配器2时,我发现这在理论上是一个很好的想法,但是有点太模糊了,无法在所有编译器中以相同的方式得到很好的支持。现在编译器随着时间的推移变得更加兼容(我主要关注的是你,Visual Studio),模板支持也得到了改进,所以也许这不是一个问题,但我觉得它仍然属于"只有在你必须的时候才这样做"的范畴。

还请记住,自定义分配器不能很好地组合-您只能得到一个!如果您的项目中的其他人出于其他原因想要为您的容器使用自定义分配器,他们将无法做到这一点(尽管您可以协调并创建一个组合分配器)。

我之前问的这个问题——也是由SIMD引起的——涵盖了很多关于阅读超过结尾(以及隐含地,在开始之前)的安全性的问题,如果您正在考虑这个问题,可能是一个很好的起点。


1从技术上讲,限制是任何对齐的读取到页面大小,4K或更大的大小对于任何当前面向矢量的通用isa来说都是足够的。

2在这种情况下,我这样做不是为了SIMD,而是为了避免malloc(),并允许具有许多小节点的容器的部分堆栈和连续快速分配。

对于您的用例,您不应该有任何疑问。但是,如果您决定在额外的空间中存储任何有用的东西,并且允许向量的大小在其生命周期内改变,那么您可能会遇到处理重新分配可能性的问题——如果重新分配是由于单独调用allocate()deallocate()而没有直接连接,那么您将如何将额外的数据从旧分配转移到新分配?


EDIT(寻址添加到问题中的代码)

在我最初的回答中,我的意思是访问分配器分配的超出请求的额外字节应该没有任何问题。然而,在内存范围内写入数据(不在vector对象当前使用的范围内,但属于未修改的分配将跨越的范围)会带来麻烦。std::vector的实现可以自由地向分配器请求比通过size()/capacity()函数公开的更多的内存,并将辅助数据存储在未使用的区域中。虽然这是高度理论性的,但不考虑这种可能性意味着打开了一扇通向未定义行为的大门。

考虑以下可能的vector分配布局:

---====================++++++++++------.........
  1. === -矢量的使用容量
  2. +++ -矢量
  3. 的未使用容量
  4. --- -被矢量过度分配(但未显示为其容量的一部分)
  5. ... -被您的分配器过度分配

你不能在区域2 (---)和3 (+++)中写任何东西。所有的写操作必须限制在区域4 (...),否则可能会损坏重要的位。