为啥将地址右移三位作为固定大小哈希表的哈希函数?

Posted

技术标签:

【中文标题】为啥将地址右移三位作为固定大小哈希表的哈希函数?【英文标题】:Why right-shifting an address by three bits as a hash function for a fixed-size hash table?为什么将地址右移三位作为固定大小哈希表的哈希函数? 【发布时间】:2021-01-07 14:11:33 【问题描述】:

我正在关注一篇文章,其中我有一个包含固定数量 2048 个篮子的哈希表。 哈希函数采用指针和哈希表本身,将地址视为位模式,将其右移三位并以哈希表的大小为模(2048)减少它:

(这里写成宏):

#define hash(p, t) (((unsigned long)(p) >> 3) & \
                    (sizeof(t) / sizeof((t)[0]) - 1))

然而,这篇文章并没有详细说明为什么它将地址右移三位(起初似乎有点武断)。我的第一个猜测是,原因是通过切断最后三位来对具有相似地址的组指针进行排序,但鉴于分配给一个应用程序的大多数地址无论如何都有相似的地址,我不明白这会有什么用;以此为例:

#include <stdio.h>

int main()

    
    int i1 = 0, i2 = 0, i3 = 0;
    
    
    printf("%p\n", &i1);
    printf("%p\n", &i2);
    printf("%p\n", &i3);
    
    printf("%lu\n", ((unsigned long)(&i1) >> 3) & 2047); // Provided that the size of the hash table is 2048.
    printf("%lu\n", ((unsigned long)(&i2) >> 3) & 2047);
    printf("%lu", ((unsigned long)(&i3) >> 3) & 2047);

    return 0;

另外,我想知道为什么它选择 2048 作为固定大小,这是否与三位移位有关。

作为参考,本文摘自 David P. Hanson 的“C 接口和实现,创建可重用软件的技术”。

【问题讨论】:

也许让固定大小的哈希表更小? (反题回答)。 但是为什么要将它右移 3 位呢? 因为你想在内存消耗(尽可能小)和性能(尽可能少的冲突)之间保持良好的“平衡”......设计哈希时总是如此-table 解决方案。 哦,等等,不是这样...假设sizeof(long) == 8,在大多数架构上,前 3 位为零。因此,您希望“摆脱它们”以减少哈希表中的冲突次数。 每隔一个哈希值就会重复一次 【参考方案1】:

虽然它不是由 C 语言标准规定的,但在大多数平台上(其中平台 = 编译器 + 指定的硬件架构),变量 x 分配在一个地址是 sizeof(x) 的倍数(即,可被整除) .

这是因为许多平台不支持未对齐的加载/存储操作(例如,将 4 字节值写入未与 4 字节对齐的地址)。

知道sizeof(long) 最多为8(同样,在大多数平台上),我们可以进一步预测每个long 变量地址的最后3 位将始终为零。

在设计哈希表解决方案时,通常会争取尽可能少的冲突。

这里,哈希解决方案采用每个地址的最后 11 位。

所以为了减少冲突的数量,我们将每个地址右移 3 位,从而用“更随机”的东西替换这 3 个“可预测的”零。

【讨论】:

仅供参考:即使硬件支持非对齐访问,它也总是会产生性能成本。对齐的访问总是会更好。此外,某些类型可以在没有内存锁的情况下在硬件上使用原子操作 - 但只有在对齐的情况下(C++ 标准库允许您测试特定原子类型是否属于这种情况,尽管我不记得该怎么做)。 @Myst:加载/存储操作的硬件性能肯定与这个问题无关,它与其中提供的哈希表解决方案无关(与其性能无关,也与及其正确性)。 你是完全正确的......但你确实写了“许多平台不支持未对齐的加载/存储操作......”,所以我想我会发表评论,澄清所有平台都存在未对齐访问的问题 - 只是在某些平台上,您可以付出性能损失的代价而不是让您的代码失败。 @Myst:好的,感谢您提供的信息丰富的评论。我不太确定所有平台是否确实存在未对齐操作的“问题”(无论如何,您需要在这种情况下定义“问题”)。一些架构“错误地”执行它们(例如,在目标地址之前写入 2 个字节,在目标地址之后写入两个字节)。一些架构甚至可能引发中断。但我相信,至少有一些架构确实支持这种类型的操作,这样做不会受到任何惩罚(即,不必将操作“拆分”成两个操作)。 X86中最有名的支持非对齐访问的架构(ARM从6或8版本开始也支持非对齐访问,我不记得了)...我在不同系统上遇到的惩罚是在 8%-36% 之间,取决于 CPU、缓存和内存地址(缓存行未命中)。未对齐的访问总是是有代价的。这只是一个更难计算的成本。 facil.io STL make/test 将尝试对齐和非对齐访问。您可以在系统上看到差异。【参考方案2】:

此代码假定要散列的对象与 8 对齐(更精确到 2^(right_shift) )。否则此哈希函数(或宏)将返回冲突结果。

#define mylog2(x)  (((x) & 1) ? 0 : ((x) & 2) ? 1 : ((x) & 4) ? 2 : ((x) & 8) ? 3 : ((x) & 16) ? 4 : ((x) & 32) ? 5 : -1)


#define hash(p, t) (((unsigned long)(p) >> mylog2(sizeof(p))) & \
                    (sizeof(t) / sizeof((t)[0]) - 1))

unsigned long h[2048];                    

int main()

    
    int i1 = 0, i2 = 0, i3 = 0;
    long l1,l2,l3;
    
    
    printf("sizeof(ix) = %zu\n", sizeof(i1));
    printf("sizeof(lx) = %zu\n", sizeof(l1));
    
    printf("%lu\n", hash(&i1, h)); // Provided that the size of the hash table is 2048.
    printf("%lu\n", hash(&i2, h));
    printf("%lu\n", hash(&i3, h));

    printf("\n%lu\n", hash(&l1, h)); // Provided that the size of the hash table is 2048.
    printf("%lu\n", hash(&l2, h));
    printf("%lu\n", hash(&l3, h));


    return 0;

https://godbolt.org/z/zq1zfP

为了使其更可靠,您需要考虑对象的大小:

#define hash1(o, p, t) (((unsigned long)(p) >> mylog2(sizeof(o))) & \
                    (sizeof(t) / sizeof((t)[0]) - 1))

然后它将适用于任何大小的数据https://godbolt.org/z/a7dYj9

【讨论】:

为什么会失败?据我了解,在最坏的情况下,它只会产生更多的冲突,这意味着运行时性能更差(比地址 对齐到 8 个字节的情况)。 @goodvibration 在 50% 的情况下它会失败。不是很好的功能godbolt.org/z/xz4cjj 哈希函数在 50% 时发生碰撞失败,不得使用 :)。 hash1 中有错字。上面的版本没有使用o(但godbolt上的那个是)。 @j3141592653589793238 如果 sizeof 是已知的编译时间(即没有可变大小的数组),那么您可以简单地使用 math.h 中的 log2 如果常量表达式是 2 的力量,编译器将删除它它将由我知道的任何现代编译器计算编译时间godbolt.org/z/3c7n5M 我已经从我的答案中删除了它,知道这会引起许多纯 C 标准的 200% 可移植性(尤其是 60 年旧的大型机)***者【参考方案3】:

内存分配必须正确对齐。 IE。硬件可以指定int 应与 4 字节边界对齐,或者 double 应与 8 字节对齐。这意味着int 的最后两位地址位必须为零,double 的三位地址位必须为零。

现在,C 允许您定义混合charintlongfloatdouble 字段(以及更多)的复杂结构。虽然编译器可以添加填充以将字段的偏移量与适当的边界对齐,但整个结构也必须与其成员之一使用的最大对齐方式正确对齐。

malloc() 不知道您要对内存做什么,因此它必须返回一个针对最坏情况对齐的分配。这种对齐是特定于平台的,但通常不少于 8 字节对齐。今天更典型的值是 16 字节对齐。

因此,哈希算法只是简单地切断了地址中几乎总是为零的三个位,因此对于哈希值而言,这并不是毫无价值的。这很容易将哈希冲突的数量减少了 8 倍。(它只切断 3 位的事实表明该函数是不久前编写的。今天应该将其编程为切断 4 位。)

【讨论】:

@goodvibration:正确对齐始终取决于实现。在每个符合 C 的实现中,每个对象都应该正确对齐;您说这通常不正确的说法是不正确的。如果特定的 C 实现不需要多字节对齐,那么该实现中的正确对齐将是一个字节。 @goodvibration 请帮自己一个忙,自己阅读一些文档。阅读 C 标准后,您可能会重新审视对齐是否是神学辩论的问题...... @cmaster-reinstatemonica So, the hash algorithm simply cuts off the three bytes of the address 哇!!剥离地址的 3 个字节。您的地址总线 256 位有多宽? @j3141592653589793238 最重要的是分配器需要在未分配的内存块中存储两个指针。因此,如果您的指针是 8 字节对齐的,则可分配的最小块大小通常为 16 字节。因此,大多数分配器会选择(这不是强制的,而是常见的做法)考虑 16 字节块,总是将块大小四舍五入至少到 16 字节边界,并返回 16 字节对齐的块。我自己曾经写过这样一个分配器。因此,16 字节既有利于向量处理(某些 CPU 确实需要它),也适合分配器 @j3141592653589793238 P__J__ 的回答显示了堆栈上值的对齐方式。这些可能需要比malloc() 调用的结果更少的对齐:malloc() 必须对齐以进行矢量处理或double 的数组,即使您只在分配中存储char 数组。但是编译器知道各个变量有多大,它们各自的对齐要求是什么,因此可以将它们打包得更紧密。在示例中,变量为int,在大多数平台上为 4 字节宽,并且需要不超过 4 字节对齐,因此编译器很乐意将它们背靠背打包。

以上是关于为啥将地址右移三位作为固定大小哈希表的哈希函数?的主要内容,如果未能解决你的问题,请参考以下文章

谁能告诉我哈希是啥?

为啥哈希表扩展通常通过将大小加倍来完成?

哈希表(JavaScript实现)

使用整数值作为哈希表的键是多么愚蠢?

为啥哈希表的大小为 127(素数)优于 128?

算法哈希表的诞生(Java)