【问题标题】:Memory-efficient string-to-string map in Python (or C)Python(或C)中的内存高效字符串到字符串映射
【发布时间】:2011-04-30 21:27:19
【问题描述】:

我需要一个内存高效的数据结构来存储大约一百万个键值对,其中键是大约 80 字节的字符串,值是大约 200 字节的字符串,键和值的总大小约为 280MB .我还需要通过键有效地查找值,最好是哈希映射。内存开销应尽可能少,例如对于 280MB 的有用数据,数据结构不应使用超过 300MB 的虚拟内存(包括malloc() 开销和其他所有内容)。使用模式如下:我们从一个空的数据结构开始,然后逐渐填充它,从不改变键,从不改变值的长度。另外,数据结构可以支持更改值的长度,但代价是 100% 的值开销(这意味着对于 x 值字节,x 字节可能会暂时浪费在未使用的缓冲区空间中)。

我需要一个纯 Python 模块,或者一个内置 Python 模块,或者一个最好带有 (C)Python 绑定的 C 实现。如果可以将整个数据结构序列化到磁盘并快速读取它,我会更喜欢。

为了证明这么小的开销是可能的,我用open addressing 创建了一个简单的设计,125 万个元素的哈希表包含指向 1MB 数据块的 4 字节指针,数据块包含密钥和值长度为base-128 varints。这种设计有一个重要的限制:它不允许在不浪费内存区域的情况下删除或更改对。根据我对 100 万个每个 280 字节的键值对的计算,开销小于 3.6%(10 080 000 字节)。上面的限制更加慷慨,它们允许 20 000 000 字节的开销。

我刚刚找到 http://www.pytables.org/ ,它提供了快速访问和内存高效的数据打包。我必须更仔细地检查它是否适合我的需要。

【问题讨论】:

  • 是否考虑使用 sqlite 实现选项?
  • 或东京内阁,或cdb,或?
  • "最好是哈希映射。内存开销应该尽可能小" 通常不兼容。哪个更重要?速度或大小。把它当作一个给定的,你永远不能同时拥有。
  • 下面的许多答案,包括我的,都忽略了您对 C 实现的要求。也许你可以告诉我们为什么你需要 C?我假设您需要它来提高性能,并忽略了这一点,因为我们还不知道该区域缺少纯 Python 选项。如果还有其他需要 C 的原因,那么我们的答案当然是遥遥无期。
  • @Martijn:你的意思是":memory:" SQLite 数据库吗?是的,这是一个有效的选择,但我不确定它是否足够紧密地打包数据。值得一试。

标签: python data-structures hash map memory-efficient


【解决方案1】:

好的,简单的方法。

对数据结构使用 python 字典。我用 100 万个随机键值对填充了一个 python 字典,其中键是 80 个字符,值是 200 个字符。它在我的计算机上占用了 360,844 Kb,超出了您不超过 300 MB 的规格,但我还是提供了它作为解决方案,因为它仍然非常节省内存。

这也无法满足您对 C API 的要求。我不确定您为什么需要 C,但由于问题被标记为 Python 并且缺少 C 标记,因此我将提供纯 Python 以查看它是否符合要求。

关于持久性。使用 cPickle 模块。它非常快,而且非常简单。要保存您的字典:

cPickle.dump(mydict, "myfile.pkl")

要重新加载您的字典:

mydict = cPickle.load("myfile.pkl")

第二个简单的想法是使用shelve 模块,它基本上是基于磁盘的python 字典。内存开销非常低(都在磁盘上)。但它也慢得多。

【讨论】:

  • 360844 Kb 有 360844*1024-280000000 == 89504256 字节的开销,远远超过问题中允许的 20000000 字节。但它的简单性令人信服,放宽要求可能是值得的。我给了这个答案+1。
  • shelve 内置模块使用 dbm、gdbm 或 bsddb 进行键值存储,它们都不能提供问题中提出的速度和紧凑的内存打包要求。
  • @pts 我的内存测量非常基础(半途而废),360844 Kb 是整个 python 进程的内存量。其中大部分是字典。但是,当然,这超出了您的内存要求。我很好奇,内存需求从何而来?
  • @hughdbrown:不,这不是家庭作业。 @Steven Rumbalski:内存要求是非常临时的,并不严格,它们背后没有科学推理。如果易于实现,我只想让我的软件使用更少的内存。一些系统(几乎没有可用内存)会受益,但我无法准确评估其中有多少。
  • 仅供参考 在我的 280000000 字节 (280MB) 测试输入中,marshal 的写入速度比 cPickle 快 3.83 倍,读取速度快 6.90 倍——cPickle 大约需要 10 秒的读取时间和 20 秒的写入时间。因此,如果我需要非常快速的(反)序列化,我可以找到或编写一个 C 扩展,它比 cPickle 快得多。
【解决方案2】:

Martijn 在评论中提到了这一点(不知道为什么人们用答案评论),但我同意:使用 SQLite。您应该尝试一下,看看它是否能满足您的需求。

【讨论】:

【解决方案3】:

如果您不打算进行大量删除,那么这并不难。删除会导致碎片化。

您还需要提交固定长度的密钥。你提到了 80 个字节。您的密钥是否允许复制?如果没有,那就更容易了。

所以,这就是你要做的。

你创建一个数组:

struct {
    char value[80];
    char *data;
} key;

并且你保持这个数组排序。

如果你的键可以重复,那么你需要:

struct link {
    char *data;
    link *next;
}

struct {
    char value[80];
    link *data;
} key;

(我的 C 生锈了,但这就是它的要点)后者的每个键都指向一个值的链接列表。

那么查找就是一个简单的二分查找。 “痛苦”在于维护这个数组和插入/删除键。这并不像听起来那么痛苦,但它节省了大量内存,尤其是在 64 位系统上。

您要减少的是指针的数量。当你有很多用指针填充的结构时,指针是昂贵的。在 64 位系统上,一个指针是 8 个字节。因此,对于单个指针,您的内存预算会占用 8MB。

因此,费用在于构建数组、复制和压缩内存(如果您“知道”您将拥有一百万行并且可以提交,那么 malloc(1000000 * sizeof(key)) 马上,它'会在扩展期间为您节省一些复制)。

但不要害怕,一旦启动并运行,性能相当不错。现代 CPU 实际上非常擅长复制 100M 的内存块。

顺便说一句,我刚刚在 Java 中做了类似的事情。在 64 位 JVM 上,具有 25M 条目的 Map 是 2G 的 RAM。我的解决方案(使用与此类似的技术)大约为 600M)。 Java 使用的指针比 C 多,但前提是一样的。

【讨论】:

  • 我为这个答案提供了 +1,因为它为实现我需要的字典提供了许多见解。但是,如果有的话,我更喜欢现有的、经过测试和优化的实现。
【解决方案4】:

您是否尝试过使用简单的字典?您的大部分数据都在字符串中,因此开销可能符合您的要求。

【讨论】:

  • 要对此进行扩展,请参阅下面的我的答案或hughdbrown的答案。
【解决方案5】:

您可以使用密钥的sha1 而不是密钥本身。如果密钥是唯一的,那么密钥的sha1 哈希也很可能。它可以节省内存,以尝试在您的限制下进行。

from random import choice
from string import letters
from hashlib import sha1

def keygen(length):
    return "".join(choice(letters) for _ in xrange(length))

def gentestdata(n=1000*1000):
    # return dict((sha1(keygen(80)).digest(), keygen(200)) for _ in xrange(n))
    d = {}
    for _ in xrange(n):
        key = sha1(keygen(80)).digest()
        assert key not in d
        value = keygen(200)
        d[key] = value
    return d

if __name__ == '__main__':
    d = gentestdata()

在我的 ubuntu 机器上,最高内存为 304 MB:

2010-10-26 14:26:02 hbrown@hbrown-ubuntu-wks:~$ ps aux | grep python
[...]
hbrown   12082 78.2  7.5 307420 303128 pts/1   S+   14:20   4:47 python

够近了吗?是 python,不是 C。


稍后:另外,如果您的数据有些冗余,您可以gzip 值。这是时间与空间的权衡。

【讨论】:

  • 看起来足够接近,但它没有存储密钥:它存储 20 字节的摘要而不是 80 字节的密钥——因此实际内存使用量为 307420 * 1024 + (80 - 20) * 1000000 == 374798080 字节,与问题中的 280000000 字节相比,太多了。
  • 如果您将 (key, value) 对放入字典中,您可以稍后通过获取该键并对其进行 sha1-hash 来检索该值。如果该键有数据,则将有 sha1-hashed 键的数据,您将检索该值。但是,如果您需要未散列的密钥(例如,“告诉我我使用过的所有密钥”),那么不,它不能满足您的要求。也许您可以更新您的问题以涵盖这一点。在相关的一点上,可以压缩密钥 - 这是否违反您的问题规则?密钥能否有利地被压缩?为什么是 300MB?
  • SHA1 是一个哈希,哈希是糟糕的密钥,因为它们是有损的。尽管是否会有重叠的“可能性是”,但事实是完全有可能性。如果您的数据小于 160 位,则没有理由使用 SHA1。如果它超过 160 位,那么您将丢失数据并且风险重叠。如果您的系统可以处理重叠,那么很好。大多数系统都不能优雅地处理这个问题,因为它们无法检测到它何时发生。
  • @WIll Hartung:我会听从专家的意见,但我的理解是“一个有 10^18(一百万)条目的数据库有大约 0.0000000000003 中的 1碰撞。”见:stackoverflow.com/questions/297960/…
  • @hughdbrown:正如我所说,如果系统可以处理重叠,那很好。如果没有,那么他们需要担心这一点。有些彩票我选择不想中奖。正如他们所说的“如果你不玩,你就赢不了”,所以我不玩。 ODDS 可能很低,但墨菲使用加载骰子。这只是老经验的介入。所以,他们只需要确定失败的后果是什么,然后从那里开始。
【解决方案6】:

使用 SQLite 是个好主意。快速实施可以轻松判断您是否足够快。


如果您确定必须自己动手,我建议您这样做:

您能预测配对的数量或上限吗?
您对总数据大小或上限的预测能力如何?

Arena allocator 用于字符串和节点。 (通常,您会处理一系列竞技场,因此您不必预测总大小)。

对齐取决于您的算法,原则上您可以将其打包成字节紧密,唯一的开销是您的过度分配,这对您的工作集的影响很小。

但是,如果您必须对这些字符串运行任何 cmp/copy 等操作,请记住,通过以下保证,您可以从这些字符串操作中压缩一点或很多:

  • 所有元素都是 CPU 字对齐的
  • 所有填充字节都是(例如)0
  • 只要不越过 CPU 边界,您就可以安全地读取“超出”字符串结尾的内容

哈希表用于索引。字典也可以,但只有在潜在的降级/重新散列是一个严重问题时才有意义。我不知道 C 的任何“库存”哈希表实现,但应该有一个,对吧?正确的?只需将分配替换为对 arena 分配器的调用即可。


内存位置

如果您可以保证查找永远不会请求不在映射中的字符串,您应该将键存储在单独的区域中,因为它们仅在哈希冲突时需要。这可以显着提高内存局部性。 (在这种情况下,如果你有一个“决赛”桌,你甚至可以将碰撞的钥匙复制到一个新的竞技场,然后扔掉所有其他的。不过,这样做的好处可能是微不足道的。)

根据您的访问模式,分离可以帮助或伤害。如果您通常在每次查找后使用该值一次,那么将它们成对地放在同一个舞台上是很棒的。如果你例如查找几个键,然后重复使用它们的值,单独的竞技场是有意义的。


如果您必须支持“有趣的字符”/Unicode,请在存储字符串之前对其进行规范化。

【讨论】:

    【解决方案7】:

    您可以使用 struct 模块来打包二进制数据并在需要时将其解包。 您可以使用这种方法实现内存高效存储。我想访问会很痛苦。

    【讨论】:

    • 不仅会很痛苦,而且所有这些字节操作都会非常缓慢。我还必须使用 bytearray 类型,它是可变的——但如果在纯 Python 中实现仍然很慢。
    【解决方案8】:

    Apache Portable Runtime (aka APR) 有一个基于 c 的哈希表。你可以在http://apr.apache.org/docs/apr/0.9/group_apr_hash.html查看文档

    使用 apr_hash_t,您存储的所有内容都是 void*。因此,它使您可以完全控制值。因此,如果您愿意,您可以存储指向 100 字节块的指针,而不是字符串的实际长度。

    【讨论】:

    • 感谢您的想法,此答案需要验证以检查 apr_hash 是否在问题允许的内存限制范围内。这也不是一个完整的实现,因为我必须编写和添加大量的 C 代码。
    【解决方案9】:

    Judy 应该节省内存:http://judy.sourceforge.net/
    (基准:http://www.nothings.org/computer/judy/,请参阅“数据结构大小”)。
    另见:http://www.dalkescientific.com/Python/PyJudy.html

    还有,

    对于固定大小的键,C++ 中有 http://panthema.net/2007/stx-btree/(我确信通过自定义 C 包装器可以从 CPython 中使用它)。 如果数据集允许,您可以将变长键存储在值中,并将变长键的哈希或前缀用作定长键。

    同样的逻辑适用于 http://google-opensource.blogspot.ru/2013/01/c-containers-that-save-memory-and-time.html 和 http://code.google.com/p/sparsehash/ - 不是使用重的 std::string 作为键,而是使用 32 位或 64 位整数键,以某种方式从真正的可变长度键。

    【讨论】:

    • 感谢您提及朱迪。我刚刚在 64 位 Linux 系统上运行了一个快速基准测试,结果显示对于 100 万个 80 字节键和 200 字节值的条目,PyJudy 使用大约 102
    • 仅供参考,我不能使用 32 位或 64 位整数作为密钥,因为我需要 80 字节的信息。我可以通过使用包含 char[80] 作为键的对象来减少开销。
    【解决方案10】:

    由于我找不到任何现有的解决方案可以将内存紧紧地打包,我决定自己用 C 语言实现它。查看我在问题中使用开放寻址的设计。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2011-01-18
      • 1970-01-01
      • 2012-08-21
      • 1970-01-01
      • 1970-01-01
      • 2012-05-11
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多