基数排序的并行版本未按预期运行(Java)

Posted

技术标签:

【中文标题】基数排序的并行版本未按预期运行(Java)【英文标题】:Parallel version of radix sort is not behaving as expected (Java) 【发布时间】:2021-03-24 21:43:01 【问题描述】:

在我的项目中,我发现排序性能是瓶颈。经过一番谷歌搜索,我想出了基数排序的并行版本(以 256 为基数)。但是它的行为不像我预期的那样。

首先将基数更改为 2^16 不会导致任何加速,理论上应该是 2。

第二次在我的并行版本中,我将其拆分为 4 个部分(核心数)并对它们进行基数排序,然后合并结果。同样,它仅与串行版本同时运行。

public class RadixSortPrototype 


  public static void parallelSort(long[] arr) 
    long[] output = new long[arr.length];

    int MAX_PART = 1_000_000;
    int numProc = Runtime.getRuntime().availableProcessors();
    int partL = Math
        .min((int) Math.ceil(arr.length / (double) numProc), MAX_PART);
    int parts = (int) Math.ceil(arr.length / (double) partL);

    Future[] threads = new Future[parts];
    ExecutorService worker = Executors.newFixedThreadPool(numProc);

    for (int i = 0; i < 8; i++) 
      int[][] counts = new int[parts][256];
      int radix = i;

      for (int j = 0; j < parts; j++) 
        int part = j;
        threads[j] = worker.submit(() -> 
          for (int k = part * partL; k < (part + 1) * partL && k < arr.length;
              k++) 
            int chunk = (int) ((arr[k] >> (radix * 8)) & 255);
            counts[part][chunk]++;
          
        );
      
      barrier(parts, threads);

      int base = 0;
      for (int k = 0; k <= 255; k++) 
        for (int j = 0; j < parts; j++) 
          int t = counts[j][k];
          counts[j][k] = base;
          base += t;
        
      

      for (int j = 0; j < parts; j++) 
        int part = j;
        threads[j] = worker.submit(() -> 
          for (int k = part * partL;
              k < (part + 1) * partL && k < arr.length;
              k++) 

            int chunk = (int) ((arr[k] >> (radix * 8)) & 255);
            output[counts[part][chunk]] = arr[k];
            counts[part][chunk]++;
          
        );
      
      barrier(parts, threads);

      for (int j = 0; j < parts; j++) 
        int part = j;
        threads[j] = worker.submit(() -> 
          for (int k = part * partL;
              k < (part + 1) * partL && k < arr.length;
              k++) 

            arr[k] = output[k];
          
        );
      
      barrier(parts, threads);
    
    worker.shutdownNow();
  

  private static void barrier(int parts, Future[] threads) 
    for (int j = 0; j < parts; j++) 
      try 
        threads[j].get();
       catch (InterruptedException | ExecutionException e) 
        e.printStackTrace();
      
    
  

任何想法为什么它运行这么慢?解决此优化的推荐方法是什么?

我真的很好奇答案。

谢谢!

更新

根据答案,我改进了数据的局部性,所以现在它使用了所有核心。更新了代码 sn-p。以下是 2 核 4 线程 CPU 的结果。

Java Parallel: 1130 ms
Radixsort Serial: 1218 ms
Radixsort Parallel: 625 ms

如果可以进一步改进,这个问题仍然悬而未决。

【问题讨论】:

输入有多大?它似乎在内存中,所以它不应该太大。在这种情况下,我会检查输入是否大到足以从并行执行中获益。请记住,启动新线程是一项昂贵的操作。根据我的经验,许多性能问题是由高内存消耗导致程序变慢。 输入是20M长。 【参考方案1】:

使用基数 2^16 = 65536 最终会慢一些,因为 L1 缓存通常是每个核心 32768 字节,基数 2^16 计数|索引数组每个使用 2^20 = 262144 字节。

基数排序的问题在于读取是顺序的,但写入与数据一样随机。根据评论,该程序以 8 个字节对 2000 万个 long 进行排序,因此 80 MB 的数据,假设 8MB L3 缓存,大部分写入将是缓存未命中。并行操作没有多大帮助,因为大多数写入都在竞争相同的 80 MB 非缓存主内存。

为了避免这个问题,我使用了另一种实现,其中第一遍执行最高有效数字基数排序以产生 256 个 bin(每个 bin 包含具有相同最高有效字节的整数)。然后,首先使用传统的基数排序最低有效位对每个 bin 进行排序。对于相当均匀的伪随机数据,256 个 bin 的大小最终几乎相等,因此 80MB 被分成 256 个 bin,每个大约 312500 字节,对于 4 个线程,有 8 个 bin,4 个用于读取,4 个用于写入,加上 count|index 数组,所有这些都将适合所有 4 个内核共有的 8MB L3 16 路关联 L3 缓存。

对于较大的数组,初始传递可以将数组拆分为 512 到 4096 个或更多的 bin。

我使用基数排序的一些旧 C++ 代码进行了一些测试,用于对伪随机 64 位整数进行排序,使用基数 2^8 = 256。我测试了 3 个实现,单线程最低有效位,单线程最高有效位在前, 和四线程最高有效位在前。当整数个数为 2 的幂时,会导致一些缓存冲突,在某些情况下会影响时间。

16000000 - 8 个 bin + 索引数组适合 8MB 三级缓存。 16777216 = 2^24, 8 个 bin + 索引数组适合 8MB L3 缓存。 30000000 - 8 个 bin + 索引数组适合 8MB L3 缓存。 33554432 = 2^25, 8 个 bin + 索引数组略大于 8MB 36000000 - 8 个 bin + 索引数组,比 8MB 大一点。

Win 7 Pro 64 bit, VS 2015, Intel 3770K 3.5 ghz 
count        1 thread LSD  1 thread MSD  4 thread MSD
16000000     0.59          0.38          0.16
16777216     1.35          0.48          0.30
30000000     0.82          0.70          0.30
33554432     3.20          1.09          0.68
36000000     0.95          0.82          0.39

Win 10 Pro 64 bit, VS 2019, Intel 10510U 1.8 ghz to 4.9 ghz
count        1 thread LSD  1 thread MSD  4 thread MSD
16000000     0.312         0.230         0.125
16777216     0.897         0.242         0.150
30000000     0.480         0.430         0.236
33554432     2.880         0.510         0.250
36000000     0.568         0.530         0.305

【讨论】:

谢谢,您的计算很有帮助。我认为它很好地涵盖了主题,但我会保持这个线程开放一段时间。我想更多地研究这个主题,你手头有一些链接可以进一步阅读优化多 CPU 的此类算法吗?它可以用于 C++。您愿意将其添加到答案的底部吗? @DominikG - 这取决于算法。例如,并行合并排序不存在此问题,因为每次运行的所有读取和对合并运行的写入都是顺序的。在这种情况下,基数排序的问题与内存的随机访问写入有关,并且可以将数组拆分为足够小的部分以适合 L3 缓存。 @DominikG - 我更新了我的答案以包括单线程的运行时间,最重要的数字在前。

以上是关于基数排序的并行版本未按预期运行(Java)的主要内容,如果未能解决你的问题,请参考以下文章

使用 OpenMP 并行化 C 中的基数排序

基数排序及其并行化

使用 CUDA 在 C 中并行化基数排序的问题

基数排序算法的Java实现

基数排序(Java)

数据结构Java版之基数排序