除了循环并行,oneTBB 库还支持并行图(Graph)。可以创建高度可扩展的图,但也可以创建完全连续的图。
使用并行图,计算由**节点(Node)表示,这些计算之间的通信通道由边 (Edge)**表示。当图中的节点接收到消息时,将生成一个任务以在传入消息上执行其主体对象。消息通过连接节点的边流过图。以下部分介绍了两个可以用图表示的应用程序示例。
下图显示了一个流或数据流应用程序,其中在每个值通过图中的节点时处理一系列值。在本例中,序列由函数 F 创建。对于序列中的每个值,G 对值求平方,H 对值求立方。 J 然后取每个平方和立方值并将它们添加到全局总和中。在序列中的所有值都被完全处理后,sum 等于从 1 到 10 的平方和立方体序列的总和。一个节点的输出成为其后继节点的输入。
下图显示了一种不同形式的图的应用程序。 在此示例中,依赖关系图用于在制作花生酱和果冻三明治的步骤之间建立偏序。 在这个偏序中,你必须先拿到面包,然后再把花生酱或果冻涂在面包上。 在你收起花生酱罐之前,你必须先涂上花生酱,同样,在你收起果冻罐之前,也必须先涂上果冻。 而且,在将两片面包放在一起之前,你需要先涂上花生酱和果冻。 这是部分顺序,因为例如,先涂花生酱还是先涂果冻都没有关系。 如果你在放下罐子之前完成三明治的制作也没关系。
虽然可以推断资源(例如面包或果冻罐)在有序步骤之间共享,但图中并未明确显示。相反,在依赖图中只有所需的步骤顺序是明确的。例如,你必须先“将果冻放在 1 片上”,然后再“收起果冻罐”。
oneTBB 库中的流图接口允许你表达诸如此类的数据流和依赖图,以及更复杂的图,包括循环、条件、缓冲等。如果你使用流图接口表达你的应用程序,运行时库会生成任务以利用图中存在的并行性。例如,在上面的第一个示例中,可能两个不同的值可能并行平方,或者相同的值可能并行平方和立方。同样,在第二个示例中,花生酱可能会涂抹在一片面包上,同时将果冻涂抹在另一片面包上。该接口表达了并行执行的合法性,但允许运行时库在运行时选择并行执行的内容。
对图并行性的支持包含在命名空间 oneapi::tbb::flow
中,并在 flow_graph.h
头文件中定义。
Basic Flow Graph Concepts
Flow Graph Basics: Graph Object
从概念上讲,流图 (Flow Graph)是节点 (Node)和边 (Edge)的集合。 每个节点只属于一个图 (Graph),边仅在同一图中的节点之间形成。 在流图接口中,一个图对象代表这个节点和边的集合,用于调用整个图的操作,例如等待与图相关的所有任务完成,重置图中所有节点的状态,取消 图中所有节点的执行。
下面的代码创建一个图对象,然后等待图的所有任务完成。 在此示例中对 wait_for_all
的调用立即返回,因为这是一个没有节点或边的图,因此不会产生任何任务。
graph g;
g.wait_for_all();
Flow Graph Basics: Nodes
**节点 (Node)**是继承自 oneapi::tbb::flow::graph_node
的类,并且通常也继承自 oneapi::tbb::flow::sender<T>
、 oneapi::tbb::flow::receiver<T>
或两者。 节点执行一些操作,通常是对传入的消息,并且可能生成零个或多个输出消息。 一些节点需要不止一条输入消息或生成不止一条输出消息。
虽然可以通过从graph_node
、sender
和receiver
继承来定义自己的节点类型,但更典型的是使用预定义的节点类型来构建图。
function_node
是 flow_graph.h
中可用的预定义类型,表示具有一个输入和一个输出的简单函数。 function_node
的构造函数接受三个参数:
template< typename Body> function_node(graph &g, size_t concurrency, Body body)
参数 | 描述 |
---|---|
Body | 主体对象的类型。 |
g | 节点所属的图。 |
concurrency | 节点的并发限制。 你可以使用并发限制来控制允许同时进行的节点调用数量,从 1(串行)到无限数量。 |
body | 用户定义的函数对象或 lambda 表达式,应用于传入消息以生成传出消息。 |
下面是创建一个包含单个 function_node
的简单的图的代码。 在此示例中,构建了属于图 g
的节点 n
,其第二个参数为 1
,这允许最多 1 次同时调用该节点。 主体是一个 lambda 表达式,它打印它接收到的每个值 v
,旋转 v
秒,再次打印该值,然后返回未修改的 v
。 未提供函数 spin_for
的代码。
graph g;
function_node< int, int > n( g, 1, []( int v ) -> int {
cout << v;
spin_for( v );
cout << v;
return v;
} );
在上面的示例中构造节点后,你可以将消息传递给它,方法是使用边将其连接到其他节点或调用其函数 try_put
。 下一节将介绍如何使用边。
n.try_put( 1 );
n.try_put( 2 );
n.try_put( 3 );
然后,你可以通过在图形对象上调用 wait_for_all
来等待消息被处理:
g.wait_for_all();
在上面的示例代码中,创建的 function_node
对象 n
的并发限制为 1
。当它收到消息序列 1
、2
和 3
时,节点 n
将产生一个任务(Task),将主体应用于第一个输入,1
。当那个 任务完成后,它将生成另一个任务将主体应用于 2
。同样,节点将等待该任务完成,然后再生成第三个任务以将主体应用于 3
。对 try_put
的调用不会阻塞,直到 任务产生; 如果一个节点不能立即产生一个任务来处理消息,消息将被缓存在节点中。 当它合法时,基于并发限制,将产生一个任务来处理下一条缓冲消息。
在上图中,每条消息都是按顺序处理的。 但是,如果你构造具有不同并发限制的节点,则可以实现并行性:
function_node< int, int > n( g, oneapi::tbb::flow::unlimited, []( int v ) -> int {
cout << v;
spin_for( v );
cout << v;
return v;
} );
你可以使用 unlimited
作为并发限制来指示库在消息到达时立即生成任务,而不管生成了多少其他任务。 你还可以使用任何特定值(例如 4
或 8
)分别将并发限制为最多 4 或 8。 重要的是要记住,生成任务并不意味着创建线程。 因此,虽然图可能会产生许多任务,但只会使用库线程池中可用的线程数来执行这些任务。
假设你在 function_node
构造函数中使用了 unlimited
并在节点上调用 try_put
:
n.try_put( 1 );
n.try_put( 2 );
n.try_put( 3 );
g.wait_for_all();
该库产生了三个任务,每个任务都将 n
的 lambda 表达式应用于其中一条消息。 如果你的系统上有足够数量的可用线程,则主体的所有三个调用都将并行发生。 但是,如果系统中只有一个线程,它们会按顺序执行。
Flow Graph Basics: Edges
大多数应用程序包含多个节点,边 (Edge) 将它们彼此连接起来。 在流图界面中,边是消息传递的有向通道。 它们是通过使用两个参数调用函数 make_edge( p, s )
来创建的:p
,前任节点,s
,后继节点。 你可以修改前面“节点主题”中使用的示例以包含第二个节点,该节点在打印之前将其接收到的值平方,然后将其连接到带有边的第一个节点。
graph g;
function_node< int, int > n( g, unlimited, []( int v ) -> int {
cout << v;
spin_for( v );
cout << v;
return v;
} );
function_node< int, int > m( g, 1, []( int v ) -> int {
v *= v;
cout << v;
spin_for( v );
cout << v;
return v;
} );
make_edge( n, m );
n.try_put( 1 );
n.try_put( 2 );
n.try_put( 3 );
g.wait_for_all();
现在有两个 function_node
对象 n
和 m
。 调用 make_edge
创建一条从 n
到 m
的边。 节点 n
是无限并发创建的,而 m
的并发限制为 1
。 n
的调用都可以并行进行,而 m
的调用将被序列化。 因为从 n
到 m
有一条边,所以 n
返回的每个值 v
都会被运行时库自动传递给节点 m
。
Flow Graph Basics: Mapping Nodes to Tasks
下图显示了上一节中两个节点图示例的一种可能执行的时间线。 n
和 m
的主体将分别称为 λn
和 λm
。 对 try_put
的三个调用产生三个任务 (Task); 每个都将 lambda 表达式 λn
应用于三个输入消息之一。 因为 n
具有无限的并发性,如果有足够的可用线程,这些任务可以并发执行。 对 g.wait_for_all()
的调用会阻塞,直到图中没有任务在执行。 与 oneTBB 中的其他 wait_for_all
函数一样,调用 wait_for_all
的线程在此期间不会空闲旋转,而是可以加入执行工作池中的其他任务。
当来自 n
的每个任务完成时,它将其输出放入 m
,因为 m
是 n
的后继。 与节点 n
不同,m
的并发限制为 1
,因此不会立即生成所有任务。 相反,它会按消息到达的顺序依次生成任务以在消息上执行其主体 λm
。 当所有任务都完成时,对 wait_for_all
的调用返回。
注意:
流程图中的所有执行都是异步发生的。 在立即生成任务或缓冲正在传递的消息之后,对try_put
的调用将控制权快速返回给调用线程。 同样,主体任务执行 lambda 表达式,然后将结果放入任何后继节点。 只有对wait_for_all
的调用会阻塞,因为它应该阻塞,即使在这种情况下,调用线程也可以在等待时用于执行 oneTBB 工作池中的任务。
上面的时间线显示了当有足够的线程来执行所有可以并行执行的任务时的顺序。 如果线程较少,一些衍生的任务将需要等待,直到有线程可以执行它们。
Flow Graph Basics: Message Passing Protocol
oneTBB 流图通过在节点之间传递消息来运行。 节点可能无法接收和处理来自其前任的消息。 为了使图最有效地运行,如果发生这种情况,节点之间的边的状态可以将其状态更改为拉,因此当后继节点能够处理消息时,它可以查询其前任以查看消息是否可用。 如果边没有从推到拉反转,则前任节点将不得不反复尝试转发其消息,直到后继节点接受它。 这将不必要地消耗资源。
一旦边处于拉模式,当后继节点不忙时,它会尝试从前任那里拉一条消息。
- 如果前任有消息,后继将处理它,边将保持拉模式。
- 如果前任没有消息,节点之间的边将从拉模式切换到推送模式。
这个 Push-Pull 协议的状态图是:
Flow Graph Basics: Single-push vs. Broadcast-push
oneTBB 流图中的节点通过推送和拉取消息进行通信。 根据节点的类型,使用两种推送消息的策略:
- 单推:无论节点存在多少后继者并且能够接受一条消息,每条消息都只会发送给一个后继者。
- 广播推送:消息将被推送到通过推送模式的边连接到节点的每个后继者,并接受该消息。
以下代码演示了这种差异:
using namespace oneapi::tbb::flow;
std::atomic<size_t> g_cnt;
struct fn_body1 {
std::atomic<size_t> &body_cnt;
fn_body1(std::atomic<size_t> &b_cnt) : body_cnt(b_cnt) {}
continue_msg operator()( continue_msg /*dont_care*/) {
++g_cnt;
++body_cnt;
return continue_msg();
}
};
void run_example1() { // example for Flow_Graph_Single_Vs_Broadcast.xml
graph g;
std::atomic<size_t> b1; // local counts
std::atomic<size_t> b2; // for each function _node body
std::atomic<size_t> b3; //
function_node<continue_msg> f1(g,serial,fn_body1(b1));
function_node<continue_msg> f2(g,serial,fn_body1(b2));
function_node<continue_msg> f3(g,serial,fn_body1(b3));
buffer_node<continue_msg> buf1(g);
//
// single-push policy
//
g_cnt = b1 = b2 = b3 = 0;
make_edge(buf1,f1);
make_edge(buf1,f2);
make_edge(buf1,f3);
buf1.try_put(continue_msg());
buf1.try_put(continue_msg());
buf1.try_put(continue_msg());
g.wait_for_all();
printf( "after single-push test, g_cnt == %d, b1==%d, b2==%d, b3==%d\n", (int)g_cnt, (int)b1, (int)b2, (int)b3);
remove_edge(buf1,f1);
remove_edge(buf1,f2);
remove_edge(buf1,f3);
//
// broadcast-push policy
//
broadcast_node<continue_msg> bn(g);
g_cnt = b1 = b2 = b3 = 0;
make_edge(bn,f1);
make_edge(bn,f2);
make_edge(bn,f3);
bn.try_put(continue_msg());
bn.try_put(continue_msg());
bn.try_put(continue_msg());
g.wait_for_all();
printf( "after broadcast-push test, g_cnt == %d, b1==%d, b2==%d, b3==%d\n", (int)g_cnt, (int)b1, (int)b2, (int)b3);
}
这段代码的输出是
after single-push test, g_cnt == 3, b1==3, b2==0, b3==0
after broadcast-push test, g_cnt == 9, b1==3, b2==3, b3==3
单推测试使用 buffer_node
,它具有用于转发消息的“单推”策略。 将三个消息放入 buffer_node
会导致推送三个消息。 还要注意只有第一个 function_node
被发送到; 一般来说,如果有多个后继者可以接受,则没有关于将节点推送到哪个节点的策略。
广播推送测试使用广播节点,它将把它收到的任何消息推送给所有接受的后继者。 将 3 条消息放入 broadcast_node
导致总共有 9 条消息推送到 function_nodes
。
只有设计用于缓冲(保存和转发接收到的消息)的节点才具有“单推”策略; 所有其他节点都有“广播-推送”策略。
Flow Graph Basics: Buffering and Forwarding
oneTBB 流图节点使用消息来传达数据并强制执行依赖关系。 如果一个节点成功地将消息传递给任何后继节点,则该节点不会对该消息采取进一步的操作。 如单推与广播推送一节所述,一条消息可以传递给一个或多个后继,这取决于节点的类型、连接到节点的后继数量以及消息是否被推送 或拉取。
有时节点无法成功地将消息推送到任何后继节点。 在这种情况下,消息会发生什么取决于节点的类型。 两种可能是:
- 节点存储稍后要转发的消息。
- 节点丢弃该消息。
如果节点丢弃未转发的消息,并且不希望出现此行为,则该节点应连接到存储无法推送的消息的缓冲节点。
如果消息已被节点存储,则可以通过两种方式将其传递给另一个节点:
- 节点的后继者可以使用
try_get()
或try_reserve()
拉取消息。 - 可以使用
make_edge()
连接后继。
如果 try_get()
成功转发消息,则将其从存储该消息的节点中删除。 如果使用 make_edge
连接节点,则该节点将尝试将存储的消息推送到新的后继节点。
Flow Graph Basics: Reservation
oneTBB 流图 join_node
有四种可能的策略:queueing
、reserving
、key_matching
和 tag_matching
。 join_node
在创建输出消息之前需要每个输入的消息。保留的 join_node
没有内部缓冲,并且在每个输入都有消息之前,它不会从其输入中提取消息。要创建输出消息,它会在每个输入端口临时保留一条消息,并且只有当所有输入端口都成功保留消息时,才会创建输出消息。如果任何输入端口保留消息失败,则 join_node
将不会拉取任何消息。
为了支持保留 join_node
,一些节点支持保留其输出。预订的工作方式是:
- 当连接到处于推送状态的保留
join_node
的节点尝试推送消息时,join_node
始终拒绝推送,并且连接节点的边切换为拉模式。 - 保留输入端口在拉动状态的每个边上调用
try_reserve
。这可能会失败;如果是,则保留输入端口将该边切换为推状态,并尝试保留由处于拉状态的边连接的下一个节点。当输入端口的前任处于保留状态时,没有其他节点可以检索保留值。 - 如果每个输入端口成功保留一条处于拉取状态的边,保留的
join_node
将使用保留的消息创建一条消息,并尝试将生成的消息推送到连接到它的任何节点。 - 如果消息成功推送到后继节点,则保留的前驱节点会被告知消息已被使用(通过调用
try_consume()
)。这些消息将被前驱节点丢弃,因为它们已被成功推送。 - 如果消息没有成功推送到任何后继节点,则保留的前驱节点会被告知消息未被使用(通过调用
try_release()
)。此时,消息可能会被推送到其他节点或被其他节点拉取。
因为保留 join_node
只会在每个输入端口至少有一个边处于拉状态时才会尝试推送,并且只有在所有输入端口都成功保留消息时才会尝试创建和推送消息,所以每个输入端口至少有一个前驱 保留的 join_node
输入端口必须是可保留的。
以下示例演示了保留 join_node
的行为。 buffer_node
缓冲他们的输出,所以他们接受他们的输出边从推模式到拉模式的切换。 broadcast_node
不缓冲消息并且不支持 try_get()
或 try_reserve()
。
void run_example2() { // example for Flow_Graph_Reservation.xml
graph g;
broadcast_node<int> bn(g);
buffer_node<int> buf1(g);
buffer_node<int> buf2(g);
typedef join_node<tuple<int,int> reserving> join_type;
join_type jn(g);
buffer_node<join_type::output_type> buf_out(g);
join_type::output_type tuple_out;
int icnt;
// join_node predecessors are both reservable buffer_nodes
make_edge(buf1,input_port<0>jn));
make_edge(bn,input_port<0>jn)); // attach a broadcast_node
make_edge(buf2,input_port<1>jn));
make_edge(jn, buf_out);
bn.try_put(2);
buf1.try_put(3);
buf2.try_put(4);
buf2.try_put(7);
g.wait_for_all();
while (buf_out.try_get(tuple_out)) {
printf("join_node output == (%d,%d)\n",get<0>tuple_out), get<1>tuple_out) );
}
if(buf1.try_get(icnt)) printf("buf1 had %d\n", icnt);
else printf("buf1 was empty\n");
if(buf2.try_get(icnt)) printf("buf2 had %d\n", icnt);
else printf("buf2 was empty\n");
}
在上面的例子中,reserving
类型的 join_node jn
的端口 0
有两个前驱:buffer_node buf1
和 broadcast_node bn
。 join_node
的端口 1
有一个前任,buffer_node buf2
。
我们将讨论一种可能的执行顺序(任务的调度可能略有不同,但最终结果将是相同的)。
bn.try_put(2);
bn
尝试将 2
转发给 jn
。 jn
不接受该值,并且从 bn
到 jn
的弧反转。 因为 bn
和 jn
都没有缓冲消息,所以消息被丢弃。 因为并非 jn
的所有输入都有可用的前辈,所以 jn
不会做任何进一步的事情。
注意:
任何不支持保留的节点在附加到保留join_node
时都将无法正常工作。 该程序演示了为什么会发生这种情况; 不推荐将非保留节点连接到需要支持保留的节点。
buf1.try_put(3);
buf1
尝试将 3
转发给 jn
。 jn
不接受该值,并且从 buf1
到 jn
的弧反转。 因为并非 jn
的所有输入都有可用的前辈,所以 jn
不会做任何进一步的事情。
buf2.try_put(4);
buf2
尝试将 4
转发给 jn
。 jn
不接受该值,并且从 buf2
到 jn
的弧反转。 现在 jn
的两个输入都有前辈,将生成一个任务来构建和转发来自 jn
的消息。 我们假设任务尚未执行。
buf2.try_put(7);
buf2
没有后继(因为 jn
的弧被反转),所以它存储值 7
。
现在生成运行 jn
运行的任务。
jn
尝试保留bn
,但失败了。 到bn
的弧切换回正向。jn
尝试保留buf1
,它成功(保留的节点为灰色。)jn
从buf1
接收值3
,但它保留在buf1
中(以防尝试从jn
转发消息失败。)jn
尝试保留buf2
,成功。jn
从buf2
接收值4
,但它保留在buf2
中。jn
构造输出消息元组<3,4>
。
现在jn
将其消息推送到buf_out
,后者接受它。 由于推送成功,jn
向buf1
和buf2
发出信号,表示使用了保留值,缓冲区丢弃这些值。 现在jn
再次尝试保留。- 没有尝试从
bn
拉取,因为从bn
到jn
的边处于推送状态。 jn
尝试保留buf1
,但失败了。buf1
的弧切换回正向。jn
不会尝试任何进一步的操作。
图中没有进一步的活动,wait_for_all()
将完成。 这段代码的输出是
join_node output == (3,4)
buf1 was empty
buf2 had 7