VC++并行编程学习(一)

并行模式库 (PPL) 提供命令式编程模型,以促进开发并发应用程序的可扩展性和易用性。 PPL 构建在并发运行时的计划和资源管理组件上。 通过提供并行作用于数据的泛型安全算法和容器,提高应用程序代码与基础线程机制之间的抽象级别。 使用 PPL 还可以开发通过为共享状态提供替代方案实现缩放的应用程序。
PPL 提供以下功能:
任务并行:基于 Windows 线程池来并行执行多个工作项(任务)的机制
并行算法:基于并发运行时对数据集合进行处理的泛型算法
并行容器和对象:对元素提供安全并发访问的泛型容器类型

这是MSDN上对PPL的介绍,可以看出,PPL是基于线程池模型的,主要是为了在并行编程中屏蔽底层特性。

并行迭代

concurrency::parallel_for 算法重复地以并行方式执行相同的任务。 其中每个任务都由迭代值进行参数化。 当一个循环体不在该循环的各个迭代之间共享资源时,此算法很有用。
parallel_for 算法会按照最佳方式对任务进行分区以便并行执行。 当工作负载不平衡时,此算法还会使用工作窃取算法和范围窃取来平衡这些分区。 当以协作方式阻止某个循环迭代时,运行时会将指派给当前线程的迭代范围重新发布给其他线程或处理器。 类似地,当某个线程完成迭代范围时,运行时会将其他线程的工作重新发布给该线程。 parallel_for 算法还支持嵌套并行。 当一个并行循环包含另一个并行循环时,运行时会以一种适合并行执行的高效方式在循环主体之间协调处理资源。

parallel_for是for语句的并行版本,基于整数的循环迭代。
parallel_for_each用于STL迭代器的迭代。
在并行循环中,PPL会用多种标准来对循环规模进行拆分,然后将拆分后的每个循环投递到相应的Worker线程中,同时还会进行负载均衡,当然这些都是线程池的机制。

函数体:

void parallel_for(_Index_type _First, _Index_type _Last, _Index_type _Step, const _Function& _Func, _Partitioner&& _Part)

其中Step是步长,Func是执行迭代的函数,Part是用于确定怎样拆分的函数。

拆分函数:

  • auto_partitioner
    这个函数简单的以虚拟处理器数为基准进行拆分:
    虚拟处理器是PPL对资源管理的一个抽象概念,通常不会超过处理器线程数。
    _Type _Get_num_chunks(_Type ) const
    {
        return static_cast<_Type>(Concurrency::details::_CurrentScheduler::_GetNumberOfVirtualProcessors());
    }
  • affinity_partitioner
    这个函数使循环的拆分同时考虑了虚拟处理器和硬件特性
    _Type _Get_num_chunks(_Type )
    {
        if (_M_num_chunks == 0)
        {
            _M_num_chunks = Concurrency::details::_CurrentScheduler::_GetNumberOfVirtualProcessors();
            _M_pChunk_locations = new location[_M_num_chunks];
        }

        return static_cast<_Type>(_M_num_chunks);
    }

location是对物理环境的抽象

  • simple_partitioner
    按指定的长度平均拆分。
    _Type _Get_num_chunks(_Type _Range_arg) const
    {
        static_assert(sizeof(_Type) <= sizeof(_Size_type), "Potential truncation of _Range_arg");
        _Size_type _Num_chunks = (static_cast<_Size_type>(_Range_arg) / _M_chunk_size);

        if (_Num_chunks == 0)
        {
           _Num_chunks = 1;
        }

        return static_cast<_Type>(_Num_chunks);
    }

如果有需要也可以按照自己的要求进行拆分,比如分组加密时可以设置_M_num_chunks为明文长度/分组长度。总之拆分函数的功能就是提供一个拆分粒度供后面使用。

代码分析

parallel_for后来会调用parallel_for_imp

void _Parallel_for_impl(_Index_type _First, _Index_type _Last, _Index_type _Step, const _Function& _Func, _Partitioner&& _Part)

这个函数简单的做了参数检查后调用_Parallel_for_partitioned_impl并在其中调用了_Parallel_chunk_impl。这里看出来,PPL将拆分后的每一段循环称为块(chunk),并以块为单位分发到处理器并行执行。在调用_Parallel_chunk_impl时同时传入了一个模板_Parallel_chunk_helper,这是一个辅助模板类用于实现Worker-Helper模型,但是在这里只利用了他的Worker,没有helper,所以是一个串行执行流程,既在每一个Chunk内是串行执行的。
在Parallel_chunk_impl内定义了一个structured_task_group(这个后面说),并且按照chunk_num将循环进行拆分。

    task_handle<_Worker_class> * _Chunk_helpers = _Holder._InitOnRawMalloca(_malloca(sizeof(task_handle<_Worker_class>) * static_cast(_Num_chunks)));

    structured_task_group _Task_group;

    _Index_type _Iterations_per_chunk = _Num_iterations / _Num_chunks;
    _Index_type _Remaining_iterations = _Num_iterations % _Num_chunks;

    // If there are less iterations than desired chunks, set the chunk number
    // to be the number of iterations.
    if (_Iterations_per_chunk == 0)
    {
        _Num_chunks = _Remaining_iterations;
    }

一个循环体将拆分后的每一段迭代封装成_Parallel_chunk_helper送入task_group,由task_group并行执行所有Chunk。

    for (_I = 0; _I < _Num_chunks - 1; _I++)
    {
        if (_Remaining_iterations > 0)
        {
            // Iterations are not divided evenly, so add 1 remainder iteration each time
            _Work_size = _Iterations_per_chunk + 1;
            _Remaining_iterations--;
        }
        else
        {
            _Work_size = _Iterations_per_chunk;
        }

        // New up a task_handle "in-place", in the array preallocated on the stack
        new(&_Chunk_helpers[_I]) task_handle<_Worker_class>(_Worker_class(_I, _First, _Start_iteration, _Start_iteration + _Work_size, _Step, _Func, std::forward<_Partitioner>(_Part)));
        _Holder._IncrementConstructedElemsCount();

        // Run each of the chunk tasks in parallel
        _Parallel_chunk_task_group_run(_Task_group, _Chunk_helpers, std::forward<_Partitioner>(_Part), _I);

        // Prepare for the next iteration
        _Start_iteration += _Work_size;
    }

其中_Parallel_chunk_task_group_run只是简单的run刚刚创建的Worker

template <typename _Worker_class, typename _Index_type, typename Partitioner>
void _Parallel_chunk_task_group_run(structured_task_group& _Task_group,
                                    task_handle<_Worker_class>* _Chunk_helpers,
                                    const Partitioner&,
                                    _Index_type _I)
{
    _Task_group.run(_Chunk_helpers[_I]);
}

最终在Worker中执行我们的迭代函数:

                    // Construct every new instance of a helper class on the stack because it is beneficial to use
                    // a structured task group where the class itself is responsible for task handle's lifetime.
                    task_handle<_Parallel_chunk_helper> * _Helper_subtask = _Holder._AddRawMallocaNode(_malloca(_Holder._GetAllocationSize()));

                    new(_Helper_subtask) task_handle<_Parallel_chunk_helper>
                        (_Parallel_chunk_helper(_M_first, _M_step, _M_function, _Worker_range, &_Worker));

                    // If _Send_range returns true, that means that there is still some non-trivial
                    // work to be done, so this class will potentially need another helper.
                    _Helper_group.run(*_Helper_subtask);

简单的使用示例:

    int *tmp = (int*)malloc(sizeof(int) * 10000);

    Concurrency::parallel_for(0, 10000, [tmp](int i)
    {
        tmp[i] = i;
    },auto_partitioner());

    std::vector<int>value(tmp, tmp + 10000);
    free(tmp);

    Concurrency::parallel_for_each(value.begin(), value.end(), [&value](int i)
    {
        value[i] = i + 1;
    });

在使用affinity_partitioner时需要单独声明affinity_partitioner类型的变量,并将变量传进函数。使用affinity_partitioner会获得最好的性能。
这里对数据的初始化操作不能使用STL中的vector,因为vector不是多线程安全的,这里简单的用数组初始化了一下。后面会说多线程安全的concurrent_vector。
我的电脑是4核8线程的处理器,简单的实验了下串行与并行下计算同一个大数算法,串行运行了94毫秒而并行运行了16ms,快了6倍左右。虽然拆分粒度是8,但需要考虑到线程调度和负载均衡的损耗,所以只提高了6倍。

映射\缩减

concurrency::parallel_transform concurrency::parallel_reduce 算法和分别 STL 算法和 std::transform std::accumulate的并行版本,即。 并发运行时版本行为与 STL 版本,但操作命令不是确定的,因为它们并行执行。 请使用这些算法,当您具有足够大从并行处理获取性能和伸缩性优点的集一起使用时。

parallel_transform

parallel_transform既将一个迭代器中的内容转换后送入另一个迭代器,适合用于图像操作。


    std::vector<int>src(10000);
    std::vector<int>dst(10000);

    parallel_transform(src.begin(),src.end(),  dst.begin(), [](int i)
    {
        return i + 1;
    });

这个函数有两个重载版本,第一个是使用一个输入迭代器,第二个使用两个输入迭代器来得到结果。

parallel_reduce

parallel_reduce是将迭代规模缩减后进行并行计算并在最后用一个串行程序总结每个迭代的结果。如果仅仅使用一个共享变量来进行并行聚合计算,势必会产生冲突导致不可预测的结果。map_reduce在缩减的迭代中使用私有变量进行串行操作,并在最后使用一个串行计算来聚合这些私有变量已达到多线程安全的目的。

        _Worker_class(_First, _Last, _Func)();
        return _Func._Combinable._Serial_combine_release();

函数定义:

template<typename _Reduce_type, typename _Forward_iterator, typename _Range_reduce_fun, typename _Sym_reduce_fun>
inline _Reduce_type parallel_reduce(_Forward_iterator _Begin, _Forward_iterator _End, const _Reduce_type& _Identity,
    const _Range_reduce_fun &_Range_fun, const _Sym_reduce_fun &_Sym_fun)

Begin是迭代开始,End是迭代结束,Identity是缩减的基本元素,Range_fun即缩减规模迭代的操作函数,Sym_fun即对缩减迭代函数结果的聚合函数。

一个简单的累加示例:

    int t=parallel_reduce(m1.begin(), m1.end(), 0, [](std::vector<int>::iterator begin, std::vector<int>::iterator end, int total) {
        for (auto i = begin;i < end;i++)
            total += *i;
        return total;
    }, [](int left,int right){
        return left + right;
    });

Range_func向用户传递了三个参数,既缩减迭代的开始,缩减迭代的结束以及用于聚合的私有变量。
Sym_fun向用户传递了两个参数,一个是Rangefunc返回的私有变量right,一个是用于最终聚合的变量left。

任务组

上面四个函数最终都是通过调用任务组的形式来实现的,任务组就是一组task_handle类型的任务。这些任务最终会被投递到底层的线程池工作线程中去执行。PPL的核心就在于它的任务组,使用任务组可以实现其他许多的并行模型,包括但不限于上面的for,foreach,transform,mapreduce。

这里使用一个快速排序算法来测试任务组。在快速排序中需要对数组进行分治:

void quicksort(ADT* list, int lo, int hi,comparision cmp)
{
    if (lo < hi)
    {
        int k = partition(list, lo, hi,cmp);
        quicksort(list, lo, k - 1,cmp);
        quicksort(list, k + 1, hi,cmp);
    }
}

分治的过程便可以使用任务组来实现:

void quicksort(ADT* list, int lo, int hi, comparision cmp,task_group& tg)
{
    if (lo < hi)
    {
        int k = partition(list, lo, hi, cmp);

        tg.run([list, lo, k, cmp, &tg]() {
            quicksort(list, lo, k - 1, cmp, tg);
        });
        tg.run([list, k, hi, cmp, &tg]() {
            quicksort(list, k + 1, hi, cmp, tg);
        });

    }
}

使用测试代码:

    DWORD t1 = GetTickCount();

    int* tmp =(int*) malloc(sizeof(int) * 100000);
    parallel_for(0, 100000, [&tmp](int i)
    {
        tmp[i] = rand();
    });

    task_group tg;
    quicksort(tmp, 0, 99999, cmp, tg);

    printf("%d\n", GetTickCount() - t1);

经测试,未使用任务组时,用时约110ms,而使用了任务组用时20ms左右。在这种类型的迭代中,一开始性能是最好的,然而随着后面分治规模的减小,负载均衡已经不能忽略了,这种差别在10W规模还看不太出来,但到了1000W量级,差别就出来了,修改一下代码:

        if (hi - lo > 4)
        {
            tg.run([list, lo, k, cmp, &tg]() {
                quicksort(list, lo, k - 1, cmp, tg);
            });
            tg.run([list, k, hi, cmp, &tg]() {
                quicksort(list, k + 1, hi, cmp, tg);
            });
        }
        else
        {
            quicksort(list, lo, k - 1, cmp, tg);
            quicksort(list, k + 1, hi, cmp, tg);
        }

修改后的代码平均比上面代码运行要少200ms左右。

你可能感兴趣的:(Win32)