在现代计算领域,GPU的并行计算能力已经显示出巨大的潜力,尤其是在处理包含大量并行元素的任务时。然而,对于多核CPU的并行计算能力,也需要进行深入的探讨和测试。本文旨在通过对比CUDA代码与使用thrust库的代码,以及C#多核并行计算代码,探讨在不同并行计算环境下的执行效率。
为了进行基准测试,首先使用C#编写了一个简单的循环,用于计算一个整数数组(随机数从0到9)的平方和。这个循环是串行执行的,作为测试的基线。代码如下:
C#
var watch = Stopwatch.StartNew();
int final_sum = 0;
for (var i = 0; i < DATA_SIZE; i++)
{
final_sum += data[i] * data[i];
}
watch.Stop();
elapsed_time = (double)watch.ElapsedMilliseconds / 1000;
其中,data是一个预先生成的数组,包含DATA_SIZE个随机整数。
选择C#的原因是对这门语言的熟悉度,以及Visual Studio 2010和.NET 4.0中对并行编程的新支持。使用.NET 4.0提供的System.Threading.Tasks命名空间,可以将for循环替换为System.Threading.Tasks.Parallel.For方法调用。这是一个静态方法,通过一个匿名函数委托定义实际的工作,这实际上对应于之前简单for循环中编写的代码块。代码如下:
C#
final_sum = 0;
Parallel.For(0, DATA_SIZE, i =>
{
Interlocked.Add(ref final_sum, data[i] * data[i]);
});
这里的Interlocked.Add方法用于使加法操作线程安全。使用四核CPU运行并行代码并没有显示出任何优势。例如,处理100万个整数时,串行代码使用了7毫秒,而并行代码消耗了113毫秒,是串行代码的15倍。这是为什么呢?
记住使用了Interlocked.Add方法来锁定线程间共享的资源final_sum。(data[i]不重要,因为每个线程只访问数组的一个元素,这些元素在不同线程之间是不同的。)这是考虑代码执行效率的关键,因为所有线程都必须排队等待访问final_sum。换句话说,尽管代码是并行化的,但对final_sum的访问仍然是串行的。这基本上是为什么代码执行时间没有减少的原因。此外,由于分配和管理线程的开销,执行时间甚至更长。
在CUDA程序中,实际上不需要将求和操作并行化到数组的每个单个元素。建议的方法是将数组分成一定数量的部分。每个部分包含相等数量的元素,不同部分的元素不重叠。理论上,可以雇佣一定数量的线程,建立线程和部分之间的一一对应关系,然后:
计算每个部分的子求和,并记录结果
对所有子求和结果进行求和
如果将数组分成1k(1k等于1*1024)部分,即线程数也是1k,实际代码可能是:
C#
//
初始线程数
long THREAD_NUM = 1024;
//
线程缓冲区
int[] result = new int[THREAD_NUM];
//
每个线程的部分大小
long unit = DATA_SIZE / THREAD_NUM;
watch.Restart();
Parallel.For(0, THREAD_NUM, t =>
{
for (var i = t * unit; i < (t + 1) * unit; i++)
{
result[t] += data[i] * data[i];
}
});
final_sum = 0;
for (var t = 0; t < THREAD_NUM; t++)
{
final_sum += result[t];
}
watch.Stop();
elapsed_time = (double)watch.ElapsedMilliseconds / 1000;
在CUDA版本中,使用了16k个线程进行计算。如何确定一个适当的线程数,以获得最佳性能?进行了敏感性研究。分别使用了1k、16k和256k的TN(即线程数),测试了代码执行时间;记录的值(单位:秒)列在下面的表中:
该表还列出了串行代码和没有将数组分割的并行代码所消耗的时间。简要来说,各项的描述如下:
相应的趋势可以总结为:
或者通过直方图进行比较:
将数据分组成一定数量的并行单元后,执行性能显著提高,提高的性能已经比串行执行的循环更好,尽管改进没有达到四核处理器的4倍。
这些数字还揭示了使用1k个线程并不理想。16k在大多数情况下更好,除了当DATA_SIZE = 32M时。这可能意味着,对于更多的数据,可能需要更多的线程来处理它们。
进行测试的最初目的是比较CUDA计算效率与多核效率。然而,在过程中,发现将串行循环移植到并行代码并不像预期的那样简单,即使用Parallel.For方法替换for循环。在实践中,就像设计CUDA程序一样,必须仔细考虑线程数。
基于测试,发现对于1M到32M个整数,使用16k个线程可以实现非常平衡的性能。尽管对于DATA_SIZE = 32M,16k个线程的效率低于32k个线程,但损失大约在5%左右。
此外,通过将这些C#结果与之前文章中的CUDA结果进行比较,还发现:
知道,在使用CUDA时,CPU和GPU之间的内存传输成本非常高。尽管有内存传输开销,CUDA代码的性能仍然优于利用2.40 GHz的4个核心的CPU的并行C#代码。至少对于这个平方求和计算来说,这是真的。这是否意味着CUDA确实为带来了巨大的潜力?
包含在zip包中的代码文件ParallelExample.cs包含了本文提到的串行和并行方法的不同策略的测试代码。
请注意,为了提取实际基准测试的平均值,必须重复执行计算足够次数;为了清晰和简化,没有在附加的代码中包含这个功能,但肯定很容易添加。