可以优化数组访问吗?

4
也许我的分析工具(Netbeans)误导了我,但我看到了一些奇怪的行为,希望在这里有人能帮我理解它。
我正在开发一个应用程序,它大量使用相当大的哈希表(键是长整型,值是对象)。内置的Java哈希表(HashMap特别)性能非常差,尝试了一些替代方案——Trove、Fastutils、Colt、Carrot——后,我开始构建自己的哈希表。
代码非常基本,使用双重散列策略。这样很好地工作并显示出所有其他选项中最佳的性能。
问题是,根据分析器,在哈希表中查找是整个应用程序中最昂贵的方法,尽管其他方法被调用“很多”次,并且/或执行了更多的逻辑。
真正让我困惑的是,只有一个类调用查找;调用方法执行查找并处理结果。两者几乎被调用相同的次数,而调用查找的方法有很多逻辑来处理查找的结果,但速度却快了约100倍。
下面是哈希查找的代码。它基本上只是对数组进行两个访问(根据分析计算哈希码的函数实际上是几乎免费的)。我不明白这段代码为什么会如此缓慢,因为它只是数组访问而已,我也没有看到任何使它更快的方法。
请注意,代码只返回与键匹配的桶,调用者需要处理桶。'size'是哈希表长度的一半,hash1在哈希表的前一半进行查找,hash2在后一半进行查找。key_index是传递给构造函数的哈希表上的final int字段,Entry对象上的values数组是一个小数组,通常长度不超过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))); 
}

我们能看到hash1/hash2中的代码吗?在Java中进行数组访问本质上比其他语言慢,因为需要进行边界检查。但是它成为任何应用程序的瓶颈是令人惊讶的。 - Mark Peters
顺便说一下,这似乎并不是真正的双重散列...您使用链接来避免冲突,对吗?双重散列是当您有一个哈希函数用于查找基础索引,第二个用于在发生冲突时确定要检查的下一个地址。 - Mark Peters
@Mark,是的,这是真的,我说错了,谢谢你的澄清。 - Michael
@Michael,当我执行1000万次get()时,每个查找大约需要2.8纳秒。你需要多快? - Peter Lawrey
你必须考虑数据如何适应缓存或主内存。如果你随机访问一个大的数据结构,性能会很差。我测试了一个应该适合L3缓存的数据集。如果你使用一个不适合缓存的数据集,40倍的性能损失并不奇怪。 - Peter Lawrey
显示剩余3条评论
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);
}

这个“Array”在索引超出边界时会有未定义的行为。我做了一个显而易见的实现:
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);

    }
}

结果有些令人惊讶。对于小于等于10的尺寸,TenArray的性能比NormalArray更快。减去开销(使用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

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


这是我最初考虑过的一个事情,但是输入对象是共享的,它们可以基于值数组中不同的值同时存在于不同的哈希表中。 - Michael
就我个人而言,我更改了Entry对象的共享方式,因为这很容易实现,并且实施了这个想法。总时间从整个执行时间的23%减少到略高于12%。我没有预料到边界检查的成本会那么高。还有其他避免边界检查的方法吗?我不认为它们可以被编译掉,对吧?=) - Michael
@Michael,编译器几乎不进行任何优化,JVM才是所有优化的执行者。你看到的平均查找时间是多少? - Peter Lawrey
@Michael:我进行了一些测试和分析,并将其添加到我的答案中。虽然我不建议实际使用,但我发现结果很有趣。 - Mark Peters
@Peter,根据分析结果进行数学计算,知道它在z次迭代中占y执行时间的x%,大约是4.5纳秒。编写一个测试程序来实际“计时”访问(哈希表包含500k个元素,进行500k次随机访问),大约需要160纳秒,或者根据Mark的建议改变后,大约需要140纳秒。测试工具只测试命中情况,所以这是命中的访问时间,对于未命中的最低估计可能只有1到2纳秒,并且在正常调用过程中可能会有很多未命中情况,这就是为什么整体计算时间相对于命中的平均时间来说较小的原因。 - Michael

1

你在解读分析结果时很可能被部分误导了。分析器极易夸大小型、频繁调用方法的性能影响。在你的情况下,获取(get)方法的分析开销可能比方法本身实际处理时间还要长。而且情况会变得更糟,因为工具也干扰了JIT(Just-In-Time Compiler)内联方法的能力。

对于这种情况的经验法则是:如果已知长度工作的总处理时间在使用分析器下增加了两至三倍以上,则分析开销将给出偏斜的结果。

为了验证你的更改确实产生了影响,始终要在不使用分析器的情况下进行性能改进的测试。分析器可以提示你找到瓶颈,但它也会误导你去看那些并没有问题的地方。

数组边界检查对性能有着惊人的影响(如果您做得相对较少),但它也很难与一般的内存访问惩罚清晰地分离。在某些琐碎的情况下,JIT可能能够消除它们(Java 6已经努力进行边界检查消除),但我认为这主要限于简单的循环结构,例如for(x=0; x<array.length; x++)。 在某些情况下,您可以通过简单的成员访问替换数组访问,从而完全避免边界检查,但仅适用于通过常量索引专门访问数组的罕见情况。我看不到将其应用于您的问题的方法。

Mark Peters建议的更改最可能不仅仅是更快,因为它消除了边界检查,而且还因为它更改了数据结构的局部性属性,使其更加缓存友好。


很多代码库只不过是数组操作而已;我一直在想是否受到了数组边界检查开销的影响,但你提到属性的局部性确实有道理。 - Michael

1
许多性能分析工具会告诉你非常混乱的信息,部分原因是它们的工作方式,部分原因是人们对性能有奇怪的想法。例如,你想知道函数被调用的次数,看着代码觉得逻辑很复杂,因此运行缓慢。
其实有一种非常简单的思考方式,可以让你轻松理解发生了什么。
  • 首先,考虑一个例程或语句活动的时间百分比,而不是它被调用的次数或平均执行时间。这样做的原因是它相对不受无关问题(如竞争进程或I/O)的影响,并且可以避免您将调用次数乘以平均执行时间并除以总时间,以确定是否足够重要。此外,百分比告诉您,归根结底,修复它可能会减少整体执行时间多少。

  • 其次,我所说的“活动”是指“在堆栈上”,其中堆栈包括当前运行的指令和所有调用“上面”的指令,直到“调用主函数”。如果一个例程负责10%的时间,包括它调用的例程,则在此期间它在堆栈上。单个语句甚至指令也是如此。(忽略“自身时间”或“独占时间”。这是一个干扰因素。)

  • 将计时器和计数器放在函数上的分析器只能为您提供部分信息。仅对程序计数器进行采样的分析器告诉您更少。您需要的是一些东西,它对调用堆栈进行采样,并通过每行(而不仅仅是通过函数)报告包含该行的堆栈采样的百分比。此外,重要的是它们在I/O或其他阻塞期间对堆栈进行采样,但不会在等待用户输入时进行采样。

有一些分析工具可以做到这一点,但我不确定Java是否有。

如果你还在跟着我,让我再提出一个问题。你正在寻找可以优化的东西,对吧?只有那些百分比足够大,值得费力去优化的东西,比如10%或更多的东西?这样一行代码的成本占堆栈的10%,也就是说它在10%的时间内都会被执行。这意味着如果取样20,000次,它将在其中约2,000次中被执行。如果只取样20次,平均而言它只会被执行2次。现在,你想找到这行代码,对吧?如果百分比稍微偏离一点,真的很重要吗,只要你找到了它?这是分析工具的另一个美好神话——计时的精度很重要。对于发现值得修复的问题,20,000个样本并不能告诉你比20个样本更多的信息。 那我该怎么办呢?只需手动取样并研究它们即可。值得优化的代码会自然地跳出来。

最后,有一个好消息。你可能可以优化多个方面。假设你解决了一个20%的问题并使其消失。总时间缩短到原来的4/5,但其他问题的时间没有减少,所以现在它们的百分比是原来的5/4,因为分母变小了。从百分比上来看,它们变得更大,更容易找到。这种效应会滚雪球般地增强,让你真正地挤压代码。


0
你可以尝试使用记忆化或缓存策略来减少实际调用的次数。如果你非常迫切,还可以尝试使用本地数组,因为对它们进行索引非常快速,并且如果你使用像 long 这样不需要 marshalling 的参数,JNI 不应该会产生太多开销。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接