为什么循环展开对大型数据集没有影响?

时间:2014-11-28 16:23:28

标签: c++ optimization loop-unrolling

我想对在triangle对象上应用的展开循环和for循环之间的执行速度差异进行基准测试。整个示例都可用here

以下是完整的代码:

#include <iostream>
#include <vector>
#include <array>
#include <random>
#include <functional>
#include <chrono>
#include <fstream>

template<typename RT>
class Point 
{
    std::array<RT,3> data; 

    public: 

        Point() = default;

        Point(std::initializer_list<RT>& ilist)
            :
                data(ilist)
        {}

        Point(RT x, RT y, RT z)
            :
                data({x,y,z})
        {};

        RT& operator[](int i)
        {
            return data[i];  
        }

        RT operator[](int i) const
        {
            return data[i];
        }

        const Point& operator += (Point const& other)
        {
            data[0] += other.data[0];
            data[1] += other.data[1];
            data[2] += other.data[2];

            return *this; 
        }

        const Point& operator /= (RT const& s)
        {
            data[0] /= s; 
            data[1] /= s;  
            data[2] /= s;  

            return *this;
        }

};

template<typename RT>
Point<RT> operator-(const Point<RT>& p1, const Point<RT>& p2)
{
    return Point<RT>(p1[0] - p2[0], p1[1] - p2[1], p1[2] - p2[2]);
}

template<typename RT>
std::ostream& operator<<(std::ostream& os , Point<RT> const& p)
{
    os << p[0] << " " << p[1] << " " << p[2]; 
    return os;
}

template<typename Point>
class Triangle 
{
    std::array<Point, 3> points; 

    public: 

        typedef typename std::array<Point, 3>::value_type value_type;

        typedef Point PointType; 

        Triangle() = default; 

        Triangle(std::initializer_list<Point>& ilist) 
            :
                points(ilist)
        {}

        Triangle(Point const& p1, Point const& p2, Point const& p3)
            :
                points({p1, p2, p3})
        {}

        Point& operator[](int i)
        {
            return points[i]; 
        }

        Point operator[](int i) const
        {
            return points[i]; 
        }

        auto begin()
        {
            return points.begin(); 
        }

        const auto begin() const
        {
            return points.begin(); 
        }

        auto end()
        {
            return points.end(); 
        }

        const auto end() const
        {
            return points.end(); 
        }
};

template<typename Triangle>
typename Triangle::PointType barycenter_for(Triangle const& triangle)
{
    typename Triangle::value_type barycenter; 

    for (const auto& point : triangle)
    {
        barycenter += point; 
    }

    barycenter /= 3.; 

    return barycenter; 
}

template<typename Triangle>
typename Triangle::PointType barycenter_unrolled(Triangle const& triangle)
{
    typename Triangle::PointType barycenter; 

    barycenter += triangle[0]; 
    barycenter += triangle[1]; 
    barycenter += triangle[2]; 

    barycenter /= 3.; 

    return barycenter; 
}

template<typename TriangleSequence>
typename TriangleSequence::value_type::value_type
barycenter(
    TriangleSequence const& triangles, 
    std::function
    <
        typename TriangleSequence::value_type::value_type (
            typename TriangleSequence::value_type const &
         )
    > triangle_barycenter 
)
{
    typename TriangleSequence::value_type::value_type barycenter; 

    for(const auto& triangle : triangles)
    {
        barycenter += triangle_barycenter(triangle); 
    }

    barycenter /= double(triangles.size()); 

    return barycenter; 
}

using namespace std;

int main(int argc, const char *argv[])
{
    typedef Point<double> point; 
    typedef Triangle<point> triangle; 

    const int EXP = (atoi(argv[1]));

    ofstream outFile; 
    outFile.open("results.dat",std::ios_base::app); 

    const unsigned int MAX_TRIANGLES = pow(10, EXP);

    typedef std::vector<triangle> triangleVector; 

    triangleVector triangles;

    std::random_device rd;
    std::default_random_engine e(rd());
    std::uniform_real_distribution<double> dist(-10,10); 

    for (unsigned int i = 0; i < MAX_TRIANGLES; ++i)
    {
        triangles.push_back(
            triangle(
                point(dist(e), dist(e), dist(e)),
                point(dist(e), dist(e), dist(e)),
                point(dist(e), dist(e), dist(e))
            )
        );
    }

    typedef std::chrono::high_resolution_clock Clock; 

    auto start = Clock::now();
    auto trianglesBarycenter = barycenter(triangles, [](const triangle& tri){return barycenter_for(tri);});
    auto end = Clock::now(); 

    auto forLoop = end - start; 

    start = Clock::now();
    auto trianglesBarycenterUnrolled = barycenter(triangles, [](const triangle& tri){return barycenter_unrolled(tri);});
    end = Clock::now(); 

    auto unrolledLoop = end - start; 

    cout << "Barycenter difference (should be a zero vector): " << trianglesBarycenter - trianglesBarycenterUnrolled << endl;

    outFile << MAX_TRIANGLES << " " << forLoop.count() << " " << unrolledLoop.count() << "\n"; 

    outFile.close();

    return 0;
}

该示例包含Point类型和Triangle类型。基准计算是三角形重心的计算。它可以通过for循环来完成:

for (const auto& point : triangle)
{
    barycenter += point; 
}

barycenter /= 3.; 

return barycenter; 

或者它可以展开,因为三角形有三个点:

barycenter += triangle[0]; 
barycenter += triangle[1]; 
barycenter += triangle[2]; 

barycenter /= 3.; 

return barycenter; 

所以我想测试哪一个计算重心的函数对于一组三角形会更快。为了充分利用测试,我通过使用整数指数参数执行主程序来对变量进行操作的三角形的数量:

./main 6

产生10 ^ 6个三角形。三角形的数量范围从100到1e06。主程序创建&#34; results.dat&#34;文件。为了分析结果,我编写了一个小的python脚本:

#!/usr/bin/python

from matplotlib import pyplot as plt
import numpy as np
import os

results = np.loadtxt("results.dat")

fig = plt.figure()

ax1 = fig.add_subplot(111)
ax2 = ax1.twinx()

ax1.loglog(); 
ax2.loglog();

ratio = results[:,1] / results[:,2]

print("Speedup factors unrolled loop / for loop: ")
print(ratio)

l1 = ax1.plot(results[:,0], results[:,1], label="for loop", color='red')
l2 = ax1.plot(results[:,0], results[:,2], label="unrolled loop", color='black')
l3 = ax2.plot(results[:,0], ratio, label="speedup ratio", color='green')

lines  = [l1, l2, l3]; 

ax1.set_ylabel("CPU count")
ax2.set_ylabel("relative speedup: unrolled loop / for loop")

ax1.legend(loc="center right")
ax2.legend(loc="center left")

plt.savefig("results.png")

要充分利用计算机上的所有内容,请复制示例代码,并使用以下代码进行编译:

g++ -std=c++1y -O3 -Wall -pedantic -pthread main.cpp -o main

要为不同的重心函数绘制测量的CPU时间,请执行python脚本(我称之为plotResults.py):

 for i in {1..6}; do ./main $i; done
./plotResults.py

我期望看到展开的循环和for循环之间的相对加速(循环时间/展开的循环时间)将随着三角形集的大小而增加。这个结论将遵循一个逻辑:如果展开的循环比for循环更快,执行大量展开循环应该比执行大量for循环更快。以下是上述python脚本生成的结果图表:

enter image description here

循环展开的影响快速消亡。一旦我使用超过100个三角形,似乎没有区别。看一下python脚本计算的加速:

[ 3.13502399  2.40828402  1.15045831  1.0197221   1.1042312   1.26175165
  0.99736715]

使用100个三角形时的加速比(列表中的3d位置对应10 ^ 2)为1.15。

我来这里是为了找出我做错了什么,因为这里肯定有问题,恕我直言。 :) 提前致谢。

修改:绘制cachegrind缓存未命中率

如果程序运行如下:

for input in {2..6}; do valgrind --tool=cachegrind  ./main $input; done

cachegrind会生成一堆输出文件,可以使用grep解析PROGRAM TOTALS,这是一个代表以下数据的数字列表,取自cachegrind manual

  

Cachegrind收集以下统计信息(缩写用于   每个统计数字在括号中给出:

I cache reads (Ir, which equals the number of instructions executed), I1 cache read misses (I1mr) and LL cache instruction read
     

未命中(ILmr)。

D cache reads (Dr, which equals the number of memory reads), D1 cache read misses (D1mr), and LL cache data read misses (DLmr).

D cache writes (Dw, which equals the number of memory writes), D1 cache write misses (D1mw), and LL cache data write misses (DLmw).

Conditional branches executed (Bc) and conditional branches mispredicted (Bcm).

Indirect branches executed (Bi) and indirect branches mispredicted (Bim).

&#34;合并&#34;缓存未命中率定义为:(ILmr + DLmr + DLmw)/(Ir + Dr + Dw)

可以像这样解析输出文件:

for file in cache*; do cg_annotate $file | grep TOTALS >> program-totals.dat; done
sed -i 's/PROGRAM TOTALS//'g program-totals.dat

然后可以使用此python脚本显示结果数据:

#!/usr/bin/python
from matplotlib import pyplot as plt
import numpy as np
import os
import locale

totalInput = [totalInput.strip().split(' ') for totalInput in open('program-totals.dat','r')]

locale.setlocale(locale.LC_ALL, 'en_US.UTF-8' ) 

totals = []

for line in totalInput:
    totals.append([locale.atoi(item) for item in line])

totals = np.array(totals)

# Assumed default output format
# Ir I1mr  ILmr Dr Dmr DLmr Dw D1mw DLmw
# 0   1     2   3   4   5   6   7    8
cacheMissRatios = (totals[:,2] + totals[:,5] + totals[:,8]) / (totals[:,0] + totals[:,3] + totals[:,6])

fig = plt.figure()
ax1 = fig.add_subplot(111)
ax1.loglog()

results = np.loadtxt("results.dat")
l1 = ax1.plot(results[:,0], cacheMissRatios, label="Cachegrind combined cache miss ratio", color='black', marker='x')
l1 = ax1.plot(results[:,0], results[:,1] / results[:,2], label="Execution speedup", color='blue', marker='o')

ax1.set_ylabel("Cachegrind combined cache miss ratio")
ax1.set_xlabel("Number of triangles")
ax1.legend(loc="center left")

plt.savefig("cacheMisses.png")

因此,当三角形访问循环展开时,将组合的LL未命中率与程序加速进行绘制,得到如下图:

enter image description here

并且似乎存在对LL错误率的依赖:随着它的上升,程序的加速下降。但是,我仍然无法找到瓶颈的明显原因。

合并的LL未命中率是否适合分析?看看valgrind输出,据报道所有未命中率都低于5%,这应该还不错,对吗?

3 个答案:

答案 0 :(得分:4)

即使展开,barycenter的计算也是一次完成的。此外,计算的每个步骤(对于单个重心)取决于前一个步骤,这意味着它们不能并行化。您当然可以通过一次计算n重心来实现更好的吞吐量,而不仅仅是一个,并对n的各种值进行基准测试,以确定哪些数量将使CPU管道饱和。

可能有助于加速计算的另一个方面是数据布局:您可以尝试将它们分成3个不同的数组(每个点一个),而不是将三角形点存储在一个结构中。 ,再次使用n的不同值进行基准测试。

关于你的主要问题,除非代码转换降低了基础算法的复杂程度(这是完全可能的),所以获得的速度应该在数据集大小上最多线性,但是具有足够大的一个,它可能达到不同的限制(例如,当一级内存 - 缓存级别1,级别2,主内存 - 变得饱和时会发生什么?)。

答案 1 :(得分:1)

  1. 对于小型数据集,您的第二个BarycenterUnrolled循环更快,因为数据集足够小,可以进行L2 / L3缓存优化。尝试交换在程序中运行测试的顺序。一个看似合乎逻辑的决定可能是将测试作为单独的进程运行,但这并不总是有效:L2 / L3高速缓存是持久的。每个过程的后续运行可以产生不同的结果。 (详见下文)

  2. 您在光谱中观察到的其他差异是噪音。您的GCC编译器在两种情况下都生成几乎相同的代码。当指定-O3时,GCC以积极展开循环(例如那个循环)而众所周知。事实上,在某些情况下,GCC会将循环展开多达16或24次迭代 - 这有时会损害某些移动芯片架构的性能。

  3. 此外,您可以使用-fno-unroll-loops进行测试...虽然我怀疑您会看到很多差异,因为您的算法的主要瓶颈是,按此顺序:

    1. 分部操作(/ = 3)
    2. 内存
    3. 关于在短数据集上运行适当的基准测试:

      为了避免短数据集上的L2 / L3缓存噪声,您需要在每次基准测试之前刷新缓存。这通常是通过在~16MB - 32MB的堆中分配一些大块数据并读取/写入垃圾来完成的。在这种情况下,建议为每个测试建立完全不同的三角形列表。

      但最好的建议通常是:&#34;不要在小数据集上运行基准测试。&#34;相反,只在非常大的数据集上运行基准测试,然后在大集合中使用最好的小集合。这适用于微优化情况,例如循环展开或cpu指令计数。如果您正在进行排序或树木行走等更高级别的算法,并且知道您的主要用例将是小型数据集,则应使用一组不同的基准标准。在那些情况下,我更喜欢创造一个&#34;大&#34;通过连接数十个小数据集来设置数据。这会强调算法中可能成为小型数据集瓶颈的部分,例如设置和结果处理。

答案 2 :(得分:0)

循环展开可以节省循环的开销。如果执行循环所花费的时间很小,则执行循环的每次迭代的时间都很短,那么您将不会节省太多。

可能会更糟。您的处理器有许多独立工作的单元。例如,您可能有一个内存单元,一个浮点单元和一个整数单元。只要这些单位中最慢的代码,您的代码就会占用。循环(递增索引,检查它是否足够小,从循环开始处开始)由整数单元执行。如果你的代码在内存单元中需要100ms,浮点单元需要80ms,整数单元需要60ms,那么需要100ms。浮点或整数单位的任何节省都不会使它更快。

请注意,通过小示例,所有数据都适合缓存。因此,存储器单元将花费相对较少的时间。假设您有一个小样本,没有展开需要60μs(内存),60μs(浮点)和80μs(整数)。现在循环展开可以帮助并将总时间从80μs减少到60μs。