并行执行类型与数据一致性保障
1. 函数并行性
在PC软件、智能手机和平板电脑等设备中,大量独立应用程序并行执行是其特点。而在高性能计算领域,函数并行性则较为少见,不过图形处理中的渲染就是函数大规模并行性的一个例子。在经典嵌入式领域,通常使用紧密关联的函数,这些函数往往是为单处理器核心(单核)执行而开发的,当切换到多核时会遇到诸多问题,因为代码的紧密交织使得其难以简单地拆分并分布到多个处理器核心上。
1.1 从单核到多核:冒泡排序示例
冒泡排序算法简单且广为人知,其实现代码如下:
void BubbleSort(unsigned int* s, unsigned int size)
{
unsigned int i,j,temp;
for(i=1; i<size; i++) {
for(j=0; j<(size-i); j++) {
if(s[j] > s[j+1])
{
temp = s[j];
s[j] = s[j+1];
s[j+1] = temp;
}
}
}
}
对于包含n个元素的数组,内层循环迭代次数c的计算公式为:$c = \sum_{i=1}^{n}(n - i) = \frac{n^2 - n}{2}$,这个数字对算法的最坏执行时间(CET)起决定性作用。
在单核项目中,使用
BubbleSort
函数非常简单,调用该函数,等待其完成任务,数组就会被排序。但如果要将该函数并行化并分布到多核处理器的多个核心上,情况就变得复杂了。即使像冒泡排序这样简单的函数也不容易并行化,在单核环境中只需几行代码,在多核环境中则需要大幅扩展。
如果将数组划分为多个子区域,为每个CPU分配一个子区域进行排序,会得到多个已排序的子区域,但这还不是最终解决方案,还需要将这些子区域重新组合成一个排序好的整体数组。如果核心数超过两个,这种合并需要分多个步骤进行。
合并两个已排序数组可以使用以下
MergeSortedArrays
函数实现:
void MergeSortedArrays( unsigned int* d, /* destination */
unsigned int* s, /* source */
unsigned int size1,
unsigned int size2 )
{
unsigned int p;
unsigned int i1 = 0;
unsigned int i2 = size1;
for (p=0; p<size1+size2; p++) {
if (s[i1] < s[i2]) {
d[p] = s[i1++];
if (i1 == size1) {
/* reached end of section 1,
so simply copy the rest */
while (i2 < size1+size2) {
d[++p] = s[i2++];
}
break;
}
} else {
d[p] = s[i2++];
if (i2 == size1+size2) {
/* reached end of section 2,
so simply copy the rest */
while (i1 < size1) {
d[++p] = s[i1++];
}
break;
}
}
}
}
与冒泡排序函数不同,该函数的循环迭代次数或复制操作次数c与数组元素数量呈线性关系,即$c = n$。
在实际操作中,不应使子区域大小相同,以便在一个较大子区域仍在排序时,将两个稍小的子区域合并。当数组元素数量超过一定规模时,冒泡排序函数对元素数量的平方依赖变得非常显著,而合并操作的线性依赖几乎不起作用。
下表展示了不同情况下的循环迭代次数:
| 情况 | 循环迭代次数 |
| — | — |
| 单核冒泡排序(n = 1200) | 719,400 |
| 等分布并行排序(n = 1200) | 3 * 79,800 |
| 非对称并行排序(n = 1200) | 1 * 179,700 + 2 * 44,850 |
从这个简单的函数并行化示例可以看出,即使是简单函数的并行化也可能迅速变得非常复杂,而且很难找到简单的解决方案。同时,将为单核开发的代码自动转换到多核环境从一开始就注定会失败。不过,如果在设计阶段就将函数设计为可并行处理,多核处理器的使用将具有巨大潜力。未来,在高层次抽象上基于模型开发或使用功能,代码生成器在生成代码时能精确了解目标系统,开发者只需将数组输入到“排序功能块”,其他细节由代码生成器处理。
1.2 复制并行与流水线并行
通常,一个特定功能的实现包含多个依次处理的步骤。例如,一个功能由两个步骤组成:第一步进行过滤A,第二步进行计算B,这两个步骤的执行时间超过1毫秒。如果该功能需要每毫秒执行一次,在单核CPU上显然无法实现。假设使用双核处理器,有两种基本方法将A块和B块分配到两个核心上:
-
复制并行
:A/B组合在一个核心上交替执行,然后在另一个核心上执行。
-
流水线并行
:部分A始终在CPU1上执行,部分B始终在CPU0上执行。
以下是两种并行方式的优缺点对比:
| 并行方式 | 优点 |
| — | — |
| 复制并行 | - 部分A和B之间的数据交换在各自CPU上本地进行,无需跨核心通信,应尽量减少这种“跨核心”通信。
- CPU0的调度更具确定性和可预测性,每2毫秒执行一次A/B组合。 |
| 流水线并行 | - 仅在CPU1上执行部分A,仅在CPU0上执行部分B,通常能更有效地使用程序缓存。
- 在异构多核处理器中,可以将代码进行分配,使某些部分的代码在特定核心上运行得特别好。
- 假设部分A过滤的数据必须首先接收,如果始终在同一计算内核上进行接收,可提供另一个优势,所有与外界的通信由该核心处理,通信栈将仅在该核心上运行,在缓存和其他内存使用方面具有相当大的优化潜力。 |
2. 指令并行性
硬件层面的指令并行执行在之前已有解释,流水线的作用正如其名,可并行处理多个指令,CPU会自动完成此操作,无需额外输入。然而,不利的跳转指令会削弱其提高效率的效果。
为编写“流水线友好”的软件,可以采取以下措施:
-
避免函数调用
:不必要的函数调用的一个典型原因是引入包装器,即一个适配层,用于将软件组件的接口适配到另一个接口。当将旧代码集成到新环境中,而新环境需要类似但不同的接口时,通常会使用这种方法。如果将旧代码的函数嵌入到空函数中以满足新接口要求,会不必要地增加每个函数的调用。为避免这种情况,可以使用以下机制来映射一个接口到另一个接口:
- 宏(“#define …”)
- 内联函数
-
避免中断
:这里实际上是指避免不必要的中断。在嵌入式系统中,一般完全避免中断既不可能也无益处。但中断常被用于信号数据接收,后续再进行处理。可以在处理开始时简单地查询(轮询)是否有新数据可用,而不是使用中断。这样可以减少许多中断的使用,且不会带来任何损失,还能降低数据不一致的风险,确保更有效地使用缓存。
3. 数据一致性与自旋锁
在多核环境中,数据一致性问题与中断相关的情况类似。当两个中断在多核处理器的不同核心上执行时,中断锁无法解决问题,因为它们仅适用于触发它们的CPU。为防止对共享内存的不幸同时访问以及由此产生的数据不一致问题,可以使用自旋锁。
自旋锁的接口如下:
StatusType GetSpinlock( SpinlockIdType SpinlockId );
StatusType ReleaseSpinlock( SpinlockIdType SpinlockId );
StatusType TryToGetSpinlock ( SpinlockIdType SpinlockId, TryToGetSpinlockType* Success );
GetSpinlock
函数用于占用资源(自旋锁),
ReleaseSpinlock
函数用于释放资源。简单的使用示例如下:
GetSpinlock(spinlock);
/* This is where the access to the protected resource occurs */
ReleaseSpinlock(spinlock);
如果在调用
GetSpinlock
时资源已被占用,函数会等待直到资源可用。这种等待通过
GetSpinlock
函数内的循环实现,因此得名(“spin”表示“循环”,“lock”表示在此期间排除其他代码的执行)。等待资源是无效率的时间,应尽量避免或至少最小化。
TryToGetSpinlock
函数是非阻塞的,无论自旋锁的状态如何,它都会立即返回,并通过引用传递的参数让调用函数知道资源是否成功分配。
以下是一个更巧妙使用自旋锁的代码示例:
1 DisableAllInterrupts();
2 TryToGetSpinlock(spinlock, &success);
3 if (success) {
4 /* 访问受保护的资源 */
5 ReleaseSpinlock(spinlock);
6 }
7 EnableAllInterrupts();
在第2行禁用中断后,尝试使用
TryToGetSpinlock
占用资源。如果成功,资源可以在受保护的情况下使用,并且可以确保其使用不会被中断延迟。如果资源已被其他代码阻塞,CPU会在循环中等待。
综上所述,在多核环境中,无论是函数并行化、指令并行性的优化,还是数据一致性的保障,都需要开发者仔细考虑和处理各种问题,以充分发挥多核处理器的性能优势。
并行执行类型与数据一致性保障
4. 自旋锁使用中的问题及解决策略
虽然自旋锁可以解决多核环境下共享资源访问的数据一致性问题,但在使用过程中会遇到一些挑战。
4.1 中断导致的任务延迟问题
在某些情况下,一个核心上的中断可能会导致另一个核心上的任务显著延迟,即使该中断不访问共享资源。例如,CPU1上的一个中断导致CPU0上的任务A被延迟,具体过程如下:
1. CPU1上的任务B占用了资源。
2. CPU0上的任务A尝试使用该资源,但此时资源已被占用,任务A开始等待。
3. 任务B在使用资源时被中断,必须等待中断处理完成才能继续处理。
4. 在此期间,CPU0处于“浪费时间”状态,不执行任何有效代码。
为了解决这个问题,一种简单的方法是在访问资源时禁用和启用中断,代码示例如下:
DisableAllInterrupts();
GetSpinlock(spinlock);
/* the protected access takes place here */
ReleaseSpinlock(spinlock);
EnableAllInterrupts();
然而,这种方法会带来新的问题。当任务B禁用中断以避免上述问题时,如果此时触发了一个与资源无关的中断,该中断必须等待任务A完成资源访问,然后等待任务B完成资源访问并启用中断后才能继续处理。
4.2 TryToGetSpinlock的应用
为了克服上述问题,可以使用
TryToGetSpinlock
函数。它是非阻塞的,无论自旋锁的状态如何,都会立即返回,并通过引用传递的参数告知调用函数资源是否成功分配。
以下是一个更巧妙使用自旋锁的代码示例:
DisableAllInterrupts();
TryToGetSpinlock(spinlock, &success);
if (success) {
/* 访问受保护的资源 */
ReleaseSpinlock(spinlock);
}
EnableAllInterrupts();
使用这种方法,在禁用中断后尝试占用资源,如果成功则访问资源并释放自旋锁;如果失败,CPU不会一直等待,而是可以继续执行其他任务。
5. 多核并行化的总结与展望
多核并行化在提高系统性能方面具有巨大潜力,但在实际应用中面临诸多挑战。
5.1 多核并行化的挑战
- 函数并行化复杂 :即使是简单的函数,如冒泡排序,在多核环境下的并行化也会变得非常复杂,需要考虑数组划分、子区域合并、核心同步等问题。
- 代码转换困难 :将为单核开发的代码自动转换到多核环境几乎是不可能的,需要在设计阶段就考虑并行处理。
-
自旋锁使用问题
:自旋锁虽然可以解决数据一致性问题,但在使用过程中会遇到中断导致的任务延迟等问题,需要巧妙使用
TryToGetSpinlock函数来克服。
5.2 未来发展方向
未来,多核并行化的发展可能会朝着以下方向进行:
-
基于模型的开发
:在高层次抽象上基于模型开发或使用功能,代码生成器在生成代码时能精确了解目标系统,开发者只需关注功能逻辑,其他细节由代码生成器处理。
-
智能代码生成
:开发更智能的代码生成工具,能够自动处理多核并行化中的复杂问题,如核心分配、同步机制等。
6. 多核并行化操作流程总结
为了更好地实现多核并行化,以下是一个通用的操作流程:
1.
功能分析
:分析要实现的功能,确定是否可以并行处理。如果功能由多个独立或可拆分的部分组成,则适合并行化。
2.
任务划分
:将功能划分为多个子任务,确保子任务之间的依赖关系清晰。例如,在排序功能中,可以将数组划分为多个子区域进行排序。
3.
核心分配
:根据子任务的特点和多核处理器的特性,将子任务分配到不同的核心上。例如,在流水线并行中,将不同的步骤分配到不同的核心。
4.
同步机制设计
:设计合适的同步机制,确保子任务之间的协调和数据一致性。例如,使用自旋锁来保护共享资源。
5.
代码实现与优化
:根据上述步骤实现代码,并进行优化,如避免不必要的函数调用和中断,提高指令并行性。
6.
测试与调试
:对多核并行化的代码进行测试和调试,确保其正确性和性能。
以下是这个操作流程的mermaid流程图:
graph LR
A[功能分析] --> B[任务划分]
B --> C[核心分配]
C --> D[同步机制设计]
D --> E[代码实现与优化]
E --> F[测试与调试]
通过遵循这个操作流程,可以更有效地实现多核并行化,充分发挥多核处理器的性能优势。
总之,多核并行化是一个充满挑战和机遇的领域,开发者需要不断学习和探索,掌握相关技术和方法,以应对日益复杂的计算需求。
超级会员免费看
6162

被折叠的 条评论
为什么被折叠?



