【译】实现一个世界上最快的int-int map

最近看了很多 Map 实现的源码,循迹着一篇比较各类实现性能比较的综述,发现这篇文章,正好切合我的要求。趁着作者给的测评程序跑着(然而已经跑了一个小时了(○´・д・)ノ),搜索了一下发现没有人把这么好的文章翻译一下,可能内容比较基础,但是这种追求卓越的精神感染到了我,让我想起来以前读CSAPP时的感觉,于是决定翻译一下。同时,作者 Mikhail Vorontsov 其他的文章也非常好,有很多 Java 调优技巧和介绍 Java 性能的内容。

原文地址:http://java-performance.info/implementing-world-fastest-java-int-to-int-hash-map/

我要感谢 Sebastiano VignaRoman Leventov 和我分享了实现哈希表的智慧。一些实现的想法也来自于 Kris Kaspersky 的 “Code Optimization: Effective Memory Usage”。

这篇文章将一步步带你领略各式各样的现代哈希表实现。在文章的最后,你将获得一个可能是撰写本文时最快的 Java int-to-int 哈希表。

开放地址法

大多数现代哈希表都是基于开放地址法。这意味着什么?你的哈希表将基于 keys 数组( values 也会放到这个数组的适当位置,所以现在先忘掉他们)。在进行每个操作时,你必须先在 keys 数组中找到特定的 key 。那么如何实现呢?

首先,你需要一个起始位置。可以用任何一个哈希函数把 key 映射到[0, length - 1]的区间上。一个 key 通常通过 hashCode 方法映射为一个整数,然后再通过一个简单的哈希函数,例如 Math.abs(key.hashCode() % array.length) (记住,% 运算可能为负值)。

正如你所知道的,大量的 keys 映射到一个小的哈希表中会产生一些冲突(我们称之为哈希冲突)。寻找不同 keys 的初始位置也一样,可以通过另一个函数来解决冲突,例如 (prevIdx + 1) % array.length ,这就要求这个函数必须能覆盖到整个数组容量。当数组长度为素数时还可以通过偏移一个素数来实现这个函数。

空单元和已删除单元

理论上,现在已经可以实现你自己的哈希表了。但实际上,你需要区别空单元和已删除单元(你可以避免标志删除单元,如果在 remove 方法中做一些额外的工作,可以看看 FastUtil 的实现)。已删除单元也叫做“墓碑”。

你的 keys 可以直接填充到空单元,如果你删除一个已经存在的 key ,你需要将它标志位“已删除”状态。

看一下如下的例子:

开放地址法示例

这个 int 哈希表用如下的初始函数和再探测函数:

1
2
initial = Math.abs( key % array.length );
nextIdx = ( prevIdx + 1 ) % array.length;

哈希表中有 keys 为1,2,3和4,但是3被删除了,所以用R来占位、

我们来看一下如何查找下面的 keys :

Key 描述
2 初始函数指向索引为2的单元,匹配查找的 key 2 ,所以不需要其他的查找了
3 初始函数指向索引为3的单元,这个单元被标记为“已删除”,所以我们需要应用在探测函数继续查找,直到要查找的 key 或者一个空单元:索引为 4 的单元不匹配,索引为 5 的单元是一个空单元,所以我们停止查找——查找失败

下面,我们看看如何添加 key = 10 : initial = key % array.length = 10 % 9 = 1 ,索引为1的单元已经被另外一个 key 占据,所以我们不能使用它。再探测索引为 2 时也是一样的,直到索引为 3 时是已删除单元,所以可以重用它,并把 key = 10 填充到其中。

去掉已删除单元

在许多情况下,如果你要将已删除单元格保留在哈希表中,那么你的哈希映射可能会降级为O(n2)复杂度。高效的哈希表用某种方式去掉已移除单元格。因此所有其他方法都需要区分2种单元状态:空闲或使用。 同时,remove 方法通常不常用,远远小于 put 方法。本文将使用FastUtil的清理逻辑。

散列加扰

采用如上的初始函数,当我们需要放置连续的 keys 时,就会造成很长的查找链。为了避免这种情况,我们需要散列加扰,打乱他们的二进制位,例如:

1
2
3
4
5
6
private static final int INT_PHI = 0x9E3779B9;

public static int ( final int x ) {
final int h = x * INT_PHI;
return h ^ (h >> 16);
}

这样,连续的 keys 就不会被防止在连续的数组单元中,继而使得查找保持在平均的长度。对于随机的 keys ,它们的分布将会更好。

现在你已经完全可以实现自己的哈希表了,我们将在本文接下来的几节实现它。

版本1:基础int-int 哈希表

我们首先实现足够简单的哈希表(一个足够有优化空间的实现)。这个实现会非常像 Trove 3.0 中的 TIntIntHashMap(我并没有抄袭它的源代码)。

它将用三个数组:一个 int[] 用来放置 keys,一个 int[] 用来放置 values 还有一个 boolean[] 用来放置“已使用”标志位。我们将为它们分配初始空间(可以是 size / fillFactor + 1)。初始函数和再探测函数如下:

1
2
initial = Math.abs( Tools.phiMix(key) % array.length);
nextIdx = ( prevIdx + 1 ) % array.length;

你可以在本文的结尾看到这个哈希表和其他哈希表的源代码。

我将对比 之前的文章 的结果.我们将它和 Koloboke 对比,本文所有的测试都是用随机 keys 集合,所有哈希表的填充因子都为 0.75 。

Map size: 10.000 100.000 1.000.000 10.000.000 100.000.000
KolobokeMap 1867 2471 3129 7546 11191
IntIntMap1 2768 3671 6105 12313 16073

太棒了!第一个没有优化的尝试只比 Koloboke 慢两倍。

版本2:避免昂贵的 % 运算——数组容量变为2的幂次

许多人认为整数的除法求余运算已经不再那么慢了——因为新的CPUs更好更快。但是,这是错误的。所有的整数除法运算扔非常慢,在性能严苛的代码中不应出现。

当前版本的哈希表利用 % 运算符求余,我们可以将数组的长度变为2的幂次来避免它。如下所示:

1
2
initial = Tools.phiMix( key ) & (array.length-1);
nextIdx = ( prevIdx + 1 ) & (array.length-1);

array.length - 1 应该被缓存到实例域中,为什么它可以被当作 mask 呢?因为如果 K=2^N ,那么 X % K == X & (K - 1)。利用 & 运算可以使得结果非负,并进一步加速计算过程。

记住所有的高性能哈希表都依靠这个优化。

让我们再看一下对比结果:

【译】实现一个世界上最快的int-int map 大专栏  r>
Map size: 10.000 100.000 1.000.000 10.000.000 100.000.000
KolobokeMap 1867 2471 3129 7546 11191
IntIntMap1 2768 3671 6105 12313 16073
IntIntMap2 2254 2767 4869 10543 16724

这个优化让我们跨越了一大步!接下来还有很长的路要走。

版本3:摆脱 m-used 数组

之前版本的哈希表用三个不同的数组来存储数据。这意味着需要获取三块不同的内存区域,会可能导致 CPU 缓存未命中。高性能的代码应该最小化缓存未命中的数量,最直观的优化是我们可以去掉 m-used 数组并用更聪明的方法标识单元。

问题是我们正在实现一个 int-to-int 哈希表,所以我们会用到任何 int来作为 key (如果一些key N 被保留并且不能被使用是多么可悲)。这意味着我们需要额外的标识存储,对么?是的,但关键是我们可以用 O(1) 来代替 O(n)!

这个想法是选用一个特殊的 key 来标识空单元。有如下两个策略(我将会使用第一个):

1.利用额外一个空间存储空单元标识的 value。还需要一个标志位来标识这个key是否被使用,每个哈希表方法开头都需要对其进行校验并作出不同的逻辑动作。
2.选择一个随机空单元 key 。如果你正要往哈希表中插入空单元 key ,选择一个新的随机空单元 key ,这个 key 是从未在哈希表中出现过的,然后覆盖之前所有的空单元。 Koloboke 是唯一用这种策略的。

顺便提一句,选择 Objects keys 数组的空单元值时将会很简单:

1
private static final Object FREE_KEY = new Object();

这个 key 将不能被其他类访问,所以将不可能会被传入到你的哈希表中。有些聪明的家伙可能记得反射并题型说,哈希表的 keys 必须重写 equals 和 hashcode ,但对这个私有的对象却不是这种情况。

正如我上面提到的,我们的实现将采用硬编码(0, 一个最方便比较的常量)并存储他对应的 value 在一个实例域中。

1
2
3
4
5
6
7
8
9
10
11
private static final int FREE_KEY = 0;


private int[] m_keys;
/** Values */
private int[] m_values;

/** Do we have 'free' key in the map? */
private boolean m_hasFreeKey;
/** Value of 'free' key */
private int m_freeValue;

我们再来比较一下测试性能:

Map size: 10.000 100.000 1.000.000 10.000.000 100.000.000
KolobokeMap 1867 2471 3129 7546 11191
IntIntMap1 2768 3671 6105 12313 16073
IntIntMap2 2254 2767 4869 10543 16724
IntIntMap3 2050 2269 3548 9074 13750

正如你看到的,哈希表随着大小增加的影响(你的哈希表越大,对CPU缓存命中的帮助就越小)。尽管,我们离 Koloboke 还很远,但是它已经在我之前的文章中排名第三,紧随 Koloboke 和 FastUtil之后。

版本 4 和 4a :用一个数组代替 keys 和 values 数组

这一步遵循前一步骤的方向——现在我们要使用单个数组来存储键和值。这将使我们能够以非常低的成本访问/修改 values ,因为它们将位于 key 的旁边。

这里有两种可行的实现:

1.用一个 long[] ,一个 key 和 一个 value 将共享一个 long 。这种方法的有效性仅限于某些类型的 key 和 value。
2.用一个 int[] ,keys 和 values 将间隔的分布。这个方法缺点就是容量只有10亿,我相信对于大多数场景不是问题。

这两种情况之间的区别在于需要使用位算术/类型转换来从 long 数组中提取 key 和 value。我的测试显示这些操作对哈希表的性能有显着的负面影响。不过我已经将 long [](IntIntMap4)和int [](IntIntMap4a)版本都包含到本文中。

小优化点

两个版本都会非常快,但你需要更多优化才能成为最快的版本。你应该了解一个hashmap,它的基本操作具有O(1)复杂度,这一点很重要,只要你不太过于追求填充因子。 这实际上意味着你必须计算散列命中路径上的指示(您检查的第一个单元,如果是空的或包含请求的 key)。优化哈希碰撞循环也很重要,但是(我重复这一点),您必须非常小心哈希命中路径,因为大多数操作最终都会以哈希命中结束。

考虑到这一点,您可能想要内联一些辅助方法,尤其是那些可以在内联时为您节省指令的方法。 例如,看一下上一个版本的get方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public int get( final int key )
{
if ( key == FREE_KEY )
return m_hasFreeKey ? m_freeValue : NO_VALUE;

final int idx = getReadIndex( key );
return idx != -1 ? m_values[ idx ] : NO_VALUE;
}

private int getReadIndex( final int key )
{
int idx = getStartIndex( key );
if ( m_keys[ idx ] == key ) //we check FREE prior to this call
return idx;
if ( m_keys[ idx ] == FREE_KEY ) //end of chain already
return -1;
final int startIdx = idx;
while (( idx = getNextIndex( idx ) ) != startIdx )
{
if ( m_keys[ idx ] == FREE_KEY )
return -1;
if ( m_keys[ idx ] == key )
return idx;
}
return -1;
}

第一行的判断大多数情况都为 true ,所以可以不在这里判断。

猜你喜欢

转载自www.cnblogs.com/liuzhongrong/p/11874919.html
今日推荐