(五)多线程那些事儿:并行库 openmp

(五)多线程那些事儿:并行库 openmp

1. concepts

1.1 处理器架构

  • 对称多处理器(SMP)架构

    • 特点:所有处理器共享一个统一的内存地址空间,并且每个处理器对内存的访问时间大致相同。操作系统将所有处理器视为平等。
    • 示例:大多数现代家用台式机和笔记本电脑都采用 SMP 架构,特别是那些配备多核处理器的系统。
    • 优点:编程模型简单,适合多任务处理和多线程应用。
  • 非一致性内存访问(NUMA)架构

    • 特点:内存被分成多个区域,每个区域与特定的处理器节点关联。不同内存区域的访问时间不同,通常本地内存访问比远程内存访问更快。
    • 示例:一些高端家用电脑和工作站可能采用 NUMA 架构,特别是那些配备多个处理器插槽的系统。
    • 优点:在处理大规模并行计算任务时,NUMA 架构可以提高性能。

在现代计算机系统中,多处理器架构的复杂性主要体现在缓存和内存访问行为上。尽管在编程模型和操作系统的视角下,许多系统可以被视为对称多处理器(SMP)系统,但实际上,由于缓存的存在,这些系统表现出非一致性内存访问(NUMA)的特性。从将系统视为 SMP 系统开始编写代码,并在实际优化工作中处理和优化 NUMA 特性。

  1. 现实更加复杂
    • 现代多处理器系统(包括多核处理器)通常具有多级缓存(如 L1、L2 和 L3 缓存),这使得内存访问的复杂性增加。
    • 由于缓存的存在,不同处理器访问不同内存地址的成本可能不同,这种情况下,系统表现出 NUMA 的特性。
  2. 任何带有缓存的多处理器 CPU 都是 NUMA 系统
    • 由于缓存的存在,处理器访问不同内存地址的成本不同,因此可以认为任何带有缓存的多处理器 CPU 都是 NUMA 系统。
  3. 从 SMP 系统开始
    • 尽管底层硬件可能表现出 NUMA 的特性,编程模型和操作系统通常将系统视为一个统一的共享内存系统,即 SMP 架构。
    • 开发人员可以从将系统视为 SMP 系统开始编写代码,这样可以简化编程模型。
  4. 优化工作
    • 在实际优化工作中,开发人员需要接受并处理系统表现出 NUMA 特性的情况。
    • 这意味着在进行性能优化时,需要考虑缓存和内存访问的影响,并针对特定的 NUMA 特性进行优化。

1.2 openmp 的概念

  • OpenMP 与手动多线程的比较 OpenMP 提供了一种高效的并行编程模型,相较于手动调用多线程,OpenMP 在以下几个方面具有优势:

    1. 线程调度优化:OpenMP 运行时系统可以更好地调度线程,减少线程切换的开销。
    2. 线程创建和切换开销:OpenMP 可以减少线程创建和切换的开销,提高程序的执行效率。
    3. 缓存行优化:在简单任务上,OpenMP 可以更好地利用缓存行,减少缓存未命中的次数。

    然而,OpenMP 并不会直接对任务做优化处理,因此一些 OpenMP 相较于手动调用多线程的提升是固定的,不会随着任务变复杂而增加。开发人员在使用 OpenMP 时,仍需考虑任务的复杂性和并行化的粒度,以充分发挥 OpenMP 的优势。

  • OpenMP 是什么 OpenMP(Open Multi-Processing)是一个用于多平台共享内存并行编程的 API,主要用于 C、C++ 和 Fortran 语言。它通过编译器指令、库函数和环境变量来实现并行编程。OpenMP 的核心思想是通过简单的编译器指令(pragma)来标记并行代码块,编译器会自动生成相应的多线程代码。

  • OpenMP 是哪一层次的优化? OpenMP 主要在编译器层面实现多线程,通过编译器指令和库函数,将并行代码转换为多线程代码,最终由操作系统的线程调度器管理线程的执行。编译器会根据 #pragma omp 指令生成相应的多线程汇编代码。由于编译器可以进行各种优化,因此在大多数情况下,使用 OpenMP 实现的多线程方法性能会更快一些。

    换句话说,预处理阶段编译器还是会保留 #pragma omp 命令。在编译阶段,编译器才会根据 #pragma omp 生成相应的多线程汇编代码。

  • OpenMP 会比手动实现的线程池快吗? OpenMP 是否比手动实现的线程池快,取决于具体的应用场景和实现细节。以下是一些考虑因素:

    • 开发效率:OpenMP 提供了高层次的并行编程接口,开发效率通常高于手动实现的线程池。
    • 性能优化:手动实现的线程池可以针对特定应用进行优化,可能在某些场景下性能优于 OpenMP。
    • 负载均衡:OpenMP 内置了负载均衡机制,可以自动将工作分配给不同的线程,而手动实现的线程池需要自行管理负载均衡。
    • 可维护性:OpenMP 代码通常更简洁、易读、易维护,而手动实现的线程池代码可能更复杂。
  • OpenMP 内部有线程池的概念吗? OpenMP 运行时库通常会维护一个线程池,以减少线程创建和销毁的开销。线程池中的线程可以重复使用,以提高性能。OpenMP 线程池的一个强大之处在于更强大的线程调度和负载均衡。一般而言,大部分自定义实现的线程池都是静态调度的。而 OpenMP 可以做到动态调度和自适应调度。

    • 静态调度:在静态调度中,迭代空间被均匀地划分给所有线程。每个线程在程序开始时就知道自己要处理的迭代范围。
    • 动态调度:在动态调度中,迭代空间被划分成多个块,线程动态地获取块进行处理。这样可以更好地平衡负载,特别是在迭代时间不均匀的情况下。
    • 自适应调度:OpenMP 还支持自适应调度策略,根据运行时的负载情况动态调整调度策略。
  • 怎么启用 OpenMP?

    • CMake 工程:使用 find_package(OpenMP REQUIRED)
    • VS 工程:项目 -> 属性 -> 配置属性 -> C/C++ 选项 -> 语言 -> OpenMP 支持 -> 设置为“是 (/openmp)” 如果 VS 工程是由 CMake 工程生成的,则会自动带上 OpenMP。
  • 总结 常规使用情况下,OpenMP 总能比手动实现的多线程更快,这一方面是因为 OpenMP 是编译器层面的,因此编译器更方便做各种优化。另一方面是大部分手动管理线程的负载均衡做得比较差劲,基本都是开了多个线程均分任务,没有考虑到不同任务的耗时不一样,动态调整的能力。除此之外,OpenMP 通常实现起来,会更简洁。

    但是手动实现的多线程,往往有更丰富的同步原语,如 std::mutexstd::condition_variable 等等。以及可以处理一些 std::asyncstd::future 等等异步任务。

    总结,OpenMP 更多用于一些重计算的库上,比如高性能计算,科学计算和工程模拟,深度学习/机器学习,图像处理等场景。在应用层软件中,OpenMP 也会用于多媒体处理(音视频解码)、游戏开发(更多是物理引擎)、图形渲染等等。在这些场景中,往往是重计算,且计算利于并行的的。使用 openmp 可充分挖掘多核性能,且写起来简单。

    对于其他一般的应用场景上,OpenMP 和多线程的性能差距一般不是瓶颈。而且应用层软件可能需要更灵活的同步原语,需要处理一些异步任务,要有 future 或者 async 概念。这些都是 OpenMP 做不到的。

2. openmp 指令

#pragma omp <directive> [clause[,clause]...]

  • 语法:OpenMP 指令通常由一个指令和若干个子句组成。指令用于指定并行化的类型,子句用于提供额外的信息和控制。
    • 主要指令:parallel, for ,sections, critical, single, flush,barrier, atomic, master, ordered, task, taskWait, taskGroup, taskLoop simd, target, teams, distribute
    • 主要子句:private, shared, default, reduction
  • 作用域:OpenMP 指令的作用域为紧接着的一个结构块,即 {} 内的代码。如果没有 {},则作用于下一行语句。

2.1 并行模式与并行控制

在 OpenMP 中,并行控制是通过一系列指令来实现的,这些指令用于创建线程团队、分割任务以及控制线程的执行。以下是一些常见的 OpenMP 并行控制指令及其功能:

2.1.1 并行模式
  1. parallel:创建一个线程团队,所有线程并行执行包含的代码块。即{}里面的会并行执行,执行多少次 openmp 会自动分配线程数,也可以手动指定线程数。
  2. for:将一个循环分割成多个小的迭代块,由不同的线程并行执行。即 for 循环里面的会并行执行,划分为几个 batch 由 openmp 决定。
  3. sections:将包含的代码块分割成不同的部分,由不同的线程并行执行。
  4. master:指定某个代码块只由主线程执行。
  5. schedule:控制循环迭代的分配方式。
    • 用法:#pragma omp for schedule(kind, chunk_size)
    • 说明:schedule 子句用于指定循环迭代的调度策略和块大小。
      • static:静态调度,预先将迭代分配给线程。
      • dynamic:动态调度,线程完成当前工作后在运行时获取更多工作。
      • guided:指导性调度,类似于动态调度,但块大小会自动调整。
      • auto:自动调度,由编译器决定调度策略。
  6. simd:显式地向编译器指示可以向量化的循环。
    • 说明:simd 指令用于利用 SIMD(单指令多数据)指令集,提高程序的执行效率。
  7. task:定义一个并行任务。
  8. taskloop:指令用于将一个循环分割成多个任务,每个任务可以独立执行。
  9. target:将代码卸载到目标设备(如 GPU)上执行。
    • 说明:target 指令用于将代码卸载到加速器上执行,以利用加速器的计算能力。
  10. teams:指令用于创建一个线程团队,通常用于嵌套并行。
  11. distribute:将循环迭代分配给不同的线程团队。
2.1.2 并行控制

静态模式 - 特点: - 是最简单的并行模式。在这种模式下,线程的数量在程序开始执行时就已经确定,并且在整个程序执行过程中保持不变。 - 线程数量固定。 - 适用于工作负载均匀且可预测的场景。

动态模式 - 特点: - 允许 OpenMP 运行时系统根据需要动态调整线程的数量。这种模式适用于工作负载不均匀或不可预测的场景。 - 线程数量可以动态调整。 - 适用于工作负载不均匀或不可预测的场景。

嵌套模式

  • 特点
    • 允许在一个并行区域内创建另一个并行区域。这种模式适用于需要多层次并行的复杂应用。
    • 支持多层次并行。
    • 适用于复杂的并行应用。

条件模式

  • 特点
    • 允许根据特定条件决定是否创建并行区域。这种模式适用于需要根据运行时条件动态决定并行执行的场景。
    • 根据条件动态决定并行执行。
    • 适用于需要灵活控制并行执行的场景。

2.2 线程同步

2.2.1 高级同步机制

用于在较高层次上控制线程的执行顺序和数据访问。这些机制包括:

  1. critical:指定某个代码块在任意时刻只能由一个线程执行,以防止竞态条件。
  2. atomic:指定对某个变量的访问是原子的,以防止竞态条件。
  3. barrier:使所有线程在此指令处等待,直到所有线程都到达此点。
  4. ordered:确保代码块按顺序执行,通常与 for 循环结合使用,顺序为循环索引的顺序。
  5. single:指定某个代码块只由一个线程执行。
  6. taskwait:指令用于等待所有子任务完成。
  7. taskgroup:指令用于定义一个任务组,任务组中的所有任务完成后才继续执行后续代码。
  8. taskyield:提示运行时系统当前任务可以让出处理器,以便其他任务可以执行。
2.2.2 低级同步机制

低级同步机制用于在较低层次上控制线程的内存访问和锁定。这些机制包括:flushlock

2.2.2.1 flush
  • flush 指令的作用 在多线程环境中,每个线程可能会有自己的本地缓存,用于存储变量的副本。这些本地缓存可以提高访问速度,但也可能导致数据不一致的问题。确保线程之间的内存一致性,强制将线程的本地缓存写回主内存,并从主内存读取最新的数据。flush 指令通过以下方式确保内存一致性:

    1. 写回本地缓存:将线程的本地缓存中的数据写回到主内存。
    2. 读取最新数据:从主内存中读取最新的数据,更新线程的本地缓存。
  • 使用场景 flush 指令通常用于以下场景:

    1. 线程间通信:确保一个线程写入的数据对其他线程可见。
    2. 同步点:在某些关键点强制刷新内存,以确保数据一致性。
  • 代码例子

    • 示例:
      #pragma omp parallel
      {
          // 并行执行的代码
          #pragma omp flush
          // 确保内存一致性
      }
      
2.2.2.2 lock
  • lock 指令的作用 使用锁来控制对共享资源的访问,防止多个线程同时访问同一资源。OpenMP 提供了简单锁和嵌套锁两种类型。

  • 嵌套锁的意义

    1. 递归访问
    • 在某些算法中,递归调用是常见的操作。如果递归调用中需要访问共享资源,嵌套锁可以确保同一个线程在递归调用中多次获取同一个锁,而不会导致死锁。
    1. 复杂的同步逻辑
    • 在一些复杂的同步逻辑中,可能需要在同一个线程内多次进入和退出临界区。嵌套锁允许线程在不释放锁的情况下重新进入临界区,从而简化了同步逻辑的实现。
    1. 避免死锁
    • 如果使用简单锁,同一个线程在尝试多次获取同一个锁时会导致死锁。嵌套锁通过维护一个计数器,记录锁被获取的次数,从而避免了这种情况。
  • 简单锁函数

    • omp_init_lock():初始化一个简单锁。
    • omp_set_lock():设置一个简单锁,阻塞直到锁可用。
    • omp_unset_lock():释放一个简单锁。
    • omp_test_lock():尝试设置一个简单锁,如果锁不可用则立即返回。
    • omp_destroy_lock():销毁一个简单锁。
  • 嵌套锁函数
    • omp_init_nest_lock():初始化一个嵌套锁。
    • omp_set_nest_lock():设置一个嵌套锁,允许同一个线程多次获取锁。
    • omp_unset_nest_lock():释放一个嵌套锁。
    • omp_test_nest_lock():尝试设置一个嵌套锁,如果锁不可用则立即返回。
    • omp_destroy_nest_lock():销毁一个嵌套锁。
  • 代码例子

    // 简单锁
    omp_lock_t lock;
    omp_init_lock(&lock);
    
    #pragma omp parallel
    {
        omp_set_lock(&lock);
        // 访问共享资源
        omp_unset_lock(&lock);
    }
    
    omp_destroy_lock(&lock);
    
    // 嵌套锁
    omp_nest_lock_t nlock;
    omp_init_nest_lock(&nlock);
    
    #pragma omp parallel
    {
        omp_set_nest_lock(&nlock);
        // 访问共享资源
        omp_unset_nest_lock(&nlock);
    }
    
    omp_destroy_nest_lock(&nlock);
    

2.3 数据环境

OpenMP 提供了多种子句来控制变量的可见性和作用域,包括 privatefirstprivatelastprivatereduction 等。

注意事项:

  • 循环指标变量是私有的
  • default(shared)是默认行为
  • 一个 parallel 指令只能能被一个 default 子句修饰。
  • 栈变量是指在函数内部声明的局部变量,它们存储在栈上。每个线程都有自己的栈,因此每个线程都有自己的栈变量副本,它们之间不会相互影响。
  1. default:指定并行区域内变量的默认共享属性。可以设置为 sharednoneprivate
    • shared:所有变量默认是共享的。
    • none:所有变量必须显式指定共享属性,否则编译器会报错。
    • private:所有变量默认是私有的。
  2. private:指定每个线程都有自己的变量副本。每个线程对私有变量的修改不会影响其他线程。
  3. firstprivate:将变量的初始值复制到每个线程的私有副本中。每个线程在并行区域开始时都有相同的始值。
  4. lastprivate:将最后一个线程的私有副本的值复制回主线程的共享变量中。通常与并行循环结合使用。
  5. reduction:指定一个归约操作,将多个线程的结果合并为一个。常用于求和、求积、求最大值和最小值等操作。

2.4 库函数&环境变量

  • 库函数

    • 线程控制函数 这些函数用于设置和获取线程的数量和状态。

      • omp_set_num_threads(int num_threads):设置并行区域使用的线程数。
      • omp_get_num_threads():获取当前并行区域中的线程数。
      • omp_get_thread_num():获取当前线程的线程号。
      • omp_get_max_threads():获取并行区域中可用的最大线程数。
      • omp_in_parallel():判断当前是否在并行区域内。
    • 动态线程调整函数 这些函数用于控制 OpenMP 运行时系统是否可以动态调整线程数。

      • omp_set_dynamic(int dynamic_threads):设置是否允许动态调整线程数。
      • omp_get_dynamic():获取当前动态调整线程数的设置。
    • 处理器数量函数 这些函数用于获取可用处理器的数量。

      • omp_num_procs():获取可用处理器的数量。
  • 环境变量 OpenMP 提供了一些环境变量,用于控制并行程序的行为。

    • OMP_NUM_THREADS:设置默认的线程数。

      • 示例:export OMP_NUM_THREADS=4
    • OMP_STACKSIZE:控制子线程的栈大小。

      • 示例:export OMP_STACKSIZE=64M
    • OMP_WAIT_POLICY:提示运行时如何处理空闲线程。

      • ACTIVE:在屏障/锁处保持线程活跃。
      • PASSIVE:在屏障/锁处尝试释放处理器。
      • 示例:export OMP_WAIT_POLICY=PASSIVE
    • OMP_PROC_BIND:控制线程是否绑定到处理器。

      • true:运行时不会在处理器之间移动线程。
      • false:运行时可以在处理器之间移动线程。
      • 示例:export OMP_PROC_BIND=true

99. quiz

1. openmp 和 std 的多线程性能比较

特性 OpenMP std::thread
易用性 高(通过 #pragma 指令简化并行化) 中(需要手动管理线程创建、同步和协调)
性能优化 自动优化,支持多种调度策略(负载均衡等) 手动优化,灵活性高但不提供自动调度
线程管理 自动线程池和调度 程序员手动管理线程的创建、销毁和同步
负载均衡 内建的负载均衡机制,适合简单并行任务 需要手动实现任务调度和负载均衡
线程开销 低,自动管理线程池,避免频繁创建销毁线程 较高,频繁创建和销毁线程会有较大开销
灵活性 较低,限制较多,只适用于特定类型的并行任务 高,适用于复杂的并发控制和多线程调度
跨平台支持 广泛支持,尤其在 HPC 环境下表现优秀 跨平台一致,标准库支持
适用场景 大规模数据并行处理(数值计算、科学计算等) 需要精细控制的复杂并发应用(线程池、任务调度等)

hpc: high-performance-calculating

  • 如果你的任务主要是计算密集型,并且能够轻松地并行化(例如矩阵计算、循环并行等),OpenMP 通常会提供更好的性能和更简洁的代码。
  • 如果你的任务需要更细粒度的控制、复杂的控制逻辑,可以使用 std::多线程方式,能够有更好的控制能力。
  • OpenMP 相比手动多线程/线程池的主要优势在于其更接近底层的自动优化机制和内建的负载均衡机制。但 c++是零抽象代价语言,编译器优化是针对更广泛场景的,如果应用场景特殊,手动实现的线程池确实可能会更快。但一般没有进行特殊优化的情况,openmp 总能比多线程快,但这个提升是相对固定的,且储存在边际效应,不会随着任务开销/复杂读变大而又进一步提升性能。

然而,OpenMP 并不会直接对任务做优化处理,因此一些 OpenMP 相较于手动调用多线程的提升是固定的,不会随着任务变复杂而增加。开发人员在使用 OpenMP 时,仍需考虑任务的复杂性和并行化的粒度,以充分发挥 OpenMP 的优势。

2. taskLoopparallel for的区别是什么?

parallel for

  • 直接将循环迭代分配给线程,每个线程执行一部分迭代。线程的分配是静态的。
  • 适用于工作量均匀的循环迭代。
  • 线程直接执行分配的迭代。

taskLoop

  • 将循环迭代分割成多个任务,这些任务被放入任务队列中,线程池中的任何线程都可以从队列中获取任务执行。任务的分配是动态的。
  • 适用于工作量不均匀的循环迭代,提供更灵活的任务调度。
  • 任务被放入任务队列中,线程从队列中获取任务执行。

3. teams的意义是什么?

  • teams 主要是为支持多计算单元的异构平台(例如 GPU)设计的。在这些平台上,单个计算单元可能不够用,或者硬件资源需要以更细粒度的方式进行调度和管理。
  • 在 GPU 中,一个线程团队通常映射到一个 计算单元(例如流处理器),而每个线程团队内部的线程数可以进一步调整。在 GPU 上使用 teams 有助于有效利用多个计算单元,并且能够根据硬件架构和资源进行灵活调度。
  • CPU 上的计算单元(即多个核心)通常不需要这样精细的管理,OpenMP 自己已经能够很好地利用 CPU 核心进行并行化。因此,CPU 上没有必要使用 teams 来进行线程团队的管理。
  • 在 CPU 上,OpenMP 默认的并行模型是通过 线程池 实现的,并且每个线程池通常会分配给多个核心。线程池中的线程会自动进行任务调度,通常我们只需要控制线程池的大小,而无需关心线程如何映射到 CPU 核心。
  • 在这种情况下,OpenMP 通过 parallel 指令和 for 循环来管理线程间的负载均衡和同步。对于大多数 CPU 核心数较少的场景,不需要进一步分解成多个线程团队。



    Enjoy Reading This Article?

    Here are some more articles you might like to read next:

  • (六)模板那些事儿:类型擦除
  • (五)模板那些事儿:模板元
  • (四)多线程那些事儿:并行库 tbb
  • (三)多线程那些事儿:怎么用好
  • (四)模板那些事儿:不定长参数