垃圾收集与一个非常大的字典

时间:2017-06-16 06:53:30

标签: algorithm dictionary data-structures hash garbage-collection

我有一个非常大的不可变密钥集,它不适合内存,还有一个更大的引用列表,必须只扫描一次。如何在RAM中完成标记阶段?我确实有一个可能的解决方案,我将在稍后写一个答案(不想破坏它),但也许还有其他解决方案我没想过。

我会尝试重述问题,使其更“真实”:

你在Facebook工作,你的任务是找到哪些用户没有用表情符号创建帖子。您所拥有的只是活动用户名列表(大约20亿),以及您必须扫描的帖子列表(用户名/文本),但只需一次。它仅包含活动用户(您无需验证它们)。

此外,您有一台计算机,具有2 GB的RAM(1 GB的奖励积分)。因此必须在RAM中完成所有操作(无需外部排序或按排序顺序读取)。两天之内。

你能做到吗?怎么样?提示:您可能希望使用哈希表,以用户名作为键,并使用一位作为值。但是用户名列表不适合内存,因此不起作用。使用用户ID可能会有效,但您只需要名称。您可以扫描用户名列表几次(可能是40次,但不是更多次)。

4 个答案:

答案 0 :(得分:1)

听起来像是我10年前解决的一个问题。

第一阶段:沟渠GC。 GC对于小对象(几个字节)的开销可能超过100%。

第二阶段:为用户名设计一个合适的压缩方案。英文每个字符大约有3位。即使您允许更多字符,平均位数也不会快速上升。

第三阶段:在内存中创建用户名字典。使用每个用户名的16位前缀来选择正确的子字典。读入所有用户名,最初只用这个前缀对它们进行排序。然后依次对每个字典进行排序。 如问题中所述,为“使用过的表情符号”结果为每个用户名分配一个额外的位。

问题现在是I / O绑定,因为计算是令人尴尬的并行。最长的阶段将是阅读所有帖子(这将是许多TB)。

请注意,在此设置中,您没有使用像String这样的花哨数据类型。字典是连续的内存块。

如果截止日期为两天,我会抛弃一些这种幻想。读取文本的I / O范围非常严重,以至于用户数据库的创建可能超过16 GB。是的,这将交换到磁盘。一次性的大不了。

答案 1 :(得分:1)

散列键,对哈希值进行排序,并以压缩形式存储已排序的哈希值。

TL; DR

我建议的算法可以被视为the solution for similar (simpler) problem的扩展。

  1. 对每个键:应用一个哈希函数,将键映射到范围[0..h]中的整数。从h = 2 * number_of_keys开始似乎相当不错。
  2. 使用这些哈希值填充所有可用内存。
  3. 对哈希进行排序。
  4. 如果哈希值是唯一的,请将其写入唯一哈希列表;否则删除它的所有副本并将其写入重复列表。这两个列表都应该以压缩形式保存:作为相邻值之间的差异,使用最佳熵编码器压缩(如arithmetic coderrange coderANS coder)。如果唯一哈希列表不为空,请将其与已排序的哈希值合并;合并时可能会发现其他重复项。如果重复项列表不为空,请将新的重复项合并到它。
  5. 在有任何未处理的密钥时重复步骤1..4。
  6. 执行步骤1..5时,再读几次键。但是忽略上一次传递中不在重复列表中的所有键。对于每个传递使用不同的散列函数(除了与上一次传递的重复列表匹配之外的任何东西,这意味着我们需要对两个不同的散列函数进行两次散列排序)。
  7. 再次读取密钥以将剩余的重复哈希列表转换为普通密钥列表。排序。
  8. 分配20亿位数组。
  9. 使用所有未占用的内存为每个压缩的哈希列表构造索引。这可以是trie或排序列表。索引的每个条目都应包含一个"状态"熵解码器允许从一开始就避免解码压缩流。
  10. 处理帖子列表并更新20亿比特的数组。
  11. 再次读取密钥将哈希值转换回密钥。
  12. 虽然使用值h = 2 * number_of_keys似乎相当不错,但我们可以尝试改变它以优化空间要求。 (设置太高会降低压缩比,将其设置得太低会导致重复次数过多)。

    这种方法不能保证结果:可以发明10个错误的哈希函数,以便每次传递时都复制每个键。但很有可能它会成功并且很可能需要大约1GB RAM(因为大多数压缩整数值在[1..8]范围内,因此每个键在压缩流中产生大约2..3位)。

    为了精确地估计空间要求,我们可以使用(复杂的?)数学证明或算法的完整实现(也非常复杂)。但是为了获得粗略估计,我们可以使用步骤1..4的部分实现。在Ideone上查看。它使用名为FSE的ANS编码器的变体(取自此处:https://github.com/Cyan4973/FiniteStateEntropy)和简单的哈希函数实现(取自此处:https://gist.github.com/badboy/6267743)。结果如下:

    Key list loads allowed:     10           20
    Optimal h/n:                 2.1          1.2
    Bits per key:                2.98         2.62
    Compressed MB:             710.851      625.096
    Uncompressed MB:            40.474        3.325
    Bitmap MB:                 238.419      238.419
    MB used:                   989.744      866.839
    Index entries:           1'122'520    5'149'840
    Indexed fragment size:    1781.71       388.361
    

    对于10个键扫描的原始OP限制,哈希范围的最佳值仅比我的猜测(2.0)略高(2.1),并且此参数非常方便,因为它允许使用32位哈希(而不是64位)那些)。所需的内存略小于1GB,这允许使用相当大的索引(因此步骤10将不会非常慢)。这里有一个小问题:这些结果显示了最后消耗了多少内存,但在这种特殊情况下(10次键扫描),我们在执行第二次传递时暂时需要超过1 GB的内存。如果我们删除第一个第一遍的结果(唯一哈希)并稍后重新计算它们,以及第7步,这可能会被修复。

    由于20键扫描的限制不那么严格,哈希范围的最佳值为1.2,这意味着算法需要更少的内存并为索引提供更多空间(因此步骤10将快5倍)。

    放宽对40次键扫描的限制不会导致任何进一步的改进。

答案 2 :(得分:1)

最小完美哈希

创建最小完美哈希函数(MPHF)。 每个密钥大约1.8位(使用 RecSplit algorithm),这大约使用了429 MB。 (这里,1 MB是2 ^ 20字节,1 GB是2 ^ 30字节。) 对于每个用户,分配一位作为标记,大约238 MB。 因此内存使用量约为667 MB。 然后阅读帖子,为每个用户计算哈希值, 并根据需要设置相关位。 再次读取用户表,计算散列,检查该位是否已设置。

生成MPHF有点棘手,不是因为它很慢 (这可能需要大约30分钟的CPU时间), 但由于内存使用情况。使用1 GB或RAM, 它需要分段完成。 我们假设我们使用32个大小相同的段,如下所示:

  • 循环segmentId从0到31.
  • 对于每个用户,计算哈希码,模32(或按位和31)。
  • 如果这与当前的segmentId不匹配,请忽略它。
  • 计算64位哈希码(使用第二个哈希函数), 并将其添加到列表中。
  • 在阅读所有用户之前执行此操作。
  • 一个段将包含大约6250万个密钥(20亿除以32),即238 MB。
  • 按键(就地)对此列表进行排序以检测重复项。 使用64位条目,重复的概率非常低, 但如果有,请使用不同的哈希函数并重试 (您需要存储使用的哈希函数)。
  • 现在计算此细分的MPHF。 RecSplit算法是我所知道的最快的算法。 也可以使用CHD算法, 但需要更多空间/生成速度较慢。
  • 重复,直到处理完所有细分。

上述算法读取用户列表32次。 如果使用更多的段,这可以减少到大约10 (例如一百万), 并且每步读取许多段,以适应内存。 对于较小的段,每个键需要较少的位 减少一个段内重复的可能性。

答案 3 :(得分:0)

我能想到的最简单的解决方案是老式的批量更新程序。它需要几个步骤,但在概念上它并不比合并内存中的两个列表复杂。这是我们几十年前在银行数据处理方面所做的事情。

  1. 按名称对用户名文件进行排序。您可以使用Gnu sort实用程序或任何其他程序来轻松地执行此操作,该程序将对大于适合内存的文件进行排序。
  2. 按用户名的顺序编写查询以返回帖子。我希望有一种方法可以将这些作为一个流。
  3. 现在您有两个流,按用户名的字母顺序排列。您所要做的就是简单的合并:
  4. 这是一般的想法:

    currentUser = get first user name from users file
    currentPost = get first post from database stream
    usedEmoji = false
    while (not at end of users file and not at end of database stream)
    {
        if currentUser == currentPostUser
        {
            if currentPost has emoji
            {
                usedEmoji = true
            }
            currentPost = get next post from database
        }
        else if currentUser > currentPostUser
        {
            // No user for this post. Get next post.
            currentPost = get next post from database
            usedEmoji = false
        }
        else
        {
            // Current user is less than post user name.
            // So we have to switch users.
            if (usedEmoji == false)
            {
                // No post by this user contained an emoji
                output currentUser name
            }
            currentUser = get next user name from file
        }
    }
    // at the end of one of the files.
    // Clean up.
    
    // if we reached the end of the posts, but there are still users left,
    // then output each user name.
    // The usedEmoji test is in there strictly for the first time through,
    // because the current user when the above loop ended might have had
    // a post with an emoji.
    while not at end of user file
    {
        if (usedEmoji == false)
        {
            output currentUser name
        }
        currentUser = get next user name from file
        usedEmoji = false
    }
    
    // at this point, names of all the users who haven't
    // used an emoji in a post have been written to the output.
    

    另一种实现方式是,如果获得#2中描述的帖子列表过于繁琐,那就是按照自然顺序扫描帖子列表,并从包含表情符号的任何帖子输出用户名。然后,对生成的文件进行排序并删除重复项。然后,您可以继续进行与上述类似的合并,但是您不必明确检查帖子是否有表情符号。基本上,如果两个文件中都出现了名称,那么您就不会输出它。