4

也许我被我的分析器(Netbeans)误导了,但我看到了一些奇怪的行为,希望这里有人可以帮助我理解它。

我正在开发一个应用程序,它大量使用了相当大的哈希表(键是长整数,值是对象)。内置 java 哈希表(特别是 HashMap)的性能非常差,在尝试了一些替代方案之后——Trove、Fastutils、Colt、Carrot——我开始自己工作。

该代码使用双散列策略非常基本。这很好用,并且显示了迄今为止我尝试过的所有其他选项的最佳性能。

根据分析器的说法,问题在于,查找哈希表是整个应用程序中最昂贵的方法——尽管其他方法被调用更多次和/或执行更多逻辑。

真正让我困惑的是查找只由一个类调用;调用方法进行查找并处理结果。两者的调用次数几乎相同,调用查找的方法中有很多逻辑来处理查找的结果,但速度大约快 100 倍。

下面是哈希查找的代码。它基本上只是对数组的两次访问(根据分析,计算哈希码的函数实际上是免费的)。我不明白这段代码怎么会这么慢,因为它只是数组访问,而且我看不到任何让它更快的方法。

请注意,代码仅返回与键匹配的存储桶,调用者应处理该存储桶。'size' 是 hash.length/2,hash1 在哈希表的前半部分进行查找,hash2 在后半部分进行查找。key_index 是传递给构造函数的哈希表上的最终 int 字段,而 Entry 对象上的 values 数组是一个小的 long 数组,通常长度为 10 或更短。

非常感谢人们对此的任何想法。

谢谢。

public final Entry get(final long theKey) {
    Entry aEntry = hash[hash1(theKey, size)];

    if (aEntry != null && aEntry.values[key_index] != theKey) {
        aEntry = hash[hash2(theKey, size)];

        if (aEntry != null && aEntry.values[key_index] != theKey) {
            return null;
        }
    }

    return aEntry;
}

编辑 hash1 & hash2 的代码

private static int hash1(final long key, final int hashTableSize) { 
    return (int)(key&(hashTableSize-1)); 
}
private static int hash2(final long key, final int hashTableSize) { 
    return (int)(hashTableSize+((key^(key>>3))&(hashTableSize-1))); 
}
4

4 回答 4

6

您的实施中没有什么让我觉得效率特别低。我承认我并没有真正遵循您的散列/查找策略,但是如果您说它在您的情况下表现出色,我会相信您。

我唯一期望可能会有所不同的是将键移出Entry.

而不是这样:

class Entry {
    long[] values;
}

//...
if ( entry.values[key_index] == key ) { //...

试试这个:

class Entry {
    long key;
    long values[];
}

//...
if ( entry.key == key ) { //...

而不是产生访问成员的成本,加上进行边界检查,然后获取数组的值,您应该只产生访问成员的成本。

是否有比数组更快的随机访问数据类型?

我对这个问题的答案很感兴趣,所以我搭建了一个测试环境。这是我的数组接口:

interface Array {
    long get(int i);
    void set(int i, long v);
}

当索引超出范围时,此“数组”具有未定义的行为。我把明显的实现放在一起:

class NormalArray implements Array {
    private long[] data;

    public NormalArray(int size) {
        data = new long[size];
    }

    @Override
    public long get(int i) {
        return data[i];
    }

    @Override
    public void set(int i, long v) {
        data[i] = v;
    }
}

然后是一个控件:

class NoOpArray implements Array {
    @Override
    public long get(int i) {
        return 0;
    }
    @Override
    public void set(int i, long v) {
    }
}

最后,我设计了一个“数组”,其中前 10 个索引是硬编码的成员。通过开关设置/选择成员:

class TenArray implements Array {
    private long v0;
    private long v1;
    private long v2;
    private long v3;
    private long v4;
    private long v5;
    private long v6;
    private long v7;
    private long v8;
    private long v9;
    private long[] extras;

    public TenArray(int size) {
        if (size > 10) {
            extras = new long[size - 10];
        }
    }

    @Override
    public long get(final int i) {
        switch (i) {
        case 0:
            return v0;
        case 1:
            return v1;
        case 2:
            return v2;
        case 3:
            return v3;
        case 4:
            return v4;
        case 5:
            return v5;
        case 6:
            return v6;
        case 7:
            return v7;
        case 8:
            return v8;
        case 9:
            return v9;
        default:
            return extras[i - 10];
        }
    }

    @Override
    public void set(final int i, final long v) {
        switch (i) {
        case 0:
            v0 = v; break;
        case 1:
            v1 = v; break;
        case 2:
            v2 = v; break;
        case 3:
            v3 = v; break;
        case 4:
            v4 = v; break;
        case 5:
            v5 = v; break;
        case 6:
            v6 = v; break;
        case 7:
            v7 = v; break;
        case 8:
            v8 = v; break;
        case 9:
            v9 = v; break;
        default:
            extras[i - 10] = v;
        }
    }
}

我用这个线束测试了它:

import java.util.Random;

public class ArrayOptimization {
    public static void main(String[] args) {
        int size = 10;
        long[] data = new long[size];
        Random r = new Random();
        for ( int i = 0; i < data.length; i++ ) {
            data[i] = r.nextLong();
        }

        Array[] a = new Array[] {
                new NoOpArray(),
                new NormalArray(size),
                new TenArray(size)
        };

        for (;;) {
            for ( int i = 0; i < a.length; i++ ) {
                testSet(a[i], data, 10000000);
                testGet(a[i], data, 10000000);
            }
        }
    }

    private static void testGet(Array a, long[] data, int iterations) {
            long nanos = System.nanoTime();
        for ( int i = 0; i < iterations; i++ ) {
            for ( int j = 0; j < data.length; j++ ) {
                data[j] = a.get(j);
            }
        }
        long stop = System.nanoTime();
        System.out.printf("%s/get took %fms%n", a.getClass().getName(), 
                (stop - nanos) / 1000000.0);
    }

    private static void testSet(Array a, long[] data, int iterations) {
        long nanos = System.nanoTime();
        for ( int i = 0; i < iterations; i++ ) {
            for ( int j = 0; j < data.length; j++ ) {
                a.set(j, data[j]);
            }
        }
        long stop = System.nanoTime();
        System.out.printf("%s/set took %fms%n", a.getClass().getName(), 
                (stop - nanos) / 1000000.0);

    }
}

结果有些出人意料。TenArray 的执行速度比 NormalArray 快得多(对于大小 <= 10)。减去开销(使用 NoOpArray 平均值),您会得到 TenArray 的时间约为普通数组的 65%。因此,如果您知道数组的可能最大大小,我想可能超过数组的速度。我想 switch 使用比数组更少的边界检查或更有效的边界检查。

NoOpArray/set took 953.272654ms
NoOpArray/get took 891.514622ms
NormalArray/set took 1235.694953ms
NormalArray/get took 1148.091061ms
TenArray/set took 1149.833109ms
TenArray/get took 1054.040459ms
NoOpArray/set took 948.458667ms
NoOpArray/get took 888.618223ms
NormalArray/set took 1232.554749ms
NormalArray/get took 1120.333771ms
TenArray/set took 1153.505578ms
TenArray/get took 1056.665337ms
NoOpArray/set took 955.812843ms
NoOpArray/get took 893.398847ms
NormalArray/set took 1237.358472ms
NormalArray/get took 1125.100537ms
TenArray/set took 1150.901231ms
TenArray/get took 1057.867936ms

现在,我不确定您是否可以在实践中获得比阵列更快的速度;显然,这种方式会产生与接口/类/方法相关的任何开销。

于 2010-11-05T15:34:57.230 回答
1

您很可能在对分析器结果的解释中被部分误导。众所周知,Profilers 夸大了经常调用的小型方法对性能的影响。在您的情况下,get() 方法的分析开销可能大于方法本身所花费的实际处理。情况进一步恶化,因为仪器也干扰了 JIT 内联方法的能力。

作为这种情况的经验法则 - 如果在分析器下运行时,已知长度的工作的总处理时间增加了两到三倍,那么分析开销会给你带来偏差的结果。

要验证您的更改是否确实产生了影响,请始终在没有 profiler 的情况下测量性能改进。分析器可以提示您有关瓶颈的信息,但它也可以欺骗您查看没有问题的地方。

数组边界检查可能会对性能产生惊人的大影响(如果您做的其他事情相对较少),但也很难将其与一般的内存访问惩罚区分开来。在一些微不足道的情况下,JIT 可能能够消除它们(在 Java 6 中已经努力消除边界检查),但这是 AFAIK 主要限于简单的循环结构,如 for(x=0; x<array.length; x++)。在某些情况下,您可以通过简单的成员访问来替换数组访问,从而完全避免绑定检查,但仅限于通过常量索引专门访问数组的极少数情况。我认为没有办法将其应用于您的问题。

Mark Peters 建议的更改很可能不仅更快,因为它消除了边界检查,而且还因为它以更缓存友好的方式改变了数据结构的局部性属性。

于 2010-11-05T17:28:23.153 回答
1

许多分析器告诉你一些非常令人困惑的事情,部分是因为它们是如何工作的,部分是因为人们对性能有一些有趣的想法。例如,您想知道函数被调用了多少次,并且您正在查看代码并认为它​​看起来像很多逻辑,因此很慢。

有一种非常简单的方式来思考这些东西,这使得理解正在发生的事情变得非常容易。

  • 首先,考虑一个例程或语句处于活动状态的时间百分比,而不是它被调用的次数或它所花费的平均时间长度。这样做的原因是它相对不受竞争进程或 I/O 等无关问题的影响,并且它节省了您必须将调用次数乘以平均执行时间并除以总时间来查看它是否很大甚至足以关心。此外,百分比告诉你,底线,多少修复它可能会减少整体执行时间。

  • 其次,我所说的“活动”是“在堆栈上”,其中堆栈包括当前正在运行的指令以及所有“高于”它的调用返回到“调用 main”。如果一个例程负责 10% 的时间,包括它调用的例程,那么在此期间它在堆栈上。个别陈述甚至指令也是如此。(忽略“自我时间”或“独享时间”。这是一种分心。)

  • 将计时器和计数器放在函数上的分析器只能为您提供部分信息。仅对程序计数器进行采样的分析器告诉您更少。您需要的是对调用堆栈进行采样并按(而不仅仅是按函数)向您报告包含该行的堆栈样本的百分比。对堆栈进行采样也很重要 a) 在 I/O 或其他阻塞期间,但 b) 不在等待用户输入时。

有分析器可以做到这一点。我不确定Java。

如果你还在我身边,让我再扔一个铃声。您正在寻找可以优化的东西,对吧?只有那些有足够大的百分比值得麻烦的事情,比如 10% 或更多?这样一行花费 10% 的代码有 10% 的时间在堆栈上。这意味着如果采集了 20,000 个样本,其中大约有 2,000 个样本。如果采集 20 个样本,则平均在其中大约 2 个样本上。现在,你正试图找到这条线,对吧?只要您找到百分比,百分比是否略有下降真的很重要吗?这是探查器的另一个令人愉快的神话——时间的精确性很重要。为了找到值得修复的问题,20,000 个样本不会告诉你比 20 个样本多得多。那我该怎么办?只需用手拿样品并研究它们. 值得优化的代码只会跳出来。

最后,还有一大堆好消息。您可能可以优化多项内容。假设你解决了 20% 的问题并让它消失。总时间缩减到原来的 4/5,但其他问题的时间并没有减少,所以现在它们的百分比是原来的 5/4,因为分母变小了。按百分比计算,它们变得更大,更容易找到。这个效果滚雪球,让你真正压缩代码。

于 2010-11-05T21:24:04.627 回答
0

您可以尝试使用记忆或缓存策略来减少实际调用的数量。如果您非常绝望,您可以尝试的另一件事是原生数组,因为索引它们的速度非常快,如果您使用不需要编组的 long 等参数,JNI 不应该调用太多开销。

于 2010-11-06T15:13:17.767 回答