当 95% 的情况下的值为 0 或 1 时,对非常大的数组的随机访问是不是有任何优化?
Posted
技术标签:
【中文标题】当 95% 的情况下的值为 0 或 1 时,对非常大的数组的随机访问是不是有任何优化?【英文标题】:Any optimization for random access on a very big array when the value in 95% of cases is either 0 or 1?当 95% 的情况下的值为 0 或 1 时,对非常大的数组的随机访问是否有任何优化? 【发布时间】:2018-10-23 17:06:10 【问题描述】:在一个非常大的数组上随机访问是否有任何可能的优化(我目前使用uint8_t
,我在问什么更好)
uint8_t MyArray[10000000];
当数组中任意位置的值为
95% 的情况为 0 或 1, 2 在 4% 的情况下, 在 3 和 255 之间 其他 1% 的案例?那么,有什么比uint8_t
数组更好的方法可以用于此?应该尽可能快地以随机顺序循环整个阵列,这对 RAM 带宽来说非常沉重,因此当有多个线程同时为不同的阵列执行此操作时,当前整个 RAM 带宽很快就饱和了。
我问是因为拥有如此大的数组 (10 MB) 感觉非常低效,而实际上除了 5% 之外,几乎所有值都是 0 或 1。所以当所有值的 95%在数组中实际上只需要 1 位而不是 8 位,这将减少内存使用量几乎一个数量级。 感觉必须有一个内存效率更高的解决方案,这将大大减少所需的 RAM 带宽,因此随机访问也明显更快。
【问题讨论】:
两位(0 / 1 / 见哈希表)和大于 1 的值的哈希表? @user202729 它取决于什么?我认为这对于任何必须像我一样做类似事情的人来说都是一个有趣的问题,所以我希望看到更多的通用解决方案,而不是一个超级特定于我的代码的答案。如果它依赖于某些东西,最好有一个答案来解释它依赖于什么,这样每个阅读它的人都可以理解是否有更好的解决方案适合自己的情况。 基本上,您要问的是sparsity。 需要更多信息...为什么访问是随机的,非零值是否遵循某种模式? @IwillnotexistIdonotexist 预计算步骤很好,但数组仍应不时修改,因此预计算步骤不应该太昂贵。 【参考方案1】:想到的一个简单的可能性是为常见情况保留一个每个值 2 位的压缩数组,每个值分别保留 4 个字节(原始元素索引为 24 位,实际值为 8 位,所以@987654322 @) 为其他数组排序。
查找值时,首先在2bpp数组中查找(O(1));如果您找到 0、1 或 2,这就是您想要的值;如果你找到 3 这意味着你必须在辅助数组中查找它。在这里,您将执行二进制搜索以查找您感兴趣的 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 字节,第二个数组需要 10000000 * 1% * 4 B = 400000 字节;因此 2900000 字节,即不到原始数组的三分之一,并且最常用的部分都保存在内存中,这应该有利于缓存(它甚至可能适合 L3)。
如果您需要超过 24 位寻址,则必须调整“辅助存储”;扩展它的一种简单方法是使用 256 元素指针数组来切换索引的前 8 位并转发到如上所述的 24 位索引排序数组。
快速基准测试
#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;
(code and data always updated in my Bitbucket)
上面的代码填充了一个 10M 元素数组,其中随机数据分布为他们帖子中指定的 OP,初始化我的数据结构,然后:
使用我的数据结构随机查找 10M 元素 通过原始数组执行相同的操作。(请注意,在顺序查找的情况下,数组总是以巨大的优势获胜,因为它是您可以做的最适合缓存的查找)
最后两个区块重复 50 次并计时;最后,计算并打印每种查找类型的均值和标准差,以及加速比(lookup_mean/array_mean)。
我在 Ubuntu 16.04 上使用 g++ 5.4.0(-O3 -static
,加上一些警告)编译了上面的代码,并在一些机器上运行它;他们中的大多数都在运行 Ubuntu 16.04,一些是较旧的 Linux,一些是较新的 Linux。在这种情况下,我认为操作系统根本不应该相关。
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
结果……好坏参半!
-
一般来说,这些机器中的大多数都有某种加速,或者至少它们是相当的。
阵列真正胜过“智能结构”查找的两种情况是在具有大量缓存且不是特别繁忙的机器上:目前,上述 Xeon E5-1650(15 MB 缓存)是夜间构建机器很闲; Xeon E5-2697(35 MB 缓存)是一台用于高性能计算的机器,在空闲时也是如此。确实有道理,原始数组完全适合其巨大的缓存,因此紧凑的数据结构只会增加复杂性。
在“性能范围”的另一端 - 但是阵列稍微快一点的地方,还有为我的 NAS 提供动力的不起眼的 Celeron;它的缓存太少,以至于阵列和“智能结构”都根本不适合它。缓存足够小的其他机器的性能类似。
Xeon X5650 必须谨慎使用 - 它们是非常繁忙的双插槽虚拟机服务器上的虚拟机;很可能,虽然名义上它有相当数量的缓存,但在测试期间它被完全不相关的虚拟机抢占了好几次。
【讨论】:
@JohnAl 你不需要结构。uint32_t
就可以了。从辅助缓冲区中删除一个元素显然会使它保持排序。可以使用std::lower_bound
然后insert
插入一个元素(而不是附加和重新排序整个事情)。更新使全尺寸二级阵列更具吸引力——我当然会从这个开始。
@JohnAl 因为值是(idx << 8) + val
,所以您不必担心值部分 - 只需使用直接比较。它总是比较小于((idx+1) << 8) + val
和小于((idx-1) << 8) + val
@JohnAl:如果这可能有用,我添加了一个populate
函数,它应该根据lookup
期望的格式填充main_arr
和sec_arr
。我实际上并没有尝试过,所以不要指望它真的正常工作:-);无论如何,它应该给你一个大致的想法。
我给这个 +1 只是为了进行基准测试。很高兴看到有关效率的问题以及多种处理器类型的结果!不错!
@JohnAI 您应该针对您的实际用例来分析它,而不是其他任何东西。白色房间的速度无关紧要。【参考方案2】:
这更像是一个“长评论”而不是一个具体的答案
除非你的数据众所周知各种用例的模式)。稀疏数据是高性能计算中的常见问题,但它通常是“我们有一个非常大的数组,但只有一些值是非零的”。
对于像我认为你的那样不为人知的模式,没有人会直接知道哪个更好,这取决于细节:随机访问的随机性 - 系统访问数据项集群,还是完全访问来自统一随机数生成器的随机数。表数据是完全随机的,还是有 0 的序列然后 1 的序列,以及其他值的分散?如果您有相当长的 0 和 1 序列,运行长度编码会很好用,但如果您有“0/1 棋盘格”,则不会工作。此外,您必须保留一张“起点”表,以便您可以合理快速地前往相关地点。
我很久以前就知道,一些大型数据库只是 RAM 中的一个大表(在本例中为电话交换用户数据),其中一个问题是处理器中的缓存和页表优化非常好无用。呼叫者很少与最近呼叫某人相同,因此没有任何类型的预加载数据,它只是纯粹随机的。大页表是该类型访问的最佳优化。
在很多情况下,在“速度和小尺寸”之间妥协是软件工程中必须选择的事情之一 [在其他工程中,这不一定是妥协]。因此,“为更简单的代码浪费内存”通常是首选。从这个意义上说,“简单”的解决方案很可能在速度方面更好,但是如果您对 RAM 有“更好的”使用,那么优化表的大小将为您提供足够的性能和大小的良好改进。有很多不同的方法可以实现这一点 - 正如评论中所建议的那样,存储两个或三个最常见值的 2 位字段,然后是其他值的一些替代数据格式 - 哈希表将是我的第一种方法,但列表或二叉树也可以工作 - 同样,这取决于您的“不是 0、1 或 2”所在的模式。同样,这取决于表中的值是如何“分散”的——它们是在集群中还是更均匀分布的模式?
但问题是您仍在从 RAM 中读取数据。然后,您将花费更多的代码来处理数据,包括一些代码来处理“这不是一个常见的值”。
大多数常见压缩算法的问题在于它们基于解包序列,因此您无法随机访问它们。并且一次将大数据拆分为 256 个条目并将 256 个条目解压缩为 uint8_t 数组、获取所需数据然后丢弃未压缩数据的开销极不可能给您带来好处性能 - 当然,假设这很重要。
最后,您可能必须实现 cmets/answers 中的一个或几个想法来测试,看看它是否有助于解决您的问题,或者内存总线是否仍然是主要限制因素。
【讨论】:
谢谢!最后,我只对当 100% 的 CPU 忙于循环遍历此类数组(不同数组上的不同线程)时更快的内容感兴趣。目前,使用uint8_t
数组时,RAM 带宽在大约 5 个线程同时工作后会饱和(在四通道系统上),因此使用超过 5 个线程不再有任何好处。我希望它使用超过 10 个线程而不会遇到 RAM 带宽问题,但是如果访问的 CPU 端变得如此缓慢以至于 10 个线程完成的工作少于之前的 5 个线程,那显然不会取得进展。
@JohnAl 你有多少核心?如果您受 CPU 限制,那么拥有比内核更多的线程是没有意义的。另外,也许是时候看看 GPU 编程了?
@MartinBonner 我目前有 12 个线程。我同意,这可能会在 GPU 上运行得非常好。
@JohnAI:如果你只是在多个线程上运行同一个低效进程的多个版本,你总是会看到有限的进展。与调整存储结构相比,为并行处理设计算法会有更大的收获。【参考方案3】:
看看这个,你可以拆分你的数据,例如:
被索引并表示值 0 的位集(std::vector 在这里很有用) 被索引并表示值 1 的位集 值 2 的 std::vector,包含引用该值的索引 其他值的映射(或 std::vector>)在这种情况下,所有值都出现在给定索引之前,因此您甚至可以删除其中一个位集,并将该值表示为其他位集中缺少的值。
这将为您节省一些内存用于这种情况,但会使最坏的情况变得更糟。 您还需要更多的 CPU 能力来进行查找。
一定要测量!
【讨论】:
1/0 的位集。一组两个索引。其余为稀疏关联数组。 这是简短的总结 让 OP 知道这些术语,以便他可以搜索每个术语的替代实现。【参考方案4】:我过去所做的是在位集的 front 中使用 hashmap。
与 Matteo 的答案相比,这将空间减半,但如果“异常”查找速度很慢(即有很多异常),则可能会更慢。
然而,“缓存为王”。
【讨论】:
与 Matteo 的答案相比,hashmap 究竟如何将空间减半?该哈希图中应该是什么? @JohnAl 使用 1 位 bitset=bitvec 而不是 2 位 bitvec。 @o11c 我不确定我是否理解正确。您的意思是有一个 1 位值的数组,其中0
表示 查看 main_arr
和 1
表示 查看 sec_arr
(在Matteos 代码)?不过,这需要比 Matteos 答案更多的空间,因为它是一个额外的阵列。与 Matteos 的答案相比,我不太明白你会如何只使用一半的空间。
你能澄清一下吗?您首先查找预期情况,然后然后查看位图?如果是这样,我怀疑散列中的缓慢查找将超过减少位图大小所节省的成本。
我认为这被称为哈希链接 - 但谷歌没有找到相关的点击,所以它一定是别的东西。它通常的工作方式是说一个字节数组,它将保存绝大多数值,例如,在 0..254 之间。然后您将使用 255 作为标志,如果您有 255 元素,您将在关联的哈希表中查找真实值。有人能记得它叫什么吗? (我想我是在旧的 IBM TR 中读到的。)无论如何,您也可以按照@o11c 建议的方式安排它 - 始终首先在哈希中查找,如果不存在,请查看您的位数组。【参考方案5】:
另一种选择是
检查结果是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% 的情况下查找。当然,像往常一样,这是否是一个好主意取决于很多其他条件,所以唯一的答案是尝试实际使用。
【讨论】:
我会说这是一个很好的折衷方案,可以获得尽可能多的缓存命中(因为减少的结构可以更容易地放入缓存中),而不会在随机访问时间上损失太多。 我认为这可以进一步改进。我过去曾在一个类似但不同的问题上取得成功,利用分支预测有很大帮助。将if(code != 3) return code;
拆分为if(code == 0) return 0; if(code==1) return 1; if(code == 2) return 2;
可能会有所帮助
@kutschkem:在这种情况下,__builtin_expect
& co 或 PGO 也可以提供帮助。【参考方案6】:
除非您的数据有规律,否则不太可能对速度或大小进行任何合理的优化,并且 - 假设您的目标是一台普通计算机 - 10 MB 也没什么大不了的。
您的问题中有两个假设:
-
数据存储不当,因为您没有使用所有位
更好地存储它会使事情变得更快。
我认为这两个假设都是错误的。在大多数情况下,存储数据的适当方式是存储最自然的表示。在您的情况下,这就是您所追求的:0 到 255 之间的数字的字节。任何其他表示都会更复杂,因此 - 所有其他事情都相同 - 更慢且更容易出错。要偏离这一一般原则,您需要一个比 95% 数据上可能存在的六个“浪费”位更有力的理由。
对于您的第二个假设,当且仅当更改数组的大小会显着减少缓存未命中时,它才是正确的。这是否会发生只能通过分析工作代码来明确确定,但我认为这不太可能产生重大影响。因为无论哪种情况,您都将随机访问数组,因此处理器将难以知道在任何一种情况下要缓存和保留哪些数据位。
【讨论】:
【参考方案7】:您已经简洁地描述了阵列的所有分布特征; 折腾数组。
您可以轻松地用随机方法替换数组,该方法产生与数组相同的概率输出。
如果一致性很重要(为相同的随机索引生成相同的值),请考虑使用bloom filter 和/或hash map 来跟踪重复点击。但是,如果您的数组访问确实是随机的,那么这是完全没有必要的。
【讨论】:
我怀疑这里使用了“随机访问”来表示访问是不可预测的,而不是它们实际上是随机的。 (即它的目的是“随机访问文件”) 是的,很有可能。然而,OP并不清楚。如果 OP 的访问在任何方面都不是随机的,则根据其他答案指示某种形式的稀疏数组。 我认为你有一个观点,因为 OP 表示他会以随机顺序循环整个数组。对于只需要观察分布的情况,这是一个很好的答案。【参考方案8】:如果您只执行读取操作,最好不要将值分配给单个索引,而是分配给索引间隔。
例如:
[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
现在您只需遍历一系列间隔并检查您的索引是否在其中一个范围内,平均而言,这可能会减少内存密集度,但会消耗更多的 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 的区间。将相应的值放入映射中,仅当在区间中未找到您要查找的项目时才检查它们。这也应该会稍微提高平均性能。
【讨论】:
有趣的想法 (+1) 但我有点怀疑它是否会证明开销是合理的,除非有很多长的 0 和/或长的 1。实际上,您建议使用数据的游程编码。在某些情况下它可能很好,但可能不是解决此问题的通用方法。 对。特别是对于随机访问,这几乎肯定比简单数组或unt8_t
慢,即使它占用的内存要少得多。【参考方案9】:
就像 Mats 在他的评论回答中提到的那样,如果不知道具体你有什么样的数据(例如,是否有长时间的 0 和等等),以及您的访问模式是什么样的(“随机”是指“到处都是”还是只是“不是严格以完全线性的方式”或“每个值恰好一次,只是随机的”或......)。
也就是说,我想到了两种机制:
位数组;即,如果您只有两个值,则可以将数组简单地压缩 8 倍;如果您有 4 个值(或“3 个值 + 其他所有值”),您可以压缩两倍。这可能不值得麻烦并且需要基准测试,特别是如果您有 真正 随机访问模式,这些模式会逃脱您的缓存,因此根本不会改变访问时间。(index,value)
或 (value,index)
表。即,对于 1% 的情况有一个非常小的表,对于 5% 的情况可能有一个表(只需要存储索引,因为它们都具有相同的值),以及最后两种情况的大压缩位数组。而“表”是指允许相对快速查找的东西;即,可能是散列、二叉树等,具体取决于您可用的内容和您的实际需求。如果这些子表适合您的 1 级/2 级缓存,您可能会很幸运。
【讨论】:
【参考方案10】:我将补充 @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。
这两者的组合使用了大约 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 位,但这需要您创建 2^15 个平衡树,平均约 305 个元素。这将产生 2.143 MB,只需 4 次迭代即可遍历树,与我们开始的 19 次迭代相比,这是一个相当大的加速。
作为最后的结论:这比 2 位向量策略要少一点内存使用量,但要实现起来却很困难。但如果它可以决定是否安装缓存,那么它可能值得一试。
【讨论】:
勇敢的努力! 试试这个:由于 4% 的情况是值 2 ...创建一组异常情况 (>1)。有点像为真正的特殊情况(> 2)描述的那样创建一棵树。如果存在于集合和树中,则使用树中的值;如果存在于集合和 not 树中,则使用值 2,否则(不存在于集合中)在您的位向量中查找。树将仅包含 100000 个元素(字节)。 Set 包含 500000 个元素(但根本没有值)。这是否在证明其增加成本的同时减小了尺寸? (100% 的查找在集合中查找;5% 的查找也需要在树中查找。) 当你有一个不可变的树时,你总是想使用一个 CFBS 排序的数组,所以没有分配节点,只有数据。【参考方案11】:如果数据和访问是均匀随机分布的,那么性能可能将取决于访问的哪一部分避免了外层缓存未命中。优化这将需要知道什么大小的数组可以可靠地容纳在缓存中。如果您的缓存足够大以容纳每五个单元格一个字节,那么最简单的方法可能是让一个字节保存 0-2 范围内的五个以三为基的编码值(有 5 个值的 243 种组合,这样将适合一个字节),以及一个 10,000,000 字节的数组,只要 base-3 值指示“2”,就会查询该数组。
如果缓存不是那么大,但每 8 个单元可以容纳一个字节,那么就不可能使用一个字节值从八个 base-3 值的所有 6,561 个可能组合中进行选择,但是由于将 0 或 1 更改为 2 的唯一效果是导致不必要的查找,正确性不需要支持所有 6,561。相反,可以关注 256 个最“有用”的值。
特别是如果 0 比 1 更常见,或者反之亦然,一个好的方法可能是使用 217 个值来编码 0 和 1 的组合,其中包含 5 个或更少的 1,16 个值来编码 xxxx0000 到 xxxx1111,16 到编码 0000xxxx 到 1111xxxx,一个编码 xxxxxxxx。对于人们可能发现的任何其他用途,将保留四个值。如果数据按照描述随机分布,则所有查询中的一小部分将命中仅包含 0 和 1 的字节(在所有 8 组的大约 2/3 中,所有位都是 0 和 1,大约 7/8那些将有六个或更少的 1 位);绝大多数没有出现在包含四个 x 的字节中,并且有 50% 的机会出现在 0 或 1 上。因此,只有大约四分之一的查询需要大数组查找。
如果数据是随机分布的,但缓存不够大,无法处理每八个元素一个字节,则可以尝试使用这种方法,每个字节处理八个以上的项目,但除非强烈倾向于 0或接近 1,无需在大数组中进行查找即可处理的值的比例将随着每个字节处理的数量的增加而缩小。
【讨论】:
【参考方案12】:很久很久以前,我只记得……
在大学里,我们的任务是加速光线追踪程序,该程序必须通过算法一遍又一遍地从缓冲区数组中读取。一位朋友告诉我始终使用 4Bytes 的倍数的 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 字节,但这在这里没有多大帮助,因为访问是随机的。【参考方案13】:我对C不是很熟悉,但是在C++中你可以使用unsigned char来表示0-255范围内的一个整数。
与普通 int 相比(同样,我来自 Java 和 C++ 世界)其中 4 字节(32 位)是必需的,unsigned char 需要 1 字节(8 位)。 因此它可能会将数组的总大小减少 75%。
【讨论】:
uint8_t
的使用可能已经是这种情况 - 8 表示 8 位。以上是关于当 95% 的情况下的值为 0 或 1 时,对非常大的数组的随机访问是不是有任何优化?的主要内容,如果未能解决你的问题,请参考以下文章