并行长时间运行任务的时间优化

时间:2019-01-08 12:21:21

标签: c# parallel-processing

简介

我正在使用一个复杂的外部库,试图在大量项目上执行它的功能。该库没有提供良好的异步接口,因此我受困于一些相当老式的代码。

我的目的是优化完成一批处理所需的时间,并演示问题,而不必包括我在下面创建的近似问题的第三方库

问题

给出一个非异步操作,您可以在其中提前知道该操作的“大小”(即复杂性):

public interface IAction
{
    int Size { get; }
    void Execute();
}

鉴于此操作有3种变体:

public class LongAction : IAction
{
    public int Size => 10000;
    public void Execute()
    {
        Thread.Sleep(10000);
    }
}

public class MediumAction : IAction
{

    public int Size => 1000;
    public void Execute()
    {
        Thread.Sleep(1000);
    }
}

public class ShortAction : IAction
{
    public int Size => 100;
    public void Execute()
    {
        Thread.Sleep(100);
    }
}

您如何优化这些动作的一长串列表,以便以某种并行方式运行时,整个批处理尽快完成?

天真地,您可以将整个批次扔到Parallel.ForEach上,并具有相当高的并行度,这当然可以工作-但是必须有一种最佳调度它们的方法,以便最大的一些首先开始。 / p>

为了进一步说明问题,如果我们使用一个超级简化的示例

  • 1个大小为10的任务
  • 5个大小为2的任务
  • 大小为1的10个任务

和2个可用线程。我可以想出两种(许多方式)来安排这些任务(黑条是停滞时间,没什么可安排的):

enter image description here

显然,第一个比第二个更早完成。

最少完整且可验证的代码

如果有人喜欢bash,请使用完整的测试代码(尝试使其比下面的我的朴素实现更快):

class Program
{
    static void Main(string[] args)
    {
        MainAsync().GetAwaiter().GetResult();
        Console.ReadLine();
    }

    static async Task MainAsync()
    {
        var list = new List<IAction>();
        for (var i = 0; i < 200; i++) list.Add(new LongAction());
        for (var i = 0; i < 200; i++) list.Add(new MediumAction());
        for (var i = 0; i < 200; i++) list.Add(new ShortAction());


        var swSync = Stopwatch.StartNew();
        Parallel.ForEach(list, new ParallelOptions { MaxDegreeOfParallelism = 20 }, action =>
        {
            Console.WriteLine($"{DateTime.Now:HH:mm:ss}: Starting action {action.GetType().Name} on thread {Thread.CurrentThread.ManagedThreadId}");
            var sw = Stopwatch.StartNew();
            action.Execute();
            sw.Stop();
            Console.WriteLine($"{DateTime.Now:HH:mm:ss}: Finished action {action.GetType().Name} in {sw.ElapsedMilliseconds}ms on thread {Thread.CurrentThread.ManagedThreadId}");
        });
        swSync.Stop();
        Console.WriteLine($"Done in {swSync.ElapsedMilliseconds}ms");
    }
}


public interface IAction
{
    int Size { get; }
    void Execute();
}

public class LongAction : IAction
{
    public int Size => 10000;
    public void Execute()
    {
        Thread.Sleep(10000);
    }
}

public class MediumAction : IAction
{

    public int Size => 1000;
    public void Execute()
    {
        Thread.Sleep(1000);
    }
}

public class ShortAction : IAction
{
    public int Size => 100;
    public void Execute()
    {
        Thread.Sleep(100);
    }
}

4 个答案:

答案 0 :(得分:1)

刚开始我要说的是问题如下。

您有一个整数列表和一个有限的加法器。 您需要一种将整数加到加法器中的算法,以使加法器的最大值尽可能小。

例如:

list = 1, 4, 10, 2, 3, 4
summers = 3

summer(1): 10
summer(2): 4 + 4
summer(3): 3 + 2 + 1

如您所见,边界因素是运行时间最长的任务。较短的可轻松并行使用或在较短的时间内使用。它类似于背包,但最终归结为一个非常简单的“最长的优先”任务排序。

伪代码(带有我发明的 类)将是:

while (taskManager.HasTasks())
{
    task = taskManager.GetLongestTask();
    thread = threadManager.GetFreeThread(); // blocks if no thread available
    thread.Run(task);
}

这只是伪代码,不是并行/异步和块。我希望您可以从中得到一些有用的信息。

答案 1 :(得分:1)

嗯,这取决于。在我的硬件上,如果我只是更改循环以运行所有长任务,那么您精心设计的示例(已修改,因此我没有整天的睡眠时间分别为1000,100和10ms)快了约30%(约15s与22s)首先:

Parallel.ForEach(list.OrderByDescending(l=>l.Size), action => ...

但是,当然,这完全取决于这些任务的负担。如果两个不同的任务大量使用相同的资源(例如共享数据库),那么并行运行这两个任务可能会获得非常有限的收益,因为它们最终将彼此锁定一段时间。

我建议您需要进行更深入的分析,然后根据任务的“可并行性”将其基于实际执行的工作进行分组,并尝试确保尽可能多地运行并行线程承担尽可能多的“兼容”任务...当然,如果一个特定任务似乎总是花所有其他任务在一起的时间,请确保首先启动一个任务。...

很难通过此处提供的详细信息提供更好的建议。

答案 2 :(得分:1)

按任务大小降序排序,然后使用TaskFactory在不同的任务中执行每个任务,从而节省了大量的运行时间。并行度保持为20。 结果为:114,676ms与原始样本中的193,713ms。 (〜40%的改善)

编辑:在您的特定示例中,列表始终是从一开始就进行排序的,但是Parallel.ForEach不会保留输入顺序。

static async Task MainAsync()
{
    var list = new List<IAction>();
    for (var i = 0; i < 200; i++) list.Add(new LongAction());
    for (var i = 0; i < 200; i++) list.Add(new MediumAction());
    for (var i = 0; i < 200; i++) list.Add(new ShortAction());

    Console.WriteLine("Sorting...");
    list.Sort((x, y) => y.Size.CompareTo(x.Size));
    int totalTasks = 0;

    int degreeOfParallelism = 20;
    var swSync = Stopwatch.StartNew();
    using (SemaphoreSlim semaphore = new SemaphoreSlim(degreeOfParallelism))
    {
        foreach (IAction action in list)
        {
            semaphore.Wait();
            Task.Factory.StartNew(() =>
            {
                try
                {
                    Console.WriteLine($"{DateTime.Now:HH:mm:ss}: Starting action {action.GetType().Name} on thread {Thread.CurrentThread.ManagedThreadId}");
                    var sw = Stopwatch.StartNew();
                    action.Execute();
                    sw.Stop();
                    Console.WriteLine($"{DateTime.Now:HH:mm:ss}: Finished action {action.GetType().Name} in {sw.ElapsedMilliseconds}ms on thread {Thread.CurrentThread.ManagedThreadId}");
                }
                finally
                {
                    totalTasks++;
                    semaphore.Release();
                }
            });
        }

        // Wait for remaining tasks....
        while (semaphore.CurrentCount < 20)
        { }

        swSync.Stop();
        Console.WriteLine($"Done in {swSync.ElapsedMilliseconds}ms");
        Console.WriteLine("Performed total tasks: " + totalTasks);
    }
}

答案 3 :(得分:1)

一种相对快速且肮脏的解决方案是在按大小减小排序的操作列表的顶部使用Documentation on this

var sorted = list.OrderByDescending(a => a.Size).ToArray();
var partitioner=Partitioner.Create(sorted, loadBalance:true);

Parallel.ForEach(partitioner, options, action =>...);

与其他答案一样,仅使用这两行性能即可提高约30%。

PLINQ对数据进行分区,并使用单独的任务一次处理整个分区。当知道输入大小时(如IList派生的数组和列表一样),将输入分成相等大小的块,并馈送到每个辅助任务。

当大小未知时(如迭代器方法,LINQ查询等情况),PLINQ使用块分区。一次检索一大块数据,并将其提供给工作人员任务。

我忘记的另一种选择是在顶级块分区上的负载平衡。这将使用小块的块分区应用于数组和IList派生的输入。负载平衡a load-balancing partitioner重载了返回的OrderablePartitioner实例,因此保留了IAction项的顺序

通过指定IEnumerable<T>选项,可以使用EnumerablePartitionerOptions.NoBuffering源实现相同的目的:

var sorted = list.OrderByDescending(a => a.Size);
var partitioner=Partitioner.Create(sorted,EnumerablePartitionerOptions.NoBuffering);

这将创建一个使用块编码的OrderablePartitioner