65.9K
CodeProject 正在变化。 阅读更多。
Home

加速器和并行编程入门

starIconstarIconstarIconstarIconstarIcon

5.00/5 (3投票s)

2022年12月12日

CPOL

7分钟阅读

viewsIcon

9597

这是我系列文章的第一篇,我将讨论并行编程以及在现代加速器上实现并行编程的各种方法。

计算机是实现目标的手段。它们使我们能够更快地解决复杂问题,提供跨全球存储和检索信息的能力,为机器人、自动驾驶汽车(某种程度上)和人工智能等卓越技术提供支撑,并有望提升地球上每个人的生活。

随着需要解决的问题变得越来越复杂,计算机体系结构、编程语言和编程模型也在不断发展。这导致了硬件加速器和领域特定编程模型的增长。

加州大学伯克利分校的David Patterson教授(我大学时所有计算机体系结构书籍的作者)曾广泛讨论过领域特定体系结构和加速器。今天,当我们谈论硬件加速器时,我们经常指的是 GPU。然而,存在着无数不同类型的加速器,它们被设计用于解决各种问题——包括深度学习和人工智能——这些加速器利用专门设计的硬件来执行大规模矩阵运算,这是深度学习工作负载的核心。

此外,英特尔® 高级矢量扩展(Intel® AVX)和英特尔® 高级矩阵扩展(Intel® AMX)等技术也内置于传统 CPU 的硬件加速功能中。

随着新加速器的兴起,如何为其编程始终是一个挑战。目前大多数加速器都基于并行执行,因此需要某种形式的并行编程。

这是我系列文章的第一篇,我将讨论并行编程以及在现代加速器上实现并行编程的各种方法。

并行概述

并行编程是我们编写代码以在任何代码/算法中表达并行性,从而使其能在加速器或多个 CPU 上运行。那么,什么是并行性呢?

并行性是指程序的某些部分可以与程序的另一部分同时运行。通常,我们将并行性分为两类:任务并行和数据并行。

任务并行

任务并行是指多个函数可以同时独立执行。一个例子是准备派对。一个人可以去拿气球,另一个人可以去拿冰淇淋,第三个人可以去拿蛋糕。虽然没有这三样东西派对就不会完整,但只要这三个人能在派对开始前完成各自的任务并在正确的地方集合,他们就可以独立于其他人完成各自的工作。

如果我们将其映射到计算机程序,每个人就相当于某种计算硬件(CPU/GPU/FPGA),而拿取各种物品就是需要运行的任务。

数据并行

数据并行是指同一个函数可以独立地在几份相同的数据上执行。想象一下,我们上面的例子中的那个人去买冰淇淋,但另外四个人也想买冰淇淋。理论上,这五个人可以同时从同一个商店的冰箱里拿冰淇淋。

在这个例子中,我们再次用人来类比计算硬件,每个人负责去买冰淇淋。冰淇淋就是我们(美味的)数据类比。

这些例子很简单,但希望能起到启发作用。在计算机体系结构领域存在更微妙的并行类型,但这些简单的类比或许有助于您在进行并行编程时理解如何思考并行性。

可用资源、可用并行性与争用

在并行性方面需要考虑的一些重要事项是可用资源和给定解决方案空间中的可用并行性。

  • 我们可能受限于完成任务的资源数量——如果我只有三个人,一次最多只能做三项任务。
  • 我们可能受限于可用的任务——如果我的派对只需要气球和蛋糕,第三个人来执行任务对我没有帮助。
  • 我们可能受限于可用数据——如果我有五个人想买冰淇淋,但商店冰箱里只剩下三个冰淇淋,那么就有两个人无事可做。

另一个需要考虑的独立问题是争用。资源通常是有限的,我们尝试并行化一个问题时经常会遇到资源访问问题。

让我们想象一下,我们去商店买冰淇淋,冰箱里有 100 个冰淇淋,但一次只能有三个人站在冰箱前。这意味着,即使有 100 个人在那里买冰淇淋,由于冰箱的访问权限有限,买冰淇淋的并行度最多也只有三个人。

这种类比适用于各种计算机硬件。一个例子:将冰箱访问类比为内存总线。如果内存总线不够宽,无法足够快地将所有数据传输到加速器,那么加速器就会卡住,等待访问数据才能执行其任务。

一个简单的代码示例

为了让这一点更具体,我们来展示一些关于前面提到的数据并行购物问题的代码。我们首先创建一个 shopper(购物者)类。该类的任务是执行购物工作。在这种情况下,我使用了一个朴素的矩阵乘法作为购物的成本。

#ifdef _WIN32
#include <Windows.h>
#else
#include <unistd.h>
#endif

#define DIMENSION 64

class Shopper {
public:
    Shopper(){}

    #pragma clang optimize off
    void Shop()
    {
        float a[DIMENSION][DIMENSION];
        float b[DIMENSION][DIMENSION];
        float mul[DIMENSION][DIMENSION];
        for (auto i = 0; i < DIMENSION; i++)
        {
            for (auto j = 0; j < DIMENSION; j++)
            {
                a[i][j] = 123.0f;
                b[i][j] = 456.0f;
                mul[i][j] = 0.0f;
            }
        }

        for (auto i = 0; i < DIMENSION; ++i)
        {
            for (auto j = 0; j < DIMENSION; ++j)
            {
                mul[i][j] = 0;
                for (auto k = 0; k < DIMENSION; ++k)
                {
                    mul[i][j] += a[i][k] * b[k][j];
                }
            }
        }
    }
};
shopper.hpp

使用 OpenMP® 实现并行

现在我定义了购物行为,让我们看看如何使用 OpenMP®(一种允许程序员为程序添加并行性的便携式解决方案)来并行运行此代码。通过将 pragma 指令添加到现有代码中,可以告诉编译器如何并行运行代码的某些部分。

#include <iostream>
#include <array>
#include <chrono>
#include "shopper.hpp"

#define SHOPPER_COUNT 65536

void Shop(std::array<Shopper, SHOPPER_COUNT> &shoppers) {
    #pragma omp parallel for
    for(int i = 0; i < SHOPPER_COUNT; ++i)
    {
        shoppers[i].Shop();
    }
}

int main()
{
    std::array<Shopper, SHOPPER_COUNT> shoppers;

    auto start = std::chrono::steady_clock::now();
    Shop(shoppers);    
    auto end = std::chrono::steady_clock::now();

    std::cout << "Elapsed time in milliseconds: "
        << std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count()
        << " ms" << std::endl;

    return 0;
}
grocery-omp.cpp

这段简单的代码由四个简单的部分组成:

  1. 第 18 行:创建 65536 个购物者
  2. 第 21 行:调用 Shop 函数
  3. 第 10-12 行:让每个购物者去购物
  4. 第 9 行:一个 pragma,告诉编译器在使用 OpenMP 时并行运行此循环的迭代

其余部分只是计时器,以便我们看到并行运行代码的好处。

OpenMP 代码的优点在于,您可以告诉编译器使用/不使用 OpenMP,从而生成串行程序。

在本篇文章中,我使用的是一台 HP Envy* 笔记本电脑,该电脑配备了 Intel® Core™ i7–12700H 处理器、32GB 内存和集成的 Intel® Iris® Xe GPU。该系统运行的是 Ubuntu* 22.04、6.0 内核以及 Intel® oneAPI DPC++/C++ 编译器

首先,我使用此命令启用了 Intel 编译器环境

> /opt/intel/oneapi/setvars.sh

以下命令编译代码

> icx -lstdc++ grocery-omp.cpp -o serial-test
> icx -lstdc++ -fiopenmp grocery-omp.cpp -o omp-test

最后一个命令包括 -fiopenmp 标志,告诉编译器使用 OpenMP 启用并行性。运行可执行文件,我在我的系统上获得了以下输出:

> ./serial-test
Elapsed time in milliseconds: 27361 ms
> ./omp-test
Elapsed time in milliseconds: 4002 ms

OpenMP 运行使用了我的多个 CPU 核心同时进行工作,从而实现了 6-7 倍的加速。

使用 oneAPI/SYCL 实现并行

OpenMP 是通过指令方法实现并行性的绝佳方式。SYCL(oneAPI 规范的一部分)与 C++ 结合,使我们能够通过显式方法表达并行性。

#include <CL/sycl.hpp>
#include <iostream>
#include <array>
#include <chrono>
#include "shopper.hpp"

#define SHOPPER_COUNT 65536

void Shop(sycl::queue &q, std::array<Shopper, SHOPPER_COUNT> &shoppers) {
    sycl::range<1> num_items{SHOPPER_COUNT};
    sycl::buffer buf(shoppers.data(), num_items);

    // Submit a command group to the queue by a lambda function that contains the
    // data access permission and device computation (kernel).
    q.submit([&](sycl::handler &h) {
        // The accessor is used to store (with write permission) the data.
        sycl::accessor item(buf, h, sycl::write_only, sycl::no_init);

        // Use parallel_for to run vector addition in parallel on device. This
        // executes the kernel.
        //    1st parameter is the number of work items.
        //    2nd parameter is the kernel, a lambda that specifies what to do per
        //    work item. The parameter of the lambda is the work item id.
        // DPC++ supports unnamed lambda kernel by default.
        h.parallel_for(num_items, [=](auto i) { item[i].Shop(); });
    });
}

int main()
{
    std::array<Shopper, SHOPPER_COUNT> shoppers;

    // The default device selector will select the most performant device.
    sycl::default_selector d_selector;

    auto start = std::chrono::steady_clock::now();

    try {
        sycl::queue q(d_selector);

        // Print out the device information used for the kernel code.
        std::cout << "Running on device: "
                << q.get_device().get_info<sycl::info::device::name>() << "\n";

        // Go shopping using SYCL
        Shop(q, shoppers);
    } catch (std::exception const &e) {
        std::cout << "An exception was caught when shopping.\n";
        std::terminate();
    }
    
    auto end = std::chrono::steady_clock::now();

    std::cout << "Elapsed time in milliseconds: "
        << std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count()
        << " ms" << std::endl;

    return 0;
}
grocery-sycl.cpp

在此示例中,SYCL 代码比之前的代码库要长。但是,我们有相同的核心代码:

  1. 第 31 行:创建 65536 个购物者
  2. 第 46 行:调用 Shop 函数
  3. 第 25 行:让每个购物者去购物

与 OpenMP 示例使用指令不同,SYCL 允许用户通过代码和 C++ 结构显式定义程序的并行行为。这提供了 OpenMP 所不具备的运行时灵活性。

根据您的用例,一种范例可能比另一种更有意义。在本例中,我们有一种方法可以通过 Intel 的 oneAPI SYCL 运行时和 SYCL_DEVICE_FILTER 环境变量以多种方式运行单个二进制文件。

> SYCL_DEVICE_FILTER=host:host:0 ./sycl-test
Running on device: SYCL host device
Elapsed time in milliseconds: 27201 ms
> SYCL_DEVICE_FILTER=opencl:cpu:1 ./sycl-test
Running on device: 12th Gen Intel(R) Core(TM) i7-12700H
Elapsed time in milliseconds: 4197 ms
> SYCL_DEVICE_FILTER=opencl:gpu:3 ./sycl-test
Running on device: Intel(R) Graphics [0x46a6]
Elapsed time in milliseconds: 3988 ms

SYCL_DEVICE_FILTER 的值来自运行 sycl-ls 命令,该命令是 Intel® oneAPI Base Toolkit 的一部分。

C++ with SYCL 实现的一个很酷之处在于,该二进制文件可以将工作定向到多个设备。

结论

理解并行性并编写并行代码是一个复杂的问题。本入门指南描述了一个简单的示例,说明如何使用 OpenMP 和C++ with SYCL 实现并行。在创建并行程序时,还有许多其他需要考虑的因素,例如多个计算资源如何共享内存和变量,以及如何跨多个资源正确地平衡工作。

我将在未来的文章中讨论这些问题。但现在,我鼓励您尝试获取上面的简单示例,看看它在您的系统上是如何工作的。

感谢阅读!

相关内容

技术文章与博客

点播网络研讨会

获取软件

Intel® oneAPI Base Toolkit
使用这套核心工具和库开始构建跨不同架构的高性能、以数据为中心的应用程序。

立即获取

查看所有工具

© . All rights reserved.