【问题标题】:Analysis: Performance of ForkJoinPool分析:ForkJoinPool 的性能
【发布时间】:2013-11-29 14:52:36
【问题描述】:

问题

由于 Fork-Join 似乎是当前的炒作并在许多答案中被推荐,我想:为什么不研究一下它的实际速度呢?

为了测量这一点,我编写了一个小程序(见下面的代码),它对数字进行一些加法并使用各种参数将其分叉出来,包括线程数、分叉深度和分叉扩展,然后测量执行时间尤其是实际计算所花费的时间与分叉所花费的时间。

抽象答案

虽然实现得很好,但 ForkJoin 是一种非常低效的并行任务方式,因为每个 fork 的成本非常高。一个简单的问题优化实现可以轻松存档 99% 的线程执行时间(这超过了使用 Fork-Join 测量的所有内容),因此这样的实现总是比 Fork-Join 实现快。此外,如果每个 fork 的实际任务很小,则 Fork-Join 实现甚至可能比单线程线性实现慢得多。

因此,Fork-Join 更多的是关于它是否有助于代码架构的问题,因为与其他实现相比,它没有任何性能优势。因此,只有在以下情况下才应使用 Fork-Join:

  • 性能并不重要,任务经常需要等待其他任务的结果才能继续。所以基本上,如果 Fork-Join 结构比简单的实现大大简化了任务。

  • 实际任务花费的成本大大超过了分叉的成本,因此损失可以忽略不计。在我的测试中,添加 2 个值的循环必须在每个 fork 中循环至少 10000 次才能获得合理的性能。

编辑:请参阅here 以获得我所指出的更深入的分析。

测试设置

在我的程序中,我有一个 RecursiveTask 计算给定 N 的斐波那契数列,这将实际计算减少到 3 个作业和 1 个加法。对于任何给定的 CPU,这应该是一项次要任务。

在测试中,我改变了线程数量、每个任务的分叉数量和斐波那契循环的长度。此外,我用 async 参数做了一些测试,但是将这个设置为 false 只显示计算时间略有减少,所以我跳过了。传播参数(每个叉子的叉子)也大部分被跳过,因为结果没有显着差异。

通常计算时间非常稳定,实际花费在任务上的时间百分比通常变化小于 1%,因此每个测试集在一个否则有 4 个核心(+4 个超核心)的空闲系统,然后选择了中值执行时间。

已通过各种测试变量验证了正确执行,尤其是已验证实际使用的线程数与最初给出的 parallelism 参数没有差异。

详细的测试结果

地点:

  • Time total 是从主线程的角度来看整个计算所花费的总时间。
  • Time task 是实际计算所有分叉组合中的斐波那契数列所花费的时间。
  • Time task percentage 是线程带来的相对收益(时间任务/总时间)。
  • spread->depth 是(设置的)传播(每个叉的叉)和(计算的)叉深度。
  • threads 是实际使用的线程数。
  • task-time/thread 是每个线程实际花费在总体上计算斐波那契数列的时间。

传播->深度测试:

Time total: 8766.670 ms, time task: 1717.418 ms ( 19.59%), spread->depth:  2->26, thread#: 1, task-time/thread: 19.59%
Time total: 7872.244 ms, time task: 1421.478 ms ( 18.06%), spread->depth: 10-> 8, thread#: 1, task-time/thread: 18.06%
Time total: 7336.052 ms, time task: 1280.036 ms ( 17.45%), spread->depth: 100-> 4, thread#: 1, task-time/thread: 17.45%

结论:分叉的数量只有很小的影响(仍然更少的分叉=更好),实现似乎相当复杂。使用其他设置收集了类似的结果,所以我在这里跳过这些。

Fib(0)(几乎所有时间都花在分叉上)

Time total: 7866.777 ms, time task: 1421.488 ms ( 18.07%), spread->depth: 10-> 8, thread#: 1, task-time/thread: 18.07%
Time total: 7085.142 ms, time task: 1349.207 ms ( 19.04%), spread->depth: 10-> 8, thread#: 2, task-time/thread:  9.52%
Time total: 6580.609 ms, time task: 1476.467 ms ( 22.44%), spread->depth: 10-> 8, thread#: 4, task-time/thread:  5.61%

结论:对于一个非常小的任务,大部分时间都花在了 fork 上,这使得单线程实现比任何 Fork-Join 设置快约 5 倍。即使有多个线程,使用 Fork-Join 也不可能获得任何性能提升。

Fib(100)

Time total: 12487.634 ms, time task: 5707.720 ms ( 45.71%), spread->depth: 10-> 8, thread#: 1, task-time/thread: 45.71%
Time total:  8386.855 ms, time task: 5768.881 ms ( 68.78%), spread->depth: 10-> 8, thread#: 2, task-time/thread: 34.39%
Time total:  7078.769 ms, time task: 6086.997 ms ( 85.99%), spread->depth: 10-> 8, thread#: 4, task-time/thread: 21.50%

结论:似乎已经接近单线程执行的盈亏平衡点,而多线程开始产生影响。单线程实现仍然比任何 Fork-Join 设置都快。

Fib(1000)

Time total:  5941.344 ms, time task:  5228.258 ms ( 88.00%), spread->depth: 10-> 7, thread#: 1, task-time/thread: 88.00%
Time total:  3160.818 ms, time task:  5244.241 ms (165.91%), spread->depth: 10-> 7, thread#: 2, task-time/thread: 82.96%
Time total: 16301.697 ms, time task: 53351.694 ms (327.28%), spread->depth: 10-> 8, thread#: 4, task-time/thread: 81.82%

结论:多线程执行的时间开始趋于稳定,几乎线性增益,而每个线程仍有约 20% 的计算时间用于分叉。虽然此时分叉可以通过线程提高性能,但幼稚的实现仍然会明显更快。

Fib(10000)

Time total:  5204.786 ms, time task:  5119.133 ms ( 98.35%), spread->depth: 10-> 6, thread#: 1, task-time/thread: 98.35%
Time total: 26033.889 ms, time task: 51084.118 ms (196.22%), spread->depth: 10-> 7, thread#: 2, task-time/thread: 98.11%
Time total: 13183.573 ms, time task: 51637.471 ms (391.68%), spread->depth: 10-> 7, thread#: 4, task-time/thread: 97.92%

结论:在这个数字上,计算超过了分叉的成本。虽然简单的实现仍然会稍微快一些,但如果任务以另一种方式实现起来会更加困难,那么分叉造成的损失可以忽略不计。

代码

public class Test {

  static final int NUM_THREADS = 4;
  static final int SPREAD = 10;
  static final int LOOPS = 4000000;
  static final int CALCULATION_N = 10000;
  static final boolean DO_ASYNC = true;
  //---
  static final long MAX_DEPTH = Math.round(Math.log(LOOPS) / Math.log(SPREAD)); // try to have the execution take about the same time

  private static class Task extends RecursiveTask<Integer> {

    final static AtomicLong timeExecute = new AtomicLong(0);
    final static AtomicLong totalLoops = new AtomicLong(0);
    final long depth;

    public Task(final long depth) {
      this.depth = depth;
    }

    @Override
    protected Integer compute() {
      if (depth < MAX_DEPTH) {
        final Task[] subTasks = new Task[SPREAD];
        for (int i = 0; i < subTasks.length; ++i) {
          subTasks[i] = new Task(depth + 1);
        }
        try {
          invokeAll(subTasks);
          final long startTime = System.nanoTime();
          int result = 0;
          for (final Task task : subTasks) {
            if (task.isCompletedNormally()) {
              result += task.get();
            }
          }
          timeExecute.addAndGet(System.nanoTime() - startTime);
          return result;
        } catch (Exception e) {
          this.completeExceptionally(e);
          return null;
        }
      } else {
        totalLoops.incrementAndGet();
        final long startTime = System.nanoTime();
        int a = 0, b = 1, h;
        for (int n = 0; n < CALCULATION_N; ++n) {
          h = b;
          b = a + b;
          a = h;
        }
        timeExecute.addAndGet(System.nanoTime() - startTime);
        return b;
      }
    }
  }

  public static void main(String[] args) {
    final AtomicInteger threadCount = new AtomicInteger(0);
    final ForkJoinPool pool = new ForkJoinPool(NUM_THREADS, new ForkJoinPool.ForkJoinWorkerThreadFactory() {
      @Override
      public ForkJoinWorkerThread newThread(ForkJoinPool pool) {
        threadCount.getAndIncrement();
        final ForkJoinWorkerThread result = ForkJoinPool.defaultForkJoinWorkerThreadFactory.newThread(pool);
        result.setPriority(Thread.MIN_PRIORITY);
        return result;
      }
    }, null, DO_ASYNC);
    final long startTime = System.nanoTime();
    final Integer result = pool.invoke(new Task(0));
    final double duration = ((double) (System.nanoTime() - startTime)) / 1000000.0;
    final double executionDuration = ((double) Task.timeExecute.get()) / 1000000.0;
    final double executionPercent = executionDuration / duration * 100.0;
    final double executionPercentPerThread = executionPercent / ((double) NUM_THREADS);

    System.out.println("Completed: " + result + " in " + Task.totalLoops.get() + " loops.");
    System.out.println(String.format("Time total: %8.3f ms, time task: %8.3f ms (%6.2f%%), spread->depth: %2d->%2d, thread#: %1d, task-time/thread: %5.2f%%", duration, executionDuration, executionPercent, SPREAD, MAX_DEPTH, threadCount.get(), executionPercentPerThread));
  }
}

请随时指出任何错误或提出改进建议。我会接受最有价值的答案以获得一些奖励积分。

【问题讨论】:

  • -1。似乎您已经得出结论,FJ 框架“效率极低”,只是在寻找讨论/争论。
  • 是的,这是我分析的结果。是的,我正在寻找支持我的结果或证明我错了的讨论和/或论点。这就是所谓的科学方法。不知道为什么你给-1。哦
  • 这不是论坛。见stackoverflow.com/help/dont-ask
  • 这个问题似乎离题了,因为它更多的是讨论而不是实际问题。

标签: java multithreading performance fork-join


【解决方案1】:

建议:

  • 打印创建的分叉数量 + 已完成工作的成本估算(即添加的数量或 BigIntegers 的长度,如果你切换到它们,它们将被求和)。该比例将显示您的分叉策略的有效性,并让您了解有意义的最小作业规模。
  • 检查您的算法 - 斐波那契指数增长,您的任务返回整数,因此您应该很快就会出现溢出。

因此,目标是选择一个阈值来表示分叉或不分叉:

实现算法时要考虑的主要事项之一 使用 fork/join 并行性是选择确定的阈值 任务是否将执行顺序计算而不是 分叉并行子任务。

如果阈值太大,则程序可能无法创建 足够的任务来充分利用可用的 处理器/内核。

如果阈值太小,那么创建任务的开销和 管理可能变得很重要。

一般来说,需要进行一些实验才能找到 适当的阈值。 Source

这也很有用:How to determine the proper work division threshold of a fork-join task

【讨论】:

  • 分叉更快没有门槛,是否使用它更多是架构问题。任何幼稚的实现总是更快。斐波那契的结果是无关紧要的。它最终会溢出,但这不会导致 Java 发出任何问题,因为溢出会被默默地丢弃。
【解决方案2】:

我还没有尝试过您的测试,但是对于任何分而治之或排队的方法,您必须权衡拆分工作、排队和作业处理以及汇总作业结果的成本。因此,与单线程版本相比,总 CPU 周期永远不会有 100% 的效率。我自己有另一个基于斐波那契的测试,我尝试设置递归限制,以便在同一个线程中递归计算 fib(limit) 而不会为下一个递归级别生成新作业。所以这个递归级别所花费的时间就是每个 ForkJoinTask 所花费的时间。我在实际基准测试之前测量了那个时间,以找到一个任务应该多长时间才能在最小开销和最大核心利用率之间取得最佳平衡。对于我测试的硬件,单路 x86 大约需要 10µs,四路机器需要 1ms。

【讨论】:

  • 添加一些在运行时测量速率的东西肯定是有意义的,因为在最坏的情况下,Fork-Join 实现甚至可能比任何单线程实现慢得多。但这极大地增加了代码复杂性,而这种结构实际上应该减少。
  • 一旦您找到适合您的目标平台的最佳位置,您就无需进一步试验。您只需要知道在正常情况下您的任务需要多长时间,这样您就知道何时停止拆分并开始工作。但无论如何,通过从单线程到 fork-join 来减少代码大小是不太可能的。
  • 最大的问题是您机器上的最佳位置可能与另一台机器上的最佳位置完全不同。我已经不得不面对这样的情况,我的代码在我的桌面上运行得更快,然后在一个极其强大的服务器机器上运行。最后,最好使用不必搜索该位置的架构。
  • 这就是我给出这些估计的原因。我们谈论的是最终由 fork-join 执行器(或任何类型的执行器,就此而言)运行的单线程作业。在配置良好的桌面上,这可能会比在潜在的虚拟化、过载的服务器机器上更快,因为多处理,即在你并行化所有内容之后,它才很快。如果作业大小保持在 1 毫秒以上(或者在过载的机器上可能更多,则执行器开销可以忽略不计。
【解决方案3】:

你的“测量”有很大的观察者效应...

您可能想用 LongAdder 替换您的 AtomicLongs 以减少测量的影响...考虑进一步减少它们...

使用 JMH 之类的框架来缓解基准测试问题...

你的测量值不是任何人都可以用来做出任何非天真的结论的东西......

FJP 是一个非常好的线程池实现,它是 JDK 中利用 cpu 内核的最佳选择。

在我的基准测试(使用 JMH)中,将 FJP 与“旧版”JDK 执行器进行比较:

https://github.com/zolyfarkas/spf4j/blob/master/spf4j-benchmarks/src/test/java/org/spf4j/concurrent/ThreadPoolBenchmarkFjp.java

https://github.com/zolyfarkas/spf4j/blob/master/spf4j-benchmarks/src/test/java/org/spf4j/concurrent/ThreadPoolBenchmarkStdJdk.java

在 jdk 1.7 FJP 上运行大约快 2 倍:

Benchmark                                   Mode  Cnt     Score     Error  Units
ThreadPoolBenchmarkFjp.fjpBenchmark        thrpt   10  6873.926 ± 334.733  ops/s
ThreadPoolBenchmarkStdJdk.stdJdkBenchmark  thrpt   10  3210.170 ± 170.883  ops/s

Jdk 1.8 FJP 速度提高了 3 倍:

Benchmark                                   Mode  Cnt     Score      Error  Units
ThreadPoolBenchmarkFjp.fjpBenchmark        thrpt   10  9679.502 ± 1160.887  ops/s
ThreadPoolBenchmarkStdJdk.stdJdkBenchmark  thrpt   10  3466.997 ±   81.594  ops/s

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2014-03-03
    • 2016-12-13
    • 2019-08-16
    • 2016-07-28
    • 2011-06-22
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多