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

Visual C++ 11 Beta并行循环基准测试

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.86/5 (30投票s)

2016年4月12日

Ms-PL

8分钟阅读

viewsIcon

65338

downloadIcon

1713

OpenMP、Parallel Patterns Library、Auto-Parallelizer 和 C++17 Parallel for_each 之间的基准测试

目录

引言

Visual Studio 11 Beta 于 2012 年 2 月发布,并于 2012 年 4 月更新。在其众多新功能中,如 ISO C++11 并发功能和 C++ AMP,最吸引人的是自动向量化器和自动并行化器。自动向量化器默认开启,通过同时执行多个迭代来为 for 循环生成 SIMD 代码,而自动并行化器(默认不开启)则使用多个线程并行运行 for 循环。MSDN 关于它们之间差异的说法如下。

自动向量化和自动并行化之间存在一些关键差异。自动向量化始终开启,无需用户交互,但自动并行化需要程序员决定要并行化哪些循环。此外,向量化提高了支持 SIMD 指令的单核 CPU 上循环的性能,而并行化则提高了多 CPU 和多核 CPU 上循环的性能。这两种功能可以协同工作,从而使向量化循环能够跨多个处理器进行并行化。

读者可能会问我为什么不写一篇关于自动向量化器的文章:我发现对于我正在进行的特定浮点计算,自动向量化代码仅比 VS2010 的非向量化代码提供了边际改进(约 10%,而不是 4 倍):在生成的汇编列表中可以看出代码确实被向量化了。而且手动向量化代码仅比自动向量化代码提供了约 10% 的改进。

在本文中,我们将简要介绍用于并行化 for 循环的 OpenMPParallel Patterns Library 和 Auto Parallelizer 方法。我们不讨论与 for 循环无关的功能。闲话少说,让我们开始我们的文章!

辉光效果

在我们的基准测试应用程序中,我们使用了 辉光效果,该效果足够密集以满足我们的目的。辉光效果在维基百科上描述为“辉光(有时称为光辉或发光)是一种计算机图形效果,用于视频游戏、演示和高动态范围渲染 (HDR) 中,以重现真实世界相机的成像伪像。”,并且通常使用图形着色器实现。辉光效果在黑暗场景和少数光线的照片上最漂亮。我只是从优秀的 WPF Shader Article 中移植了 Direct3D 辉光像素着色器。原始来源如下。

/// <class>BloomEffect</class>
/// <description>An effect that intensifies bright regions.</description>
//-----------------------------------------------------------------------------------------
// Shader constant register mappings (scalars - float, double, Point, Color, Point3D, etc.)
//-----------------------------------------------------------------------------------------

/// <summary>Intensity of the bloom image.</summary>
/// <minValue>0</minValue>
/// <maxValue>4</maxValue>
/// <defaultValue>1</defaultValue>
float BloomIntensity : register(C0);

/// <summary>Saturation of the bloom image.</summary>
/// <minValue>0</minValue>
/// <maxValue>10</maxValue>
/// <defaultValue>1</defaultValue>
float BloomSaturation : register(C1);

/// <summary>Intensity of the base image.</summary>
/// <minValue>0</minValue>
/// <maxValue>4</maxValue>
/// <defaultValue>1</defaultValue>
float BaseIntensity : register(C2);

/// <summary>Saturation of the base image.</summary>
/// <minValue>0</minValue>
/// <maxValue>10</maxValue>
/// <defaultValue>1</defaultValue>
float BaseSaturation : register(C3);

//--------------------------------------------------------------------------------------
// Sampler Inputs (Brushes, including ImplicitInput)
//--------------------------------------------------------------------------------------

sampler2D implicitInputSampler : register(S0);

//--------------------------------------------------------------------------------------
// Pixel Shader
//--------------------------------------------------------------------------------------
float3 AdjustSaturation(float3 color, float saturation)
{
    float grey = dot(color, float3(0.3, 0.59, 0.11));
    return lerp(grey, color.rgb, saturation);
}

float4 main(float2 uv : TEXCOORD) : COLOR
{
    float BloomThreshold = 0.25f;

    float4 color = tex2D(implicitInputSampler, uv);
    float3 base = color.rgb / color.a;
    float3 bloom = saturate((base - BloomThreshold) / (1 - BloomThreshold));
    
    // Adjust color saturation and intensity.
    bloom = AdjustSaturation(bloom, BloomSaturation) * BloomIntensity;
    base = AdjustSaturation(base, BaseSaturation) * BaseIntensity;
    
    // Darken down the base image in areas where there is a lot of bloom,
    // to prevent things looking excessively burned-out.
    base *= (1 - saturate(bloom));
    
    // Combine the two images.
    return float4((base + bloom) * color.a, color.a);
}

我不会在此处展示 C++ 源代码,因为 C++ 辉光源代码更冗长,因为 Direct3D 着色器对向量操作有更好的支持。float3float4 是向量浮点类型的示例。**注意:**此处所说的向量不是指 STL vector。为了编写 C++ 版本,我必须手动实现以下函数:点积dot)、saturate(将值限制在 0.0 到 1.0 之间)和 线性插值lerp)。Direct3D 着色器中的 lerp 函数实现为 x + s(y-x),其中 xy 是第一个和第二个向量,s 是用于插值的值。

color.rgb 需要一些解释;读者可能会难以理解 color.rgb 形式的混色语法。colorfloat4 类型的一个变量,它是一个包含 4 个 float 元素的向量。基本上,color.rgb 告诉着色器编译器获取 rgb 元素,以相同的 rgb 顺序形成一个新的 float3 变量(一个包含 3 个 float 元素的向量)。而 color.a 告诉编译器将 colora 元素放入一个标量 float 变量。

串行循环

这是我们将要并行化的 for 循环的参考源代码。需要注意的是,当我们并行执行 for 循环时,for 循环不再按顺序执行,并且我们不知道它可能会以何种顺序执行。因此,只有没有循环依赖的循环才能正确并行执行。为了获得良好的性能,迭代次数必须足够大,并且/或者每次迭代的工作量必须足够密集。然而,性能永远无法与可用 CPU 核心的比例相匹配,因为线程的创建和调度总是存在一些开销。

DWORD startTime = timeGetTime();

for(UINT row = 0; row < bitmapDataDest.Height; ++row)
{
    BloomEffect effect(m_fBloomIntensity, m_fBloomSaturation, 
                       m_fBaseIntensity, m_fBaseSaturation);
    for(UINT col = 0; col < bitmapDataDest.Width; ++col)
    {
        UINT index = row * stride + col;

        pixelsDest[index] = effect.ComputeBloomInt(pixelsSrc[index]);
    }
}

DWORD endTime = timeGetTime();

OpenMP 循环

OpenMP 2.0 自 8.0 版本以来一直存在于 Visual C++ 中。它也受其他 C++ 编译器支持。默认情况下,OpenMP 在 Visual Studio 中未开启。要开启 OpenMP,我们需要进入项目属性,在 **C/C++** 的 **语言** 部分,为 **Open MP 支持** 选择 **是 (/openmp)**。请注意:OpenMP 没有处理其循环中异常的机制。

DWORD startTime = timeGetTime();

#pragma omp parallel for
for(int row = 0; row < bitmapDataDest.Height; ++row)
{
    BloomEffect effect(m_fBloomIntensity, m_fBloomSaturation, 
                       m_fBaseIntensity, m_fBaseSaturation);
    for(UINT col = 0; col < bitmapDataDest.Width; ++col)
    {
        UINT index = row * stride + col;

        pixelsDest[index] = effect.ComputeBloomInt(pixelsSrc[index]);
    }
}

DWORD endTime = timeGetTime();

为了并行化 for 循环,我们在 for 循环前放置 #pragma omp parallel for,以指示 OpenMP 我们希望并行化该 for 循环。

Parallel Patterns Library 循环

Parallel Patterns Library (PPL) 是 Visual Studio 2010 中引入的一个基于任务的并发库。在使用 PPL 之前,我们必须包含其头文件 ppl.h 并使用其 Concurrency 命名空间。

#include <ppl.h>

DWORD startTime = timeGetTime();

using namespace Concurrency;
parallel_for((UINT)0, bitmapDataDest.Height, [&](UINT row)
{
    BloomEffect effect(m_fBloomIntensity, m_fBloomSaturation, 
                       m_fBaseIntensity, m_fBaseSaturation);
    for(UINT col = 0; col < bitmapDataDest.Width; ++col)
    {
        UINT index = row * stride + col;

        pixelsDest[index] = effect.ComputeBloomInt(pixelsSrc[index]);
    }
});

DWORD endTime = timeGetTime();

第一个参数是索引的起始值,第二个参数是迭代的最大值加一。第三个参数是一个用于每次迭代执行的 lambda。[&] 表示我们按引用捕获 lambda 中使用的变量。有关 C++ lambda 的更多信息,读者可以参考此 链接。读者应该注意到调用 lambda 的操作有一些开销。对于下面的示例,PPL 的性能将远远落后于 OpenMP 和 Auto Parallelizer **以及串行代码**,因为 lambda 调用比加法操作更昂贵。

using namespace Concurrency;
parallel_for(0, 1000000, [&](UINT i)
{
    c[i] = a[i] + b[i];
});

自动并行器循环

Auto Parallelizer 设计用于与 Auto Vectorizer 协同工作,这意味着 Auto Parallelizer 可以并行化已经被自动向量化的循环。对于已经自动向量化的循环,用户可以期待性能提高 4 倍,因为在一个迭代中,4 个元素是使用 SSE 执行的。结合 Auto Parallelizer(假设在 4 核机器上运行),理论性能可以达到 16 倍!

Visual Studio 11 Beta 中的 Auto Parallelizer 默认不开启。要开启 Auto Parallelizer,我们需要进入项目属性,在 **C/C++** 的 **代码生成** 部分,为 **启用并行代码生成** 选择 **是 (/Qpar)**。要为 for 循环启用 Auto Parallelizer,我们添加一个形式为 #pragma loop(hint_parallel(N)) 的 pragma,其中 N 是一个字面量常量。如果用户尝试用一个基于运行时检测到的 CPU 核心数量的整数常量变量替换它,编译器将抱怨 hint_parallel 必须是字面量常量。

DWORD startTime = timeGetTime();

#pragma loop(hint_parallel(8))
for(UINT row = 0; row < bitmapDataDest.Height; ++row)
{
    BloomEffect effect(m_fBloomIntensity, m_fBloomSaturation, 
                       m_fBaseIntensity, m_fBaseSaturation);
    for(UINT col = 0; col < bitmapDataDest.Width; ++col)
    {
        UINT index = row * stride + col;

        pixelsDest[index] = effect.ComputeBloomInt(pixelsSrc[index]);
    }
}

DWORD endTime = timeGetTime();

Auto Parallelizer 在基准测试应用程序中似乎不起作用。这就是为什么我附上了一个控制台项目来展示 Auto Parallelizer 的工作原理。我在 Microsoft Connect 上报告了此 bug。控制台应用程序使用了数组,而 MFC 应用程序使用了数组的别名指针。我的猜测是它可能不起作用是因为使用了数组的别名指针,而不是数组。

Parallel for_each 循环

C++17 为标准库添加了并行算法支持,以帮助程序利用并行执行来提高性能。在下面的 GUI 基准测试中,为了给 std::beginstd::end 参数赋值,需要构造和初始化一个 vector。我没有将 vector 的构造包含在整体计时中。在 VS2017 15.8 版本中,其他并行方法的原始基准测试代码与 VS2012 中的代码保持不变。

// Construct a vector here
std::vector<UINT> vec_cnt(bitmapDataDest.Height);
std::iota(std::begin(vec_cnt), std::end(vec_cnt), 0);

DWORD startTime = timeGetTime();

std::for_each(std::execution::par, std::begin(vec_cnt), std::end(vec_cnt), [&](UINT row)
{
    BloomEffect effect(m_fBloomIntensity, m_fBloomSaturation, 
                       m_fBaseIntensity, m_fBaseSaturation);
    for (UINT col = 0; col < bitmapDataDest.Width; ++col)
    {
        UINT index = row * stride + col;

        pixelsDest[index] = effect.ComputeBloomInt(pixelsSrc[index]);
    }
});

DWORD endTime = timeGetTime();

控制台基准测试结果

基准测试在 8 核超线程 Intel i7 870(运行频率 2.93GHz)上进行。由于 i7 870 是支持超线程的 CPU,它实际上有 4 个真实核心(如果 CPU 支持超线程,我们将核心数除以 2)。以下是在运行发布模式且系统上没有运行其他密集型程序时的基准测试结果。

Serial : 72ms
OpenMP : 16ms
PPL    : 12ms
AutoP  : 62ms

读者可能希望在运行他们常用的程序时运行基准测试,以查看在他们的用户将使用的真实生活条件下,他们的程序表现如何。

**更新后的基准测试** 在另一台机器上重新进行:该 CPU 是 12 核超线程 CoffeeLake Intel i7 8700(运行频率 3.20GHz)。它有 6 个物理核心。

Serial    : 27
OpenMP    : 10
PPL       : 20
AutoP     : 34
PForEach  :151

在具有最高优化设置 (Ox) 的控制台测试中,C++17 并行 for_each 的表现非常糟糕,我相信是由于 lambda 开销,而在 GUI 基准测试中的性能与其他并行方法(如 OpenMP 和 PPL)相当。在 GUI 基准测试中,lambda 开销不是主要因素,因为 lambda 主体执行的工作量与 lambda 调用相比相当可观。在决定使用哪种并行循环之前,我们应该谨慎行事,并使用典型的加载情况进行基准测试。

结论

我们已经研究了 OpenMP、Parallel Patterns Library 和 Auto-Parallelizer 的性能。遗憾的是,Auto-Parallelizer 在基准测试应用程序中不起作用。虽然我们必须注意 PPL 的 lambda 调用开销与加载量的比较,但没有理由选择其中一个而不是另一个。在某些 PC 上,PPL 的性能优于 OpenMP。为了代码的可移植性,用户可以坚持使用 OpenMP,因为它也由其他 C++ 编译器实现,而 PPL 和 Auto-Parallelizer 仅在 Microsoft Visual Studio 中提供。要构建示例源代码,用户需要下载并安装 Visual Studio 11 Beta。对于如何改进基准测试应用程序或文章,欢迎提出任何反馈。源代码托管在 GitHub

感谢阅读!

历史

  • 2020 年 4 月 26 日:为基准测试添加了 SSE2 选项,但这似乎只会加速 OpenMP 的计时。请记住在进行基准测试前关闭所有应用程序。
  • 2018 年 11 月 19 日:在 GUI 辉光和控制台应用程序基准测试中添加了 C++17 并行 for_each
  • 2012 年 4 月 30 日:添加了关于 HLSL 混色运算符的说明
  • 2012 年 4 月 25 日:首次发布
© . All rights reserved.