2.2 描述并行度
已经获知了代码最为耗时的部分,接下来开始并行化重要的循环体。通常最好的优化方法是始于最为耗时的子程序,逐步向下探索。加速耗时75%的代码,效果优于加速仅耗时15%的代码。这表明应该首先致力于matvec子程序的加速,然后再对waxpby和dot进行加速。但是,因为这可能是读者的OpenACC处女行,所以从这三个函数中最为简单的一个开始,逐步改进,直至优化最为复杂的子程序。这就是并非首先加速matvec函数的原因。
2.2.1 加速waxpby
从vector_functions.h第33行代码开始,并行化waxpby子程序。该函数仅包含一个循环,通过向循环体起始点添加OpenACC kernels编译指导命令进行并行化。通过添加该编译指导命令,告知编译器,该循环具有OpenACC编译指导命令,要求编译器为目标加速器生成代码。将要在装有NVIDIA Tesla K20c GPU的计算机上执行这段代码,因此,选择tesla目标加速器。激活OpenACC对NVIDIA GPU的支持,需要使用-ta=tesla命令行选项。尽管编译器的目标加速器是用于大数据中心硬件环境的NVIDIA Tesla GPU,目标代码依然可以运行在其他的NVIDIA GPU上。对makefile文件进行的修改见图2-5,编译器对waxpby子程序编译产生的反馈信息见图2-6。
从编译器反馈信息可知,尽管生成了一个GPU核函数,但当编译器试图并行化循环体时发生了错误。编译器发现循环变量存在依赖性。当某次循环依赖于其他循环迭代中的数据时,数据依赖就产生了。经过仔细检查循环体,发现循环间是相互独立的,循环体中的变量与其他循环中的变量是毫不相关的。那么问题产生了,为什么编译器认为该循环存在数据依赖呢?这个问题是由于C和C++编程语言的底层特性导致的。C/C++语言使用指针表示内存中的数组,但不同的指针很可能指向相同的内存。问题在于,编译器无法证实循环体中的三个数组不是别名的或相互重叠的。因此,谨慎起见,编译器假定该循环的并行化是不安全的。关于这几个数组,应该向编译器提供更多的信息。一个可选的做法是向编译器提供更多关于指针的信息,告知编译器这些指针不是指向同一个内存地址的。添加C99的restrict关键词可以解决这个问题。尽管这不是C++关键字,PGI编译器仍可在C++代码中辨识出该关键字还可以通过使用OpenACC loop导语向编译器提供关于循环自身的更多的信息。loop导语告知编译器关于紧邻该导语的下一个循环的额外信息。可以通过loop independent子句告知OpenACC编译器,该循环的所有迭代是相互独立的,即任意迭代间的数据相互不依赖。这告知编译器不要采用默认的循环体数据依赖性的假定。以上两种措施保证了程序员对编译器的控制。如果假定被破坏,将产生不可预测的结果。该代码中,无法确保向量不是别名化的,因为有时候该函数调用时,传递进来的y和w可能是同一个向量。因此,使用loop independent来替代。waxpby最终代码见图2-7,编译器反馈信息见图2-8。从编译器反馈信息可知,现在,第41行对应的循环已经是可并行化的了。
仔细检查编译器反馈信息,发现编译器不仅并行化了该循环,它还为GPU做了更多的编译工作。编译器发现,目标加速器与主机CPU使用的是不同的物理存储器,因此,有必要将输入数组在GPU上进行内存分配,计算完成后将结果数组拷贝回主机端。第40行输出显示,编译器发现xcoefs和ycoefs是输入数组,因此编译器为它们生成了面向GPU的copy in操作。wcoefs是输出数组,因此编译器为它生成了拷贝出GPU的copy out操作。反馈信息实际上显示了data子句,提供了关于数据是如何在计算区域使用的额外信息。在本示例中,编译器能够正确地确定这三个数组的大小、形状和用法。因此,编译器为这些数组显式生成了一些data子句。有时候,编译器对于数据移动表现得过于谨慎,因此,程序员必须重写这些默认的data子句,并在kernels导语后添加相应的显式data子句。其他情况下,编译器实际上无法获知计算区域中数组的大小和形状,因此编译器将停止运行并向程序员提出询问。这种情况与在优化matvec子程序中遇到的情况类似。
在继续下一步任务前,重新编译和运行一遍代码并检查可能由于并行化而引入的错误是十分必要的。对代码进行小改动后的排错是很容易的,难度远小于大幅改动代码后再进行DEBUG。运行代码后,发现计算结果是一致的,但是代码运行速度反而下降了。如果使用PGProf性能分析器运行可执行文件,将能够发现代码减速的原因。图2-9展示了性能分析器收集的GPU时间线,为清晰起见,这里进行了一些放大。从时间线上可以看到独立的GPU运算(核函数)和相关的数据移动。注意到,对于每次waxpby调用,将两个数组拷贝到设备中,计算完成后将一个数组拷贝回主机端供其他函数利用。这种做法非常低效。理想情况下,希望数据尽量驻留在GPU存储器中,并极力避免这些不必要的即时拷贝。解决这一问题的唯一途径是将其他函数也进行并行化,这样数据移动就不再必要了。
2.2.2 加速dot
快速浏览一下dot子程序,编译器将利用OpenACC kernels编译指导命令。与之前一样,将要对感兴趣的循环添加kernels指导命令并且重新编译代码。图2-10展示了该函数的OpenACC版本,图2-11展示了编译器反馈信息。从反馈信息可知,第29行中生成了一个隐式归约(reduction)。循环的每次迭代计算其自身对应的xcoefs[i]*ycoefs[i]值,但编译器认为程序实际上并不关注各循环的计算结果,而是关注这些结果的和。
归约实现了循环中n个不同数值计算以及最终求得它们的和。由于浮点数计算的天生误差,需要明确,并行归约的计算结果虽然同样正确,但这个结果与串行计算结果略有差别。计算结果差别的大小与被加数本身、被加数的个数、加法执行的次序等因素有关,但结果的差别可能只有几位数字。切记,串行结果和并行结果都是正确的,因为浮点数计算本身就是不精确的。由于使用了kernels编译指导命令将循环并行化,编译器将帮助处理和辨识复杂的归约计算。如果使用了更为高级的parallel编译指导命令,而非kernels,并行归约计算的操作讯号的传递这一任务将落在程序员的肩膀上。在转到下一部分前,请不要忘记再次运行代码来检查错误。
2.2.3 加速matvec
最后一个需要加速的子程序是matvec。如前所述,该函数是一个恰当的起点,可作为一个教学练习。该子程序是目前为止最重要的程序,且该子程序具有一些有趣的复杂性,它要求采取额外的手段来表达并行性。采用其他两个子程序中类似的手段开始并行化。对数组添加restrict关键字,使用kernels导语修饰循环。当编译子程序时,编译器报错了,见图2-12所示。
第34行显示的加速器限制是这段编译器输出结果中最重要的部分。它表示编译器无法获知kernels区域中数组的大小,尤其是最内层循环中出现的三个数组。在之前的两个例子中,编译器可以基于循环迭代次数来确定有多少数据需要迁移到加速器上,但由于matvec内部的循环边界是隐藏在构建的矩阵数据结构中的,因此编译器无法获知如何将关键的数组迁移到加速器上。这个例子非常典型,需要给kernels导语添加显式data子句来提示编译器。表2-1列出了五种常用的data子句和它们的意义。已经见过copyin和copyout子句,它们在之前的例子中已被编译 器隐式使用了。
为了在GPU上通过编译器实现matvec中循环的并行化,需要实现数据的卸载。至少需要告知编译器循环中涉及的三个数组的形状。这三个数组用来读取,故可以使用copyin子句,但需要告知编译器数组的形状,通过查找matrix.h文件中的allocate_3d_poisson_matrix函数可以获知这一信息。data子句接受一个变量列表,内含变量大小和形状的信息。C和C++中,数组的大小和形状通过方括号([和])标明。方括号内含起始索引和需要拷贝的数组元素的个数。例如[0:100]表示从索引0处开始,拷贝100个数组元素。Fortran语言中使用圆括号,且使用拷贝的第一个元素和最后一个元素的索引进行标明。例如(1:100)表示从索引1开始到索引100结束进行数组元素的拷贝。语法上的不同是为了刻意保持编程风格与相应的编程语言语法传统相一致。因为Fortran语言的变量可以进行自我标定,因此,对于向设备端进行整个变量的拷贝时,数据大小和形状信息就可以省略。
图2-13展示了修改后的matvec函数代码。注意到向kernels导语添加了copy数据子句,告知编译器如何在设备端进行存储空间分配,且该数据仅作为循环的输入。通过这些修改,现在可以在加速器上运行完整的共轭梯度计算并行版本程序并获得正确的输出。但如图2-14所示,运行时间比原始代码反而变长了。
重新通过PGProf性能调试器运行可执行程序,检查为何GPU并行代码运行如此缓慢。图2-15展示了PGProf反馈的GPU时间线,为了显示得更为清晰,这里进行了放大。