背景
由索尼、东芝和IBM合作开发的Cell处理器是一项雄心勃勃且非传统的处理器设计,它承诺提供非凡的计算能力。Cell架构在2006年推出的PlayStation 3中首次亮相,旨在利用多个专用协处理器的并行处理能力。
要理解为什么Cell架构如此奇特,你需要知道在2000年代初,通过提高时钟频率来提升性能的传统方法已经达到了极限。Intel的Pentium架构无法进一步发展,其承诺的继任者也无处可寻。同样,IBM的PowerPC G5处理器未能实现承诺的3 GHz时钟速度。人们开始宣称这是摩尔定律的终结。
在如何根本性地重构计算架构以更好地扩展性能的众多想法中,许多人认为将架构从单一处理设计转变为分工设计,即多个较小的机器协作以分布式计算任务可能是可行的。这与我们今天在云工作负载中使用微服务的方式类似。
Cell的通用“领导”核心,被称为PowerPC处理元素(PPE),负责指导Cell电路的整体操作。它充当中央处理单元,管理工作负载并将任务委派给“助手”核心。
由于其非传统的数据总线设计,Cell架构被归类为网络芯片(NoC),而不是传统的系统芯片(SoC)。这种设计被称为元素互联总线(EIB),是由IBM设计的新颖互连拓扑结构,旨在解决之前架构中CPU组件面临的性能瓶颈和拥塞问题。
与传统的单总线拓扑结构不同,EIB采用的令牌环拓扑结构旨在处理大量并发流量。数据以128位数据包的形式传输,每个环可以容纳多达三个并发传输,前提是数据包不重叠。
除了EIB用于连接Cell架构的不同部分外,系统还包括几个负责数据移动和通信的高性能接口。这些接口包括:
宽带引擎接口单元(BEI):负责促进Cell与外部设备或系统之间的通信。
内存接口控制器(MIC):管理对主内存的访问,优先考虑EIB。
Flex I/O总线:为Cell架构提供额外的I/O能力。
PowerPC处理单元(PPU)
PPE,或Power处理元素,是Cell架构的通用“领导”核心。与IBM将现有处理器适应新要求的先前迭代不同,PPE是专为Cell架构量身定制的新CPU设计。然而,它基于PowerPC指令集架构(ISA)版本2.02,这是在被重新品牌为Power ISA之前的最后一个PowerPC规范。PPE与PowerPC G5有共同的血统,因为它们都源自主要用于工作站和超级计算机的POWER4架构。
IBM决定利用PowerPC技术为PPE提供动力,这是由几个因素驱动的。首先,PowerPC是一个成熟的平台,在Macintosh用户群中经过了大约10年的测试和改进,满足了索尼对Cell架构的要求。此外,PowerPC架构可以根据需要适应不同的环境。至关重要的是,使用一个众所周知且成熟的架构提供了与现有编译器和代码库的兼容性,为新游戏机提供了显著的优势。
PPE实现了PowerPC ISA版本2.02,包括用于浮点平方根操作的可选操作码。它还扩展了一个名为向量/SIMD多媒体扩展(VMX)的SIMD(单指令,多数据)指令集。值得注意的是,PPE中缺少了一些原始PowerPC规范中的元素,例如小端模式(Cell仅在大端模式下运行)和一些操作码。
重要的是,尽管PPE利用了现有的PowerPC技术,但IBM还是根据PowerPC 2.02规范从头开始构建PPE,而不是简单地适应现有处理器。这种方法允许IBM为Cell的独特多核架构和性能要求优化PPE的设计,并与SPU建立交互能力。
协同处理单元(SPU)
协同处理器单元(SPU)使用指令集架构进行编程。虽然SPU和PowerPC处理单元(PPU)都遵循简化指令集计算机(RISC),但SPU的ISA是专有的,主要由单指令多数据(SIMD)指令集组成。因此,SPU具有128个128位通用寄存器,这些寄存器可以容纳由32/16位定点或浮点值组成的向量。相反,为了节省内存,SPU指令明显紧凑,仅长32位。初始段包含操作码,其余部分可以引用要并行计算的多达三个操作数。
这种架构与PlayStation 2中引入的前向向量浮点单元相似,尽管有实质性的增强。例如,开发者不再需要学习特定于SPU的专有汇编语言,因为IBM和索尼提供了工具包,使用C++、C或汇编语言编程SPU。
SPEs是某种程度上的通用协处理器,不局限于单一应用,允许它们协助PPE完成广泛的任务,前提是开发者能够正确编程它们。但SPEs更倾向于作为Cell架构的“助手”核心,与“领导”PPE协作,提供加速的向量处理能力。通过利用SPEs的专门设计和本地内存,Cell架构旨在从PPE卸载计算密集型任务,潜在地在能够利用并行处理和向量操作的应用中实现显著的性能提升。
SPE的核心是协同处理器单元(SPU),等同于PPE中的PPU。但与PPU不同,SPU与Cell架构的其余部分隔离,并且没有与PPE或其他SPUs共享内存。相反,SPU包含称为本地内存(LS)的工作空间。然而,这些本地内存的内容可以通过内存流控制器(MFC)来回移动。在功能上,SPU与PPU相比更有限。它不包括内存管理功能(地址转换和内存保护)或高级功能,如动态分支预测。然而,SPU擅长向量处理操作。要编程SPU,开发者使用PPE调用PlayStation 3操作系统提供的例程。这些例程将为SPU编写的可执行文件上传到目标SPU,并信号它开始执行。之后,PPE保持对SPU线程的引用,以同步为目的。
内存流控制器(MFC)是连接SPU与Cell架构其余部分的组件,充当类似于PPE中的PowerPC处理器存储子系统(PPSS)的接口。MFC的主要功能是在SPU的本地内存和Cell的主内存之间移动数据,并保持SPU与其邻近组件的同步。为了执行其职责,MFC嵌入了一个直接内存访问(DMA)控制器来处理元素互联总线(EIB)和SPU的本地内存之间的通信。此外,MFC还包含另一个名为协同总线接口(SBI)的组件,位于EIB和DMA控制器之间。SBI是一个复杂的电路,它解释从外部接收的命令和数据,并信号SPE的内部单元。作为Cell架构的前门,SBI以两种模式运行:总线主模式(SPE适应于从外部请求数据)或总线从模式(SPE设置为从外部接收命令)。值得注意的是,考虑到EIB数据包的限制(最多128位长),MFC的DMA块每个周期只能移动多达16 KB的数据。如果数据传输超过此限制,EIB将在执行期间抛出“总线错误”异常。
为Cell架构编程
由于Cell架构与传统PC架构截然不同,它被认为是非常难以编程的(按设计)。尽管如此,IBM提出了一些想法,说明程序员如何在Cell架构中构建应用程序。
PPE中心方法
这些方法将主要计算责任放在PPE上,将SPEs视为协处理器或加速器,以卸载特定任务或工作负载。这一类别的三种主要模式包括:
多阶段流水线模型:
PPE充当驱动器,将工作发送到单个SPE。 每个SPE执行其分配的计算,并将结果传递给流水线中的下一个SPE。链中的最终SPE将处理后的数据发送回PPE。 由于SPE之间的高通信开销和复杂性,此模型不推荐用于主要任务。
平行阶段模型:
PPE将主要任务分解为独立的子任务。 每个子任务被分配给不同的SPE进行并行执行。SPEs在完成后将处理结果返回给PPE。 PPE将所有SPE的结果结合起来产生最终输出。 这种方法对于数据依赖性最小的高度并行化工作负载是有效的。
服务模型:
每个SPE被分配特定服务或工作(例如,音频解码、视频编码、物理计算)。根据程序的不断变化的要求,可以动态调整分配给SPEs的服务。 PPE充当作业调度器,根据所需服务将输入数据发送到适当的SPE。在等待结果的同时,PPE可以执行其他任务或管理系统资源。 这种模型适用于具有明确定义的任务,可以卸载给专用SPE的应用。
SPE中心方法
与PPE中心模型相比,SPE中心方法更加强调SPEs作为主要计算引擎,PPE在资源管理和协调中扮演支持角色。
使用它们的内部直接内存访问(DMA)单元,SPEs直接获取并执行主内存中存储的任务。 PPE负责最初在内存中设置这些任务,并将它们分配给适当的SPEs。一旦SPEs开始执行分配的任务,PPE的参与就最小化了,允许SPEs以高度自治 的方式运行。 这种方法可以潜在地解锁更高水平的并行性和效率,因为SPEs不受与PPE频繁通信的限制。 然而,它也引入了数据分区、同步、资源管理、容错和可移植性到其他架构方面的挑战。
混合方法
实际上,许多应用程序可能会从结合PPE中心和SPE中心模型的元素的混合方法中受益。例如:
PPE可以处理高层次的任务管理和协调整体工作流程。同时,计算密集型或可并行化的工作负载可以利用SPE中心模型卸载到SPEs。 PPE还可以在SPE计算之前或之后对数据执行预处理或后处理任务。 通过策略性地划分工作负载并利用DMA传输,可以最小化PPE和SPEs之间的通信和数据传输。
选择编程风格取决于多种因素,包括应用程序的性质、性能要求、数据依赖性和开发团队对Cell BE架构和编程模型的熟悉程度。
虽然Cell BE的异构多核架构提供了显著的计算能力,但有效地编程它提出了几个挑战,开发者必须克服:
代码分区和负载平衡:
确定代码和数据在PPE和SPEs之间的最优分区对于最大化性能至关重要。 在SPEs之间平衡工作负载以避免瓶颈并确保有效利用资源是一项复杂任务。 开发者必须仔细分析数据依赖性、通信模式和计算需求,以做出明智的分区决策。
内存管理和数据传输:
每个SPE有一个有限的本地存储(256 KB),用于指令和数据,需要仔细管理内存。必须使用DMA传输高效地协调主内存和SPE本地存储之间的数据传输,以最小化停顿和瓶颈。 通常采用双缓冲和软件缓存等技术,以重叠计算与数据传输。
SIMD向量化:
SPEs具有SIMD指令集,能够并行操作数据向量。为了充分利用SIMD能力,开发者必须向量化他们的代码,这涉及重构算法和数据布局以暴露并行性。 编译器自动向量化支持有限,通常需要开发者手动向量化努力。
分支预测和控制流:
SPEs缺乏动态分支预测硬件,使得具有不可预测分支的控制密集型代码效率较低。 开发者必须采用软件流水线、预测和分支提示指令等技术,以减轻分支错误预测的影响。
同步和通信:
协调PPE和多个SPEs之间的执行和通信需要仔细的同步机制,以避免竞态条件并确保数据一致性。 必须实现高效的内核间通信协议和消息传递方案,通常利用邮箱、信号通知器和DMA传输。
调试和分析:
由于执行的分布式特性和对SPEs的有限可见性,调试和分析Cell BE架构上的并行应用程序面临挑战。 IBM、索尼和第三方供应商开发了专门的调试和分析工具,以帮助开发者识别性能瓶颈并优化他们的应用程序。
这为我们提供了很多挑战,但也提供了一些非常有趣的机会,可以利用C语言的编码风格和技巧,这些可能是许多人在处理典型的x86二进制文件时不熟悉的。
理解数学
atan2函数计算正x轴和笛卡尔平面上的向量(x,y)之间的角度,是各种科学和工程应用中的基本操作,如计算机图形学、机器人技术和信号处理。
从数学上讲,反正切函数表示为:
[ atan(x) = \theta, tan(\theta) = x ]
反正切函数的范围通常是[-π/2, π/2]弧度,或[-90°, 90°]度。然而,在许多应用中,希望有[-π, π]弧度,或[-180°, 180°]度的全范围。这是通过考虑输入x和y值的符号来实现的,导致atan2(y, x)函数,这是反正切函数的一个变体。
atan2(y, x)函数计算正x轴和向量(x,y)在笛卡尔平面之间的角度(以弧度为单位)。它定义为:
[ atan2(y, x) = arctan(y/x) ]
计算atan2的第一步是使用众所周知的三角恒等式将输入参数(x,y)简化到特定范围。这个过程称为参数简化,通过将输入域限制在狭窄区间来简化后续计算。对于atan2,通常使用以下恒等式:
atan2(-y, x) = -atan2(y, x) atan2(y, -x) = π - atan2(y, x) atan2(y, x) = π/2 - atan2(x, y) 对于 |y| > |x|
一旦输入参数被简化,核心计算涉及在简化范围内评估atan(y/x)函数。一种常见的方法是使用最小最大多项式近似,它在狭窄区间内提供了对函数的准确近似。
最小最大多项式近似是一种在数值分析中使用的 技术,用于寻找一个给定度数n的多项式P(x),使其在指定区间[a, b]上尽可能接近函数f(x)。主要目标是最小化多项式近似和函数之间的最大绝对偏差,这可以量化为:
[ \max_{x \in [a, b]} |f(x) - P(x)| ]
左侧表示峰值绝对误差。多项式P(x)_P(x)被选择以最小化这个最大误差,因此称为“最小最大”。多项式近似可以表示为:
[ P(x) = c_0 + c_1 (x - x_0) + c_2 (x - x_0)^2 + \ldots + c_n (x - x_0)^n ]
这里,x0通常代表区间[a, b]内的中心点,通常是中点,这可以策略性地选择以减少计算复杂性或改善误差分布的对称性。常数c0, c1, …, cn是多项式的系数,确定这些系数是为了实现最小最大目标。
Remez交换算法是一种流行的计算这些系数的方法。它迭代调整系数和最大误差发生点的集合,寻求在这些点上使误差相等,并最小化其峰值。由于其在处理问题的非线性方面的效率,该算法特别适合寻找最小最大解决方案。
评估最小最大多项式近似是atan2实现的关键组成部分。两种常见的高效多项式评估技术是Horner方案和Estrin方案。
Horner方案是一种数值稳定的算法,用于多项式评估,它最小化乘法的数量。给定一个n次多项式:
[ P(x) = a_n x^n + a_{n-1} x^{n-1} + \ldots + a_1 x + a_0 ]
Horner方案将这个多项式重构为:
[ P(x) = (((\ldots(a_n x + a_{n-1}) x + a_{n-2}) \ldots ) x + a_1) x + a_0 ]
这种嵌套乘法方法将计算复杂性从O(n^2)操作(如果天真地计算)降低到O(n)乘法和O(n)加法,从而提高效率,特别是在大型n时。这种方案对于顺序处理环境特别有效,因为它顺序更新结果,并在计算期间只维护一个累加器。
另一方面,Estrin方案是一种分解形式的多项式,可以使用一系列融合乘加(FMA)指令进行评估。与Horner方案相比,Estrin方案通常需要更多的指令,但它可以更有效地利用指令级并行性,可能在像CELL SPE这样的超标量架构上提高性能。像Horner方案一样,它减少了多项式评估的复杂性,但这样做允许同时执行操作。对于同一个多项式P(x)_P(x), Estrin方案将项分组以实现并行计算:
[ P(x) = (((\ldots(a_n x^2 + a_{n-1}) x^2 + a_{n-2}) \ldots ) x^2 + a_1) x + a_0 ]
然而,这种表示通常最适合于度数是2的幂的多项式,允许平衡分割。如果n不是2的幂,可能会添加系数为零的虚拟项以适应这个方案。Estrin方法特别适用于支持指令级并行性和融合乘加(FMA)指令的架构,允许同时执行多个操作。
设计考虑代码
通过结合精心设计的算法和许多优化技术,可以在CELL SPE处理器上实现atan2计算的显著性能提升。
使用本地存储器
CELL BE由一个基于PowerPC的Power处理元素(PPE)和八个专用协处理器组成,称为协同处理元素(SPEs)。每个SPE都有自己的协同处理单元(SPU)和一个小的本地存储器(256 KB),称为本地存储(LS)。
SPU的LS是高速临时存储器,它是SPU的主要工作存储器。数据必须使用直接内存访问(DMA)操作显式地在主系统存储器和LS之间传输,因为SPU不能直接访问主存储器。
与普通硬件缓存不同,LS是软件管理的存储器,这意味着程序员对使用DMA操作在LS和主存储器之间的数据传输有显式控制。相比之下,硬件缓存对程序员是透明的,由处理器的存储器管理单元(MMU)自动管理。
LS也不与主存储器或其他LS同步。数据一致性必须由程序员通过DMA传输和同步原语显式管理。另一方面,硬件缓存与主存储器和其他系统缓存保持一致性,对程序员透明。
在为CELL SPE编程时,确保内存对齐至关重要,因为CELL BE的DMA操作和SIMD指令通常需要16字节对齐以获得最佳效率。程序员必须使用DMA操作显式管理主存储器和LS之间的数据传输,这涉及到启动传输、等待完成和同步数据以确保一致性。实现双缓冲可以重叠计算与数据传输,隐藏延迟并提高性能。提前将数据预取到LS可以减少延迟,并确保SPU连续访问所需数据。同步原语,如屏障和信号,对于维护LS和主存储器之间以及不同SPEs之间的数据一致性至关重要。
直接内存访问的进一步发展
双缓冲DMA(直接内存访问)是一种技术,它允许数据传输和计算的重叠,从而提高系统的效率,特别是在CELL BE架构中。CELL BE包括一个Power处理元素(PPE)和多个协同处理元素(SPEs)。每个SPE都有自己的协同处理单元(SPU)和本地存储(LS)。LS是SPU使用的高速临时存储器,但必须使用DMA操作显式地在LS和主存储器之间传输数据。
在双缓冲中,两个缓冲区被交替用于传输和处理数据。当一个缓冲区被SPU处理时,另一个缓冲区被用新数据填充。这种技术确保SPU可以继续处理而不需要等待数据传输完成,从而隐藏存储器操作的延迟并增加整体吞吐量。
缓冲区初始化:在LS中分配两个缓冲区
buffer0
和buffer1
。使用指针current_buffer
和next_buffer
来管理这些缓冲区。初始DMA传输:调用
dma_transfer
函数启动第一次DMA传输,将current_buffer
用主存储器中的数据填充。处理和重叠传输:当SPU处理
current_buffer
中的数据时,下一个DMA传输填充next_buffer
与后续的数据块。这种重叠最小化了空闲时间。交换缓冲区:处理完
current_buffer
后,将结果写回主存储器。然后交换缓冲区和DMA标签,然后重复该过程。
通过并行性循环展开
循环展开是一种优化技术,通过减少循环控制开销和增加指令级并行性来提高循环的性能。这种技术涉及在单个迭代中复制循环体多次,从而减少迭代次数,并允许更多操作并行执行。
考虑一个每次迭代处理一个元素的循环:
for (int i = 0; i < n; ++i) {
process(data[i]);
}
如果我们将这个循环展开4倍,循环体被复制四次,循环控制指令相应调整:
for (int i = 0; i < n; i += 4) {
process(data[i]);
process(data[i + 1]);
process(data[i + 2]);
process(data[i + 3]);
}
通过这样做,迭代次数减少了4倍,从而减少了循环控制开销(增加计数器和检查循环条件),并暴露了更多并行执行的机会。
循环展开增强了双缓冲技术的效率,通过最小化循环控制操作的开销和增加并行度。这在CELL BE架构中特别有益,因为SPU可以利用SIMD指令同时处理多个数据点。
以下是如何在代码中应用循环展开:
#define BUFFER_SIZE 128
#define TAG 1
#define UNROLL_FACTOR 4
void
doublebuff(uint32_t ea_data, uint32_t ea_result, int num_elements)
{
qword buffer0[BUFFER_SIZE] __attribute__((aligned(16)));
qword buffer1[BUFFER_SIZE] __attribute__((aligned(16)));
qword *current_buffer = buffer0;
qword *next_buffer = buffer1;
int current_tag = TAG;
int next_tag = TAG + 1;
// 初始DMA传输
dma_transfer(current_buffer, ea_data, current_tag);
for (int i = 0; i < num_elements; i += BUFFER_SIZE) {
if (i + BUFFER_SIZE < num_elements) {
dma_transfer(next_buffer, ea_data + (i + BUFFER_SIZE) * sizeof(qword), next_tag);
}
// 处理当前缓冲区与循环展开
for (int j = 0; j < BUFFER_SIZE; j += UNROLL_FACTOR) {
for (int k = 0; k < UNROLL_FACTOR && (i + j + k) < num_elements; ++k) {
qword y = current_buffer[(j + k) * 2];
qword x = current_buffer[(j + k) * 2 + 1];
qword result = _cp_fatan2(y, x);
current_buffer[j + k] = result;
}
}
mfc_put(current_buffer, ea_result + i * sizeof(qword), BUFFER_SIZE * sizeof(qword), current_tag, 0, 0);
mfc_write_tag_mask(1 << current_tag);
mfc_read_tag_status_all();
qword *temp_buffer = current_buffer;
current_buffer = next_buffer;
next_buffer = temp_buffer;
int temp_tag = current_tag;
current_tag = next_tag;
next_tag = temp_tag;
}
}
缓冲区在LS中初始化,确保它们对16字节边界对齐,以实现SIMD操作的高效性。首先启动DMA传输,将数据从主存储器加载到current_buffer
。然后,在循环内部,通过4倍展开循环体。这减少了迭代次数,并允许每次迭代处理多个元素,减少了循环开销,并提高了并行性。处理完current_buffer
后,结果被写回主存储器,然后为下一次迭代交换缓冲区。
通过融合乘加(FMA)操作多项式近似
现在我们可以开始讨论这一切的数学基础。它始于计算单个精度浮点值或四个单精度浮点值向量的反正切,使用多项式近似。我们可以使用Estrin方法,分别使用融合乘加(FMA)指令评估分子和分母多项式,然后将分子除以分母以获得最终结果。
const qword xp2 = si_fm(range_x, range_x);
const qword znum0 = f_atan_p0;
const qword znum1 = si_fma(znum0, xp2, f_atan_p1); // FMA: (znum0 * xp2) + f_atan_p1
const qword znum2 = si_fma(znum1, xp2, f_atan_p2); // FMA: (znum1 * xp2) + f_atan_p2
const qword znum3 = si_fma(znum2, xp2, f_atan_p3); // FMA: (znum2 * xp2) + f_atan_p3
const qword znum = si_fma(znum3, xp2, f_atan_p4); // FMA: (znum3 * xp2) + f_atan_p4
const qword zden0 = si_fa(xp2, f_atan_q0);
const qword zden1 = si_fma(zden0, xp2, f_atan_q1); // FMA: (zden0 * xp2) + f_atan_q1
const qword zden2 = si_fma(zden1, xp2, f_atan_q2); // FMA: (zden1 * xp2) + f_atan_q2
const qword zden3 = si_fma(zden2, xp2, f_atan_q3); // FMA: (zden2 * xp2) + f_atan_q3
const qword zden = si_fma(zden3, xp2, f_atan_q4); // FMA: (zden3 * xp2) + f_atan_q4
一个n次的多项式可以写成:
[ P(x) = a_0 + a_1x + a_2x^2 + \ldots + a_nx^n ] [ = a_0 + x(a_1 + x(a_2 + \ldots + x(a_{n-1} + xa_n))) ]
这种表示允许使用嵌套乘法和加法操作来评估多项式。在_cp_fatan
实现中,分子多项式评估如下:
[ znum = f_atan_p0 + xp2 * (f_atan_p1 + xp2 * (f_atan_p2 + xp2 * (f_atan_p3 + xp2 * f_atan_p4))) ]
类似地,分母多项式评估如下:
[ zden = f_atan_q0 + xp2 * (f_atan_q1 + xp2 * (f_atan_q2 + xp2 * (f_atan_q3 + xp2 * f_atan_q4))) ]
Estrin方法特别适合于支持融合乘加(FMA)指令的架构,因为每个嵌套级别可以使用单个FMA操作计算。这正是实现所做的,使用si_fma
内联函数执行嵌套乘法和加法操作。
Estrin方法的主要优点是它最小化了评估多项式所需的操作数量。对于一个n次的多项式,Estrin方法只需要n次乘法和n次加法,这在操作数量上是最优的。
然而,需要注意的是,Estrin方法可能会引入数值不稳定性,由于舍入误差的累积,特别是对于高次多项式或大幅度的输入值。为了缓解这个问题,我们需要通过执行一系列FMA操作来改进分母值。首先,计算(1 - zden_r0) * zden
使用si_fnms
(融合负乘减)内联函数:
const qword zden_r1 = si_fnms(zden_r0, zden, f_one);
这个操作计算(1 - zden_r0) * zden
并从1.0
中减去结果。这一步的目的是去除zden
的小数部分,有效地计算zden
的整数部分。
最后,实现通过添加小数部分回到整数部分来改进分母值:
const qword zden_r = si_fma(zden_r1, zden_r0, zden_r0);
这个操作计算zden_r1 * zden_r0 + zden_r0
,相当于将小数部分(zden_r0
)加到整数部分(zden_r1 * zden_r0
)。结果是zden
的更准确表示,记为zden_r
。
有了改进的分母值zden_r
,实现现在可以更准确地执行最终除法:
const qword zdiv = si_fm(znum, zden_r);
这一步计算zdiv = znum / zden_r
,这是反正切近似的最终结果。
改进分母值的目的是减少当分母接近零时舍入误差的影响。通过分离分母的整数和小数部分并改进小数部分,实现可以保持更高的精度并减少舍入误差的累积,特别是在分母值很小的情况下。
数学上,改进过程可以表示如下:
设zden = f + i
,其中f
是小数部分,i是整数部分。
然后:
[ zden_r0 = f ] [ zden_r1 = (1 - f) \cdot (f + i) = i + f - f^2 \approx i ] [ zden_r = zden_r1 + zden_r0 = i + f = zden ]
通过改进分母值(zden_r
)以更准确地表示zden
,实现可以减轻舍入误差的潜在数值不稳定性,特别是在分母值接近零的情况下。
通过范围缩减改进值
范围缩减对于提高反正切近似的准确性和效率至关重要。它涉及将输入值x
映射到较小的范围,在该范围内可以使用多项式更准确地近似反正切函数。
像大多数现代处理器一样,CELL BE使用固定数量的位来表示单精度(32位)和双精度(64位)浮点数的符号位(尾数)和指数。这种有限的精度可能会导致在表示某些值或执行算术操作时出现舍入误差。例如,当使用多项式近似计算反正切函数时,输入值可能需要除以或乘以某些常数。如果这些常数不能在浮点格式中精确表示,舍入误差可能会在整个计算中累积,可能导致不准确的结果。
const qword range0_mask = si_fcgt(pos_x, f_t3p8);
const qword range1_gt_mask = si_fcgt(f_pt66, pos_x);
const qword range1_eq_mask = si_fceq(f_pt66, pos_x);
const qword range1_mask = si_or(range1_gt_mask, range1_eq_mask);
const qword range2_mask = si_nor(range0_mask, range1_mask);
range0_mask
标识pos_x
(输入x
的绝对值)大于tan(3π/8)
的情况,这是一个常数值f_t3p8
。si_fcgt
内联函数执行浮点大于比较。
range1_mask
标识pos_x
小于或等于0.66
的情况,这是一个常数值f_pt66
。它通过使用si_or
组合两个掩码来计算:range1_gt_mask
(对于pos_x < 0.66
)和range1_eq_mask
(对于pos_x == 0.66
)。
range2_mask
标识range0_mask
或range1_mask
未覆盖的剩余情况。它通过执行range0_mask
和range1_mask
的按位NOR操作(si_nor
)来计算。
这些范围掩码用于根据输入值所在的范围选择不同的计算和近似方法。
对于range0
情况,pos_x
大于tan(3π/8)
,范围缩减涉及计算-1.0 / pos_x
:
const qword range0_x0 = si_frest(pos_x);
const qword range0_x1 = si_fi(pos_x, range0_x0);
const qword range0_x2 = si_fnms(range0_x1, pos_x, f_one);
const qword range0_x3 = si_fma(range0_x2, range0_x1, range0_x1);
const qword range0_x = si_xor(range0_x3, f_msb);
const qword range0_y = f_pio2;
这个计算使用一系列FMA(融合乘加)指令进行,以提高准确性和性能。最终结果range0_x
然后使用XOR操作与f_msb
(翻转符号位)取反。对应的range0_y
值设置为π/2
。
对于range1
情况,pos_x
小于或等于0.66
,范围缩减很直接:
const qword range1_x = pos_x;
const qword range1_y = f_zero;
range1_x
值简单地设置为pos_x
,对应的range1_y
值设置为0.0
。
对于range2
情况,涵盖pos_x
的其余值,范围缩减涉及计算(pos_x - 1.0) / (pos_x + 1.0)
:
const qword range2_y = f_pio4;
const qword range2_x0num = si_fs(pos_x, f_one);
const qword range2_x0den = si_fa(pos_x, f_one);
const qword range2_x0 = si_frest(range2_x0den);
const qword range2_x1 = si_fnms(range2_x0, range2_x0den, f_one);
const qword range2_x2 = si_fma(range2_x1, range2_x0, range2_x0);
const qword range2_x = si_fm(range2_x0num, range2_x2);
这个计算也使用FMA指令进行,以提高效率。对应的range2_y
值设置为π/4
。
计算完特定范围的值(range0_x
,range1_x
,range2_x
,range0_y
,range1_y
,range2_y
)后,根据范围掩码选择适当的值:
const qword range_x0 = si_selb(range2_x, range0_x, range0_mask);
const qword range_x = si_selb(range_x0, range1_x, range1_mask);
const qword range_y0 = si_selb(range2_y, range0_y, range0_mask);
const qword range_y = si_selb(range_y0, range1_y, range1_mask);
si_selb
内联函数用于执行条件选择操作,如果相应的掩码位是0
,则选择第一个值;如果掩码位是1
,则选择第二个值。范围特定的值range_x
和range_y
然后用于_cp_fatan
函数的后续多项式近似步骤。
这种范围缩减的目的是将输入值x
映射到较小的范围,通常[-1, 1],在这个范围内可以使用多项式更准确地近似反正切函数。通过将输入范围分解为多个子范围并应用不同的范围缩减策略,实现可以提高近似的准确性和效率。
特殊值处理
我们需要处理特殊情况,以确保在处理极端情况或异常输入时,近似结果符合数学规范,并产生准确的结果,即使在可能导致未定义或错误行为的极端情况下也能如此。
正负无穷输入
当一个输入(x或y)是无穷大时,反正切函数的数学行为需要明确定义。例如,当y是正无穷大,x是负无穷大时,结果应该是3π/4弧度。这些情况不能由一般多项式近似处理有限输入值。
创建掩码x_eqinf_mask
和x_eqninf_mask
,使用si_fceq
识别x
是否等于正无穷或负无穷。
如果x
是正无穷大,结果应该是π/2
。这是通过在result_y0
(前一步的结果)和f_pio2
(π/2
的常数值)之间选择来实现的:
const qword result_y1 = si_selb(result_y0, f_pio2, x_eqinf_mask);
如果x
是负无穷大,结果应该是-π/2
。这是通过在result_y1
(前一步的结果)和f_npio2
(-π/2
的常数值)之间选择来实现的:
const qword result = si_selb(result_y1, f_npio2, x_eqninf_mask);
输入为零
当一个或两个输入为零时,反正切函数的数学行为变得未定义,或取决于x和y的具体符号。例如,当y为0且x为负时,结果应该是π弧度,而当y为0且x为正时,结果应该是0弧度。这些情况需要特别处理以确保正确的结果。
const qword x_eqz_mask = si_fceq(f_zero, x);
const qword result_y0 = si_selb(pos_yaddz, x, x_eqz_mask);
const qword x_eqinf_mask = si_fceq(f_inf, x);
const qword x_eqninf_mask = si_fceq(f_ninf, x);
const qword result_y1 = si_selb(result_y0, f_pio2, x_eqinf_mask);
const qword result = si_selb(result_y1, f_npio2, x_eqninf_mask);
首先,使用si_fceq
内联函数识别输入x
是否等于0.0
,结果存储在x_eqz_mask
中。
如果x
等于0.0
,结果应该是0.0
。这是通过在pos_yaddz
(前几步计算的反正切值)和x
(即0.0
)之间选择来实现的:
const qword result_y0 = si_selb(pos_yaddz, x, x_eqz_mask);
非数字(NaN)
如果x或y是NaN,根据IEEE 754浮点标准,atan2(y, x)的结果也应该是NaN。NaN值代表无效或未定义的结果,它们需要正确地通过数学运算传播。CELL BE本身支持各种舍入模式,包括最近舍入、向正无穷舍入、向负无穷舍入和向零舍入。选择舍入模式可以影响最终结果,特别是当处理接近可表示范围边界的异常值或输入时。例如,当计算x或y接近零的atan2(y, x)时,舍入模式可以决定结果是舍入为零还是非零值。
CELL BE提供硬件支持以处理像无穷大和NaN这样的异常浮点值。然而,涉及这些值的算术操作的行为可能并不总是直观或在不同场景中一致的。例如,当计算一个输入是无穷大,另一个是有限值的atan2(y, x)时,结果可能取决于输入的具体符号,可能需要特别处理以确保准确和一致的行为。
思考和结论
SPE中心方法向量化数学函数的一个关键优势是,它可以潜在地解锁更高水平的并行性和效率,因为SPEs不受持续与PPE通信的约束。这对于那些可以有效并行化和跨多个SPEs分布的工作负载特别有益。
SPUs还具有“SIMD类型指令集”和128位寄存器,可以容纳32/16位定点或浮点值的向量。这种SIMD架构更适合于并行执行对多个数据元素执行相同操作。利用能够执行单精度(32位)和双精度(64位)浮点运算的浮点单元(FPU)在这方面也有所帮助。
然而,采用SPE中心编程模型也引入了几个挑战,如开发者必须仔细分区由每个SPE处理的数据和任务,确保多个SPEs并发访问共享数据结构时没有冲突或竞态条件。必须采用适当的同步机制,如锁或屏障,以维护数据一致性。
而在分布式计算环境,如SPE中心模型中,容错和错误处理也变得更加关键。必须有机制来检测和从潜在的失败或错误中恢复,以及优雅地处理一个或多个SPEs变得不可用的场景。
最后,实现SPE中心算法的代码库可能更难以移植到其他不具有与Cell BE相同异构架构的平台。这可能限制了代码库在不同硬件目标之间的可重用性和可移植性。这在一定程度上也是PS3仿真如此难以破解的原因之一,索尼自己也在PS4中放弃了对PS3的向后兼容性支持。
尽管最初备受炒作,Cell处理器最终未能获得广泛采用,成为了一个架构死胡同。
Cell失败的一个根本原因可以归因于Dennard缩放的瓦解。Dennard缩放这一原则几十年来支配着晶体管行为,预测随着晶体管变得更小,其功率密度将保持不变,允许更高的时钟速度而不会增加功耗或发热。
Cell处理器是在工程师仍然期望在消费电子芯片上实现5GHz以上时钟速度的时代设计的。然而,随着Dennard缩放的局限性变得明显,依赖高时钟速度的Cell设计变得越来越站不住脚。结果,处理器不得不被挤进它原本不设计的角色,索尼不得不匆忙集成来自NVIDIA的独立GPU来补偿Cell的不足图形能力,这导致了一些生产问题。
除了硬件限制之外,Cell的工具和文档也受到广泛批评,进一步加剧了开发者有效利用处理器能力所面临的挑战。这可能是因为索尼可能希望阻止开发者将他们的游戏跨平台移植,这就是为什么直到现在我们才看到许多PS3游戏移植到其他平台。
虽然Cell处理器代表了推动并行计算边界的雄心勃勃和创新努力,但其在更通用计算任务中的表现常常令人失望,由于编程挑战和架构的固有限制(尽管它在某些并行工作负载,如矩阵数学和密码破解中表现出色)。尽管对其在某些专业工作负载的初始潜力有炒作,Cell处理器的非传统设计被证明是一步错棋,最终逐渐被遗忘。
谈到处理器设计,下一篇文章可能是关于CUDA内核的,但谁知道呢,哈哈。