4

プロファイラー (Netbeans) に惑わされているのかもしれませんが、奇妙な動作が見られます。ここの誰かが理解してくれることを願っています。

私は、かなり大きなハッシュ テーブル (キーは long、値はオブジェクト) を多用するアプリケーションに取り組んでいます。組み込みの Java ハッシュ テーブル (具体的には HashMap) のパフォーマンスは非常に低く、いくつかの代替手段 (Trove、Fastutils、Colt、Carrot) を試した後、自分で作業を開始しました。

コードは、ダブル ハッシュ戦略を使用した非常に基本的なものです。これはうまく機能し、これまでに試した他のすべてのオプションの中で最高のパフォーマンスを示しています。

問題は、プロファイラーによると、ハッシュ テーブルへのルックアップが、アプリケーション全体で最もコストのかかる単一のメソッドであるということです。他のメソッドが何度も呼び出されたり、より多くのロジックを実行したりしているにもかかわらずです。

私を本当に混乱させているのは、ルックアップが 1 つのクラスによってのみ呼び出されることです。呼び出し元のメソッドがルックアップを行い、結果を処理します。どちらもほぼ同じ回数呼び出され、ルックアップを呼び出すメソッドには、ルックアップの結果を処理するための多くのロジックが含まれていますが、約 100 倍高速です。

以下は、ハッシュ ルックアップのコードです。基本的に、配列へのアクセスは 2 回だけです (プロファイリングによると、ハッシュ コードを計算する関数は事実上無料です)。このビットのコードは単なる配列アクセスであるため、どのように遅くなるかわかりません。また、高速化する方法もわかりません。

コードは単にキーに一致するバケットを返すことに注意してください。呼び出し元はバケットを処理する必要があります。'size' は hash.length/2 で、hash1 はハッシュ テーブルの前半で検索を行い、hash2 は後半で検索を行います。key_index は、コンストラクターに渡されるハッシュ テーブルの最後の int フィールドであり、Entry オブジェクトの値配列は、通常は長さが 10 以下の long の小さな配列です。

これに関する人々の考えは大歓迎です。

ありがとう。

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

ほとんどの場合、プロファイラーの結果の解釈で部分的に誤解されている可能性があります。プロファイラーは、頻繁に呼び出される小さなメソッドのパフォーマンスへの影響を過大評価していることで有名です。あなたの場合、 get() メソッドのプロファイリング オーバーヘッドは、おそらくメソッド自体で費やされる実際の処理よりも大きくなります。インストルメンテーションはメソッドをインライン化する JIT の機能にも干渉するため、状況はさらに悪化します。

この状況の経験則として、既知の長さの作業の合計処理時間が、プロファイラーで実行しているときに 2 倍から 3 倍に増加すると、プロファイリングのオーバーヘッドによって歪んだ結果が得られます。

変更が実際に影響を与えることを確認するには、常にプロファイラーを使用せずにパフォーマンスの改善を測定してください。プロファイラーはボトルネックについてヒントを与えることができますが、何も問題がない場所を見るように騙すこともできます。

配列境界チェックは、パフォーマンスに驚くほど大きな影響を与える可能性がありますが (他にほとんど何もしない場合)、一般的なメモリ アクセス ペナルティと明確に区​​別することも困難です。いくつかの些細なケースでは、JIT はそれらを排除できる可能性があります (Java 6 で境界チェックの排除に向けた取り組みが行われています) が、これは主に for(x=0; x<array.length; x++)。状況によっては、バインドされたチェックを完全に回避して、配列アクセスを単純なメンバー アクセスに置き換えることができる場合がありますが、定数インデックスによって排他的に配列にアクセスするまれなケースに限定されます。あなたの問題にそれを適用する方法はありません。

Mark Peters によって提案された変更は、境界チェックを排除するためだけでなく、よりキャッシュに適した方法でデータ構造の局所性プロパティを変更するため、高速になる可能性が最も高いです。

于 2010-11-05T17:28:23.153 に答える
1

多くのプロファイラは非常に紛らわしいことを言いますが、その理由の 1 つは、それらがどのように機能するか、そしてもう 1 つは人々が最初からパフォーマンスについておかしな考えを持っているためです。たとえば、関数が何回呼び出されているのか疑問に思っていて、コードを見て、多くのロジックのように見えて遅いと考えているとします。

このことについて考えるための非常に単純な方法があり、何が起こっているのかを非常に簡単に理解することができます。

  • まず、ルーチンやステートメントが呼び出された回数や平均所要時間ではなく、アクティブな時間の割合で考えてください。その理由は、競合するプロセスや I/O などの無関係な問題の影響を比較的受けにくく、コール数に平均実行時間を掛けて合計時間で割る手間が省けるためです。気にするのに十分です。また、パーセントは、全体的な実行時間をどれだけ修正できるかを示します。

  • 第二に、「アクティブ」とは「スタック上」を意味します。スタックには、現在実行中の命令と、その「上」にあるすべての呼び出しが「call main」に戻されます。ルーチンが 10% の時間 (それが呼び出すルーチンを含む) を担当している場合、その間はスタック上にあります。個々のステートメントや指示についても同じことが言えます。(「セルフタイム」や「専用タイム」は無視してください。気を散らすものです。)

  • 関数にタイマーとカウンターを配置するプロファイラーは、この情報の一部しか提供できません。プログラムカウンターのみをサンプリングするプロファイラーは、さらに少ないことを教えてくれます。必要なのは、コール スタックをサンプリングし、その行を含むスタック サンプルの割合を (関数だけでなく)行ごとレポートするものです。また、スタックをサンプリングすることも重要です。a) I/O またはその他のブロックが発生している間、b) ユーザー入力を待機している間はサンプリングしません。

これを行うことができるプロファイラーがあります。Javaについてはわかりません。

あなたがまだ私と一緒にいるなら、別のリンガーを投げさせてください. 最適化できるものを探していますよね?そして、10% 以上のように、トラブルに見合うだけの十分な割合を持つものだけですか? このような 10% のコストがかかるコード行は、10% の時間スタック上にあります。つまり、20,000 個のサンプルを取得した場合、そのうちの約 2,000 個にあるということです。20 個のサンプルが取得された場合、平均して約 2 個のサンプルに含まれています。今、あなたは線を見つけようとしていますよね?あなたがそれを見つける限り、パーセントが少しずれていても本当に問題ですか? これは、プロファイラーのもう 1 つの幸せな神話の 1 つです。タイミングの精度が重要であるということです。修正する価値のある問題を見つけるには、20,000 個のサンプルから得られる情報は 20 個を超える数では得られません。それで、私は何をしますか?手でサンプルを取り、それらを研究するだけです. 最適化する価値のあるコードは、すぐに飛び出してしまいます。

最後に、大きな朗報があります。おそらく、最適化できるものが複数あります。20% の問題を修正して解消するとします。全体の時間は以前の 4/5 に短縮されますが、他の問題にはそれほど時間がかからないため、分母が小さくなったため、割合は以前の 5/4 になりました。割合的には大きくなり、見つけやすくなりました。この効果は雪だるま式になり、コードを実際に絞り込むことができます。

于 2010-11-05T21:24:04.627 に答える
0

メモ化またはキャッシュ戦略を使用して、実際の呼び出しの数を減らすことができます。非常に絶望的な場合に試すことができるもう 1 つの方法は、ネイティブ配列です。これらのインデックス作成は信じられないほど高速であり、マーシャリングを必要としない long などのパラメーターを使用している場合、JNI はあまり多くのオーバーヘッドを呼び出すべきではありません。

于 2010-11-06T15:13:17.767 に答える