从编译时依赖图(DAG)构建异步`future`回调链

时间:2016-03-03 17:24:36

标签: c++ multithreading algorithm asynchronous future

我有一个编译时directed acyclic graph的异步任务。 DAG显示了任务之间的依赖关系:通过分析它,可以理解哪些任务可以并行运行(在单独的线程中)以及需要等待其他任务完成的任务在他们开始之前(依赖)。

我想使用boost::future.then(...)when_all(...)延续辅助函数从DAG生成回调链。这一代的结果将是一个函数,当被调用时,将启动回调链并执行DAG所描述的任务,并行运行尽可能多的任务。

但是,我遇到了麻烦,找到了适用于所有情况的通用算法。

我制作了一些图纸,以使问题更容易理解。这是一个图例,它将向您展示图纸中的符号含义:

Legend: how to read the images.

让我们从一个简单的线性DAG开始:

Example 0: linear DAG.

此依赖关系图包含三个任务ABCC取决于BB取决于A。这里没有并行性的可能性 - 生成算法会构建类似于此的东西:

boost::future<void> A, B, C, end;

A.then([]
    {
        B.then([]
            {
                C.get();
                end.get();
            });
    });

(请注意,所有代码示例都不是100%有效 - 我忽略了移动语义,转发和lambda捕获。)

有许多方法可以解决这个线性DAG:无论是从结束还是从开头开始,构建正确的回调链都是微不足道的。

引入forks and joins时,事情开始变得更加复杂。

这是一个带有分叉/加入的DAG:

Example 1: DAG with a fork/join.

很难想到与此DAG匹配的回调链。如果我尝试向后工作,从最后开始,我的推理如下:

  • end取决于BD(合并)
    • D取决于C
    • BC取决于A(叉)

可能的链看起来像这样:

boost::future<void> A, B, C, D, end;

A.then([]
    {
        boost::when_all(B, C.then([]
                               {
                                   D.get();
                               }))
            .then([]
                {
                    end.get();
                });
    });

我发现很难手工编写这个链条,我也对它的正确性表示怀疑。我无法想到实现可以生成此算法的一般方法 - 由于when_all需要将其参数移入其中,因此还存在其他困难。

让我们看看最后一个,甚至更复杂的例子:

Example 2: complex DAG.

这里我们希望尽可能地利用并行性。考虑任务EE可以与任何[B, C, D]并行运行。

这是一个可能的回调链:

boost::future<void> A, B, C, D, E, F, end;

A.then([]
    {
        boost::when_all(boost::when_all(B, C).then([]
                            {
                                D.get();
                            }),
            E)
            .then([]
                {
                    F.then([]
                        {
                            end.get();
                        });
                });
    });

我试图通过多种方式提出一般算法:

  • 从DAG开始,尝试使用.then(...)延续来构建链。这不适用于连接,因为目标连接任务会重复多次。

  • 从DAG结束开始,尝试使用when_all(...)延续生成链。这会因为分叉而失败,因为创建分叉的节点会重复多次。

显然,&#34;广度优先遍历&#34;方法在这里运作不好。从我手写的代码示例中,似乎算法需要知道forks和join,并且需要能够正确地混合.then(...)when_all(...)个continuation。

以下是我的最后一个问题:

  • 是否始终可以从任务依赖关系的DAG生成基于future的回调链,其中每个任务在回调链中只出现一次?

  • 如果是这样,在给定任务依赖性DAG构建回调链的情况下,如何实现一般算法?

编辑1:

Here's an additional approach我正试图探索。

我们的想法是从DAG生成([dependencies...] -> [dependents...])地图数据结构,并从该地图生成回调链。

如果len(dependencies...) > 1,则value加入节点。

如果len(dependents...) > 1,则key fork 节点。

地图中的所有键值对都可以表示为when_all(keys...).then(values...)续点。

困难的部分是找出正确的顺序,以便扩大&#34; (考虑与解析器类似的东西)节点以及如何将fork / join continuation连接在一起。

考虑以下地图,由图片4生成。

depenendencies  |  dependents
----------------|-------------
[F]             :  [end]
[D, E]          :  [F]
[B, C]          :  [D]
[A]             :  [E, C, B]
[begin]         :  [A]

通过应用某种类似解析器的减少/传递,我们可以得到一个&#34; clean&#34;回调链:

// First pass:
// Convert everything to `when_all(...).then(...)` notation
when_all(F).then(end)
when_all(D, E).then(F)
when_all(B, C).then(D)
when_all(A).then(E, C, B)
when_all(begin).then(A)

// Second pass:
// Solve linear (trivial) transformations
when_all(D, E).then(
    when_all(F).then(end)
)
when_all(B, C).then(D)
when_all(
    when_all(begin).then(A)
).then(E, C, B)

// Third pass:
// Solve fork/join transformations
when_all(
    when_all(begin).then(A)
).then(
    when_all(
        E, 
        when_all(B, C).then(D)
    ).then(
        when_all(F).then(end)
    )   
)

第三遍是最重要的一个,但也是一个看起来很难设计算法的。

注意必须在[B, C]列表中找到[E, C, B],以及[D, E]依赖列表中D必须如何解释为when_all(B, C).then(D)的结果1}}并与E中的when_all(E, when_all(B, C).then(D))链接在一起。

也许整个问题可以简化为:

如果地图包含[dependencies...] -> [dependents...]个键值对,那么如何实现将这些对转换为when_all(...) / .then(...)延续链的算法?

编辑2:

这里有一些pseudocode我想出了上述方法。它似乎适用于我尝试的DAG,但我需要花更多的时间在它上面,并且在心理上&#34;用其他更棘手的DAG配置测试它。

6 个答案:

答案 0 :(得分:8)

最简单的方法是从图形的条目节点开始,就像您手动编写代码一样。为了解决join问题,您无法使用递归解决方案,需要获得图表的topological ordering然后根据订单构建图表

这保证了在构建节点时,已经创建了所有的前任。

为了实现这一目标,我们可以使用带有reverse postordering的DFS。

进行拓扑排序后,您可以忘记原始节点ID,并在列表中引用其编号的节点。为此,您需要创建一个编译时间映射,允许使用拓扑排序中的节点索引而不是节点原始节点索引来检索节点前导。

编辑:关于如何在编译时实现拓扑排序,我重构了这个答案。

要在同一页面上,我会假设您的图表如下所示:

struct mygraph
{
     template<int Id>
     static constexpr auto successors(node_id<Id>) ->
        list< node_id<> ... >; //List of successors for the input node

     template<int Id>
     static constexpr auto predecessors(node_id<Id>) ->
        list< node_id<> ... >; //List of predecessors for the input node

     //Get the task associated with the given node.
     template<int Id>
     static constexpr auto task(node_id<Id>);

     using entry_node = node_id<0>;
};

第1步:拓扑排序

您需要的基本要素是node-id的编译时集。在TMP中,集合也是一个列表,只是因为在set<Ids...>Ids的顺序很重要。这意味着您可以使用相同的数据结构来编码有关节点是否已被访问以及同时生成的排序的信息。

/** Topological sort using DFS with reverse-postordering **/
template<class Graph>
struct topological_sort
{
private:
    struct visit;

    // If we reach a node that we already visited, do nothing.
    template<int Id, int ... Is>
    static constexpr auto visit_impl( node_id<Id>,
                                      set<Is...> visited,
                                      std::true_type )
    {
        return visited;
    }

    // This overload kicks in when node has not been visited yet.
    template<int Id, int ... Is>
    static constexpr auto visit_impl( node_id<Id> node,
                                      set<Is...> visited,
                                      std::false_type )
    {
        // Get the list of successors for the current node
        constexpr auto succ = Graph::successors(node);

        // Reverse postordering: we call insert *after* visiting the successors
        // This will call "visit" on each successor, updating the
        // visited set after each step.
        // Then we insert the current node in the set.
        // Notice that if the graph is cyclic we end up in an infinite
        // recursion here.
        return fold( succ,
                     visited,
                     visit() ).insert(node);

        // Conventional DFS would be:
        // return fold( succ, visited.insert(node), visit() );
    }

    struct visit
    {
        // Dispatch to visit_impl depending on the result of visited.contains(node)
        // Note that "contains" returns a type convertible to
        // integral_constant<bool,x>
        template<int Id, int ... Is>
        constexpr auto operator()( set<Is...> visited, node_id<Id> node ) const
        {
            return visit_impl(node, visited, visited.contains(node) );
        }
    };

public:
    template<int StartNodeId>
    static constexpr auto compute( node_id<StartNodeId> node )
    {
        // Start visiting from the entry node
        // The set of visited nodes is initially empty.
        // "as_list" converts set<Is ... > to list< node_id<Is> ... >.
        return reverse( visit()( set<>{}, node ).as_list() );
    }
};

此算法包含上一个示例中的图表(假设A = node_id<0>B = node_id<1>等),生成list<A,B,C,D,E,F>

第2步:图表地图

这只是一个适配器,它根据给定的顺序修改图中每个节点的Id。因此,假设先前的步骤返回list<C,D,A,B>,此graph_map会将索引0映射到C,将索引1映射到D等。

template<class Graph, class List>
class graph_map
{   
    // Convert a node_id from underlying graph.
    // Use a function-object so that it can be passed to algorithms.
    struct from_underlying
    { 
        template<int I>
        constexpr auto operator()(node_id<I> id) 
        { return node_id< find(id, List{}) >{}; }
    };

    struct to_underlying
    { 
        template<int I>
        constexpr auto operator()(node_id<I> id) 
        { return get<I>(List{}); }
    };

public:        
    template<int Id>
    static constexpr auto successors( node_id<Id> id )
    {
        constexpr auto orig_id = to_underlying()(id);
        constexpr auto orig_succ = Graph::successors( orig_id );
        return transform( orig_succ, from_underlying() );
    }

    template<int Id>
    static constexpr auto predecessors( node_id<Id> id )
    {
        constexpr auto orig_id = to_underlying()(id);
        constexpr auto orig_succ = Graph::predecessors( orig_id );
        return transform( orig_succ, from_underlying() );
    }

    template<int Id>
    static constexpr auto task( node_id<Id> id )
    {
        return Graph::task( to_underlying()(id) );
    }

    using entry_node = decltype( from_underlying()( typename Graph::entry_node{} ) );
};

第3步:汇总结果

我们现在可以按顺序迭代每个节点id。由于我们构建图表地图的方式,我们知道I的所有前置项都有一个小于I的节点ID,用于每个可能的节点I

// Returns a tuple<> of futures
template<class GraphMap, class ... Ts>
auto make_cont( std::tuple< future<Ts> ... > && pred )
{
     // The next node to work with is N:
     constexpr auto current_node = node_id< sizeof ... (Ts) >();

     // Get a list of all the predecessors for the current node.
     auto indices = GraphMap::predecessors( current_node );

     // "select" is some magic function that takes a tuple of Ts
     // and an index_sequence, and returns a tuple of references to the elements 
     // from the input tuple that are in the indices list. 
     auto futures = select( pred, indices );

     // Assuming you have an overload of when_all that takes a tuple,
     // otherwise use C++17 apply.
     auto join = when_all( futures );

     // Note: when_all with an empty parameter list returns a future< tuple<> >,
     // which is always ready.
     // In general this has to be a shared_future, but you can avoid that
     // by checking if this node has only one successor.
     auto next = join.then( GraphMap::task( current_node ) ).share();

     // Return a new tuple of futures, pushing the new future at the back.
     return std::tuple_cat( std::move(pred),
                            std::make_tuple(std::move(next)) );         
}


// Returns a tuple of futures, you can take the last element if you
// know that your DAG has only one leaf, or do some additional 
// processing to extract only the leaf nodes.
template<class Graph>
auto make_callback_chain()
{
    constexpr auto entry_node = typename Graph::entry_node{};

    constexpr auto sorted_list = 
         topological_sort<Graph>::compute( entry_node );

    using map = graph_map< Graph, decltype(sorted_list) >;

    // Note: we are not really using the "index" in the functor here, 
    // we only want to call make_cont once for each node in the graph
    return fold( sorted_list, 
                 std::make_tuple(), //Start with an empty tuple
                 []( auto && tuple, auto index )
                 {
                     return make_cont<map>(std::move(tuple));
                 } );
}

Full live demo

答案 1 :(得分:7)

如果可能发生冗余依赖关系,请先删除它们(请参阅例如https://mathematica.stackexchange.com/questions/33638/remove-redundant-dependencies-from-a-directed-acyclic-graph)。

然后执行以下图形转换(在合并节点中构建子表达式),直到您到达单个节点(以类似于计算电阻网络的方式):

Graph transformations

*:其他传入或传出依赖项,具体取决于位置

(...):单个节点中的表达式

Java代码,包括更复杂示例的设置:

public class DirectedGraph {
  /** Set of all nodes in the graph */
  static Set<Node> allNodes = new LinkedHashSet<>();

  static class Node {
    /** Set of all preceeding nodes */
    Set<Node> prev = new LinkedHashSet<>();

    /** Set of all following nodes */
    Set<Node> next = new LinkedHashSet<>();

    String value;

    Node(String value) {
      this.value = value;
      allNodes.add(this);
    }

    void addPrev(Node other) {
      prev.add(other);
      other.next.add(this);
    }

    /** Returns one of the next nodes */
    Node anyNext() {
      return next.iterator().next();
    }

    /** Merges this node with other, then removes other */
    void merge(Node other) {
      prev.addAll(other.prev);
      next.addAll(other.next);
      for (Node on: other.next) {
        on.prev.remove(other);
        on.prev.add(this);
      }
      for (Node op: other.prev) {
        op.next.remove(other);
        op.next.add(this);
      }
      prev.remove(this);
      next.remove(this);
      allNodes.remove(other);
    }

    public String toString() {
      return value;
    }
  }

  /** 
   * Merges sequential or parallel nodes following the given node.
   * Returns true if any node was merged.
   */
  public static boolean processNode(Node node) {
    // Check if we are the start of a sequence. Merge if so.
    if (node.next.size() == 1 && node.anyNext().prev.size() == 1) {
      Node then = node.anyNext();
      node.value += " then " + then.value;
      node.merge(then);
      return true;
    }

    // See if any of the next nodes has a parallel node with
    // the same one level indirect target. 
    for (Node next : node.next) {

      // Nodes must have only one in and out connection to be merged.
      if (next.prev.size() == 1 && next.next.size() == 1) {

        // Collect all parallel nodes with only one in and out connection 
        // and the same target; the same source is implied by iterating over 
        // node.next again.
        Node target = next.anyNext().next();
        Set<Node> parallel = new LinkedHashSet<Node>();
        for (Node other: node.next) {
          if (other != next && other.prev.size() == 1
             && other.next.size() == 1 && other.anyNext() == target) {
            parallel.add(other);
          }
        }

        // If we have found any "parallel" nodes, merge them
        if (parallel.size() > 0) {
          StringBuilder sb = new StringBuilder("allNodes(");
          sb.append(next.value);
          for (Node other: parallel) {
            sb.append(", ").append(other.value);
            next.merge(other);
          }
          sb.append(")");
          next.value = sb.toString();
          return true;
        }
      }
    }
    return false;
  }

  public static void main(String[] args) {
    Node a = new Node("A");
    Node b = new Node("B");
    Node c = new Node("C");
    Node d = new Node("D");
    Node e = new Node("E");
    Node f = new Node("F");

    f.addPrev(d);
    f.addPrev(e);

    e.addPrev(a);

    d.addPrev(b);
    d.addPrev(c);

    b.addPrev(a);
    c.addPrev(a);

    boolean anyChange;
    do {
      anyChange = false;
      for (Node node: allNodes) {
        if (processNode(node)) {
          anyChange = true;
          // We need to leave the inner loop here because changes
          // invalidate the for iteration. 
          break;
        }
      }
      // We are done if we can't find any node to merge.
    } while (anyChange);

    System.out.println(allNodes.toString());
  }
}

输出:A then all(E, all(B, C) then D) then F

答案 2 :(得分:2)

如果您不再以显式依赖关系和组织DAG的形式思考它,这似乎相当容易。每个任务都可以按照以下内容进行组织(C#,因为解释这个想法要简单得多):

class MyTask
{
    // a list of all tasks that depend on this to be finished
    private readonly ICollection<MyTask> _dependenants;
    // number of not finished dependencies of this task
    private int _nrDependencies;

    public int NrDependencies
    {
        get { return _nrDependencies; }
        private set { _nrDependencies = value; }
    }
}

如果您以这种形式组织了DAG,问题实际上非常简单:可以执行_nrDependencies == 0的每个任务。所以我们需要一个类似于以下内容的run方法:

public async Task RunTask()
{
    // Execute actual code of the task.
    var tasks = new List<Task>();
    foreach (var dependent in _dependenants)
    {
        if (Interlocked.Decrement(ref dependent._nrDependencies) == 0)
        {
            tasks.Add(Task.Run(() => dependent.RunTask()));
        }
    }
    await Task.WhenAll(tasks);
}

基本上,只要我们的任务完成,我们就会遍历所有家属并执行所有那些没有更多未完成依赖项的人。

要开始全部工作,你唯一需要做的就是为所有开始时没有依赖的任务调用RunTask()(因为我们有一个DAG,所以至少有一个必须存在)。所有这些任务完成后,我们就知道整个DAG已经执行完毕。

答案 3 :(得分:2)

此图不是在编译时构建的,但我不清楚这是否是必需的。该图保存在以adjacency_list<vecS, vecS, bidirectionalS>实现的增强图中。单个调度将启动任务。我们只需要每个节点的边缘,以便我们知道我们在等什么。这是在下面的调度程序中实例化时预先计算的。

我认为不需要完整的拓扑排序。

例如,如果依赖图是:

enter image description here

使用scheduler_driver.cpp

对于

中的联接

enter image description here

重新定义Graph以定义有向边。

所以,回答你的两个问题:

。是的,对于DAG。每个节点只需要唯一的直接依赖关系,可以如下预先计算。然后,可以使用单个调度启动依赖关系链,并且多米诺骨牌链会崩溃。

。是的,请参阅下面的算法(使用C ++ 11线程,而不是boost::thread)。对于分叉,通信需要shared_future,而基于future的通信支持联接。

scheduler_driver.hpp:

#ifndef __SCHEDULER_DRIVER_HPP__
#define __SCHEDULER_DRIVER_HPP__

#include <iostream>
#include <ostream>
#include <iterator>
#include <vector>
#include <chrono>

#include "scheduler.h"

#endif

scheduler_driver.cpp:

#include "scheduler_driver.hpp"

enum task_nodes
  {
    task_0,
    task_1,
    task_2,
    task_3,
    task_4,
    task_5,
    task_6,
    task_7,
    task_8,
    task_9,
    N
  };

int basic_task(int a, int d)
{
  std::chrono::milliseconds sleepDuration(d);
  std::this_thread::sleep_for(sleepDuration);
  std::cout << "Result: " << a << "\n";
  return a;
}

using namespace SCHEDULER;

int main(int argc, char **argv)
{

  using F = std::function<R()>;

  Graph deps(N);
  boost::add_edge(task_0, task_1, deps);
  boost::add_edge(task_0, task_2, deps);
  boost::add_edge(task_0, task_3, deps);
  boost::add_edge(task_1, task_4, deps);
  boost::add_edge(task_1, task_5, deps);
  boost::add_edge(task_1, task_6, deps);
  boost::add_edge(task_2, task_7, deps);
  boost::add_edge(task_2, task_8, deps);
  boost::add_edge(task_2, task_9, deps);

  std::vector<F> tasks = 
    {
      std::bind(basic_task, 0, 1000),
      std::bind(basic_task, 1, 1000),
      std::bind(basic_task, 2, 1000),
      std::bind(basic_task, 3, 1000),
      std::bind(basic_task, 4, 1000),
      std::bind(basic_task, 5, 1000),
      std::bind(basic_task, 6, 1000),
      std::bind(basic_task, 7, 1000),
      std::bind(basic_task, 8, 1000),
      std::bind(basic_task, 9, 1000)
    };

  auto s = std::make_unique<scheduler<int>>(std::move(deps), std::move(tasks));
  s->doit();

  return 0;
}

scheduler.h:

#ifndef __SCHEDULER2_H__
#define __SCHEDULER2_H__

#include <iostream>
#include <vector>
#include <iterator>
#include <functional>
#include <algorithm>
#include <mutex>
#include <thread>
#include <future>
#include <boost/graph/graph_traits.hpp>
#include <boost/graph/adjacency_list.hpp>
#include <boost/graph/depth_first_search.hpp>
#include <boost/graph/visitors.hpp>

using namespace boost;

namespace SCHEDULER
{

  using Graph = adjacency_list<vecS, vecS, bidirectionalS>;
  using Edge = graph_traits<Graph>::edge_descriptor;
  using Vertex = graph_traits<Graph>::vertex_descriptor;
  using VectexCont = std::vector<Vertex>;
  using outIt = graph_traits<Graph>::out_edge_iterator;
  using inIt = graph_traits<Graph>::in_edge_iterator;

  template<typename R>
    class scheduler
    {
    public:
      using ret_type = R;
      using fun_type = std::function<R()>;
      using prom_type = std::promise<ret_type>;
      using fut_type = std::shared_future<ret_type>;

      scheduler() = default;
      scheduler(const Graph &deps_, const std::vector<fun_type> &tasks_) :
        g(deps_),
        tasks(tasks_) { init_();}
        scheduler(Graph&& deps_, std::vector<fun_type>&& tasks_) :
          g(std::move(deps_)),
          tasks(std::move(tasks_)) { init_(); }
        scheduler(const scheduler&) = delete;
        scheduler& operator=(const scheduler&) = delete;

        void doit();

    private:
        void init_();
        std::list<Vertex> get_sources(const Vertex& v);
        auto task_thread(fun_type&& f, int i);

        Graph g;
        std::vector<fun_type> tasks;
        std::vector<prom_type> prom;
        std::vector<fut_type> fut;
        std::vector<std::thread> th;
        std::vector<std::list<Vertex>> sources;

    };

  template<typename R>
    void
    scheduler<R>::init_()
    {
      int num_tasks = tasks.size();

      prom.resize(num_tasks);
      fut.resize(num_tasks);

      // Get the futures
      for(size_t i=0;
          i<num_tasks;
          ++i)
        {
          fut[i] = prom[i].get_future();
        }

      // Predetermine in_edges for faster traversal
      sources.resize(num_tasks);
      for(size_t i=0;
          i<num_tasks;
          ++i)
        {
          sources[i] = get_sources(i);
        }
    }

  template<typename R>
    std::list<Vertex>
    scheduler<R>::get_sources(const Vertex& v)
    {
      std::list<Vertex> r;
      Vertex v1;
      inIt j, j_end;
      boost::tie(j,j_end) = in_edges(v, g);
      for(;j != j_end;++j)
        {
          v1 = source(*j, g);
          r.push_back(v1);
        }
      return r;
    }

  template<typename R>
    auto
    scheduler<R>::task_thread(fun_type&& f, int i)
    {
      auto j_beg = sources[i].begin(), 
        j_end = sources[i].end();
      for(;
          j_beg != j_end;
          ++j_beg)
        {
          R val = fut[*j_beg].get();
        }

      return std::thread([this](fun_type f, int i)
                         {
                           prom[i].set_value(f());
                         },f,i);
    }

  template<typename R>
    void
    scheduler<R>::doit()
    {
      size_t num_tasks = tasks.size();
      th.resize(num_tasks);

      for(int i=0;
          i<num_tasks;
          ++i)
        {
          th[i] = task_thread(std::move(tasks[i]), i);
        }
      for_each(th.begin(), th.end(), mem_fn(&std::thread::join));
    }

} // namespace SCHEDULER

#endif

答案 4 :(得分:1)

我不确定您的设置是什么以及为什么需要构建DAG,但我认为简单的贪婪算法可能就足够了。

when (some task have finished) {
     mark output resources done;
     find all tasks that can be run;
     post them to thread pool;
}

答案 5 :(得分:1)

考虑使用英特尔的TBB Flow Graph库。