CUDA与多核CPU并行计算效率比较

在现代计算领域,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个随机整数。

.NET 4.0中的并行编程

选择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(即线程数),测试了代码执行时间;记录的值(单位:秒)列在下面的表中:

该表还列出了串行代码和没有将数组分割的并行代码所消耗的时间。简要来说,各项的描述如下:

  • 单核:串行执行的简单循环的执行时间
  • 多核:使用System.Threading.Tasks.Parallel.For方法而不将数组分割成部分的执行时间
  • TN-1k:如果将数组data分割成1k组,并使用Parallel.For方法来管理它们。在每组中,使用内部简单循环计算子求和
  • TN-16k:与前者相同,但将数组分割成16k组
  • TN-256k:与前者相同,但将数组分割成256k组

相应的趋势可以总结为:

或者通过直方图进行比较:

将数据分组成一定数量的并行单元后,执行性能显著提高,提高的性能已经比串行执行的循环更好,尽管改进没有达到四核处理器的4倍。

这些数字还揭示了使用1k个线程并不理想。16k在大多数情况下更好,除了当DATA_SIZE = 32M时。这可能意味着,对于更多的数据,可能需要更多的线程来处理它们。

进行测试的最初目的是比较CUDA计算效率与多核效率。然而,在过程中,发现将串行循环移植到并行代码并不像预期的那样简单,即使用Parallel.For方法替换for循环。在实践中,就像设计CUDA程序一样,必须仔细考虑线程数。

基于测试,发现对于1M到32M个整数,使用16k个线程可以实现非常平衡的性能。尽管对于DATA_SIZE = 32M,16k个线程的效率低于32k个线程,但损失大约在5%左右。

此外,通过将这些C#结果与之前文章中的CUDA结果进行比较,还发现:

  • 与串行代码相比,即简单循环代码,C++代码的性能优于C#代码;两种代码都在相同的Q6600 CPU上运行。
  • CUDA代码的性能优于并行C#代码,尽管两者都使用了16k个线程。CUDA在9800 GTX+芯片上运行,而C#代码在Q6600处理器上运行。

知道,在使用CUDA时,CPU和GPU之间的内存传输成本非常高。尽管有内存传输开销,CUDA代码的性能仍然优于利用2.40 GHz的4个核心的CPU的并行C#代码。至少对于这个平方求和计算来说,这是真的。这是否意味着CUDA确实为带来了巨大的潜力?

代码说明

包含在zip包中的代码文件ParallelExample.cs包含了本文提到的串行和并行方法的不同策略的测试代码。

请注意,为了提取实际基准测试的平均值,必须重复执行计算足够次数;为了清晰和简化,没有在附加的代码中包含这个功能,但肯定很容易添加。

沪ICP备2024098111号-1
上海秋旦网络科技中心:上海市奉贤区金大公路8218号1幢 联系电话:17898875485