首页 | 新闻 | 新品 | 文库 | 方案 | 视频 | 下载 | 商城 | 开发板 | 数据中心 | 座谈新版 | 培训 | 工具 | 博客 | 论坛 | 百科 | GEC | 活动 | 主题月 | 电子展
返回列表 回复 发帖

多核多处理器架构软件设计的注意事项应用细节

多核多处理器架构软件设计的注意事项应用细节

避免伪共享如果两个或多个处理器正在向同一缓存行的不同部分中写入数据,那么很多缓存和总线通信可能会导致其他处理器上的旧行的每个缓存副本失效或进行更新。这称为 “伪共享” 或者也称为 “CPU 缓存行干扰”。和两个或多个线程共享同一数据(因此需要程序化的同步机制来确保按顺序访问)的真正共享不同,当两个或多个线程访问位于同一缓存行上的无关数据时,就会产生伪共享。
通过以下代码片段更好地了解伪共享(参考:)。
清单 1. 用于演示伪共享的代码示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
double sumLocal[N_THREADS];
. . . . .
. . . . .
void ThreadFunc(void *data)
{
      . . . . . . .
      int id = p->threadId;
      sumLocal[id] = 0.0;
      . . . . . .
      . . . . . .
      for (i=0; i<N; i++)
      sumLocal[id] += p;
      . . . . . .
}




在上面的代码示例中,变量 sumLocal 的大小与线程的数量相同。数组 sumLocal 可能会导致伪共享,因为当它们修改的元素位于同一缓存行上时,多个线程会在数组中执行写入操作。 演示了当在 sumLocal 中修改两个连续的元素时,线程 0 和线程 1 之间的伪共享。线程 0 和线程 1 正在修改数组 sumLocal 中连续的不同元素。这些元素在内存中彼此邻近,因此将位于同一个缓存行上。如图所示,缓存行被加载到 CPU0 和 CPU1 的缓存中(灰色箭头)。即使线程正在修改内存中的不同区域(红色和蓝色箭头),为了保持缓存一致性,在加载该缓存行的所有处理器上,该缓存行会失效,从而强制进行更新。
图 2. 伪共享(参考:部分中的避免和标识线程之间的伪共享。)伪共享会严重降低应用程序的性能,并且很不容易检测到。有关演示伪共享不良影响的简单程序,请参阅  部分中的文章 “多核平台 - CS Liu 的内存问题”。
避免伪共享的技巧
  • 可以使用可用于对某个特定处理器进行有条件地编译的编译器对齐指令,通过将数据结构与缓存行边界对齐来避免伪共享。例如,在 Linux® 平台上,头文件 adm-i386/cache.h 为 Intel® x86 架构系列的 L1 缓存行大小定义了宏 L1_CACHE_BYTES。您也可以通过编程方式确定处理缓存行的大小。有关在缓存行边界上对齐数据结构以及通过编程方式创建跨平台函数以获取缓存行大小的详细信息,请参阅  部分。
  • 另一个技巧涉及对经常访问的数据结构领域进行分组,以便让它们成为单个的缓存行,因此可以通过单个内存访问来加载它们。这会减少内存延迟。但是如果数据结构非常巨大,则可能会增加缓存占用,并且可能需要牺牲一些打包效率来减少或消除伪共享。有关详细信息,请参阅  部分中的 “缓存编程样式的元素” 文章。
  • 为了防止数组中发生伪共享,应该将数组与缓存的大小匹配。结构的大小必须是处理器缓存行大小的整数倍。
  • 如果需要假定缓存行大小以便强制对齐,则应使用 32 字节。请注意:
    • 32 字节对齐的缓存行同时也是 16 字节对齐的缓存行。
    • 在大多数处理器上,适合假定 32 字节缓存行大小。例如,IBM PowerPC® 601 名义上有一个 64 字节缓存行,但它实际上有两个已连接的 32 字节缓存行。Sparc64 有一个 32 字节 L1 和一个 64 字节 L2 缓存行。Alpha 有一个 32 字节 L1 缓存行。Itanium 架构有一个 64 字节 L1 缓存行,IBM System z® 有一个 256K L1 缓存行和 128 字节缓存行,而 x86 处理器有 64 字节 L1 缓存行。
    • 在一个核上有效执行的通用规则是将数据紧紧包裹,以便该数据占用较少的空间。但在多核处理器上,包裹共享数据可能会导致严重的伪共享。通常,解决方案是紧紧包裹数据,给每个线程提供它自己的专用副本以便使其继续工作,之后再将结果进行合并。
  • 线程使用了衬垫结构或数据,确保不同线程所拥有或修改的数据位于不同的缓存行上。
消除伪共享?错误!在理想情况下,我们的目标是消除共享,而不只是伪共享。通常,软件设计应该尽力消除对锁、同步机制以及共享的需求。有关的重要观点,请参阅 Dmitriy Vyukov 的。

伪共享不容易检测,但有几个工具(如 Oprofile 和 Valgrind 的 )可以为您提供帮助。
消除或减少锁争用这个设计注意事项是前面提到的两个考虑事项的扩展,目的是避免内存争用和伪共享。正如前面部分所述,软件设计者的主要目标应该是消除共享,以便线程或进程之间不会发生资源争用。前面部分中所述的一些技巧(如使用线程本地变量代替全局共享区域)可以防止发生内存争用和伪共享。但是,该技巧并不适用于所有情况。
例如,如果有一个保持资源状态的数据结构,则有可能无法在每个线程中包含该结构的副本。该数据结构可能必须由应用程序中的所有线程读取和修改。因此,必须使用同步技术来保持数据的一致性以及此类共享数据结构的完整性。如果存在用于保护共享资源的锁或同步构造,则可能会在多个线程或进程之间出现锁争用,从而降低性能。
在多核、多处理器系统上,可能有空间来同时运行大量线程或进程,但是,如果这些线程必须不断地彼此竞争,以访问或修改共享资源或数据结构,那么系统的总体吞吐量会有所降低。这会导致应用程序无法通过伸缩来有效地利用可用的计算资源。在由于锁争用而导致性能降低的极坏情况下,随着核心或处理器数量的增加,应用程序的性能会有所降低。
避免锁争用的技巧
  • 避免在数据结构中发生锁争用的方法之一是采用并发数据结构设计和无锁算法,这会消除锁以及传统的同步技巧(比如互斥)。有多种并发数据结构的设计并不需要利用同步机制,比如互斥。有关此类并发数据结构设计的一些示例,请参阅  部分。
  • 无锁算法的一些示例如下:
    • 使用  的可伸缩并发哈希表:该技巧的最简单示例是 ,它专用于 Linux 内核,大大提高了 Linux 内核的性能,并简化了 Linux 内核的代码。
    • 无锁可扩展有序分割的哈希列表:这个无锁递归可扩展哈希算法使用了无锁的链接列表,这些列表使用原子指令来修改链接的列表。
  • 在 Linux 内核中,广泛使用了每处理器变量,系统上的每个处理器都获得了自己的一个给定变量的副本。访问每处理器变量不需要使用锁,此外,因为在不同的处理器上,这些变量未在线程之间共享,因此没有伪共享或内存争用。这种技巧非常适合收集统计信息。
减少锁争用的技巧
  • 当使用传统锁或同步技巧(如自旋锁)时,必须注意的是,不要使用单片锁或全局锁,而是将这些锁分成更细小的部分。因此,锁会保护数据结构中的某个特定区域以及较小的区域。这样多个线程就能够通过获取保护这些成员的相应锁,在同一数据结构的不同成员上并发进行操作。这种方法可以实现更多并发。
  • 甚至当软件设计中的同步机制能够实现更好的并发和减少锁争用时,也可能会由于伪共享而导致发生性能问题。例如,考虑一个哈希数据结构。如果存在一个自旋锁数组,用于保护哈希中的每个哈希桶,那么在自旋锁数组中可能会出现伪共享。两个线程在两个不同的处理器上运行,每个线程都锁定哈希中的不同哈希桶,那么当它们所需的自旋锁位于同一个缓存行上时,可能会发生伪共享。因此,在设计此类算法时需要考虑采用避免发生伪共享的通用技巧。
检测锁争用以及消除或减少锁争用对于在多核、多处理器环境中提供应用程序的可伸缩性非常重要。操作系统提供了用于检测和度量由于锁争用而导致性能瓶颈的实用工具。例如,Solaris 提供了 Lockstat 实用工具,用于度量内核模块中的锁争用。同样,Linux 内核也提供了 Lockstat 和 Lockdep 框架,用于检测和度量锁争用以及性能瓶颈。"Windows 性能工具包 - Xperf" 在 Windows 上提供了类似的功能。有关的详细信息,请参阅  部分。
避免堆争用C/C++ 标准内存管理例程是使用特定于平台的内存管理 API 实现的,它通常基于堆的概念。这些库例程(无论是单线程版本还是多线程版本)在单个堆上分配或释放内存。它是全局资源,在某个进程中的线程之间共享并争用。堆争用是内存密集型多线程应用程序的瓶颈之一。
避免堆争用的技巧
  • 使用线程本地/专用堆进行内存管理,从而消除了资源争用。在 Windows 平台上,可以使用 HeapCreate() 为每个线程创建一个专用堆,并将返回的堆句柄传递给 HeapAlloc()/HeapFree() 函数。
部分中的 “多核平台的内存问题 - CS Liu” 文章提供了一个堆争用示例。在这个参考资料中,作者演示了如何使用专用堆将性能提高 3 倍左右(与使用全局堆相比)。
注意:
  • 在 Windows 平台上,当创建堆时,可以设置 heap_no_serialization 标志,这意味着从多个线程访问它时不需要进行同步。但事实证明,在 vista 及更高版本的操作系统上,将该标志设置为线程私有堆速度会很慢。
  • Heap_no_serialization 和一些调试方案将禁用 “Low Fragment Heap” 功能,现在这是堆的事实默认策略,因此进行了高度优化。
提高处理器关联处理器关联是一个线程或进程属性,该属性告诉操作系统可以在哪些核或逻辑处理器上运行进程。这更适合嵌入式软件设计。
显示了一个系统配置,该配置包含两个处理器,每个处理器有两个核,这两个核都有一个共享的 L2 缓存。在该配置中,在同一处理器上的两个核之间以及不同处理器上的两个核之间,缓存子系统的行为将会有所不同。如果两个相关的进程或某个进程的两个相关线程被分配给同一处理器上的两个核,那么它们可以更好地利用共享的 L2 缓存,而且可以减少保持缓存一致性的开销。
图 3. 多个核共享一个 L2 缓存的系统设计注意事项
  • 嵌入式系统的软件设计者可以利用这个保持缓存一致性相对较低的开销,通过编程控制向核分配线程。
  • 在 Linux 和 Windows 操作系统上的以下系统调用可以告诉应用程序,对于特定的进程来说,处理器是如何关联的,并为进程设置处理器关联掩码:                  清单 2. 用于管理处理器关联的系统调用
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    Linux Example:
    /* Get a process' CPU affinity mask */
    extern int
    sched_getaffinity(pid_t pid, size_t cpusetsize, cpu_set_t *cpuset);

    /* Set a process's affinity mask */
    extern int
    sched_setaffinity(pid_t pid, size_t cpusetsize, const cpu_set_t *cpuset);

    Windows Example:
    /* Set processor affinity */
    BOOL WINAPI
    SetProcessAffinityMask(Handle hProcess, DWORD_PTR dwProcessAffinityMask);

    /* Set Thread affinity */
    DWORD_PTR WINAPI
    SetThreadAffinityMask(Handle hThread, DWORD_PTR dwThreadAffinityMask);




编程模型当为应用程序中的线程分配工作时,软件设计者可以考虑两个不同的编程模型。它们是:
  • 功能分解
    • 该模型的目标是了解应用程序或软件必须执行的操作,并将每个操作分配给不同的线程。
    • 可以将多个操作组合在一个线程中,但功能分解的结果是每个操作都由特定的线程来执行。
  • 域分解或称为数据分解
    • 该模型的目标是分析软件或应用程序所需的数据集,以便对它们进行管理或处理,并将这些数据集分解成可以单独处理的较小组件。
    • 软件中的线程会重复要执行的操作,但每个线程将在单独的数据组件上执行操作。
软件所需的操作类型以及数据特性都会影响模型的选择,但是必须了解这些模型如何在多处理器或多核环境中执行。
软件设计者需要考虑的几个注意事项
  • 尽管数据分解中的缓存交互看似非常简单(意味着数据分解优于功能分解),但支持域分解所需的软件设计可能面临非常大的挑战。
  • 分解模型中的数据仍然需要位于不同的缓存行上,以避免出现伪共享的可能。
在 部分中的多核多处理器系统的软件设计问题参考文献中,详细介绍了编程模型、为多核多处理器架构进行软件设计时应用这些模型的挑战和优势。
返回列表