问题的出现
这两天学习了C++SIMD运算的方法,并准备应用于项目,我改写了一下之前光线追踪库中向量类型的存储类型与计算方法,但却在运行时遇到了问题。
class vec3{
public:
// float e[3];
//改写后的存储类型
__m128 e;
//各种向量运算,都对此类型做出相关修改
}
编译链接时都没有出错,但运行时却告诉我读取位置0xFFFFFFFF时发生访问冲突。
出错位置是在算向量减法的地方,调用了_mm_div_ps函数,前面输出SIMD的值用于调试,发现值都正常,并且这个函数前几次调用都好使,甚至有两个值完全一样,但前一个没出问题,后一个就出异常;这样的出错,完全没有征兆。
上谷歌查,看到一个提醒:SIMD类型需要对应的字节对齐。这我理解,毕竟SIMD就是为了更高效的运算,如果不处于整16字节,那肯定会取两次操作数,原本省下的时间也会少了很多。我当即检查了一下vec3的内存布局,不过只有这一个成员变量,剩下都是函数,还没有虚表指针,就算有,以SIMD类型的对齐大小,也肯定占在整16字节上。应该不是这个问题?
不过在输出警告的地方,又发现了新的警告,类似下边的程序:
#include
class vec {
public:
vec(float x, float y, float z, float w=0)
:data(_mm_set_ps(w, z, y, x)){}
private:
__m128 data;
};
int main() {
vec* p = new vec(0, 0, 0);
__m128* data = new __m128;
}
main函数中的两句都会警告:
warning C4316: “vec”: 在堆上分配的对象可能不是对齐 16
warning C4316: “__m128”: 在堆上分配的对象可能不是对齐 16
原来,对象内部保证了16位的对齐,但对象整体没有保证16位的地址对齐。
最基本的解决方法,就是placement new,在已经申请好16位地址对齐的空间中初始化对象,如何找到16整对齐的空间?如果我自己写程序,有两种方案,而原库中,也有自己的解决方案。
问题的解决
一、随机分配
void* AlignMemoryAlloc(size_t size, size_t align) {
void* res = nullptr;
do {
if (res != nullptr)
free(res);
res = malloc(size);
} while (reinterpret_cast(res) % align != 0);
return res;
}
void AlignMemoryFree(void *placement) {
free(placement);
}
int main() {
void* tmp = AlignMemoryAlloc(sizeof(vec), 16);
void* tmp2 = AlignMemoryAlloc(sizeof(__m128), 16);
vec* p = new(tmp)vec(0, 0, 0);
__m128* data = new(tmp2)__m128;
p->~vec();
AlignMemoryFree(tmp);
AlignMemoryFree(tmp2);
}
这个例子中的malloc和free用operator new和operator delete代替也是可以的:
void* AlignMemoryAlloc(size_t size, size_t align) {
void* res = nullptr;
do {
if (res != nullptr)
operator delete(res);
res = operator new(size);
} while (reinterpret_cast(res) % align != 0);
return res;
}
void AlignMemoryFree(void *placement) {
operator delete(placement);
}
或者更绝,直接重载operator new和operator delete操作符,逻辑和上面一致。
如果没分配到整16的地址,就释放掉继续分配,这样有很大的随机性,假如堆上分配只保证4字节对齐,那么每次分配到整16字节的概率只有25%。
二、分配保证存在16位对齐地址的块数
struct AlignMemory {
unsigned char* placement;
unsigned char* start;
};
AlignMemory AlignMemoryAlloc(size_t size, size_t align) {
AlignMemory memory;
memory.placement = new unsigned char[size + align - 4];
memory.start = memory.placement;
while (reinterpret_cast(memory.start++) % 16 != 0);
memory.start--;
return memory;
}
void AlignMemoryFree(AlignMemory memory) {
delete[] memory.placement;
}
int main() {
AlignMemory tmp = AlignMemoryAlloc(sizeof(vec), 16);
AlignMemory tmp2 = AlignMemoryAlloc(sizeof(__m128), 16);
vec* p = new(tmp.start)vec(0, 0, 0);
__m128* data = new(tmp2.start)__m128;
p->~vec();
AlignMemoryFree(tmp);
AlignMemoryFree(tmp2);
system("pause");
}
这个写法的思路源自这里 C++ class for aligning objects on the stack
堆上分配虽然无法保证16字节对齐,但4字节对齐总是可以保证的(也需要看平台,实在不行,直接大小+对齐也是可以的);就比如例子上的vec,大小是16,对齐的也是16,那么在28个字节中,一定会找到一个16地址开始,大小为16的空间。
找到16开头的空间,并把地址存储起来,在placement new中,用到已对齐的地址构造对象;在析构函数之后,手动回收空间。
这个方法胜在分配时间稳定,但空间的利用率较低,只有,不过可以批量生产vec,或开辟出一块vec池,用于生产连续的vec,假如当前池能放下10个vec,那么利用率就能达到,当连续的空间越大,对空间的利用率也越大。
三、自带方案
作为SIMD包,肯定考虑了这个问题,_mm_malloc函数和_mm_free函数,即可分配和释放对齐地址的对象:
int main() {
void* tmp = _mm_malloc(sizeof(vec), 16);
void* tmp2 = _mm_malloc(sizeof(__m128), 16);
vec* p = new(tmp)vec(0, 0, 0);
__m128* data = new(tmp2)__m128;
p->~vec();
_mm_free(tmp);
_mm_free(tmp2);
system("pause");
}
程序组织
上面我们成功解决了地址对齐问题,但新的问题凸显出来了,只要是包含__m128类型的对象,无论是像vec这样直接持有,还是其他通过继承、持有等间接方式持有__m128类型的对象,只要动态分配内存,就会产生此类问题,并且内存的分配问题被暴露在使用的时候,使程序十分不简洁;因此最好在最初阶段,就解决__m128的对齐问题,在这里我封装了一个SSE类:
class SSE {
public:
__m128 data;
static std::unique_ptr>* newSSE(float a, float b, float c, float d) {
SSE* res = (SSE*)_mm_malloc(sizeof(SSE), 16);
new(res)SSE(a, b, c, d);
auto del = [](SSE* ptr) {
ptr->~SSE();
_mm_free(ptr);
};
return new std::unique_ptr>(res, del);
}
private:
SSE(float a, float b, float c, float d)
:data(_mm_set_ps(a, b, c, d)){}
};
SSE是封装的__m128,无法直接构造,而是通过newSSE方法,返回自身的unique_str,并且这个unique_str有自身的删除器,可以只管创建,不管删除。
相应的,vec类也跟着改变,并且为了测试,我增加了几个常用方法:
class vec {
public:
vec(float x, float y, float z, float w=0)
:data(SSE::newSSE(w, z, y, x)){}
vec(vec& v2)
:data(SSE::newSSE(v2[0], v2[1], v2[2], v2[3])) {}
vec& operator=(vec& v2) {
data->data = v2.data->data;
}
inline float dot(vec& other) {
__m128 res = _mm_mul_ps(data->data, other.data->data);
return res.m128_f32[0] + res.m128_f32[1] + res.m128_f32[2] + res.m128_f32[3];
}
inline float& operator[] (size_t index) {
return data->data.m128_f32[index];
}
private:
std::unique_ptr> data;
};
使用:
vec* v = new vec(4, 5, 6);
vec* v2 = new vec(1, 2, 3);
vec v3(0, 0, 0);
std::cout << v->dot(*v2);
std::cout << v3[0] << " " << v3[1] << " " << v3[2] << std::endl;
完全正常,手动分配地址、难用的问题被解决了,但似乎引来了新的问题,SSE的大小没有变化,还是16,但vec持有了unique_str,大小一下变成了48,空间代价一下增加到原来的三倍。并且vec创建时,多了unique_str的构造和析构的开销。
这个方法可以改善,是在Thread和Mutex使用中得到的灵感,直接定义一个guard类管理SSE,不用unique来守护,guard在构造时接受一个SSE指针,析构时自动回收这段空间。
这种方法避免了申请一个unique造成更大的空间浪费,但抽象的曾经很多,构造和析构的开销依旧没有解决。
思考
用到vec类型时,存在两种情况,其一是作为对象本身属性,例如球心,这种类型不会频繁的构造和析构;其二是作为更好的计算数据结构,这时会频繁的构造和析构。
如果vec类型能判断自身是在堆上还是在栈上构造,根据情况,选择用普通__m128还是unqiue_str存储。
不过在堆上分配SIMD类型的想法本身可能也是错误的,包括上面vec的点积运算的运用也可能是错误的。SIMD算乘只需要一个指令,但随后将四个乘积相加还需要一个个算,最少也要调用三次加法,还可能出现流水线断流;并且从SIMD中取浮点数的操作也会对性能产生影响。
相比起来,矩乘算法要好得多,将矩阵第一行同时乘四个相同的x组成的向量,第二行同时乘4个y……结果竖着相加,得到向量矩阵的乘积,这样才最好的利用了SIMD。
这次的探索不是很成功,但记录下程序出错的解决方案,以及一部分组织程序的方法,下一步将继续向下探索。