最近在做一个功能,计算量很大,需要把程序并行化以利用多核优势。
刚开始是使用OpenMP,在我的机器上,4C8T的i7,CPU可以达到90%以上,在任务管理器的小图标里看着就跟把CPU占满了一样,所以我认为这个程序的并行化工作就算结束了,所以就把程序放到了一台48核的云主机上跑,结果发现CPU利用率最多只能到40%。看来是有什么地方产生了依赖。
我首先想到的是线程中使用的omp critical语句的地方,这个是用来保护结果集的,就是所有线程都往一个结果集里写入数据,那这个肯定是需要保护的。所以第一次尝试是给每个线程单独分配一个结果集,计算结果完成之后再由主线程把各个子集合并起来。这样做完之后发现并没有什么不同,并发度还是上不去,看来不是这个原因。
后来就又在代码里找怀疑对象,SQLite又进入了我的眼里。之前发现过SQLite一个连接在进行并发读取的时候性能不是很好,现在就是这样。为了保险起见,又写了个Demo验证了一个,单进程内多个只读连接的并发读取性能确实比单个只读连接的并发性能好很多。所以我很有信心地把程序改成SQLite连接也让每个线程使用一个。然后又去跑,发现还是没什么不同。
至此,所有的线程就没啥关系了,各自用各自的数据,各自用各自的核心。
难道是任务调度的问题?然后我就尝试了各种schedule模式及块大小,发现都没有啥效果。后来又用PPL实现了一个线程池,自己做调度,然后结果还是那样。
然后我就发愁了,到网上找可以查找并发瓶颈的工具,啥也没找着……
这期间我发现如果使用多个进程进行这些计算,并发度是可以提上去的,然后我就考虑是不是要把并发模式改成多进程的,如果使用MPI,还方便以后做成多机计算。
但是后来经同事提醒,原来手里一直就有这样的工具,那就是调试器。如果多线程程序有并行依赖,那在程序运行过程中中断,看各个线程都停在哪儿,应该可以很大概率定位到瓶颈位置。
然后我在我的机器上尝试,发现每次中断都有4个左右的线程是中断在new/delete上的,然后我就明白了,所有我创建的数据结构都有线程的副本,但是堆是进程内所有线程共用的,也肯定有加锁,应该基本确定问题所在了。
为了保险起见,我还是先写Demo验证一下吧,最初的版本长这样:
#omp parallel for
for(int I = 0 ; I < 1000000; ++i)
{
for(int j = 0 ; j < 10000; ++j)
delete new int;
}
发现可以把CPU跑满啊,这应该说明new/delete的性能还是挺好的,难道我错怪它们了?
后来还是经大神提醒,把申请的大小变随机,要大一些,申请和释放隔一个随机间隔。
最后代码被改成了这样:
#pragma omp parallel for schedule(static, 1000)
for (auto i = 0ll; i < 10000000000; ++i)
{
for (int j = 0; j < 1000000; ++j)
{
const auto l = rand() * 1000 + 1000; //32,767,000
auto p = new int[l];
const auto t = rand() % 100000;
for (int k = 0; k < t; ++k)
p[0] += p[1];
delete[] p;
}
}
然后CPU利用率确实上不去了,大概在30%~40%,我觉得是复现了问题。那接下来就各自按线程进行堆内存申请吧:
std::vector initHeaps()
{
std::vector res(omp_get_num_procs(), NULL);
for(auto & h : res)
h = HeapCreate(HEAP_NO_SERIALIZE, 1024 * 1024, 0);
return res;
}
auto g_heaps = initHeaps();
void* operator new(size_t size)
{
const auto heapIndex = omp_get_thread_num();
auto p = HeapAlloc(g_heaps[heapIndex], HEAP_NO_SERIALIZE, size);
if (!p)
throw std::bad_alloc();
else
return p;
}
void operator delete(void* p)
{
const auto heapIndex = omp_get_thread_num();
auto res = HeapFree(g_heaps[heapIndex], HEAP_NO_SERIALIZE, p);
}
结果在进行vector初始化时会申请空间,然后会调用new,然后所有的自定义heap还没有创建。其实这里主要是heap[0]没有创建,因为在非并发环境下omp_get_thread_num会返回0。
所以继续改:
const auto HEAP_COUNT = 1024;
HANDLE* initHeaps()
{
static HANDLE s_heaps[HEAP_COUNT] = { 0 };
for (int i = 0; i < HEAP_COUNT; ++i)
s_heaps[i] = HeapCreate(0, 1024 * 1024, 0);
return s_heaps;
}
HANDLE* g_heaps = initHeaps();
在我的环境里1024个堆够了,目前浪费几个GB的内存也可以接受。
在Demo里,这样做确实可以把所有的堆内存申请和释放分开来,然后把CPU跑满,接下来就把这些代码移植到工作代码上。结果,HeapFree会抛异常,因为在工作环境下,并不是所有delete都是在其申请时的线程进行的,所以就会有内存在其它的堆里释放,当然会报错。
那既然所在线程是不确定的,那有什么是确定的呢?
内存地址?貌似不可以,因为在new的时候就需要确定heapIndex,但是这个时候还没有内存地址呢,怎么确定heapIndex呢!
某次申请内存的大小?貌似可以,这个是在申请之前就有的,可以用来计算heapIndex。但是delete的时候是没有这个参数的,好像又走到死胡同了……
当天回家路上想到,可以把这个size记录下来啊,申请的时候多申请几个字节,存这个长度,然后返回给调用方这几个字节之后的地址;等delete的时候把这个地址传回来,再去前几个字节取这个长度,就可以再次计算出来heapIndex了!
说干就干:
void* operator new(size_t size)
{
const auto heapIndex = size % HEAP_COUNT;
auto p = HeapAlloc(g_heaps[heapIndex], 0, size + sizeof(size_t));
if (!p)
throw std::bad_alloc();
else
{
*(size_t*)p = size;
return (byte*)p + 8;
}
}
void operator delete(void* p)
{
auto realP = (byte*)p - 8;
const auto size = *(size_t*)realP;
const auto heapIndex = size % HEAP_COUNT;
if (FALSE == HeapFree(g_heaps[heapIndex], 0, realP))
{
const auto e = GetLastError();
}
}
看起来不错,跑一圈!
然后立马就报错了,delete的时候得到了一个明显不对的长度。经过分析,应该是某个依赖库通过VC默认的new申请的空间,却在重写的delete里释放了。
其实到这里,我已心生退意,“重写全局new&delete绝大多数时候不是好主意”!
但是,我还想再试一把,基于问题,那就把从两个地方申请的内存区分开呗!只用这个长度肯定是不够的,因为默认new申请的空间的前一段里也很有可能有一个类型于正常长度的数据。那就自己添加一个比较特别的标识,代码如下:
const auto HEAP_COUNT = 256;
const auto HEAP_FLAG = 0;
HANDLE* initHeaps()
{
static HANDLE s_heaps[HEAP_COUNT] = { 0 };
for (int i = 0; i < HEAP_COUNT; ++i)
s_heaps[i] = HeapCreate(HEAP_FLAG, 1024*1024, 0);
return s_heaps;
}
HANDLE* g_heaps = initHeaps();
const size_t UNIQUE_MASK = 0xF0E1D2C3B4A59687;
inline byte hash(const size_t& size)
{
byte res = 0;
for (int i = 0 ; i < 8; ++i)
{
res ^= byte((size >> (i * 8)) & 0xff);
}
return res;
}
void* operator new(size_t size)
{
const auto heapIndex = hash(size);
auto p = HeapAlloc(g_heaps[heapIndex], HEAP_FLAG, size + sizeof(size_t) * 2);
if (!p)
throw std::bad_alloc();
else
{
*(size_t*)p = size;
*((size_t*)p + 1) = UNIQUE_MASK;
return (byte*)p + sizeof(size_t) * 2;
}
}
void operator delete(void* p)
{
auto pMask = (size_t*)((byte*)p - sizeof(UNIQUE_MASK));
if (UNIQUE_MASK == *pMask)
{
auto pSize = pMask - 1;
auto realP = pSize;
const auto heapIndex = hash(*pSize);
if (FALSE == HeapFree(g_heaps[heapIndex], HEAP_FLAG, realP))
{
const auto e = GetLastError();
}
}
else
{
free(p);
}
}
这样并不能真正100%保证没问题,比如通过默认new申请的内存前边正好是这个标识,另外一种情况是默认new申请的内存前边是不可读的区域。虽然我不清楚默认new的内存布局,但是也可以猜测这两种情况出现的概率都不大,先跑一圈!
前边的计算过程一切正常,没有报错,CPU的利用率虽然没到100%,但也比之前有了很大进步。不过最后在进行清理的时候还是会报错。
到这里我已经无心继续走这条路了,只要我不能控制所有的内存申请与释放,这种做法就很危险。
接下来用MPI做多进程并发吧,还方便以后改成分布式计算。