为程序争取更多性能时,使用基于线程的方法来编写多线程程序并不是一个好的办法,而更好的是用逻辑任务来表达你的程序
关于TBB的安装配置请参考:并行循环(http://www.cppprog.com/2009/0325/92.html)
为程序争取更多性能时,使用基于线程的方法来编写多线程程序并不是一个好的办法,而更好的是用逻辑任务来表达你的程序,理由如下:
你使用线程库所建立的线程是逻辑线程,它们要映射到硬件的物理线程中去。当每个物理线程各自运行一个逻辑线程时效率最高,其它情况下会由于不匹配而致使性能下降。TBB调度试图避免这种不匹配,使一个物理线程对应一个逻辑线程。
任务和线程相比关键优势是它们更轻量,在Linux系统,启动和终止一个任务的速度是线程的18倍,在Windows系统,这个比率更是超过了100。这是因为每个线程有自己的一堆资源,象寄存器和堆栈,在Linux里,线程甚至有自己的进程ID。与之形成对比的是任务,它只是一个小例程,而且,任务不是抢先式的。
TBB里的任务效率高在于它们的调度是“不公平的”,线程调度典型的做法是分发时间片,这种分发是“公平的”,因为这是一个在不知道程序的高级别组织形式下最安全的策略。基于任务编程时,任务调度有高级别信息,所以可以为了效率而牺牲公平性。实际上,它经常延迟启动一个任务直到进程确实要用到它为止。
任务调度尽量做到负载平衡,只要把你的程序分解成一组足够小的任务,任务调度通常会很好地分配这些任务到线程中去并让各线程平衡负荷。
最后,一个主要优势是使你处于更高级别的、基于任务来思考。
在并行循环(http://www.cppprog.com/2009/0325/92.html) 一文中,所有例程都首先创建一个task_scheduler_init对象,它就是TBB的任务调度服务。在使用任务之前,也要先创建这个对象。
任务task
所有的TBB任务都从task继承,并重载其中的纯虚函数task* execute()。
TBB库还有一个特殊的task,空任务:
-
- class empty_task: public task {
- task* execute() {
- return NULL;
- }
- };
下面是一个最简单的使用task的例子:
- #include <iostream>
- #include <tbb/task_scheduler_init.h>
- #include <tbb/task.h>
-
- using namespace std;
- using namespace tbb;
-
- struct printtask
- :task
- {
- printtask(int n)
- :m_n(n){}
- task* execute()
- {
- cout << m_n;
- return NULL;
- }
- private:
- int m_n;
- };
-
- int main()
- {
- task_scheduler_init init;
-
- task *dummy = new(task::allocate_root()) empty_task;
- dummy->set_ref_count(10+1);
- for(int i=0; i<10; i++)
- {
- task* childtask = new(dummy->allocate_child()) printtask(i);
- dummy->spawn(*childtask);
- }
- dummy->wait_for_all();
- dummy->destroy(*dummy);
-
- return 0;
- }
本例中,打印10个数字,分别从0-9。因为任务是并行执行的,所以打印结果是乱序的。
printtask是一个打印任务,打印一个数字。
empty_task类型的dummy是一个空任务,它是所有打印任务的父任务,我们要利用它的wait_for_all等待所有子任务完成。
默认task的execute()执行完成后这个任务就会被删除,所在新建的所有printtask类型子任务不用显式地删除它们。
同样,dummy一直没有执行,所以这个删除任务就由我们来处理。task必须使用destroy来删除。
task还有一个特色是它只能使用placement new的形式新建任务,placement new中使用的是一个代理对象,这些代理对象可以是下面中的一个:
task::allocate_root() |
生成根任务 |
this->allocate_continuation() |
生成一个和当前任务同级的任务,并把当前任务的父任务转移过来。一般用于立即返回当前任务并由这个新任务代替当前任务继续做接下去的事。 |
this->allocate_child() |
生成当前任务的子任务 |
this->task::allocate_additional_child_of(parent) |
为指定的parent生成一个子任务 |
本例中还用到了三个task的方法
- void set_ref_count(int count)
- void spawn(task& child)
- void wait_for_all()
set_ref_count设置任务的参考计数,当任务中的一个子任务完成后参考计数减一
spawn把子任务放入“就绪池”并马上返回
wait_for_all等待任务的参考计数降到1为止(所以上面是:dummy->set_ref_count(10+1);),然后把这个计数值设为0。
这里还有必要提一下,task还提供了一个spawn_and_wait_for_all(task& child)方法,相当于spawn(child);wait_for_all();。据说性能更高一点。
spawn还支持一个叫task_list的类作为参数,这是一个存放任务的队列,它只有四个方法:
- void push_back(task& task);
- task& pop_front();
- void clear();
- bool empty() const;
看名字即可知道怎样使用它们了。比如上面的例子我们可以这样写:
- int main()
- {
- task_scheduler_init init;
-
- task *dummy = new(task::allocate_root()) empty_task;
- dummy->set_ref_count(10+1);
-
- task_list tl;
- for(int i=0; i<10; i++)
- {
- task* childtask = new(dummy->allocate_child()) printtask(i);
-
- tl.push_back(*childtask);
- }
-
- dummy->spawn_and_wait_for_all(tl);
- dummy->destroy(*dummy);
-
- return 0;
- }
任务调度算法
TBB任务调度使用的是称为工作偷取(work stealing)的技术,每个线程维护一个“就绪池”,“就绪池”的结构可以看成是一个准备执行的任务列表数组。
从上例的task组织结构(parent-child)可以看出,TBB的任务是以树型来组织的。其中“就绪池”的数组中第n个元素对应任务树的第n级,因此称数组头部是“就绪池”的“浅”位,越到尾部越“深”,每个元素是一个任务列表,这个列表是后进先出的顺序。
线程依次按照下面的规则选择下一个执行的任务:
- 由前一个任务的execute()方法返回的任务。
- 这个线程中最晚一个所有子任务都已完成的任务。
- 从“就绪池”最“深”的非空列表中取得一个任务。
- 与线程关联的任务。
- 随机从其它线程“就绪池”的最浅列表中取得一个任务。
任务进入“就绪池”有三种途径:
- 使用spawn等方法明确地放入
- 任务被void recycle_to_reexecute()方法标记为re-execution
- 由于子任务完成,任务的参考计数被降至0。
并行排序
在并行循环(http://www.cppprog.com/2009/0325/92.html) 一文中,已经介绍了一个叫parallel_sort的函数,它可以快速并行排序。对应于std::sort,它是不稳定的。
这里,我们使用task写一个对应于std::stable_sort稳定排序的并行版本。
在众多稳定排序算法中,这里我选择使用归并排序:一是因为这种排序方式相对简单;二是归并排序不会有数据竞争,便于并行化;三是它的思想正好可以建立成一个二叉任务树。(另外,貌似std::stable_sort也是用归并算法排序的)
归并排序简单地说就是:将数组分割成两份,分别对它们进行归并排序,然后把已排好序的两份合并。
完整代码:
- #include <iostream>
- #include <algorithm>
- #include <functional>
- #include <tbb/task_scheduler_init.h>
- #include <tbb/task.h>
- #include <tbb/tick_count.h>
-
- template<class T, class F>
- struct stable_sort_task
- :tbb::task
- {
- typedef typename stable_sort_task<T,F> this_type;
- typedef typename std::iterator_traits<T>::value_type value_type;
-
- stable_sort_task(T begin_, T end_,F &func)
- :m_begin(begin_),m_end(end_),m_func(func)
- {}
-
- tbb::task* execute()
- {
- int size = m_end - m_begin;
- if(size < 200)
- {
- std::stable_sort(m_begin,m_end,m_func);
- }
- else
- {
- T m = m_begin + (size+1)/2;
-
- this_type &t1 = *new(allocate_child()) this_type(m_begin,m,m_func);
- this_type &t2 = *new(allocate_child()) this_type(m,m_end,m_func);
-
- set_ref_count(3);
- spawn(t1);
- spawn(t2);
- wait_for_all();
-
- value_type* temp = new value_type[size];
- std::merge(m_begin,m,m,m_end,temp,m_func);
- std::copy(temp,temp+size,m_begin);
- delete []temp;
- }
- return NULL;
- }
- private:
- T m_begin, m_end;
- F &m_func;
- };
-
- template <class T, class F>
- void parallel_stable_sort( T begin_, T end_, F func )
- {
- stable_sort_task<T,F> &t = *new(tbb::task::allocate_root()) stable_sort_task<T,F>(begin_,end_,func);
- tbb::task::spawn_root_and_wait(t);
- }
-
- int main()
- {
- tbb::task_scheduler_init init;
-
- const size_t TESTSIZE = 5000;
- int test[TESTSIZE];
-
- for(int i=0; i<TESTSIZE; i++) test[i]=i;
- tbb::tick_count t0, t1;
-
- t0 = tbb::tick_count::now();
- for(int i=0; i<100; i++)
- {
- parallel_stable_sort(test, test + TESTSIZE, std::greater<int>());
- parallel_stable_sort(test, test + TESTSIZE, std::less<int>());
- }
- t1 = tbb::tick_count::now();
- std::cout << (t1-t0).seconds() << std::endl;
-
- t0 = tbb::tick_count::now();
- for(int i=0; i<100; i++)
- {
- std::stable_sort(test, test + TESTSIZE, std::greater<int>());
- std::stable_sort(test, test + TESTSIZE, std::less<int>());
- }
- t1 = tbb::tick_count::now();
- std::cout << (t1-t0).seconds() << std::endl;
-
- return 0;
- }
在我的双核CPU+VC2005的Debug模式下显示1.9秒和3.2秒。说明我们的并行排序效率的提升还是明显的。
在stable_sort_task的execute()方法中,有一个判断:if(size < 200),如果小于200就直接使用串行方法排序。
我们也可以断续分解直到剩下一个元素为止,为什么不这么做呢?其实这个200是个“艺术”问题,它和实际应用有关,大数可以减少task的调度,但可能造成不能充分利用CPU或负载不平衡。小数正好相反,而且过小的话如果task调度开销超过算法本身开销就更不合算了。
parallel_stable_sort函数建立一个根任务,并使用spawn_root_and
转自http://www.cppprog.com/2009/0401/96_3.html
任务调度算法
TBB任务调度使用的是称为工作偷取(work stealing)的技术,每个线程维护一个“就绪池”,“就绪池”的结构可以看成是一个准备执行的任务列表数组。
从上例的task组织结构(parent-child)可以看出,TBB的任务是以树型来组织的。其中“就绪池”的数组中第n个元素对应任务树的第n级,因此称数组头部是“就绪池”的“浅”位,越到尾部越“深”,每个元素是一个任务列表,这个列表是后进先出的顺序。
线程依次按照下面的规则选择下一个执行的任务:
- 由前一个任务的execute()方法返回的任务。
- 这个线程中最晚一个所有子任务都已完成的任务。
- 从“就绪池”最“深”的非空列表中取得一个任务。
- 与线程关联的任务。
- 随机从其它线程“就绪池”的最浅列表中取得一个任务。
任务进入“就绪池”有三种途径:
- 使用spawn等方法明确地放入
- 任务被void recycle_to_reexecute()方法标记为re-execution
- 由于子任务完成,任务的参考计数被降至0。
并行排序
在并行循环(http://www.cppprog.com/2009/0325/92.html) 一文中,已经介绍了一个叫parallel_sort的函数,它可以快速并行排序。对应于std::sort,它是不稳定的。
这里,我们使用task写一个对应于std::stable_sort稳定排序的并行版本。
在众多稳定排序算法中,这里我选择使用归并排序:一是因为这种排序方式相对简单;二是归并排序不会有数据竞争,便于并行化;三是它的思想正好可以建立成一个二叉任务树。(另外,貌似std::stable_sort也是用归并算法排序的)
归并排序简单地说就是:将数组分割成两份,分别对它们进行归并排序,然后把已排好序的两份合并。
完整代码:
- #include <iostream>
- #include <algorithm>
- #include <functional>
- #include <tbb/task_scheduler_init.h>
- #include <tbb/task.h>
- #include <tbb/tick_count.h>
-
- template<class T, class F>
- struct stable_sort_task
- :tbb::task
- {
- typedef typename stable_sort_task<T,F> this_type;
- typedef typename std::iterator_traits<T>::value_type value_type;
-
- stable_sort_task(T begin_, T end_,F &func)
- :m_begin(begin_),m_end(end_),m_func(func)
- {}
-
- tbb::task* execute()
- {
- int size = m_end - m_begin;
- if(size < 200)
- {
- std::stable_sort(m_begin,m_end,m_func);
- }
- else
- {
- T m = m_begin + (size+1)/2;
-
- this_type &t1 = *new(allocate_child()) this_type(m_begin,m,m_func);
- this_type &t2 = *new(allocate_child()) this_type(m,m_end,m_func);
-
- set_ref_count(3);
- spawn(t1);
- spawn(t2);
- wait_for_all();
-
- value_type* temp = new value_type[size];
- std::merge(m_begin,m,m,m_end,temp,m_func);
- std::copy(temp,temp+size,m_begin);
- delete []temp;
- }
- return NULL;
- }
- private:
- T m_begin, m_end;
- F &m_func;
- };
-
- template <class T, class F>
- void parallel_stable_sort( T begin_, T end_, F func )
- {
- stable_sort_task<T,F> &t = *new(tbb::task::allocate_root()) stable_sort_task<T,F>(begin_,end_,func);
- tbb::task::spawn_root_and_wait(t);
- }
-
- int main()
- {
- tbb::task_scheduler_init init;
-
- const size_t TESTSIZE = 5000;
- int test[TESTSIZE];
-
- for(int i=0; i<TESTSIZE; i++) test[i]=i;
- tbb::tick_count t0, t1;
-
- t0 = tbb::tick_count::now();
- for(int i=0; i<100; i++)
- {
- parallel_stable_sort(test, test + TESTSIZE, std::greater<int>());
- parallel_stable_sort(test, test + TESTSIZE, std::less<int>());
- }
- t1 = tbb::tick_count::now();
- std::cout << (t1-t0).seconds() << std::endl;
-
- t0 = tbb::tick_count::now();
- for(int i=0; i<100; i++)
- {
- std::stable_sort(test, test + TESTSIZE, std::greater<int>());
- std::stable_sort(test, test + TESTSIZE, std::less<int>());
- }
- t1 = tbb::tick_count::now();
- std::cout << (t1-t0).seconds() << std::endl;
-
- return 0;
- }
在我的双核CPU+VC2005的Debug模式下显示1.9秒和3.2秒。说明我们的并行排序效率的提升还是明显的。
在stable_sort_task的execute()方法中,有一个判断:if(size < 200),如果小于200就直接使用串行方法排序。
我们也可以断续分解直到剩下一个元素为止,为什么不这么做呢?其实这个200是个“艺术”问题,它和实际应用有关,大数可以减少task的调度,但可能造成不能充分利用CPU或负载不平衡。小数正好相反,而且过小的话如果task调度开销超过算法本身开销就更不合算了。
parallel_stable_sort函数建立一个根任务,并使用spawn_root_and
Continuation Passing模式
上例stable_sort_task的execute方法的执行过程是生成两个子任务,等待两个子任务完成再开始归并数组。
等待可能会引起性能下降,我们可以利用上面所说的任务进入“就绪池”途径第三条:“由于子任务完成,任务的参考计数被降至0”来消除这个等待。
方法是:
- 使用allocate_continuation作为代理对象生成一个同级任务,这样当前任务的父任务转移到了这个新任务之下,原任务退出时不再会修改父任务的参考计数(事实上这时原任务的parent属性为NULL)。
- 在这个新任务下建立两个子任务来处理子串的排序工作。
- 设置新任务的参考计数为2
- 当两个子任务完成时,此新任务的参考计数被降为0,由此进入“就绪池”准备执行。
- 两个排完序的子串的归并工作由这个新任务完成。
说来话长,直接看代码:
-
- template<class T, class F>
- struct merge_task
- :tbb::task
- {
- typedef typename std::iterator_traits<T>::value_type value_type;
- typedef typename std::iterator_traits<T>::distance_type dist_type;
- T m_begin, m_end, m_mid;
- F &m_func;
- dist_type m_size;
-
- merge_task(T begin_, T end_,F &func)
- :m_begin(begin_),m_end(end_),m_func(func),
- m_size(end_ - begin_)
- {
- m_mid = begin_ + (m_size + 1)/2;
- }
-
- tbb::task* execute()
- {
-
-
- value_type* temp = new value_type[m_size];
- std::merge(m_begin,m_mid,m_mid,m_end,temp,m_func);
- std::copy(temp,temp+m_size,m_begin);
- delete []temp;
- return NULL;
- }
- };
-
- template<class T, class F>
- struct stable_sort_task
- :tbb::task
- {
- typedef typename stable_sort_task<T,F> this_type;
- typedef typename merge_task<T,F> merge_task_type;
- typedef typename std::iterator_traits<T>::value_type value_type;
-
- stable_sort_task(T begin_, T end_,F &func)
- :m_begin(begin_),m_end(end_),m_func(func)
- {}
-
- tbb::task* execute()
- {
- if(m_end - m_begin < 200)
- {
- std::stable_sort(m_begin,m_end,m_func);
- }
- else
- {
-
- merge_task_type &tc =
- *new(allocate_continuation()) merge_task_type(m_begin, m_end, m_func);
-
- this_type &t1 = *new(tc.allocate_child()) this_type(m_begin, tc.m_mid, m_func);
- this_type &t2 = *new(tc.allocate_child()) this_type(tc.m_mid, m_end, m_func);
-
- tc.set_ref_count(2);
- tc.spawn(t1);
- tc.spawn(t2);
- }
- return NULL;
- }
- private:
- T m_begin, m_end;
- F &m_func;
- };
实测,貌似速度快了那么一点点,应该不是心理作用吧^_^
Recycling Parent as a Child
使用Continuation Passing模式后,stable_sort_task类的execute方法做的工作是生成新任务然后立即退出并被删除。
观察代码会发现,其中新建的用于排序的子任务和当前任务相差不大,我们完全可以把已执行完所有工作的当前任务转换为排序子串的子任务重新利用。
重用任务并转化成其它任务的子任务的方法是
void recycle_as_child_of( task& new_parent );
调用此方法后,this在execute()返回后不会被自动删除;父任务被设置为new_parent。
修改后的stable_sort_task类execute方法如下:
- tbb::task* execute()
- {
- if(m_end - m_begin < 200)
- {
- std::stable_sort(m_begin,m_end,m_func);
- }
- else
- {
-
- merge_task_type &tc = *new(allocate_continuation()) merge_task_type(m_begin, m_end, m_func);
-
- this_type &t1 = *new(tc.allocate_child()) this_type(m_begin, tc.m_mid, m_func);
-
-
- tc.set_ref_count(2);
- tc.spawn(t1);
-
- this->recycle_as_child_of(tc);
- m_begin = tc.m_mid;
-
-
- return this;
- }
- return NULL;
- }
实测,貌似速度又快了那么一点点^_^
不适合使用基于任务编程的场合
使用任务调度通常接近线程的最高性能,不过在有些情形下使用任务调度并不合适,任务调度是为高性能算法的非阻塞任务设计的,或者在阻塞次数较小的情况下。
如果线程经常阻塞,使用任务调度时就会有性能损失(因为任务是非抢先式的)。阻塞一般在等待I/O或长时间互斥时发生。如果你有阻塞任务,最好使用线程。
TBB任务调度可以安全地和你自己的线程混合工作。
转自http://www.cppprog.com/2009/0401/96_3.html