【问题标题】:External shuffle: shuffling large amount of data out of memory外部洗牌:将大量数据洗牌出内存
【发布时间】:2013-01-15 16:32:55
【问题描述】:

我正在寻找一种方法来随机播放大量无法放入内存(大约 40GB)的数据。

我有大约 3000 万个长度可变的条目,存储在一个大文件中。我知道该文件中每个条目的开始和结束位置。我需要对这些不适合 RAM 的数据进行洗牌。

我想到的唯一解决方案是将包含从1N 的数字的数组打乱,其中N 是条目数,Fisher-Yates algorithm 然后将条目复制到新文件中,按照这个顺序。不幸的是,这个解决方案涉及大量的查找操作,因此会非常慢。

有没有更好的解决方案来打乱大量均匀分布的数据?

【问题讨论】:

  • 我假设你想要一个均匀分布的排列。对吗?
  • @amit:是的,没错。
  • 我打算买一台 64 GB 的电脑,内存要 285 英镑。对内存中的数据进行排序可能会慢 100 倍或更糟。
  • @PeterLawrey:太好了。因此,我的软件的每位用户花费 285 英镑,我可以解决问题而无需编写任何代码;-p 或者我可以等 10 年,直到他们都购买了那么多 RAM .
  • 更便宜的解决方案是拥有 64 GB 的 SSD,您可以花 33 英镑购买。仍然存在超头搜索,但它可以比使用 HDD 快 100 倍。

标签: java algorithm bigdata


【解决方案1】:
  • 对数据库条目进行逻辑分区(例如按字母顺序)
  • 根据您创建的分区创建索引
  • 构建 DAO 以根据索引进行敏感化

【讨论】:

    【解决方案2】:

    首先摆脱shuffle 的问题。为此,请为您的条目发明一种产生类似随机结果的哈希算法,然后对哈希进行正常的外部排序。

    现在您已将shuffle 转换为sort,您的问题变成了寻找适合您的口袋和内存限制的有效外部排序算法。现在应该像google 一样简单。

    【讨论】:

    • 哈希冲突很可能会使排列产生偏差。如果这不可接受,那么此解决方案将不起作用。
    • @ThiagoChaves - 为什么可能?像 SHA1 这样简单快速的东西,甚至是一个简单的校验和,肯定会很好。
    • 哈希是否必要?标准外部排序似乎由内存中排序和外部合并组成。您可以用 Fisher-Yates shuffle 替换内存中的排序,并用随机合并替换外部合并。我无法证明这会导致真正的随机顺序,但我目前看不出有任何原因。
    • @aldel,我写了一个我认为是 complete proof 的东西,随机合并——正确定义——可以按照你的建议使用。对任何反馈都感兴趣!
    • 虽然这听起来简单而高效,但使用普通的 QuickSort 等,您最终将拥有 O(n log n) 运行时间,但可以在 O(n) 的核心时间内完成洗牌,正如下面@paulhankin 所建议的:对所有数据进行分区需要 O(n),并且通过将所有数据与来自同一分区的随机元素交换来对所有数据进行洗牌也需要 O(n),总共需要 O(n)。
    【解决方案3】:

    我建议保留您的一般方法,但在进行实际复制之前反转地图。这样,您可以按顺序读取并进行分散写入,而不是相反。

    在程序可以继续之前,必须在请求时完成读取。可以将写入留在缓冲区中,从而增加在实际执行写入之前对同一磁盘块累积多次写入的可能性。

    【讨论】:

      【解决方案4】:

      前提

      据我了解,使用 Fisher-Yates 算法和您拥有的有关条目位置的数据,您应该能够获得(并计算)以下列表:

      struct Entry {
          long long sourceStartIndex;
          long long sourceEndIndex;
          long long destinationStartIndex;
          long long destinationEndIndex;
      }
      

      问题

      从现在开始,天真的解决方案是在源文件中寻找每个条目,读取它,然后寻找目标文件中条目的新位置并写入。

      这种方法的问题在于它使用了太多的搜索。

      解决方案

      更好的方法是减少查找次数,为每个文件使用两个巨大的缓冲区。

      我建议源文件使用一个小缓冲区(比如 64MB),目标文件使用一个大缓冲区(用户可以承受的最大 - 比如 2GB)。

      最初,目标缓冲区将映射到目标文件的前 2GB。此时,在源缓冲区中以 64MB 的块读取整个源文件。阅读时,将正确的条目复制到目标缓冲区。当您到达文件末尾时,输出缓冲区应该包含所有正确的数据。将其写入目标文件。

      接下来,将输出缓冲区映射到目标文件的下一个 2GB 并重复该过程。继续,直到你写完整个输出文件。

      注意

      由于条目具有任意大小,因此在缓冲区的开头和结尾很可能会有条目的后缀和前缀,因此您需要确保正确复制数据!

      预计时间成本

      执行时间基本上取决于源文件的大小、应用程序的可用 RAM 和 HDD 的读取速度。假设一个 40GB 的文件、一个 2GB 的 RAM 和一个 200MB/s 的 HDD 读取速度,程序将需要读取 800GB 的数据(40GB * (40GB / 2GB))。假设 HDD 不是高度碎片化的,则用于寻道的时间可以忽略不计。这意味着读取将占用一小时!但是,如果幸运的是,用户有 8GB 的​​ RAM 可用于您的应用程序,则时间可能会减少到仅 15 到 20 分钟。

      我希望这对你来说已经足够了,因为我没有看到任何其他更快的方法。

      【讨论】:

        【解决方案5】:

        虽然您可以对随机键使用外部排序,正如OldCurmudgeon 所建议的那样,但随机键不是必需的。您可以将内存中的数据块打乱,然后按照aldel 的建议通过“随机合并”将它们连接起来。

        值得更清楚地说明“随机合并”的含义。给定两个大小相等的混洗序列,随机合并的行为与merge sort 中的完全相同,不同之处在于,使用来自 0 和 1 的混洗序列的布尔值选择要添加到合并列表中的下一项,完全零和一一样多。 (在归并排序中,将使用比较进行选择。)

        证明它

        我断言这行得通是不够的。我们怎么知道这个过程给出了一个打乱的序列,这样每个排序都是同样可能的?可以给出带有图表和一些计算的证明草图。

        首先,定义。假设我们有 N 唯一项,其中N 是偶数,M = N / 2N 项目以两个M-item 序列 提供给我们,标记为01,保证以随机顺序排列。合并它们的过程会产生一个序列N项目,这样每个项目来自序列0或序列1,并且来自每个序列的相同数量的项目。它看起来像这样:

        0: a b c d
        1: w x y z
        N: a w x b y c d z
        

        请注意,虽然01 中的项目看起来是有序的,但它们在这里只是标签,顺序没有任何意义。它只是将01 的顺序连接到N 的顺序。

        由于我们可以从标签中分辨出每个项目来自哪个序列,因此我们可以创建一个由 0 和 1 组成的“源”序列。打电话给c

        c: 0 1 1 0 1 0 0 1
        

        根据上述定义,c 中的零总是与零一样多。

        现在观察,对于N 中任何给定的标签排序,我们可以直接复制c 序列,因为标签保留了有关它们来自的序列的信息。给定Nc,我们可以重现01 序列。所以我们知道从序列N 到一个三元组(0, 1, c) 总是有一条路径。换句话说,我们有一个 reverse 函数 r,它从 N 标签到三元组 (0, 1, c) -- r(N) = (0, 1, c) 的所有排序集合中定义。

        我们还有一个来自任何三元组r(n) 的转发函数f,它根据c 的值简单地重新合并01。这两个函数一起表明r(N) 的输出和N 的排序之间存在一一对应关系。

        但我们真正想要证明的是,这种一一对应是详尽无遗的——也就是说,我们想要证明N没有额外的排序' 不对应于任何三元组,并且没有额外的三元组不对应于N 的任何排序。如果我们能证明这一点,那么我们可以通过以均匀随机的方式选择三元组(0, 1, c),以均匀随机的方式选择N的排序。

        我们可以通过计数箱子来完成证明的最后一部分。假设每个可能的三元组都有一个 bin。然后我们将N 的每一个订单都放入bin 中,以获得r(N) 给我们的三元组。如果 bin 的数量与 orderings 的数量完全相同,那么我们就有了详尽的一对一对应关系。

        从组合学中,我们知道N 唯一标签的排序数是N!。我们也知道01的订购数都是M!。而且我们知道c的可能序列数为N choose M,与N! / (M! * (N - M)!)相同。

        这意味着总共有

        M! * M! * N! / (M! * (N - M)!)
        

        三倍。但是N = 2 * M,所以N - M = M,上面的简化为

        M! * M! * N! / (M! * M!)
        

        那只是N!。 QED。

        实施

        要以均匀随机的方式选取三元组,我们必须以均匀随机的方式选取三元组的每个元素。对于01,我们在内存中使用简单的Fisher-Yates shuffle 来实现这一点。唯一剩下的障碍是生成正确的 0 和 1 序列。

        重要的是 -- 重要! -- 只生成具有相等数量的零和一的序列。否则,您没有从具有统一概率的Choose(N, M) 序列中进行选择,并且您的随机播放可能有偏差。最明显的方法是对包含相等数量的 0 和 1 的序列进行洗牌......但问题的整个前提是我们无法在内存中容纳那么多的 0 和 1!所以我们需要一种方法来生成随机的 0 和 1 序列,这些序列受到约束,使得 0 的数量与 1 的数量完全相同。

        要以概率一致的方式执行此操作,我们可以模拟从瓮中绘制标记为 0 或 1 的球,无需替换。假设我们从 50 个 0 球和 50 个 1 球开始。如果我们对瓮中每种球的数量进行计数,就可以保持一个选择其中一个的运行概率,这样最终的结果就不会有偏差。 (可能类似于 Python 的)伪代码是这样的:

        def generate_choices(N, M):
            n0 = M
            n1 = N - M
            while n0 + n1 > 0:
                if randrange(0, n0 + n1) < n0:
                    yield 0
                    n0 -= 1
                else:
                    yield 1
                    n1 -= 1
        

        由于浮点错误,这可能不是完美,但它会非常接近完美。

        算法的最后一部分至关重要。详尽地通过上述证明可以清楚地表明,其他生成 1 和 0 的方法不会给我们适当的洗牌。

        在真实数据中执行多次合并

        还有一些实际问题。上面的论点假设了一个完美平衡的合并,它还假设你的数据只有内存的两倍。这两种假设都不可能成立。

        事实证明,拳头并不是一个大问题,因为上述论点实际上并不需要相同大小的列表。只是如果列表大小不同,计算会稍微复杂一些。如果您通过上述将M 列表1 替换为N - M,则所有详细信息都以相同的方式排列。 (伪代码的编写方式也适用于任何大于零且小于NM。然后将恰好有M 零和M - N 1。)

        第二个意思是,在实践中,可能会有很多很多的块以这种方式合并。该过程继承了合并排序的几个属性——特别是,它要求对于K 块,您必须大致执行K / 2 合并,然后执行K / 4 合并,依此类推,直到所有数据都已合并。每批合并将遍历整个数据集,大约有log2(K) 批,运行时间为O(N * log(K))。普通的 Fisher-Yates 洗牌在N 中将是严格线性的,因此理论上对于非常大的K 会更快。但是在K 变得非常非常大之前,惩罚可能会比磁盘查找惩罚小得多。

        因此,这种方法的好处来自智能 IO 管理。而对于 SSD,它甚至可能不值得——寻找惩罚可能不足以证明多次合并的开销是合理的。 Paul Hankin 的回答有一些实用技巧,可以帮助您思考所提出的实际问题。

        一次合并所有数据

        进行多个二进制合并的另一种方法是一次合并所有块——这在理论上是可能的,并且可能导致O(N) 算法。 c 中的值的随机数生成算法需要生成从 0K - 1 的标签,以便最终输出具有每个类别的正确数量的标签。 (换句话说,如果您将三个块与101213 项目合并,那么c 的最终值将需要有0 十次,1 十二次,和2 十三次。)

        我认为可能有一个O(N) 时间、O(1) 空间算法可以做到这一点,如果我能找到一个或解决一个问题,我会在这里发布。结果将是一个真正的O(N) shuffle,就像 Paul Hankin 在他的回答结尾所描述的那样。

        【讨论】:

          【解决方案6】:

          一个简单的方法是选择一个K,这样1/K 的数据可以轻松地放入内存中。假设您有 16GB RAM,也许 K=4 用于您的数据。我假设您的随机数函数具有rnd(n) 的形式,它生成从0n-1 的统一随机数。

          然后:

          for i = 0 .. K-1
             Initialize your random number generator to a known state.
             Read through the input data, generating a random number rnd(K) for each item as you go.
             Retain items in memory whenever rnd(K) == i.
             After you've read the input file, shuffle the retained data in memory.
             Write the shuffled retained items to the output file.
          

          这很容易实现,避免了很多寻找,而且显然是正确的。

          另一种方法是根据随机数将输入数据划分为K 文件,然后遍历每个文件,在内存中洗牌并写入磁盘。这减少了磁盘 IO(每个项目被读取两次并写入两次,与第一种方法相比,每个项目被读取 K 次并写入一次),但您需要小心缓冲 IO 以避免大量查找,它使用更中间的磁盘,并且实现起来有些困难。如果您只有 40GB 的数据(所以 K 很小),那么对输入数据进行多次迭代的简单方法可能是最好的。

          如果使用 20ms 作为读取或写入 1MB 数据的时间(并假设内存中的 shuffle 成本微不足道),简单的方法将花费 40*1024*(K+1)*20ms,即 1分 8 秒(假设 K=4)。中间文件方法将花费 40*1024*4*20ms,大约是 55 秒,假设您可以最小化查找。请注意,SSD 的读取和写入速度大约快 20 倍(即使忽略搜索),因此您应该期望使用 SSD 在 10 秒内执行此任务。来自Latency Numbers every Programmer should know的号码

          【讨论】:

          • +1 用于实际的线性时间算法,而不是按随机键排序。尤其是您的解决方案 2,即使您说它在实践中可能会更糟。
          猜你喜欢
          • 2021-06-14
          • 1970-01-01
          • 1970-01-01
          • 2016-04-29
          • 2015-04-15
          • 2013-11-26
          • 1970-01-01
          • 1970-01-01
          相关资源
          最近更新 更多