有没有办法“分解”共同字段以节省空间?

5

我是一个有用的助手,可以为您进行翻译。

我有一个大型数组(>百万)的Item,每个Item的形式如下:

struct Item { void *a; size_t b; };

有几个不同的a字段——也就是说,有许多具有相同a字段的项。
我想要"分解"这些信息,以节省大约50%的内存使用率。
然而,问题在于这些Item具有重要的排序,并且这可能会随时间改变。因此,我不能只是为每个不同的a创建一个单独的Item[],因为这将失去相对于彼此的项目的顺序。
另一方面,如果我在size_t index;字段中存储所有项目的排序,则会失去从删除void *a;字段中获得的任何内存节省。
所以,我有没有办法在这里真正节省内存呢?
(注意:例如,我已经想到使用unsigned char来索引到一个小数组中的a,但我想知道是否有更好的方法。那样需要我使用未对齐的内存或将每个Item[]拆分成两个,这对于内存局部性来说并不理想,因此我希望有其他更好的选择。)
5个回答

7
(注:我已经能想到例如使用无符号字符来索引一个小数组,但我想知道是否有更好的方法。)
这种想法是正确的方向,但不是那么简单,因为你会遇到一些令人讨厌的对齐/填充问题,这将抵消你的内存收益。
在这个时候,当你开始试图挖掘像这样的结构的最后几个字节时,你可能会想要使用位字段。
#define A_INDEX_BITS 3
struct Item { 
  size_t a_index : A_INDEX_BITS; 
  size_t b       : (sizeof(size_t) * CHAR_BIT) - A_INDEX_BITS; 
};

请注意,这将限制b可用的位数,但在现代平台上,其中sizeof(size_t)为8,在其中剥离3-4个位很少会成为问题。

1
为了最大化可移植性,请使用 CHAR_BIT 取代 8。 - Angew is no longer proud of SO
我明白了,所以只处理64位?32位的我无能为力吗?(+1) - user541686
1
@einpoklum CHAR_BIT 是指 char 变量的位数,因此它取决于平台。是的,在大多数现代硬件上,它几乎可以保证是8,但这并不是一般情况下都成立的。而且,CHAR_BIT8 更具有自我描述性。 - Angew is no longer proud of SO
我接受这个答案,因为似乎64位的节省是我所能期望的最好的,但如果有人发现更好的方法,请随时发布。谢谢! - user541686

5
使用多种轻量级压缩方案(请参见此文档以获取示例和参考信息)来表示a*值。例如,@Frank的答案采用了DICT后跟NS。如果您有长时间运行相同的指针,可以在其上考虑使用RLE(Run-Length编码)。

@skeller:如果你想更深入地探讨这些方案的元思维和哲学,你可能会对我的一篇两页的小论文感兴趣。 - einpoklum
+1 听起来这只适用于 64 位系统,对于 32 位系统我什么都不会保存?(除了 void* 的一半,因为 32 位的本质,你可以说已经是某种东西了。) - user541686
@Mehrdad:该链接是关于具体实现的,但概念并不特定于任何特定的硬件。毕竟,Frank的答案是一般建议的一个具体案例。 - einpoklum
@einpoklum: 嗯,指针打包的事情是特定于64位的 - 在32位上,我没有足够的位数可以丢失。这就是为什么我在想是否可以做得更好。 - user541686
@Mehrdad:应用DICT后,它只有log(不同'a'的数量)位。 - einpoklum

3
这是一种有些hack的方法,但我过去曾经用过并取得了一定的成功。对象访问的额外开销被显著的内存减少所补偿。
典型的使用场景是在一个环境中,(a) 值实际上是带有限个不同类型的类型指示器的歧义联合体,并且 (b) 值大多数情况下保留在大的连续向量中。
在这种环境下,某些值的有效负载部分很可能使用了为其分配的所有位。可能还需要将数据类型存储在对齐的内存中。
实际上,现在大多数主流CPU不再需要对齐访问,我会使用一个紧密结构代替以下hack。如果您不需要支付非对齐访问的费用,则将{一个字节类型 + 八字节值}作为九个连续字节存储可能是最优的;唯一的成本是您需要将索引访问乘以 9 而不是8,因为9是编译时常量,这是微不足道的。
如果您必须支付非对齐访问的费用,则可以尝试以下方法。 "增强"值的向量具有以下类型:
// Assume that Payload has already been typedef'd. In my application,
// it would be a union of, eg., uint64_t, int64_t, double, pointer, etc.
// In your application, it would be b.

// Eight-byte payload version:
typedef struct Chunk8 { uint8_t kind[8]; Payload value[8]; }

// Four-byte payload version:
typedef struct Chunk4 { uint8_t kind[4]; Payload value[4]; }

向量是块的向量。为了使hack起作用,它们必须在8字节(或4字节)对齐的内存地址上分配,但我们已经假定对于Payload类型需要对齐。

这个hack的关键是如何表示指向单个值的指针,因为该值在内存中不是连续的。我们使用指向其“kind”成员的指针作为代理:

typedef uint8_t ValuePointer;

然后使用以下低开销但不是零开销的函数:

#define P_SIZE 8U
#define P_MASK P_SIZE - 1U
// Internal function used to get the low-order bits of a ValuePointer.
static inline size_t vpMask(ValuePointer vp) {
  return (uintptr_t)vp & P_MASK;
}
// Getters / setters. This version returns the address so it can be
// used both as a getter and a setter
static inline uint8_t* kindOf(ValuePointer vp) { return vp; }
static inline Payload* valueOf(ValuePointer vp) {
  return (Payload*)(vp + 1 + (vpMask(vp) + 1) * (P_SIZE - 1));
}

// Increment / Decrement
static inline ValuePointer inc(ValuePointer vp) {
  return vpMask(++vp) ? vp : vp + P_SIZE * P_SIZE;
}

static inline ValuePointer dec(ValuePointer vp) {
  return vpMask(vp--) ? vp - P_SIZE * P_SIZE : vp;
}

// Simple indexed access from a Chunk pointer
static inline ValuePointer eltk(Chunk* ch, size_t k) {
  return &ch[k / P_SIZE].kind[k % P_SIZE];
}

// Increment a value pointer by an arbitrary (non-negative) amount
static inline ValuePointer inck(ValuePointer vp, size_t k) {
  size_t off = vpMask(vp);
  return eltk((Chunk*)(vp - off), k + off);
}

我省略了很多其他的技巧,但我相信你可以自行理解。

通过交错存储值的各个部分,一个很酷的事情是它具有中等良好的局部性。对于8字节版本,几乎一半的时间,对某种类型和值的随机访问仅会命中一个64字节缓存线;其余时间会命中两个连续的缓存线,结果是向前(或向后)遍历向量与遍历普通向量一样友好,只不过因为对象大小减半所以使用的缓存线更少。4字节版本甚至更加缓存友好。


如果我理解正确的话,这听起来与我的答案非常相似 :) +1 - user541686
@Mehrdad:如果是这样,我没有理解你的回答。它更像是“结构体数组”,只不过数组是交错的,或者是“紧凑结构体”(目前被接受的答案),只不过包装方式是排列的。与你所说的不同,它不需要访问未对齐的内存,并且它可以轻松地添加新值a(直到达到unit8_t的限制,因此可能没有区别)。向量不是动态形状的——至少在我的应用程序中不是——并且只使用位操作进行索引。 - rici
这里的技巧是我使用单个指针来表示非连续的结构体。我并不是说我是第一个想到这个方法的人,但也许我是第一个不为使用此技巧而感到尴尬的人 :) 多年前,在一种解释器虚拟机的 POC 中使用了它,当时在一台不喜欢不对齐浮点数且没有多余 RAM 的 CPU 上。 - rici
啊,是的 - 在我的情况下,我将ab的位编码为一个整数(以使其更接近信息理论最优),但在你的情况下,你将它们分开以获得更快的性能。看起来我们在所有答案中涵盖了整个光谱。 :-) - user541686
此外,这个方法也适用于32位负载。但是,使用未对齐的结构体也可以实现,除非你的架构不允许这样做。 - rici

2
一种有用的方法是采用结构数组方法。也就是说,有三个向量...
vector<A> vec_a;
vector<B> vec_b;
SomeType  b_to_a_map;

你可以访问你的数据,如同...
Item Get(int index)
{
    Item retval;
    retval.a = vec_a[b_to_a_map[index]];
    retval.b = vec_b[index];
    return retval;
}

现在,您需要为SomeType选择一个合适的选项。例如,如果vec_a.size()为2,则可以使用vector<bool>或boost::dynamic_bitset。对于更复杂的情况,您可以尝试位打包,例如支持A的4个值,我们只需更改函数为...
int a_index = b_to_a_map[index*2]*2 + b_to_a_map[index*2+1];
retval.a = vec_a[a_index];

您可以始终通过使用范围压缩来击败位压缩,使用除法/取模来存储每个项目的小数位长度,但复杂度会迅速增加。
这里有一个很好的指南:http://number-none.com/product/Packing%20Integers/index.html

2
我认为我已经找到了在信息理论上最优的方法去做这件事……但在我的情况下,它并不值得,但我将在这里解释一下,以便如果有人需要时可以帮助他们。
然而,这需要非对齐内存(某种意义上)。
或许更重要的是,你会失去轻松动态添加新值 a 的能力。
真正重要的是这里有多少个不同的 Item,即有多少个不同的 (a,b) 对。毕竟,可能对于一个 a 有十亿个不同的 b,但对于其他的 a,只有一小撮,因此你想利用这一点。
如果我们假设有 N 个不同的项目可供选择,则我们需要 n = ceil(log2(N)) 位来表示每个 Item。所以,我们真正想要的是一个包含 n 位整数数组,其中 n 在运行时计算出来。然后,一旦你获取了 n 位整数,你就可以在基于你所知道的每个 ab 的数量的二分搜索中花费 log(n) 时间来确定它对应哪个 a。(这可能会影响一下性能,但取决于不同的 a 数量。)
你无法以良好的内存对齐方式完成此操作,但这并不太糟糕。你需要创建一个 uint_vector 数据结构,其中每个元素的位数是可以动态指定的数量。然后,在随机访问时,你需要进行一些除法或模运算,并进行位移以提取所需整数。
需要注意的是,除以可变量可能会严重影响你的随机访问性能(尽管它仍将是 O(1))。缓解这种情况的方法可能是编写几个常用 n 值的过程(C++ 模板在这里很有帮助!),然后使用各种 if (n == 33) { handle_case<33>(i); }switch (n) { case 33: handle_case<33>(i); } 等分支进入它们,以便编译器将除数视为常量,并根据需要生成移位/加法/乘法,而不是除法。
只要你需要每个元素固定数量的位数,这就是信息理论上最优的,这也是你随机访问所需要的。但是,如果你放松这个限制,你可以做得更好:你可以将 多个 整数打包到 k * n 位中,然后使用更多的数学提取它们。这也可能会降低性能。 (或者,长话短说:C 和 C++ 真正需要一个高性能的 uint_vector 数据结构……)

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