Open MPI [GFB+04] 是消息传递接口 (MPI) 标准的开源软件实现。在理解 Open MPI 的架构和内部机制之前,必须先讨论 MPI 标准的一些背景知识。
MPI 标准由MPI 论坛创建和维护,这是一个开放的组织,由来自工业界和学术界的并行计算专家组成。MPI 定义了一个 API,用于一种特定类型的可移植、高性能进程间通信 (IPC):消息传递。具体来说,MPI 文档描述了 MPI 进程之间可靠的离散类型消息传递。虽然“MPI 进程”的定义在给定平台上可能会有不同的解释,但它通常对应于操作系统的进程概念(例如,POSIX 进程)。MPI 专门旨在作为中间件实现,这意味着上层应用程序调用 MPI 函数来执行消息传递。
MPI 定义了一个高级 API,这意味着它抽象了实际用于在进程之间传递消息的底层传输。其理念是,发送进程 X 可以有效地发出“将这个包含 1,073 个双精度值的数组发送给进程 Y”的指令。相应的接收进程 Y 可以有效地发出“从进程 X 接收包含 1,073 个双精度值的数组”的指令。然后就会发生奇迹,包含 1,073 个双精度值的数组就会到达 Y 的等待缓冲区。
请注意,在这个交换过程中缺少什么:没有连接的概念,没有要解释的字节流,也没有交换的网络地址。MPI 将所有这些都抽象掉了,不仅是为了隐藏这些复杂性,不影响上层应用程序,而且是为了使应用程序能够在不同的环境和底层消息传递传输之间移植。具体来说,正确的 MPI 应用程序在各种平台和网络类型之间是源代码兼容的。
MPI 不仅定义了点对点通信(例如,发送和接收),还定义了其他通信模式,例如集体通信。集体操作是指多个进程参与单个通信操作。例如,可靠的广播,是指一个进程在操作开始时拥有一个消息,在操作结束时,组中的所有进程都拥有该消息。MPI 还定义了其他概念和通信模式,这里没有介绍。(截至撰写本文时,MPI 标准的最新版本是 MPI-2.2 [For09]。即将发布的 MPI-3 标准的草案版本已发布;它最早可能在 2012 年底完成。)
MPI 标准有很多实现,支持各种平台、操作系统和网络类型。一些实现是开源的,一些是闭源的。正如其名称所暗示的,Open MPI 是开源实现之一。典型的 MPI 传输网络包括(但不限于):以太网上的各种协议(例如,TCP、iWARP、UDP、原始以太网帧等)、共享内存和 InfiniBand。
MPI 实现通常用于所谓的“高性能计算”(HPC) 环境。MPI 本质上为模拟代码、计算算法和其他“大数运算”类型的应用程序提供 IPC。这些代码运行的输入数据集通常代表了单台服务器无法处理的计算工作量;MPI 作业会分散在数十、数百甚至数千台服务器上,这些服务器协同工作以解决一个计算问题。
也就是说,使用 MPI 的应用程序本质上是并行的,而且计算密集型。所有 MPI 作业中的处理器核心都以 100% 的利用率运行并不罕见。需要说明的是,MPI 作业通常在专用环境中运行,在这些环境中,MPI 进程是机器上运行的唯一应用程序(当然,除了最基本的操作系统功能)。
因此,MPI 实现通常侧重于提供极高的性能,由以下指标衡量
MPI 标准的第一个版本 MPI-1.0 于 1994 年发布 [Mes93]。MPI-2.0 是在 MPI-1 基础上添加的一组功能,于 1996 年完成 [GGHL+96]。
在 MPI-1 发布后的第一个十年中,出现了各种各样的 MPI 实现。许多实现是由供应商为其专有网络互连提供的。许多其他实现来自研究和学术界。这些实现通常是“研究质量”,这意味着它们的目标是研究各种高性能网络概念,并提供其工作的概念证明。然而,其中一些实现质量很高,因此获得了普及,并吸引了许多用户。
Open MPI 代表了四个研究/学术开源 MPI 实现的结合:LAM/MPI、LA/MPI(洛斯阿拉莫斯 MPI)和 FT-MPI(容错 MPI)。PACX-MPI 团队的成员在 Open MPI 诞生后不久就加入了 Open MPI 小组。
当我们意识到,除了优化和功能方面的细微差别之外,我们的软件代码库非常相似时,这四个开发团队的成员决定合作。这四个代码库都有自己的优点和缺点,但总的来说,它们或多或少地做了相同的事情。那么,为什么要竞争呢?为什么不集中我们的资源,一起工作,创造一个更好的 MPI 实现呢?
经过多次讨论,我们决定放弃现有的四个代码库,只保留以前项目中的最佳想法。这一决定主要基于以下前提
因此,Open MPI 诞生了。它的第一个 Subversion 提交是在 2003 年 11 月 22 日进行的。
由于各种原因(主要与性能或可移植性相关),C 和 C++ 是主要实现语言的唯一两个选择。最终放弃了 C++,因为不同的 C++ 编译器往往会根据不同的优化算法在内存中布局结构体/类,导致不同的网络表示。因此,C 被选为主要实现语言,这影响了几个架构设计决策。
在启动 Open MPI 时,我们知道它将是一个庞大而复杂的代码库
因此,我们花了很多时间设计架构,重点关注三件事
Open MPI 有三个主要的抽象层,如图 15.1所示
OPAL 还提供了 Open MPI 在不同操作系统之间的核心可移植性,例如发现 IP 接口、在同一服务器上的进程之间共享内存、处理器和内存亲和性、高精度计时器等。
在没有或几乎没有分布式计算支持的简单环境中,ORTE 使用 rsh
或 ssh
来启动并行作业中的各个进程。更高级的、专用于 HPC 的环境通常具有调度程序和资源管理器,用于在许多用户之间公平地共享计算资源。这些环境通常提供专门的 API 来启动和管理计算服务器上的进程。ORTE 支持各种这样的管理环境,例如(但不限于):Torque/PBS Pro、SLURM、Oracle Grid Engine 和 LSF。
由于可移植性是一个主要要求,因此 MPI 层支持各种网络类型和底层协议。一些网络在底层特性和抽象方面是相似的;一些则不是。
尽管每个抽象都构建在它下面的抽象之上,但出于性能原因,ORTE 和 OMPI 层可以在需要时绕过底层抽象层,直接与操作系统和/或硬件进行交互(如图 15.1所示)。例如,OMPI 层使用操作系统绕过方法与某些类型的 NIC 硬件通信,以获得最大的网络性能。
每一层都构建成一个独立的库。ORTE 库依赖于 OPAL 库;OMPI 库依赖于 ORTE 库。将各层分离成各自的库,成为防止抽象违规的有力工具。具体来说,如果某一层错误地尝试使用更高层中的符号,应用程序将无法链接。多年来,这种抽象强制机制帮助许多开发人员避免了无意中模糊三层之间的界限。
虽然 Open MPI 合作的最初成员共享着类似的核心目标(生成一个可移植的高性能 MPI 标准实现),但我们的组织背景、观点和议程却截然不同,现在仍然如此。因此,我们花费了大量时间来设计一种架构,让我们能够保持差异性,同时又共享一个共同的代码库。
运行时可加载的组件是一个自然的选择(也称为动态共享对象或“DSO”或“插件”)。组件强制执行通用 API,但对该 API 的实现几乎没有限制。具体来说:相同接口行为可以以多种不同的方式实现。然后,用户可以在运行时选择使用哪些插件。这甚至允许第三方独立开发和发布他们自己的 Open MPI 插件,而无需依赖于核心 Open MPI 软件包。允许任意扩展性是一种非常自由的策略,无论是在 Open MPI 开发人员的直接范围内,还是在更大的 Open MPI 社区中都是如此。
这种运行时灵活性是 Open MPI 设计理念的关键组成部分,并且深深地融入整个架构之中。例如:Open MPI v1.5 系列包含 155 个插件。仅举几个例子,有用于不同memcpy()
实现的插件,用于在远程服务器上启动进程的插件,以及用于在不同类型的底层网络上进行通信的插件。
使用插件的主要优势之一是,多个开发人员群体可以自由地尝试不同的实现,而不会影响 Open MPI 的核心。这在 Open MPI 项目的早期阶段是一个关键特性。有时开发人员并不总是知道实现某个功能的正确方法,或者有时他们只是意见不一致。在这两种情况下,每个团队都会在组件中实现他们的解决方案,允许其他开发人员社区轻松地比较和对比。代码比较当然也可以在没有组件的情况下完成,但组件概念有助于保证所有实现都暴露完全相同的外部 API,因此提供完全相同的必要语义。
由于组件概念提供的灵活性,它在 Open MPI 的所有三层中都得到了广泛的应用;在每一层中都有许多不同类型的组件。每种类型的组件都被包含在一个框架中。一个组件属于且仅属于一个框架,而一个框架仅支持一种组件。 图 15.2 是 Open MPI 架构布局的模板;它展示了 Open MPI 的一些框架及其包含的一些组件。(Open MPI 的其余框架和组件以相同的方式布局。)Open MPI 的层、框架和组件集合被称为模块化组件架构 (MCA)。
btl
和 coll
位于 OMPI 层,plm
位于 ORTE 层,timer
位于 OPAL 层。最后,使用框架和组件的另一个主要优势是它们的固有可组合性。Open MPI v1.5 中拥有超过 40 个框架,赋予用户混合搭配不同类型的插件的能力,使他们能够创建有效地针对其个人系统定制的软件栈。
每个框架在其在 Open MPI 源代码树中的相应子目录中完全自包含。子目录的名称与框架的名称相同;例如,memory
框架位于 memory
目录中。框架目录至少包含以下三个项目
<framework>.h
的头文件将位于顶层框架目录中(例如,内存框架包含 memory/memory.h
)。这个众所周知的头文件定义了框架中的每个组件必须支持的接口。该头文件包含接口函数的函数指针类型定义、用于封送这些函数指针的结构体以及任何其他必要的类型、属性字段、宏、声明等。base
子目录包含提供框架核心功能的粘合代码。例如,memory
框架的基础目录是 memory/base
。基础代码通常由一些逻辑上的基础工作组成,例如在运行时查找和打开组件、多个组件可能利用的通用实用功能等。memory/posix
子目录包含内存框架中的 POSIX 组件)。类似于每个框架定义其组件必须遵守的接口,框架还定义了其他操作方面,例如它们如何引导自身、如何选择要使用的组件以及如何关闭。框架在设置方面存在差异的两个常见示例是多对多框架与一对多框架,以及静态框架与动态框架。
多对多框架。
有些框架的功能可以在同一个进程中以多种不同的方式实现。例如,Open MPI 的点对点网络框架将加载多个驱动程序插件,以允许单个进程在多个网络类型上发送和接收消息。
此类框架通常会打开他们能找到的所有组件,然后查询每个组件,有效地询问:“您要运行吗?”组件通过检查运行它们的系统来确定它们是否要运行。例如,一个点对点网络组件将查看其支持的网络类型是否在系统上可用且处于活动状态。如果不是,组件将回复“不,我不想要运行”,导致框架关闭并卸载该组件。如果该网络类型可用,组件将回复“是,我要运行”,导致框架保持组件处于打开状态以供进一步使用。
一对多框架。
其他框架提供了一些功能,对于这些功能来说,在运行时拥有多个实现是不合理的。例如,创建并行作业的一致检查点(意味着作业实际上被“冻结”并且可以任意恢复)必须使用相同的后端检查点系统在作业中的每个进程上执行。与所需后端检查点系统接口的插件是每个进程中必须加载的唯一检查点插件——所有其他插件都是不必要的。
动态框架。
大多数框架允许其组件通过 DSO 在运行时加载。这是查找和加载组件最灵活的方法;它允许诸如显式不加载某些组件、加载未包含在主线 Open MPI 发行版中的第三方组件等功能。
静态框架。
一些一对多框架具有额外的约束,迫使它们唯一的组件在编译时(而不是运行时)被选中。静态链接一对多组件允许直接调用其成员函数(而不是通过函数指针调用),这在高度性能敏感的功能中可能很重要。一个例子是memcpy
框架,它提供了针对平台优化的 memcpy()
实现。
此外,一些框架提供可能需要在 Open MPI 完全初始化之前使用的功能。例如,使用某些网络栈需要复杂的内存注册模型,而这反过来又需要替换 C 库的默认内存管理例程。由于内存管理是整个进程的固有特性,替换默认方案只能在main
之前完成。因此,此类组件必须静态链接到 Open MPI 进程中,以便它们可以在main
之前的钩子中使用,远早于 MPI 甚至被初始化之前。
Open MPI 插件分为两部分:一个组件结构体和一个模块结构体。组件结构体及其引用的函数通常统称为“组件”。类似地,“模块”统称为模块结构体及其函数。这种划分有点类似于 C++ 类和对象。每个进程只有一个组件;它描述了整个插件,其中包含一些对所有组件(无论框架如何)都通用的字段。如果组件选择运行,它将用于生成一个或多个模块,这些模块通常执行框架所需的大部分功能。
在接下来的几节中,我们将构建 BTL(字节传输层)框架中 TCP 组件所需的结构。BTL 框架影响点对点消息传输;毫不奇怪,TCP 组件使用 TCP 作为其消息传递的底层传输。
组件结构体。
无论框架如何,每个组件都包含一个众所周知、静态分配和初始化的组件结构体。该结构体的名称必须符合模板mca_<framework>_<component>_component
。例如,BTL 框架中 TCP 网络驱动程序组件的结构体名为 mca_btl_tcp_component
。
具有模板化的组件符号既可以保证组件之间不会发生名称冲突,又可以使 MCA 核心通过dlsym(2)
(或每个受支持的操作系统中的适当等效项)查找任何任意的组件结构体。
基本组件结构体包含一些逻辑信息,例如组件的正式名称、版本、框架版本符合性等。这些数据用于调试、清单列表以及运行时合规性和兼容性检查。
struct mca_base_component_2_0_0_t { /* Component struct version number */ int mca_major_version, mca_minor_version, mca_release_version; /* The string name of the framework that this component belongs to, and the framework's API version that this component adheres to */ char mca_type_name[MCA_BASE_MAX_TYPE_NAME_LEN + 1]; int mca_type_major_version, mca_type_minor_version, mca_type_release_version; /* This component's name and version number */ char mca_component_name[MCA_BASE_MAX_COMPONENT_NAME_LEN + 1]; int mca_component_major_version, mca_component_minor_version, mca_component_release_version; /* Function pointers */ mca_base_open_component_1_0_0_fn_t mca_open_component; mca_base_close_component_1_0_0_fn_t mca_close_component; mca_base_query_component_2_0_0_fn_t mca_query_component; mca_base_register_component_params_2_0_0_fn_t mca_register_component_params; };
基本组件结构体是 TCP BTL 组件的核心;它包含以下函数指针
NULL
打开函数指针。TCP BTL 组件的打开函数主要初始化一些数据结构,并确保用户没有设置无效的参数。
TCP BTL 组件的关闭函数关闭侦听套接字并释放资源(例如,接收缓冲区)。
BTL 框架不使用通用的查询函数(它定义了自己的函数;见下文),因此 TCP BTL 不会填充它。
TCP BTL 组件注册函数创建了各种用户可设置的运行时参数,例如允许用户指定要使用的 IP 接口的那些参数。
组件结构也可以在每个框架和/或每个组件的基础上扩展。框架通常会创建一个新的组件结构,其中组件基本结构是第一个成员。这种嵌套允许框架添加自己的属性和函数指针。例如,一个需要更专门的查询函数(与基本组件提供的查询函数相比)的框架可以在其框架特定的组件结构中添加一个函数指针。
提供点对点 MPI 消息传递功能的 MPI btl
框架使用了这种技术。
struct mca_btl_base_component_2_0_0_t { /* Base component struct */ mca_base_component_t btl_version; /* Base component data block */ mca_base_component_data_t btl_data; /* btl-framework specific query functions */ mca_btl_base_component_init_fn_t btl_init; mca_btl_base_component_progress_fn_t btl_progress; };
以 TCP BTL 框架查询函数为例,TCP BTL 组件 btl_init
函数执行了以下几项操作:
(IP 地址,端口)
注册到一个中央存储库,以便其他 MPI 进程知道如何联系它。类似地,插件可以使用自己的成员来扩展框架特定的组件结构。btl
框架中的 tcp
组件就是这么做的;它在自己的组件结构中缓存了许多数据成员。
struct mca_btl_tcp_component_t { /* btl framework-specific component struct */ mca_btl_base_component_2_0_0_t super; /* Some of the TCP BTL component's specific data members */ /* Number of TCP interfaces on this server */ uint32_t tcp_addr_count; /* IPv4 listening socket descriptor */ int tcp_listen_sd; /* ...and many more not shown here */ };
这种结构嵌套技术实际上是对 C++ 单继承的简单模拟:指向 struct mca_btl_tcp_component_t
实例的指针可以转换为三种类型中的任何一种,以便它可以被不了解“派生”类型的抽象层使用。
话虽如此,在 Open MPI 中通常不鼓励强制转换,因为它会导致非常微妙、难以发现的错误。这种 C++ 模拟技术除外,因为它具有明确定义的行为,有助于强制执行抽象屏障。
模块结构。
模块结构由每个框架单独定义;它们之间几乎没有共同点。根据框架的不同,组件会生成一个或多个模块结构实例来表明它们希望被使用。
例如,在 BTL 框架中,一个模块通常对应于一个网络设备。如果一个 MPI 进程运行在具有三个“上行”以太网设备的 Linux 服务器上,则 TCP BTL 组件将生成三个 TCP BTL 模块;每个模块对应于每个 Linux 以太网设备。然后,每个模块将完全负责所有发送和接收操作,这些操作都与其以太网设备相关联。
将所有内容整合在一起。
图 15.3 显示了 TCP BTL 组件中结构的嵌套方式,以及它如何为三个以太网设备中的每一个生成一个模块。
以这种方式组合 BTL 模块允许上层 MPI 进程引擎以平等的方式对待所有网络设备,并执行用户级通道绑定。
例如,考虑在上面描述的三个设备配置中发送一条大型消息。假设三个以太网设备中的每一个都可以用于到达预期的接收者(可达性由 TCP 网络和网络掩码决定,以及一些定义明确的启发式方法)。在这种情况下,发送方将把大型消息分成多个片段。每个片段将以循环的方式分配给一个 TCP BTL 模块(因此每个模块将被分配大约三分之一的片段)。然后,每个模块通过其相应的以太网设备发送其片段。
这看起来可能是一个复杂的方案,但它出奇地有效。通过在多个 TCP BTL 模块之间对大型消息的发送进行流水线处理,典型的 HPC 环境(例如,每个以太网设备都在单独的 PCI 总线上)可以在多个以太网设备之间维持几乎最大的带宽速度。
开发人员在编写代码时经常会做出决定,例如:
用户往往认为,开发人员会以一种通常适合大多数系统类型的方式回答这些问题。但是,HPC 社区充满了希望积极调整其硬件和软件堆栈以榨取所有可能的计算周期的科学家和工程师高级用户。尽管这些用户通常不想修改其 MPI 实现的实际代码,但他们确实希望通过选择不同的内部算法、选择不同的资源消耗模式,或在不同情况下强制使用特定网络协议来进行调整。
因此,在设计 Open MPI 时,引入了 MCA 参数系统;该系统是一种灵活的机制,允许用户在运行时更改内部 Open MPI 参数值。具体而言,开发人员在整个 Open MPI 代码库中注册字符串和整数 MCA 参数,以及相关联的默认值和描述性字符串,这些字符串定义了参数是什么以及它是如何使用的。一般规则是,开发人员应该使用运行时可设置的 MCA 参数,而不是硬编码常量,从而允许高级用户调整运行时行为。
三个抽象层的基本代码中包含许多 MCA 参数,但 Open MPI 的大部分 MCA 参数位于各个组件中。例如,TCL BTL 插件有一个参数,指定应该使用 TCPv4 接口、TCPv6 接口还是两种类型的接口。或者,可以设置另一个 TCP BTL 参数来指定要使用的确切以太网设备。
用户可以通过用户级命令行工具 (ompi_info
) 发现可用的参数。可以以多种方式设置参数值:在命令行上、通过环境变量、通过 Windows 注册表或在系统级或用户级 INI 样式文件中。
MCA 参数系统补充了运行时插件选择灵活性的理念,并已被证明对用户非常有价值。尽管 Open MPI 开发人员努力为各种情况选择合理的默认值,但每个 HPC 环境都不同。不可避免地,在某些环境中,Open MPI 的默认参数值将不适合——甚至可能对性能有害。MCA 参数系统允许用户积极主动地调整 Open MPI 的行为,以适合其环境。这不仅减轻了许多上游变更或错误报告请求,还允许用户在参数空间中进行实验,以找到最适合其特定系统的配置。
由于 Open MPI 核心成员如此多样化,我们每个人不可避免地会学到一些东西,作为一个团队,我们会学到很多东西。以下列表描述了其中的一些教训。
消息传递性能和资源利用率是高性能计算的王者和王后。Open MPI 的设计初衷是能够在高性能的尖端运行:发送短消息的延迟极低,支持的网络上短消息注入速率极高,大型消息的带宽快速提升到最大值,等等。抽象很好(出于许多原因),但必须小心设计,以免妨碍性能。或者换句话说:仔细选择有助于建立浅层、高效的调用栈(而不是深层、功能丰富的 API 调用栈)的抽象。
话虽如此,我们也必须接受,在某些情况下,必须抛弃抽象——而不是架构。举个例子:Open MPI 为其一些最关键的性能操作手写了汇编代码,例如共享内存锁定和原子操作。
值得注意的是,图 15.1 和 15.2 显示了 Open MPI 的两种不同的架构视图。它们不代表高性能代码部分的运行时调用栈或调用调用分层。
经验教训
出于性能的考虑(例如,前面提到的汇编代码),拥有粗陋的、复杂的代码是可以接受的(尽管不可取),而且不幸的是有时是必要的。但是,始终更可取的是花时间弄清楚如何使用良好的抽象来离散化和隐藏尽可能多的复杂性。几周的设计可以节省数百或数千个开发人员在处理错综复杂、微妙的、意大利面条式代码上的维护时间。
我们积极尝试避免在 Open MPI 中重新发明其他人已经编写的代码(当这些代码与 Open MPI 的 BSD 许可证兼容时)。具体来说,我们毫不犹豫地直接重用或与其他人的代码进行接口。
在尝试解决高度复杂的工程问题时,没有“非我所创”的宗教可言;从逻辑上讲,只有在可能的情况下重用外部代码才是合理的。这种重用可以使开发人员专注于 Open MPI 独有的问题;重新解决别人已经解决过的问题毫无意义。
GNU Libtool Libltdl 包是这种代码重用的一个很好的例子。Libltdl 是一个小型库,它提供了一个便携式 API 用于打开 DSO 并查找其中的符号。Libltdl 在各种操作系统和环境中受支持,包括 Microsoft Windows。
Open MPI可以自己提供此功能——但为什么?Libltdl 是一款优秀的软件,正在积极维护,与 Open MPI 的许可证兼容,并且提供了所需的确切功能。鉴于这些因素,Open MPI 开发人员重新编写此功能没有现实意义的收益。
经验教训
当别处存在合适的解决方案时,不要犹豫,立即集成它,并停止浪费时间重新发明它。
另一个指导性架构原则一直是优化常见情况。例如,重点放在将许多操作分成两个阶段:设置和重复操作。假设设置可能很昂贵(即:缓慢)。所以只做一次,然后就过去了。针对更常见的情况进行优化:重复操作。
例如,malloc()
可能很慢,特别是如果需要从操作系统分配页面。因此,与其只为单个传入网络消息分配足够字节,不如分配足够的空间以容纳大量传入消息,将结果分成单个消息缓冲区,并设置一个空闲列表来维护它们。这样,第一次请求消息缓冲区可能很慢,但后续请求将快得多,因为它们只是从空闲列表中出队。
经验教训
将常见操作分成(至少)两个阶段:设置和重复操作。这样不仅可以提高代码性能,而且随着时间的推移,代码也更容易维护,因为不同的操作是分开的。
还有太多经验教训无法在此详细描述;以下是一些可以简要总结的额外教训:
如果我们必须列出从 Open MPI 项目中学习到的三件**最重要**的事情,我认为它们应该是: