非常に大きな配列でランダムアクセスのための可能な最適化はありますか(私は現在uint8_t
を使用しています、そして私は何がより良いかについて尋ねています)
uint8_t MyArray[10000000];
配列内の任意の位置の値が
それで、これに使用するuint8_t
配列より良い何かがありますか?ランダムな順序で配列全体をループ処理するのは可能な限り高速であるべきです、そしてこれはRAM帯域幅を非常に重くします。現在、RAM帯域幅全体が急速に飽和しています。
5%を除くほぼすべての値が0または1になることが実際にわかっている場合、このような大きな配列(10 MB)を用意するのは非常に非効率的だと私は思っています。実際には8ビットではなく1ビットしか必要としないでしょう、これはメモリ使用量をほぼ一桁減らすでしょう。これに必要なRAM帯域幅を大幅に削減し、その結果としてランダムアクセスの場合は大幅に高速になる、よりメモリ効率の高いソリューションが必要であると思われます。
頭に浮かぶ簡単な可能性は、一般的な場合には値ごとに2ビットの圧縮された配列を保持し、値ごとに分離された4バイト(元の要素インデックス用に24ビット、実際の値用に8ビット、(idx << 8) | value)
)他のもの。
値を検索するときは、まず2bpp配列(O(1))で検索を行います。 0、1、または2が見つかった場合は、それが必要な値です。 3が見つかった場合は、2次配列で調べなければならないことを意味します。ここでは、バイナリ検索を実行して、興味のある index を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の要素の配列を作成し、私のデータ構造を初期化してから:
(シーケンシャルルックアップの場合、最もキャッシュに優しいルックアップが可能なので、配列は常に非常に大きな勝利を収めます。)
これらの最後の2つのブロックは50回繰り返され、計時されます。最後に、各タイプのルックアップの平均と標準偏差が、スピードアップ(lookup_mean/array_mean)とともに計算されて印刷されます。
私はUbuntu 16.04上でg ++ 5.4.0(-O3 -static
、およびいくつかの警告)を使って上記のコードをコンパイルし、それをいくつかのマシンで実行しました。それらのほとんどは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
結果は...混在しています。
他の選択肢は
言い換えれば:
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は "other"を意味します。
この構造は更新が簡単で、25%以上のメモリを使用しますが、大部分は5%のケースでのみ検索されます。もちろん、いつものように、それが良い考えであるかどうかは他の多くの条件に依存するので、唯一の答えは実際の使用法を試すことです。
これは具体的な答えというよりは「長いコメント」です。
あなたのデータがよく知られているものでない限り、誰かがあなたの質問に直接答えることはできないでしょう(そして私はあなたの説明にマッチするものは何も知りませんが、全ての種類のデータパターンについては全く知りません)ユースケースの種類)スパースデータは高性能コンピューティングでは一般的な問題ですが、通常は「非常に大きな配列がありますが、一部の値だけがゼロ以外になる」ということです。
私があなたの考えているもののようなよく知られていないパターンのために、誰も直接知っている人はいないでしょう、そしてそれは詳細に依存します:ランダムアクセスはどのくらいランダムですか - データ項目のクラスタにアクセスするシステムです。一様乱数ジェネレータテーブルデータは完全にランダムですか、それとも他の値が散在している状態で0のシーケンスがあり、次に1のシーケンスがあるか。ランレングスエンコーディングは、0と1のかなり長いシーケンスがある場合はうまくいきますが、「0/1のチェッカーボード」がある場合はうまくいきません。また、「出発点」の表を保持する必要があるので、適切な場所にすばやく移動することができます。
私は昔からRAMの大きなテーブル(この例では電話交換の加入者データ)に過ぎないことを知っています。そして問題の1つはキャッシュとページテーブルの最適化です。プロセッサはかなり無駄です。呼び出し元が最近誰かに電話をかけたときと同じになることはめったにありません。事前にロードされたデータはありません。純粋にランダムなものです。大きなページテーブルは、そのタイプのアクセスに最適です。
多くの場合、「スピードと小型」の妥協は、ソフトウェア工学で選ぶべきものの1つです(他の工学では、必ずしもそれほど妥協する必要はありません)。そのため、「単純なコードのためにメモリを浪費する」ことが非常によく選択されます。この意味では、 "単純な"解決策は速度の点ではかなり優れていますが、RAMの使用率が "良い"場合は、テーブルのサイズを最適化すると十分なパフォーマンスとサイズの改善が得られます。あなたがこれを達成することができる多くの異なる方法があります - コメントで示唆されるように、2つか3つの最も一般的な値が格納される2ビットのフィールド、そして他の値のための代替データフォーマット - 私はハッシュテーブルです最初のアプローチですが、リストや二分木でもうまくいくかもしれません - 繰り返しますが、それはあなたの "not 0、1 or 2"がどこにあるかのパターンに依存します。繰り返しますが、それは値がテーブル内でどのように「散在している」かに依存します - それらはクラスタ内にあるのか、それともより均等に分布したパターンになっているのか?
しかし、それに関する問題はあなたがまだRAMからデータを読んでいるということです。その後、「これは一般的な値ではありません」に対処するためのコードを含め、データの処理にさらにコードを費やすことになります。
最も一般的な圧縮アルゴリズムの問題点は、解凍シーケンスに基づいているため、ランダムにアクセスできないことです。そして、大きなデータを一度に256エントリのまとまりに分割し、256をuint8_t配列に解凍し、必要なデータを取得してから非圧縮データを捨てることによるオーバーヘッドは、良い結果をもたらすことはほとんどありません。パフォーマンス - もちろん、それがいくらか重要であると仮定して。
結局、あなたはたぶんコメント/答えの中であなたの問題を解決するのに役立つかどうか、あるいはメモリバスが依然として主な制限要因であるかどうかを確かめるために1つか2、3のアイデアを実装しなければならないでしょう。
私が過去にやったことはビットセットの front でハッシュマップを使うことです。
これはMatteoの答えと比較してスペースを半分にしますが、「例外」検索が遅い(すなわち、たくさんの例外がある)場合は遅くなるかもしれません。
しかし、多くの場合、「キャッシュは王様」です。
データにパターンがない限り、適切な速度やサイズの最適化が行われることはまずありません。通常のコンピュータをターゲットにしていると仮定しても、10 MBというのはそれほど大きな問題ではありません。
あなたの質問には2つの仮定があります。
私はこれらの仮定の両方が間違っていると思います。ほとんどの場合、データを格納するための適切な方法は、最も自然な表現を格納することです。あなたの場合、これはあなたが行っていたものです:0から255の間の数のバイト。他の表現はもっと複雑になるでしょう。この一般的な原則から逸脱する必要があるためには、データの95%に6つの "無駄な"ビットが含まれる可能性よりも強い理由が必要です。
2番目の前提条件として、アレイのサイズを変更してもキャッシュミスが大幅に減少する場合に限り、それが当てはまります。これが起こるかどうかは、作業コードをプロファイリングすることによってのみ決定的に決定することができます、しかし私はそれが実質的な違いを生むことはまずないと思います。どちらの場合もランダムに配列にアクセスするので、どちらの場合もプロセッサはデータのどのビットをキャッシュして保持するかを判断するのに苦労します。
データとアクセスが一様にランダムに分散されている場合、パフォーマンスはおそらく、アクセスのどの部分が外部レベルのキャッシュミスを回避するかによって異なります。それを最適化するには、どのサイズの配列を確実にキャッシュに収容できるかを知る必要があります。キャッシュが5つのセルごとに1バイトを収容するのに十分な大きさである場合、最も単純なアプローチは0から2の範囲の5つの3進エンコード値を1バイトに保持することです。 base-3の値が "2"を示しているときはいつでも照会される10,000,000バイトの配列とともに、1バイトに収まります。
キャッシュがそれほど大きくなくても、8セルあたり1バイトに対応できる場合、1つのバイト値を使用して8つの3進数の6,561の可能な組み合わせすべての中から選択することはできません。 0または1を2に変更することは、そうでなければ不要な検索を引き起こすことになるでしょう、正確さは6,561すべてをサポートすることを必要としないでしょう。代わりに、256個の最も「役に立つ」値に焦点を合わせることができます。
特に0が1よりも一般的である場合、またはその逆の場合は、5以下の1を含む0と1の組み合わせをエンコードするには217の値を、0000xxxxから0000をエンコードするには16の値を使用します。 1111xxxx、およびxxxxxxxx用の1つ。 4つの値は、他のどんな用途にも使用できるように残ります。データが上記のようにランダムに分散されている場合、すべてのクエリのごくわずかな過半数が0と1だけを含むバイトにヒットします(8のすべてのグループの約2/3で、すべてのビットは0と1、そしてそれらは6以下の1ビットを持ちます。 4つのxを含むバイトに着地しなかったものの大多数と、0または1に着地する可能性が50%あるでしょう。したがって、大規模な配列検索を必要とするのは、4回に1回のクエリだけです。
データがランダムに分散されていても、キャッシュが8要素あたり1バイトを処理するのに十分な大きさでない場合、各バイトが8項目を超える処理でこのアプローチを試みることができます。つまり、big配列でルックアップを行わなくても処理できる値の割合は、各バイトで処理される数が増えるにつれて小さくなります。
@ o11c の答えに付け加えます。彼の言い回しは少し混乱するかもしれないからです。最後のビットとCPUサイクルを絞り込む必要がある場合は、次のようにします。
5%の "何か他の"ケースを保持する balanced binary検索ツリーを構築することから始めます。すべてのルックアップで、ツリーをすばやく調べます。つまり、10000000個の要素があります。そのうちの5%がツリー内にあります。したがって、ツリーのデータ構造には500000個の要素があります。これをO(log(n))時間で歩くと、19回繰り返します。私はこれについては専門家ではありませんが、メモリ効率のよい実装がいくつかあると思います。推測しましょう:
合計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ビットベクトル戦略をわずかなメモリ使用量で打ち負かしますが、実装するのは全体的な闘争です。しかし、それがキャッシュを適合させるかどうかの違いを生むことができるなら、それは試してみる価値があるかもしれません。
読み取り操作のみを実行する場合は、単一のインデックスに値を割り当てるのではなく、インデックスの間隔に値を割り当てる方が良いでしょう。
例えば:
[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
}
今、あなたはただ間隔のリストを通して反復して、あなたのインデックスがそれらの一つの範囲内にあるかどうかをチェックする必要があります。
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バイトの倍数であるRAM読み取りを常に使用するように言われました。そこで、配列を[x1、y1、z1、x2、y2、z2、...、xn、yn、zn]のパターンから[x1、y1、z1,0、x2、y2、z2]のパターンに変更しました。 、0、…、x n、y n、z n、0]である。各3D座標の後に空のフィールドを追加するという意味です。いくつかのパフォーマンステストの後:それは速かったです。長い話を簡単に言えば、あなたの配列から4バイトの倍数をRAMから、そしておそらくは正しい開始位置からも読むので、あなたは検索インデックスがその中にある小さなクラスタを読み、CPUのこの小さなクラスタから検索インデックスを読む。 (あなたの場合、あなたはfill-fieldsを挿入する必要はないでしょう、しかし概念は明確であるべきです)
たぶん、他の倍数が新しいシステムの鍵になるかもしれません。
私はこれがあなたのケースでうまくいくかどうかわからないので、それがうまくいかない場合:申し訳ありません。それがうまくいけば私はいくつかのテスト結果について聞いて幸せになるでしょう。
シモンズ:ああ、アクセスパターンや近くにアクセスされたインデックスがあれば、キャッシュされたクラスタを再利用できます。
PPS:複数の要因が16Bytesやそれに近いものだったのかもしれませんが、長すぎる昔のことですが、私は正確に覚えています。
これを見て、あなたはあなたのデータを分割することができます、例えば:
この場合、すべての値は特定のインデックスまで表示されるため、一方のビットセットを削除して、もう一方のビットセットが欠落しているとして表すこともできます。
最悪の場合はさらに悪くなるでしょうが、これはこの場合あなたのためにいくらかのメモリを節約するでしょう。ルックアップを実行するには、さらに多くのCPUパワーが必要です。
必ず測定してください。
Matsが彼のコメント回答で述べているように、特にどんな種類のデータを持っているかなど)を知らずに実際に最善の解決策が何であるかを言うのは難しいです。あなたのアクセスパターンは次のようになります( "ランダム"は "いたるところに"という意味ですか、単に "厳密には完全に線形ではない"という意味です。
とは言っても、頭に浮かぶのは2つのメカニズムです。
(index,value)
または(value,index)
テーブルつまり、1%の場合には1つの非常に小さなテーブル、5%の場合には1つのテーブル(すべて同じ値を持つインデックスを格納するだけでよい)、最後の2つのケースには大きな圧縮ビット配列があります。そして「テーブル」とは、比較的素早く検索できるものを意味します。つまり、利用可能なものや実際のニーズに応じて、ハッシュ、バイナリツリーなどです。これらのサブテーブルがあなたの1/2レベルのキャッシュに収まるならば、あなたはラッキーになるかもしれません。私はCにあまり馴染みがありませんが、 C++ では、0 - 255の範囲の整数を表すために unsigned char を使うことができます。
通常の int と比較して(ここでも、 Java および C++ worldから来ています)、 4 byte (32 bit)が必要です。 unsigned char は 1バイト (8ビット)が必要です。そのため、配列の合計サイズが75%減少する可能性があります。