95%の場合の値が0または1の場合の非常に大きな配列でのランダムアクセスの最適化


133

非常に大きな配列でのランダムアクセスの最適化の可能性はありますuint8_tか?

uint8_t MyArray[10000000];

配列の任意の位置の値が

  • 0または1のための95%の全ての症例の、
  • 24%の症例、
  • 他の1%のケースでは3から255の間ですか?

それで、uint8_tこれに使用する配列よりも良いものはありますか?配列全体をランダムな順序でループするのは可能な限り迅速である必要があり、これはRAM帯域幅で非常に重いため、複数のスレッドが異なる配列に対して同時にそれを行う場合、現在はRAM帯域幅全体です。すぐに飽和します。

5%を除くほとんどすべての値が0または1であることが実際にわかっている場合、そのような大きな配列(10 MB)を持つことは非常に非効率だと感じているので、質問します。したがって、配列内のすべての値の95%実際には、8ビットではなく1ビットしか必要ありません。これにより、メモリ使用量がほぼ1桁削減されます。これに必要なRAM帯域幅を大幅に削減し、結果としてランダムアクセスの速度を大幅に向上させる、よりメモリ効率の高いソリューションが必要だと感じています。


36
2ビット(0/1 /ハッシュテーブルを参照)と1より大きい値のハッシュテーブル?
user253751

6
@ user202729それは何に依存していますか?これは私と同じようなことをしなければならない人にとって興味深い質問だと思うので、私のコードに非常に固有の答えではなく、もっと普遍的な解決策を見たいと思います。それが何かに依存している場合、それが何に依存しているかを説明する回答を用意して、それを読んだ全員が自分のケースにもっと良い解決策があるかどうかを理解できるようにするとよいでしょう。
JohnAl、

7
基本的に、あなたが尋ねていることはスパースと呼ばれています。
Mateen Ulhaq 2018年

5
詳細情報が必要...アクセスがランダムで、ゼロ以外の値がパターンに従うのはなぜですか?
Ext3h

4
@IwillnotexistIdonotexist事前計算のステップは問題ありませんが、配列は時々変更する必要があるため、事前計算のステップが高すぎることはありません。
JohnAl

回答:


155

頭に浮かぶ簡単な可能性は、一般的なケースでは値ごとに2ビットの圧縮配列を保持し、値ごとに4バイト(元の要素のインデックスでは24ビット、実際の値では8ビット(idx << 8) | value))の分離された配列を保持することです。他のもの。

値を検索するときは、最初に2bpp配列(O(1))で検索を行います。0、1、または2が見つかった場合は、それが必要な値です。3を見つけた場合は、セカンダリアレイで検索する必要があることを意味します。ここで、バイナリ検索を実行して、左シフト8で関心のあるインデックスを探し(O(log(n)は小さいn、これは1%である必要がある)、4から値を抽出します。バイトもの。

std::vector<uint8_t> main_arr;
std::vector<uint32_t> sec_arr;

uint8_t lookup(unsigned idx) {
    // extract the 2 bits of our interest from the main array
    uint8_t v = (main_arr[idx>>2]>>(2*(idx&3)))&3;
    // usual (likely) case: value between 0 and 2
    if(v != 3) return v;
    // bad case: lookup the index<<8 in the secondary array
    // lower_bound finds the first >=, so we don't need to mask out the value
    auto ptr = std::lower_bound(sec_arr.begin(), sec_arr.end(), idx<<8);
#ifdef _DEBUG
    // some coherency checks
    if(ptr == sec_arr.end()) std::abort();
    if((*ptr >> 8) != idx) std::abort();
#endif
    // extract our 8-bit value from the 32 bit (index, value) thingie
    return (*ptr) & 0xff;
}

void populate(uint8_t *source, size_t size) {
    main_arr.clear(); sec_arr.clear();
    // size the main storage (round up)
    main_arr.resize((size+3)/4);
    for(size_t idx = 0; idx < size; ++idx) {
        uint8_t in = source[idx];
        uint8_t &target = main_arr[idx>>2];
        // if the input doesn't fit, cap to 3 and put in secondary storage
        if(in >= 3) {
            // top 24 bits: index; low 8 bit: value
            sec_arr.push_back((idx << 8) | in);
            in = 3;
        }
        // store in the target according to the position
        target |= in << ((idx & 3)*2);
    }
}

提案したような配列の場合、最初の配列に10000000/4 = 2500000バイト、2番目の配列に10000000 * 1%* 4 B = 400000バイトが必要です。したがって、2900000バイト、つまり元の配列の3分の1未満であり、最も使用頻度の高い部分はすべてメモリに保持されるため、キャッシュに適しています(L3に適合することもあります)。

24ビットを超えるアドレス指定が必要な場合は、「セカンダリストレージ」を調整する必要があります。それを拡張する簡単な方法は、インデックスの上位8ビットを切り替えて、上記のように24ビットのインデックス付きのソートされた配列に転送する256要素のポインター配列を用意することです。


クイックベンチマーク

#include <algorithm>
#include <vector>
#include <stdint.h>
#include <chrono>
#include <stdio.h>
#include <math.h>

using namespace std::chrono;

/// XorShift32 generator; extremely fast, 2^32-1 period, way better quality
/// than LCG but fail some test suites
struct XorShift32 {
    /// This stuff allows to use this class wherever a library function
    /// requires a UniformRandomBitGenerator (e.g. std::shuffle)
    typedef uint32_t result_type;
    static uint32_t min() { return 1; }
    static uint32_t max() { return uint32_t(-1); }

    /// PRNG state
    uint32_t y;

    /// Initializes with seed
    XorShift32(uint32_t seed = 0) : y(seed) {
        if(y == 0) y = 2463534242UL;
    }

    /// Returns a value in the range [1, 1<<32)
    uint32_t operator()() {
        y ^= (y<<13);
        y ^= (y>>17);
        y ^= (y<<15);
        return y;
    }

    /// Returns a value in the range [0, limit); this conforms to the RandomFunc
    /// requirements for std::random_shuffle
    uint32_t operator()(uint32_t limit) {
        return (*this)()%limit;
    }
};

struct mean_variance {
    double rmean = 0.;
    double rvariance = 0.;
    int count = 0;

    void operator()(double x) {
        ++count;
        double ormean = rmean;
        rmean     += (x-rmean)/count;
        rvariance += (x-ormean)*(x-rmean);
    }

    double mean()     const { return rmean; }
    double variance() const { return rvariance/(count-1); }
    double stddev()   const { return std::sqrt(variance()); }
};

std::vector<uint8_t> main_arr;
std::vector<uint32_t> sec_arr;

uint8_t lookup(unsigned idx) {
    // extract the 2 bits of our interest from the main array
    uint8_t v = (main_arr[idx>>2]>>(2*(idx&3)))&3;
    // usual (likely) case: value between 0 and 2
    if(v != 3) return v;
    // bad case: lookup the index<<8 in the secondary array
    // lower_bound finds the first >=, so we don't need to mask out the value
    auto ptr = std::lower_bound(sec_arr.begin(), sec_arr.end(), idx<<8);
#ifdef _DEBUG
    // some coherency checks
    if(ptr == sec_arr.end()) std::abort();
    if((*ptr >> 8) != idx) std::abort();
#endif
    // extract our 8-bit value from the 32 bit (index, value) thingie
    return (*ptr) & 0xff;
}

void populate(uint8_t *source, size_t size) {
    main_arr.clear(); sec_arr.clear();
    // size the main storage (round up)
    main_arr.resize((size+3)/4);
    for(size_t idx = 0; idx < size; ++idx) {
        uint8_t in = source[idx];
        uint8_t &target = main_arr[idx>>2];
        // if the input doesn't fit, cap to 3 and put in secondary storage
        if(in >= 3) {
            // top 24 bits: index; low 8 bit: value
            sec_arr.push_back((idx << 8) | in);
            in = 3;
        }
        // store in the target according to the position
        target |= in << ((idx & 3)*2);
    }
}

volatile unsigned out;

int main() {
    XorShift32 xs;
    std::vector<uint8_t> vec;
    int size = 10000000;
    for(int i = 0; i<size; ++i) {
        uint32_t v = xs();
        if(v < 1825361101)      v = 0; // 42.5%
        else if(v < 4080218931) v = 1; // 95.0%
        else if(v < 4252017623) v = 2; // 99.0%
        else {
            while((v & 0xff) < 3) v = xs();
        }
        vec.push_back(v);
    }
    populate(vec.data(), vec.size());
    mean_variance lk_t, arr_t;
    for(int i = 0; i<50; ++i) {
        {
            unsigned o = 0;
            auto beg = high_resolution_clock::now();
            for(int i = 0; i < size; ++i) {
                o += lookup(xs() % size);
            }
            out += o;
            int dur = (high_resolution_clock::now()-beg)/microseconds(1);
            fprintf(stderr, "lookup: %10d µs\n", dur);
            lk_t(dur);
        }
        {
            unsigned o = 0;
            auto beg = high_resolution_clock::now();
            for(int i = 0; i < size; ++i) {
                o += vec[xs() % size];
            }
            out += o;
            int dur = (high_resolution_clock::now()-beg)/microseconds(1);
            fprintf(stderr, "array:  %10d µs\n", dur);
            arr_t(dur);
        }
    }

    fprintf(stderr, " lookup |   ±  |  array  |   ±  | speedup\n");
    printf("%7.0f | %4.0f | %7.0f | %4.0f | %0.2f\n",
            lk_t.mean(), lk_t.stddev(),
            arr_t.mean(), arr_t.stddev(),
            arr_t.mean()/lk_t.mean());
    return 0;
}

(コードとデータは常に私のBitbucketで更新されます)

上記のコードは、投稿で指定されたOPとして分散されたランダムデータを10M要素の配列に入力し、データ構造を初期化してから、次のようにします。

  • データ構造で10M要素のランダムルックアップを実行します
  • 元の配列を通じて同じことを行います。

(シーケンシャルルックアップの場合、配列は常に最も大きなキャッシュフレンドリーなルックアップであるため、大きな基準で勝つことに注意してください)

これらの最後の2つのブロックは50回繰り返され、時間を計られます。最後に、各タイプのルックアップの平均と標準偏差が計算され、スピードアップ(lookup_mean / array_mean)とともに出力されます。

上記のコード-O3 -staticをUbuntu 16.04 でg ++ 5.4.0(およびいくつかの警告)を使用してコンパイルし、一部のマシンで実行しました。それらのほとんどはUbuntu 16.04、一部の古いLinux、一部の新しいLinuxを実行しています。この場合、OSはまったく関係ないと思います。

            CPU           |  cache   |  lookup s)   |     array s)  | speedup (x)
Xeon E5-1650 v3 @ 3.50GHz | 15360 KB |  60011 ±  3667 |   29313 ±  2137 | 0.49
Xeon E5-2697 v3 @ 2.60GHz | 35840 KB |  66571 ±  7477 |   33197 ±  3619 | 0.50
Celeron G1610T  @ 2.30GHz |  2048 KB | 172090 ±   629 |  162328 ±   326 | 0.94
Core i3-3220T   @ 2.80GHz |  3072 KB | 111025 ±  5507 |  114415 ±  2528 | 1.03
Core i5-7200U   @ 2.50GHz |  3072 KB |  92447 ±  1494 |   95249 ±  1134 | 1.03
Xeon X3430      @ 2.40GHz |  8192 KB | 111303 ±   936 |  127647 ±  1503 | 1.15
Core i7 920     @ 2.67GHz |  8192 KB | 123161 ± 35113 |  156068 ± 45355 | 1.27
Xeon X5650      @ 2.67GHz | 12288 KB | 106015 ±  5364 |  140335 ±  6739 | 1.32
Core i7 870     @ 2.93GHz |  8192 KB |  77986 ±   429 |  106040 ±  1043 | 1.36
Core i7-6700    @ 3.40GHz |  8192 KB |  47854 ±   573 |   66893 ±  1367 | 1.40
Core i3-4150    @ 3.50GHz |  3072 KB |  76162 ±   983 |  113265 ±   239 | 1.49
Xeon X5650      @ 2.67GHz | 12288 KB | 101384 ±   796 |  152720 ±  2440 | 1.51
Core i7-3770T   @ 2.50GHz |  8192 KB |  69551 ±  1961 |  128929 ±  2631 | 1.85

結果は...混合です!

  1. 一般に、これらのマシンのほとんどでは、ある種のスピードアップがあり、少なくとも同等の速度です。
  2. アレイが「スマート構造」ルックアップよりも真に優れている2つのケースは、大量のキャッシュを備えたマシンであり、特にビジーではありません。Xeon E5-2697(35 MBキャッシュ)は、アイドル状態でも高性能計算を行うためのマシンです。それは理にかなっています、元の配列は巨大なキャッシュに完全に収まるので、コンパクトなデータ構造は複雑さを増すだけです。
  3. 「パフォーマンススペクトル」の反対側-しかし、ここでもアレイがわずかに高速である場合、私のNASを強化する控えめなCeleronがあります。キャッシュが非常に少ないため、配列も「スマート構造」もまったく入りません。キャッシュが十分に小さい他のマシンも同様に動作します。
  4. Xeon X5650は注意が必要です。これらは非常にビジーなデュアルソケット仮想マシンサーバー上の仮想マシンです。名目上はまともな量のキャッシュがありますが、テストの期間中、完全に無関係な仮想マシンによって数回プリエンプトされる可能性があります。

7
@JohnAl構造体は必要ありません。A uint32_tで結構です。二次バッファから要素を消去すると、要素はソートされたままになります。要素の挿入はstd::lower_bound、その後にinsert(全体を追加して並べ替えるのではなく)行うことができます。更新により、フルサイズのセカンダリアレイがより魅力的になります-私は確かにそれから始めます。
Martin Bonnerがモニカをサポートする

6
@JohnAl値なので、値の(idx << 8) + val部分について心配する必要はありません。単純な比較を使用してください。それは常に以下((idx+1) << 8) + valと比較((idx-1) << 8) + val
します

3
@JohnAl:それは有用である可能性がある場合、私が追加populate投入べき機能main_arrsec_arrその形式に応じてlookup予想しています。私は実際にそれをすることを期待していない、それを試していなかった、本当に正しく動作:-)。とにかく、それはあなたに一般的な考えを与えるはずです。
Matteo Italia

6
ベンチマークのためにこれを+1します。効率に関する質問と、複数のプロセッサタイプの結果についても確認してください。いいね!
Jack Aidley、2018年

2
@JohnAI実際のユースケースにのみプロファイルする必要があります。ホワイトルームの速度は重要ではありません。
Jack Aidley、2018年

33

別のオプションは

  • 結果が0、1、または2かどうかを確認します
  • 定期的に検索しない場合

つまり、次のようなものです。

unsigned char lookup(int index) {
    int code = (bmap[index>>2]>>(2*(index&3)))&3;
    if (code != 3) return code;
    return full_array[index];
}

ここbmapで、要素ごとに2ビットを使用し、値3は「その他」を意味します。

この構造は更新が簡単で、25%多いメモリを使用しますが、大部分は5%のケースでのみ検索されます。もちろん、いつものように、それが良いかどうかは他の多くの条件に依存するので、唯一の答えは実際の使用法を試すことです。


4
これは、ランダムなアクセス時間を大幅に失うことなく、可能な限り多くのキャッシュヒットを取得するための妥協案だと思います(縮小された構造がより簡単にキャッシュに収まるため)。
meneldal

これはさらに改善できると思います。私は過去に成功しましたが、ブランチの述部を活用することが非常に役立つ同様の異なる問題がありました。これは、分割するのを助けることができるif(code != 3) return code;if(code == 0) return 0; if(code==1) return 1; if(code == 2) return 2;
kutschkem

@kutschkem:その場合、__builtin_expect&coまたはPGOも役立ちます。
Matteo Italia

23

これは具体的な答えというよりは「長いコメント」です

あなたのデータがよく知られているものでない限り、誰もがあなたの質問に直接答えることはできないと思います(そして私はあなたの説明に一致するものは何も知りませんが、すべての種類のすべてのデータパターンについて何も知りませんユースケースの種類)。スパースデータは、ハイパフォーマンスコンピューティングの一般的な問題ですが、通常は「非常に大きな配列がありますが、一部の値だけが非ゼロ」です。

私があなたの考えているようなよく知られていないパターンの場合、誰が直接より良いかを知ることはありません、そしてそれは詳細に依存します:ランダムアクセスはランダムアクセスです-システムはデータアイテムのクラスターにアクセスしていますか、それとも完全にランダムです均一な乱数ジェネレータ。テーブルデータは完全にランダムですか、または0のシーケンスと1のシーケンスがありますか?ランレングスエンコーディングは、0と1のシーケンスがかなり長い場合はうまく機能しますが、「0/1のチェッカーボード」がある場合は機能しません。また、「出発点」のテーブルを保持する必要があるので、適切な場所に適切にすばやく移動できます。

私は長い間、いくつかの大きなデータベースはRAM内の大きなテーブル(この例では電話交換のサブスクライバーデータ)であることを知っています。問題の1つは、プロセッサーのキャッシュとページテーブルの最適化がほとんど役に立たないことです。呼び出し元が最近誰かに電話をかけるのと同じであることはめったにないため、事前に読み込まれたデータはなく、純粋にランダムです。大きなページテーブルは、そのタイプのアクセスに最適な最適化です。

多くの場合、「速度と小さいサイズ」の間で妥協することは、ソフトウェアエンジニアリングで選択しなければならないことの1つです(他のエンジニアリングでは必ずしもそれほど妥協する必要はありません)。したがって、「より単純なコードのためにメモリを浪費する」ことは、しばしば好ましい選択です。この意味で、「単純な」ソリューションの方が速度の点で優れていますが、RAMを「よりよく」使用する場合は、テーブルのサイズを最適化すると、十分なパフォーマンスが得られ、サイズが大幅に改善されます。これを実現する方法はたくさんあります-コメントで提案されているように、2つまたは3つの最も一般的な値が格納される2ビットのフィールド、そして他の値の代替データ形式-ハッシュテーブルが私のものになるでしょう最初のアプローチですが、リストまたはバイナリツリーも機能する可能性があります-再び、それは、「0、1、または2ではない」のパターンに依存します。繰り返しますが、それは値がテーブルでどのように「分散」されているかによって異なります-それらはクラスター内にあるのか、それともより均一に分散されたパターンなのか?

しかし、それに関する問題は、まだRAMからデータを読み取っているということです。次に、「これは一般的な値ではありません」に対処するためのコードを含む、データの処理にさらに多くのコードを費やしています。

最も一般的な圧縮アルゴリズムの問​​題は、それらがアンパックシーケンスに基づいているため、ランダムにアクセスできないことです。そして、ビッグデータを一度に256エントリのチャンクに分割し、256をuint8_t配列に解凍し、必要なデータをフェッチしてから、圧縮されていないデータを破棄するオーバーヘッドは、良い結果をもたらす可能性が非常に低いパフォーマンス-もちろんそれがある程度重要であると仮定します。

最終的には、コメントまたは回答に1つまたはいくつかのアイデアを実装してテストし、それが問題の解決に役立つかどうか、またはメモリバスが依然として主な制限要因であるかどうかを確認する必要があります。


ありがとう!結局のところ、CPUの100%がそのような配列(異なる配列の異なるスレッド)のループ処理でビジー状態になっているときに何が速くなるかに興味があります。現在、uint8_tアレイでは、(5チャネルシステムで)〜5スレッドが同時に動作するとRAM帯域幅が飽和するため、5つ以上のスレッドを使用してもメリットはありません。RAM帯域幅の問題に遭遇することなく> 10スレッドを使用したいのですが、アクセスのCPU側が非常に遅くなり、以前に5スレッドよりも10スレッド少ない処理が行われる場合、それは明らかに進歩しません。
JohnAl

@JohnAlコアはいくつありますか?CPUバウンドの場合、コアより多くのスレッドを使用しても意味がありません。また、GPUプログラミングを検討する時間もあるでしょうか?
Martin Bonnerがモニカをサポートする

@MartinBonner私は現在12のスレッドを持っています。そして私は同意します。これはおそらくGPUで非常にうまく実行されます。
JohnAl

2
@JohnAI:同じ非効率的なプロセスの複数のバージョンを複数のスレッドで単に実行している場合は、常に限られた進行状況しか表示されません。ストレージ構造を調整するよりも、並列処理用のアルゴリズムを設計する方が大きなメリットがあります。
Jack Aidley、2018年

13

私が過去にやったことは、ビットセットのでハッシュマップを使用することです。

これは、Matteoの回答に比べてスペースを半分にしますが、「例外」ルックアップが遅い場合(つまり、多くの例外がある場合)は遅くなる可能性があります。

しかし、しばしば「キャッシュは王様」です。


2
ハッシュマップはマッテオの答えに比べてどれほど正確にスペースを半分にしますか?そのハッシュマップには何があるべきですか?
JohnAl、2018年

1
@JohnAl 2ビットbitvecの代わりに1ビットbitset = bitvecを使用します。
o11c

2
@ o11c正しく理解しているかわかりません。(Matteosコードの場合)を見て、を見る0という1ビット値の配列があるということですか?ただし、アレイが1つ追加されるため、Matteosの回答よりも全体的に多くのスペースが必要になります。Matteosの回答と比較して半分のスペースしか使用しない方法を私はよく理解していません。main_arr1sec_arr
JohnAl

1
これを明確にしてもらえますか?最初に予想されるケース調べ、次にビットマップを調べますか?もしそうなら、私はハッシュでの遅い検索がビットマップのサイズを減らすことで節約を圧倒するだろうと思います。
Martin Bonnerがモニカをサポートする

これはハッシュリンクと呼ばれていると思いましたが、googleは関連するヒットを検出しなかったため、別の何かである必要があります。通常はそれが機能する方法は、大部分がたとえば0..254の間の値を保持するバイト配列を持つことでした。次に、フラグとして255を使用します。255の要素がある場合は、関連するハッシュテーブルで真の値を検索します。誰かがそれが何と呼ばれたか覚えていますか?(私はそれを古いIBM TRで読んだと思います。)とにかく、@ o11cが提案する方法で配置することもできます。常にハッシュを最初に検索し、そこにない場合は、ビット配列を確認してください。
davidbak

11

データにパターンがない限り、合理的な速度またはサイズの最適化がある可能性は低く、通常のコンピューターを対象としている場合、10 MBはそれほど大きな問題ではありません。

質問には2つの前提があります。

  1. すべてのビットを使用していないため、データの保存が不十分です
  2. それをよりよく保存すると、物事がより速くなります。

これらの仮定はどちらも間違っていると思います。ほとんどの場合、データを保存する適切な方法は、最も自然な表現を保存することです。あなたの場合、これはあなたが行ったものです:0と255の間の数値のバイト。他の表現はより複雑になるため、他のすべてのものが等しい-より遅く、エラーが発生しやすくなります。この一般的な原則から逸脱する必要がある場合は、データの95%で6つの「無駄」ビットになる可能性よりも強力な理由が必要です。

2番目の仮定では、配列のサイズを変更した結果、キャッシュミスが大幅に減少する場合にのみ当てはまります。これが発生するかどうかは、実際に機能するコードをプロファイリングすることによってのみ決定できますが、大きな違いが生じる可能性は非常に低いと思います。どちらの場合でもランダムに配列にアクセスするため、プロセッサはどちらの場合でもキャッシュして保持するデータのビットを知るのに苦労します。


8

データとアクセスが均一にランダムに分散されている場合、パフォーマンスはおそらく、アクセスのどの部分が外部レベルのキャッシュミスを回避するかに依存します。これを最適化するには、キャッシュに確実に収容できる配列のサイズを知る必要があります。キャッシュが5セルごとに1バイトを収容するのに十分な大きさである場合、最も単純なアプローチは、1バイトが0〜2の範囲で5つの基本3つのエンコードされた値を保持することです(5つの値の組み合わせは243あるため、 1バイトに収まる)と、base-3の値が「2」を示すときに常に照会される10,000,000バイトの配列。

キャッシュがそれほど大きくないが、8セルあたり1バイトを収容できる場合、1バイトの値を使用して、8つのbase-3値の6,561通りの可能な組み合わせすべてから選択することはできませんが、 0または1を2に変更すると、それ以外の場合は不要なルックアップが発生します。正確さは、6,561をすべてサポートする必要はありません。代わりに、256の最も「有用な」値に焦点を当てることができます。

特に、0が1よりも一般的である場合、またはその逆の場合は、217の値を使用して、5以下の1を含む0と1の組み合わせをエンコードし、16の値はxxxx0000〜xxxx1111をエンコードし、16は0000xxxx〜 1111xxxx、xxxxxxxxの1つ。4つの値は、他の用途で使用できるように残ります。上記のようにデータがランダムに分散されている場合、すべてのクエリのわずかな過半数が、0と1だけを含むバイトにヒットします(8のすべてのグループの約2/3、すべてのビットは0と1、約7/8)それらは6以下の1ビットを持つでしょう); 到達しなかったものの大多数は、4つのxを含むバイトに到達し、50%の確率で0または1に到達します。したがって、大規模な配列ルックアップが必要になるのは、4つのクエリのうち約1つだけです。

データがランダムに分散されているが、キャッシュが8要素あたり1バイトを処理するのに十分な大きさでない場合、各バイトが8を超えるアイテムを処理するこのアプローチを使用できますが、0または1への強いバイアスがない限り、大きな配列でルックアップを行わなくても処理できる値の割合は、各バイトで処理される数が増えるにつれて小さくなります。


7

@ o11cの答えに追加します。彼の言い回しは少しわかりにくいかもしれません。最後のビットとCPUサイクルを絞る必要がある場合は、次のようにします。

5%の「何か他の」ケースを保持するバランスのとれたバイナリ検索ツリーを構築することから始めます。すべてのルックアップで、ツリーをすばやくウォークします。要素は10000000あり、その5%がツリー内にあります。したがって、ツリーデータ構造は500000要素を保持します。これをO(log(n))時間で歩くと、19回反復できます。私はこれについての専門家ではありませんが、メモリ効率の良い実装がいくつかあると思います。推測してみましょう:

  • バランスのとれたツリー。サブツリーの位置を計算できます(インデックスはツリーのノードに格納する必要はありません)。ヒープ(データ構造)が線形メモリに格納されるのと同じ方法。
  • 1バイト値(2から255)
  • インデックス用に3バイト(10000000は23ビットで、3バイトに適合します)

合計、4バイト:500000 * 4 = 1953 kB。キャッシュに適合します!

他のすべての場合(0または1)では、ビットベクトルを使用できます。ランダムアクセスの場合、他の5%のケースを省略できないことに注意してください:1.19 MB。

これら2つの組み合わせでは、約3,099 MBを使用します。この手法を使用すると、3.08倍のメモリを節約できます。

しかし、これは残念ながら@Matteo Italia(2.76 MBを使用)の答えに勝るものではありません。他に何かできることはありますか?最もメモリを消費する部分は、ツリー内の3バイトのインデックスです。これを2に減らすことができれば、488 kBを節約でき、合計メモリ使用量は2.622 MBと小さくなります。

これどうやってやるの?インデックスを2バイトに減らす必要があります。この場合も、10000000は23ビットを使用します。7ビットをドロップできる必要があります。これを行うには、10000000要素の範囲を78125要素の2 ^ 7(= 128)領域に分割します。これで、これらの領域ごとに平均3906要素のバランスの取れたツリーを構築できます。適切なツリーの選択は、ターゲットインデックスを2 ^ 7(またはビットシフト>> 7)で単純に除算することによって行われます。これで、保存に必要なインデックスを残りの16ビットで表すことができます。格納する必要のあるツリーの長さにはオーバーヘッドが多少ありますが、これは無視できることに注意してください。また、この分割メカニズムにより、ツリーをウォークするために必要な反復数が減り、7ビットを落としたため、反復数が7に減りました。残っているのは12反復のみです。

理論的には次の8ビットをカットするプロセスを繰り返すこともできますが、これには平均で305要素の2 ^ 15のバランスツリーを作成する必要があります。これは2.143 MBになり、ツリーをたどるのに4回の反復しかありません。これは、最初に行った19回の反復と比較すると、かなり高速です。

最後の結論として、これは2ビットのベクトル戦略にほんの少しのメモリ使用量を打ち負かしますが、実装するにはかなりの苦労があります。しかし、それがキャッシュのフィッティングの違いを生むことができるなら、それは試してみる価値があるかもしれません。


1
勇敢な努力!
davidbak

1
これを試してください:ケースの4%が値2なので、一連の例外的なケース(> 1)を作成します。本当に例外的なケース(> 2)で説明されているように、ツリーを多少作成します。セットとツリーに存在する場合、ツリーの値を使用します。ツリーではなくセットに存在する場合は、値2を使用します。それ以外の場合は(セットに存在しない)ビットベクトルを検索します。ツリーには100000要素(バイト)のみが含まれます。セットには500000要素が含まれています(値はまったくありません)。これにより、コストの増加を正当化しながらサイズを縮小できますか?(ルックアップの100%がセットで検索されます。ルックアップの5%もツリーで検索する必要があります。)
davidbak '15年

不変のツリーがある場合は常にCFBSでソートされた配列を使用する必要があるため、ノードには割り当てがなく、データのみが割り当てられます。
o11c

5

読み取り操作のみを実行する場合は、単一のインデックスではなく、インデックスの間隔に値を割り当てることをお勧めします。

例えば:

[0, 15000] = 0
[15001, 15002] = 153
[15003, 26876] = 2
[25677, 31578] = 0
...

これは構造体で行うことができます。OOアプローチが好きな場合は、これに似たクラスを定義することもできます。

class Interval{
  private:
    uint32_t start; // First element of interval
    uint32_t end; // Last element of interval
    uint8_t value; // Assigned value

  public:
    Interval(uint32_t start, uint32_t end, uint8_t value);
    bool isInInterval(uint32_t item); // Checks if item lies within interval
    uint8_t getValue(); // Returns the assigned value
}

今度は、間隔のリストを反復処理して、インデックスが間隔の1つにあるかどうかを確認するだけです。これは、平均してメモリ消費が大幅に少なくなりますが、CPUリソースのコストが高くなります。

Interval intervals[INTERVAL_COUNT];
intervals[0] = Interval(0, 15000, 0);
intervals[1] = Interval(15001, 15002, 153);
intervals[2] = Interval(15003, 26876, 2);
intervals[3] = Interval(25677, 31578, 0);
...

uint8_t checkIntervals(uint32_t item)

    for(int i=0; i<INTERVAL_COUNT-1; i++)
    {
        if(intervals[i].isInInterval(item) == true)
        {
            return intervals[i].getValue();
        }
    }
    return DEFAULT_VALUE;
}

サイズの降順で間隔を並べ替えると、探しているアイテムが早く見つかる可能性が高くなり、平均メモリとCPUリソースの使用量がさらに減少します。

サイズ1のすべての間隔を削除することもできます。対応する値をマップに入れ、探している項目が間隔内に見つからなかった場合にのみそれらをチェックします。これにより、平均パフォーマンスも少し向上します。


4
興味深いアイデア(+1)ですが、0のロングランや1のロングランが多くない限り、オーバーヘッドを正当化するのには少し懐疑的です。実際には、データのランレングスエンコーディングの使用を提案しています。状況によっては良いかもしれませんが、おそらくこの問題に対する一般的なアプローチとしては適切ではありません。
ジョンコールマン、

正しい。特にランダムアクセスの場合、メモリの消費量がはるかに少なくても、これはほぼ確実に単純な配列やに比べて遅くunt8_tなります。
左回り、

4

ずっと前に、私はただ覚えていることができます...

大学では、レイトレーサープログラムを高速化するタスクを得ました。これは、バッファーアレイからアルゴリズムで何度も何度も読み取る必要があります。友人から、常に4バイトの倍数のRAM読み取りを使用するように言われました。そこで、配列を[x1、y1、z1、x2、y2、z2、...、xn、yn、zn]のパターンから[x1、y1、z1,0、x2、y2、z2]のパターンに変更しました、0、...、xn、yn、zn、0]。各3D座標の後に空のフィールドを追加することを意味します。いくつかのパフォーマンステストの後:それはより高速でした。要するに、RAMから配列から4バイトの倍数を読み取り、場合によっては正しい開始位置からも読み取ります。そのため、検索されたインデックスが含まれている小さなクラスターを読み取り、この小さなクラスターからCPUで検索されたインデックスを読み取ります。(あなたのケースでは、フィルフィールドを挿入する必要はありませんが、コンセプトは明確でなければなりません)

新しいシステムでは、他の倍数も重要になる可能性があります。

これがあなたのケースでうまくいくかどうかはわかりませんので、うまくいかない場合:すみません。それがうまくいけば、私はいくつかのテスト結果について聞いてうれしいです。

PS:ああ、アクセスパターンや近くにアクセスされたインデックスがある場合は、キャッシュされたクラスターを再利用できます。

PPS:それは、複数の要素が16Bytesのようなものだったのかもしれません。私が正確に覚えているのは、あまりにも昔のことです。


おそらくキャッシュライン(通常32バイトまたは64バイト)について考えているでしょうが、アクセスはランダムであるため、ここではあまり役に立ちません。
スルト

3

これを見ると、たとえば次のようにデータを分割できます。

  • インデックスが付けられ、値0を表すビットセット(std :: vectorはここで役立ちます)
  • インデックスが付けられ、値1を表すビットセット
  • この値を参照するインデックスを含む2の値のstd :: vector
  • 他の値のマップ(またはstd :: vector>)

この場合、すべての値は特定のインデックスまで表示されるため、ビットセットの1つを削除して、他のビットセットでは欠落している値を表すこともできます。

これにより、このケースのメモリが節約されますが、最悪のケースはさらに悪化します。ルックアップを行うには、より多くのCPUパワーも必要です。

必ず測定してください!


1
1/0のビットセット。2のインデックスのセット。そして、残りの疎連想配列。
Red.Wave

これが短い要約です
-JVApen

OPに用語を知らせて、それぞれの代替実装を検索できるようにします。
Red.Wave

2

Matsが彼のコメントと回答で言及しているように、具体的にどのようなデータを持っているか(たとえば、0の実行が長いなど)やアクセスパターンが何であるかを知らずに、実際に最良のソリューションは何かを言うのは難しいのように(「ランダム」とは、「いたるところにある」または「厳密に完全に線形ではない」または「すべての値を1回だけ、ランダム化する」または...を意味します。)

とはいえ、頭に浮かぶメカニズムは2つあります。

  • ビット配列。つまり、値が2つしかない場合は、配列を単純に8倍に圧縮できます。4つの値(または「3つの値+その他すべて」)がある場合は、2倍に圧縮できます。特にキャッシュをエスケープし、アクセス時間をまったく変更しない実際のランダムアクセスパターンがある場合は、問題の価値がない可能性があり、ベンチマークが必要になります。
  • (index,value)または(value,index)テーブル。つまり、1%の場合に非常に小さなテーブルが1つ、5%の場合に1つのテーブル(インデックスがすべて同じ値である場合にのみ格納する必要がある)と、最後の2つのケースには大きな圧縮ビット配列があります。「テーブル」とは、比較的迅速に検索できるものを意味します。つまり、利用可能なものと実際のニーズに応じて、ハッシュやバイナリツリーなどが含まれます。これらのサブテーブルが1次/ 2次キャッシュに収まる場合は、幸運になるかもしれません。

1

私はCにはあまり詳しくありませんが、C ++ではunsigned charを使用して0〜255の範囲の整数を表すことができます。

4バイト(32ビット)が必要な通常のint(ここでもJavaC ++の世界から来ている)と比較すると、unsigned charに1バイト(8ビット)が必要です。そのため、アレイの合計サイズが75%減少する可能性があります。


それはおそらくすでにを用いた場合であるuint8_t 8つの手段8ビット- 。
Peter Mortensen

-4

アレイのすべての分布特性を簡潔に説明しました。配列を投げます。

配列は、配列と同じ確率的出力を生成するランダム化された方法で簡単に置き換えることができます。

一貫性が重要な場合(同じランダムインデックスに対して同じ値を生成する場合)、ブルームフィルターハッシュマップを使用して繰り返しヒットを追跡することを検討してください。ただし、配列へのアクセスが実際にランダムである場合、これは完全に不要です。


18
ここでは「ランダムアクセス」が使用されていて、アクセスが予測不可能であることを示しているのではなく、実際にランダムであるのではないかと思います。(つまり、「ランダムアクセスファイル」という意味で意図されています)
Michael Kay

はい、そうです。しかし、OPは明確ではありません。OPのアクセスがランダムではない場合、他の回答と同様に、スパース配列の形式が示されます。
Dúthomhas

1
OPが配列全体をランダムな順序でループすることを示したので、あなたはそこにポイントがあると思います。分布のみを観察する必要がある場合、これは良い答えです。
Ingo Schalk-Schupp
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.