基于哈希表的容器是非常快速的关联数组(例如unordered_map
,unordered_set
)。
它们的性能高度依赖于用于为每个条目创建索引的哈希函数。随着哈希表的增长,元素会一次又一次地重新出现。
指针是简单类型,基本上是唯一标识对象的4/8字节值。问题是由于多个LSB为零,使用地址作为散列函数的结果是无效的。
示例:
struct MyVoidPointerHash {
size_t operator()(const void* val) const {
return (size_t)val;
}
};
更快的实现是丢失一些位:
struct MyVoidPointerHash2 {
size_t operator()(const void* val) const {
return ((size_t)val) >> 3; // 3 on 64 bit, 1 on 32 bit
}
};
后者在大型应用程序上的性能提高了10-20%,该应用程序使用散列集和映射以及经常构建和清除的数万个元素。
有人能为哈希指针提供更好的方案吗?
该功能需要:
更新 - 基准测试结果
我运行了两组测试,一组用于int*
,另一组用于大小为4KB的类指针。结果非常有趣。
我使用std::unordered_set
进行所有测试,数据大小为16MB,在单个new
调用中分配。第一个算法运行了两次,以确保缓存尽可能热,并且CPU正在全速运行。
设置:VS2013(x64),i7-2600,Windows 8.1 x64。
return (size_t)(val);
return '(size_t)(val) >> 3;
uintptr_t ad = (uintptr_t)val;
return (size_t)((13 * ad) ^ (ad >> 15));
uintptr_t ad = (uintptr_t)val;
return (size_t)(ad ^ (ad >> 16));
代码:
template<typename Tval>
struct MyTemplatePointerHash1 {
size_t operator()(const Tval* val) const {
static const size_t shift = (size_t)log2(1 + sizeof(Tval));
return (size_t)(val) >> shift;
}
};
测试1 - int*
:
测试1 - 4K_class*
:
UPDATE2:
到目前为止,Winner是模板化哈希(Hash5)函数。各种块尺寸的最佳性能水平。
更新3: 添加了基线的默认哈希函数。事实证明它远非最佳。
答案 0 :(得分:18)
从理论的角度来看,正确的答案是:&#34;使用std::hash
这可能是专业的,并且如果不适用,请使用好的哈希功能而不是快速。哈希函数的速度与其质量&#34; 无关。。
实用的答案是:&#34;使用std::hash
,这是一个非常糟糕的,但仍然表现得非常好。&#34; < / p>
<强> TL; DR 强>
在引起好奇之后,我在周末跑了大约30个小时的基准。除其他事项外,我试图得到一个平均案例与最坏情况,并试图通过在插入的集合大小方面对桶数进行故意错误提示来强迫std::unordered_map
进入最坏情况行为。
我将可怜的哈希值(std::hash<T*>
)与众所周知的总体优良品质的通用哈希值(djb2,sdbm)进行比较,并将这些哈希值的变化归因于非常短的输入长度,以及哈希值显然被认为是在哈希表中使用(murmur2和murmur3),以及实际上比没有散列更糟糕的穷人哈希,因为它们会丢弃熵。
由于对齐引起的最低2-3位指针始终为零,因此我认为将一个简单的右移测试为&#34; hash&#34;是值得的,因此只使用非零信息,以防万一哈希表例如仅使用最低N位。原来是合理的转变(我也试过不合理的转变!)这实际上表现得相当不错。
我的一些发现是众所周知的,并不令人惊讶,其他人非常令人惊讶:
std::unordered_map
表现得很糟糕。即使故意提示铲斗计数会导致多次重新哈希,但整体性能并没有太差。只有以极其荒谬的方式抛弃大部分熵的非常糟糕的散列函数能够显着影响性能超过10-20%(例如right_shift_12
,这实际上会产生结果在50,000个输入中只有12个不同的哈希值!在这种情况下,哈希映射运行速度大约慢100倍也就不足为奇了 - 我们基本上在链表上进行随机访问查找。)。std::hash<T*>
的专业化对GCC来说是完全可悲的(一个简单的reinterpret_cast
)。有趣的是,一个执行相同功能的仿函数在插入时的执行速度更快,在随机访问时执行速度更慢。差异很小(在8-10秒的测试运行中十几毫秒),但它不是噪声,它一直存在 - 可能与指令重新排序或流水线有关。令人惊讶的是,完全相同的代码(也是无操作)一致在两种不同的情况下表现不同。std::hash
。通常他们会落在前3-4名。测试不只是对任何4字节或8字节(或其他)对齐值,而是对从堆上分配完整元素集获得的实际地址进行测试,并将分配器提供的地址存储在std::vector
(然后删除了对象,不需要它们)
按照原始顺序(&#34;顺序&#34;)和在std::unordered_map
上应矢量。
对于大小为4,16,64,256和1024的50,000和1,000,000个对象的集合进行了测试(为简洁起见,这里省略了64个结果,它们因为你在中间的某个地方而被忽略了在16到256之间 - StackOverflow只允许发布30k个字符) 测试套件进行了3次,结果在这里和那里变化3或4毫秒,但整体相同。这里发布的结果是最后一次运行。
&#34;随机&#34;中的插入顺序测试以及访问模式(在每个测试中)都是伪随机的,但对于测试运行中的每个散列函数都完全相同。
哈希基准测试下的时序用于在整数变量中总计4,000,000,000个哈希值。
列std::random_shuffle
是50次迭代创建insert
,分别插入50,000和1,000,000个元素并销毁地图的时间(以毫秒为单位)。
列std::unordered_map
是在&#39;向量中对100,000个伪随机元素进行查找的时间(以毫秒为单位)。然后在access
中查找该地址
此时间平均包括一个缓存未命中用于访问unordered_map
中的随机元素,至少对于大型数据集(小数据集完全适合L2)。
2.66GHz Intel Core2,Windows 7,gcc 4.8.1 / MinGW-w64_32的所有时序。定时器粒度@ 1ms。
源代码可用on Ideone,这也是因为Stackoverflow的30k字符限制。
注意:在桌面PC上运行完整的测试套件需要2个多小时,所以如果要重现结果,请准备好散步。
vector
答案 1 :(得分:10)
让这个问题搁置一段时间之后,我会发布我最好的哈希函数到目前为止指针:
template<typename Tval>
struct MyTemplatePointerHash1 {
size_t operator()(const Tval* val) const {
static const size_t shift = (size_t)log2(1 + sizeof(Tval));
return (size_t)(val) >> shift;
}
};
它适用于各种尺寸的高性能 如果有人有更好的功能,我会改变接受的答案。
答案 2 :(得分:7)
散列函数返回的结果类型为size_t
,但它被容器转换为“桶索引”,标识正确的桶以定位对象。
我认为这个转换没有在标准中指定:但我希望这通常是Modulo N操作,其中N是桶的数量 - 并且N通常是2的幂,因为桶数增加一倍当点击次数过多时,这是增加大小的好方法。 Modulo N操作意味着 - 对于指针 - 朴素哈希函数只使用一小部分桶。
真正的问题是容器的“良好”哈希算法必须基于桶大小的知识和您正在散列的值。例如,如果您在表中存储的对象都是1024字节的大小,则每个指针的低位10位可能是相同的。
struct MyOneKStruct x[100]; //bottom 10 bits of &x[n] are always the same
因此,任何应用程序的“最佳”哈希可能需要大量的试验和错误以及测量,以及您正在散列的值的分布知识。
然而,我不是简单地将指针向下移动N位,而是尝试将顶部的“字”转换为底部字。很像@ BasileStarynkevich的回答。
关于添加哈希表的提议有趣阅读。我强调以下段落:http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2003/n1456.html
编写一个有效的完全通用哈希函数是不可能的 适用于所有类型。 (你不能只是将一个对象转换为原始内存和 散列字节;除了其他原因,这个想法因为失败而失败 填充。)因此,还因为一个好的哈希函数 只有在特定使用模式的背景下才有用,这是必不可少的 允许用户提供自己的哈希函数。
答案 3 :(得分:5)
显然,答案取决于系统和处理器(特别是因为页面大小和字大小)。我在提议
struct MyVoidPointerHash {
size_t operator()(const void* val) const {
uintptr_t ad = (uintptr_t) val;
return (size_t) ((13*ad) ^ (ad >> 15));
}
};
有鉴于此,在许多系统上,页面大小通常为4K字节(即2 12 ),因此右移>>15
会将重要的地址部分放在较低的位中。 13*
主要是为了好玩(但是13是素数)并且更多地改变了比特。独占或^
是混合位,非常快。因此,散列的低位是指针的许多位(高和低)的混合。
我没有声称在这样的哈希函数中放了很多“科学”。但他们碰巧经常工作得很好。因人而异。我猜你应该避免停用ASLR!
答案 4 :(得分:1)
无法在性能赛道上击败您的解决方案(char
,1024大小struct
),但在正确性方面有一些改进:
#include <iostream>
#include <new>
#include <algorithm>
#include <unordered_set>
#include <chrono>
#include <cstdlib>
#include <cstdint>
#include <cstddef>
#include <cmath>
namespace
{
template< std::size_t argument, std::size_t base = 2, bool = (argument < base) >
constexpr std::size_t log = 1 + log< argument / base, base >;
template< std::size_t argument, std::size_t base >
constexpr std::size_t log< argument, base, true > = 0;
}
struct pointer_hash
{
template< typename type >
constexpr
std::size_t
operator () (type * p) const noexcept
{
return static_cast< std::size_t >(reinterpret_cast< std::uintptr_t >(p) >> log< std::max(sizeof(type), alignof(type)) >);
}
};
template< typename type = std::max_align_t, std::size_t i = 0 >
struct alignas(alignof(type) << i) S
{
};
int
main()
{
constexpr std::size_t _16M = (1 << 24);
S<> * p = new S<>[_16M]{};
auto latch = std::chrono::high_resolution_clock::now();
{
std::unordered_set< S<> *, pointer_hash > s;
for (auto * pp = p; pp < p + _16M; ++pp) {
s.insert(pp);
}
}
std::cout << std::chrono::duration_cast< std::chrono::milliseconds >(std::chrono::high_resolution_clock::now() - latch).count() << "ms" << std::endl;
delete [] p;
return EXIT_SUCCESS;
}