SIMD类型堆上分配方法探究

问题的出现

  这两天学习了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。
  这次的探索不是很成功,但记录下程序出错的解决方案,以及一部分组织程序的方法,下一步将继续向下探索。

你可能感兴趣的:(SIMD类型堆上分配方法探究)