【问题标题】:Java sequential implementation is 4 times faster than parallel implementationJava顺序实现比并行实现快4倍
【发布时间】:2015-07-15 12:04:36
【问题描述】:

我创建了一个非常简单的场景,我发现了一个我无法理解的非常奇怪的行为。

在以下链接下,我创建了一个顺序实现: http://ideone.com/B8JYeA 基本上有几个固定大小的大数组。算法遍历它们并更改值。

for(int i = 0; i < numberOfCells; i++) {
    h0[i] =  h0[i] + 1;
    h1[i] =  h1[i] + 1;
    h2[i] =  h2[i] + 1;
    h3[i] =  h3[i] + 1;
    h4[i] =  h4[i] + 1;
}

如果我在我的工作站上运行它大约需要 5 秒。

我在并行版本中实现了相同的功能。并且 8 个线程同时运行它。代码应该是线程安全的,线程之间没有依赖关系。

但代码在我的工作站上的运行速度仍然慢了大约 4 倍: http://ideone.com/yfwVmr

final int numberOfThreads = Runtime.getRuntime().availableProcessors();

ExecutorService exec = Executors.newFixedThreadPool(numberOfThreads);

for(int thread = 0; thread < numberOfThreads; thread++) {
    final int threadId = thread;
    exec.submit(new Runnable() {
        @Override
        public void run() {
            for(int i = threadId; i < numberOfCells; i += numberOfThreads) {
                h0[i] =  h0[i] + 1;
                h1[i] =  h1[i] + 1;
                h2[i] =  h2[i] + 1;
                h3[i] =  h3[i] + 1;
                h4[i] =  h4[i] + 1;
            }
        }
    });
}

exec.shutdown();

有人知道为什么会这样吗?

编辑:这个问题与其他问题不同,因为原因可能是缓存问题。我该如何解决这个缓存问题?

【问题讨论】:

  • 这很快就关闭了。另一个问题非常不具体,例如“有时速度较慢”。 这里,可以期待更多有趣的答案....
  • 重新打开了这个问题,因为它正在寻找更具体的问题代码。

标签: java multithreading parallel-processing threadpool executorservice


【解决方案1】:

不是一个真正的答案,但是:首先,我会尽量保持数据访问的局部性:

final int numberOfCellsPerThread = numberOfCells / numberOfThreads;

public void run() {
    final int start = threadId * numberOfCellsPerThread;
    final int end = start + numberOfCellsPerThread;
    for(int i = start; i < end; i++) {
        h0[i] =  h0[i] + 1;
        h1[i] =  h1[i] + 1;
        h2[i] =  h2[i] + 1;
        h3[i] =  h3[i] + 1;
        h4[i] =  h4[i] + 1;
    }
}

如需更多关于位置为何重要的说明,请参阅 Why does cache locality matter for array performance?http://en.wikipedia.org/wiki/Locality_of_reference

基本上,它只是尽可能使用缓存中已经存在的数据。由于缓存的大小有限,如果a[i] 已经在缓存中,例如由于之前的读取操作,a[i+1] 在缓存中的机会也相当高。例如,至少高于a[i+100] 的机会。

此外,从内存中的顺序读取可能会被硬件优化为突发,并且最容易通过预取逻辑进行预测。

【讨论】:

  • 您要确保数据位于不同的缓存行上。即至少相隔 64-128 个字节。
  • 我现在进行了相当多的测试,以各种方式对数据进行切片和切块,并以各种方式将其传递给执行器服务,并且从我的所有方法来看,维护局部性的简单实用的解决方案(类似于您的建议)基本上是最快的。我已经赞成它,但如果你简短地解释为什么局部性很重要,它可能会成为一个“真正的”答案,并且可能更正/扩展代码以复制+粘贴(可能作为 MVCE,但至少指出 @例如,987654327@ 实际上应该是numberOfCellsPerThread
  • 感谢您抽出宝贵时间进行测试。 - 我真的忽略了numberOfCellsPerThread 问题,感谢您指出。
  • 现在,可以添加一些提示以小心numberOfCells 不是numberOfThreads 的倍数的情况,这会导致计算中跳过最后几个单元格,但我认为这个想法现在变得清晰(呃),这些细节可能留给实施者。
【解决方案2】:

最大的开销是启动和停止线程所花费的时间。如果我将数组的大小从 10000 减少到 10,则需要大约相同的时间。

如果您保留线程池,并为每个线程分配工作以写入本地数据集,那么在我的 6 核机器上速度会快 4 倍。

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;


public class ParallelImplementationOptimised {
    static final int numberOfThreads = Runtime.getRuntime().availableProcessors();
    final ExecutorService exec = Executors.newFixedThreadPool(numberOfThreads);

    private int numberOfCells;

    public ParallelImplementationOptimised(int numberOfCells) {
        this.numberOfCells = numberOfCells;
    }

    public void update() throws ExecutionException, InterruptedException {

        List<Future<?>> futures = new ArrayList<>();
        for(int thread = 0; thread < numberOfThreads; thread++) {
            final int threadId = thread;
            futures.add(exec.submit(new Runnable() {
                @Override
                public void run() {
                    int num = numberOfCells / numberOfThreads;
                    double[] h0 = new double[num],
                            h1 = new double[num],
                            h2 = new double[num],
                            h3 = new double[num],
                            h4 = new double[num],
                            h5 = new double[num],
                            h6 = new double[num],
                            h7 = new double[num],
                            h8 = new double[num],
                            h9 = new double[num];
                    for (int i = 0; i < num; i++) {
                        h0[i] = h0[i] + 1;
                        h1[i] = h1[i] + 1;
                        h2[i] = h2[i] + 1;
                        h3[i] = h3[i] + 1;
                        h4[i] = h4[i] + 1;
                        h5[i] = h5[i] + 1;
                        h6[i] = h6[i] + 1;
                        h7[i] = h7[i] + 1;
                        h8[i] = h8[i] + 1;
                        h9[i] = h9[i] + 1;
                    }
                }
            }));
        }
        for (Future<?> future : futures) {
            future.get();
        }
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {

        ParallelImplementationOptimised si = new ParallelImplementationOptimised(10);

        long start = System.currentTimeMillis();

        for (int i = 0; i < 10000; i++) {
            if(i % 1000 == 0) {
                System.out.println(i);
            }
            si.update();
        }

        long stop = System.currentTimeMillis();
        System.out.println("Time: " + (stop - start));
        si.exec.shutdown();
    }

}

SequentialImplementation 3.3 秒。 ParallelImplementationOptimized 0.8 秒。


您似乎正在同一缓存行上写入相同的数据。这意味着数据必须通过 L3 缓存未命中,这比访问 L1 缓存花费的时间长 20 倍。我建议您尝试完全独立的数据结构,它们至少相隔 128 字节,以确保您不会触及相同的缓存行。

注意:即使您打算完全覆盖整个高速缓存行,x64 CPU 也会首先拉入高速缓存行的先前值。

另一个问题可能是

为什么没有慢 20 倍?

已获取缓存行的 CPU 内核可能有两个使用超线程运行的线程(即两个线程可以在本地访问数据),并且该 CPU 可能会绕循环几次,然后将缓存行丢失给另一个要求它的CPU核心。这意味着 20 倍的惩罚不是针对每次访问或每次循环,而是通常足以让您获得慢得多的结果。

【讨论】:

  • 您能否提供更多解释,说明为什么每次写入高速缓存行(?)都必须在下一次计算之前传递到 L3? (这取决于是否使用volatile?)
  • @HannoBinder 如果每次访问都是通过 L3 进行的,它会慢 20 倍或更多。 volatile 真的无济于事,因为您不能拥有数组的 volatile 元素,只有对数组的 volatile 引用。理论上,JIT 可以优化循环以仅执行最后一次迭代,而使用 volatile 可能会阻止这种情况,但在这种情况下,它无论如何都不会消除循环。
  • 显然你是正确的只有 reference 是易变的。我不确定volatile 是否仍然不会为每次访问数组时产生内存屏障。因此,如果将没有volatile 的(单线程)实现与(不必要的)volatile 的(多线程)实现进行比较,我认为这可能会有所不同。
  • @HannoBinder 有 AtomicIntergerArray 支持对每个元素进行可变访问。
  • 我想知道应该如何强制将数据放在不同的缓存行上。 JVM 规范明确表示(!)它没有说明任何(!)关于内存布局的内容。快速测试,例如有 10 个线程和 h[10][numCells](“肯定”相距甚远)没有显示任何加速。与此相反,保持局部性(正如 Hanno Binder 建议的那样)确实 使它更快(尽管不如顺序实现快 - 这有很多原因,主要是内存瓶颈,但我没有看看如何通过推测缓存来避免或减轻这种情况......)。
猜你喜欢
  • 1970-01-01
  • 2019-06-28
  • 1970-01-01
  • 1970-01-01
  • 2010-10-15
  • 1970-01-01
  • 2018-12-02
  • 1970-01-01
  • 2017-05-03
相关资源
最近更新 更多