【问题标题】:3 bit unsigned integer in cc中的3位无符号整数
【发布时间】:2015-12-29 06:18:16
【问题描述】:

我正在建立一个深度神经网络来玩 connect-4,它必须在一台非常有限的机器上与其他 AI 机器人竞争(还不知道具体的限制,只是我只有几个核心和一个少量内存)。因此,我希望以任何可能的方式优化我的训练集。它目前代表董事会中的状态:

b 用于空白(无件)

x 代表“x”件作品

o 代表“o”作品

win 赢得设置

loss 设置丢失

draw 用于绘制设置

基本上我正在尝试映射它,以便我的 3 位整数可以代替这些内存繁重的字符串。我考虑过使用short,但它比char 16 位更差。我想像这样映射它:

000 -> b

001 -> x

010 -> o

011 -> win

100 -> loss

101 -> draw

因为我可以用 3 位空间而不是字符来表示这些状态(每个字符 8 位,哎呀!)我想尝试一下。但是我不确定如何在 c 中实例化这样的变量。

训练集长 67557 行,每行代表一个 6x7 棋盘,后面有一个赢/输/平子句。因此,每个字符节省 5 位将节省每行 (5*6*7)+(5*1) = 215 位和整体上节省 215*67557 = 14524755 位(总共 2.90 MB 中的 1.81 MB,整体空间减少了 62%)。

【问题讨论】:

  • 顺便说一下,connect-4 是一个已解决的游戏:第一个玩家可以总是获胜。实施它,你就会破坏竞争!
  • 平局、输赢状态,不应该与棋子的位置无关吗?一个棋子可以是空的、圆形的、十字形的还是win,这似乎很奇怪?如果断开这两者,则可以仅将片段状态存储在两个位中,这更适合一个字节(每个字节四个片段)。然后将平局/赢/输状态连接到 棋盘 而不是单独的棋子。
  • @JoachimPileborg 这是我想到的,但是,我正在优化当前数据集,其中包含x,b,b,b...o,o,x,b,b,win 之类的行,并认为最好的起点是迭代它并将当前设置替换为一个优化的。
  • 您需要很多棋子,但只有一个胜/松/平指示器,对吧?除了对空间进行更多优化以分离状态之外,当您不必担心三个位跨越字节或字边界时,代码也将更加更加简单。只是说... :)
  • 就像 Joachim 所说,仅使用 2 位作为地图坐标更有效。有了这个,你需要 6*7*2 = 84 位的地图。如果您将其放入 6 个 16 位变量中,您仍然有 12 位用于结果(赢/输/平)。或者在 11 个 8 位变量中,结果为 4 位。

标签: c optimization types int neural-network


【解决方案1】:

这里有两三个不同的东西要混为一谈。

  • 训练文件格式
  • 解析后训练集的内存存储格式(如果您需要保留已解析的状态以供将来参考)
  • 单板状态的解压表示 + 可选? W/L/D 旗

所有这三种格式都可以不同。训练文件可以是文本以便于编辑。当然,即使您的主程序以二进制格式读取训练集,该二进制文件也可以通过单独的工具从易于编辑的文本格式“编译”。

处理单板位置的内部表示:

这需要快速访问和循环。由于您正在训练神经网络,而不是直接编写 AI,因此您可能不需要非常多地使用这种表示。如果您不需要做的只是将每个元素应用于神经网络输入,那么使用单独的格式是没有意义的:只需直接从更紧凑的表示解压缩到神经网络输入。

但是,如果您必须多次循环遍历单个棋盘状态,则有一些有趣的选择。正如多人指出的那样,应该将赢/输/平/未定标志与棋盘状态分开考虑。因此,每个板都有一个标志,而不是在每个板位置存储标志。

  • bit-board:例如,我读过有关使用 64 位 unsigned int 存储所有白色棋子所在位置的国际象棋引擎(例如狡猾)。您可以将位图按位或在一起,以查找所有白色部分的位置。

    位图(一张用于o,一张用于x)将记录整个状态。一个connect-4板有6*7个网格位置,所以每个位图可以是64bits,但是32b太小了。 popcount(board.o) 告诉您板上有多少 o。 assert(o & x == 0) 将是一个很好的完整性检查,因为在同一位置永远不可能有 o 和 x。

    在一个结构中使用两个压缩的 42b 字段是个坏主意,因为加载/存储会很慢。即使将它们打包成 48 位字段(因此它们以字节边界结束)也会导致加载/存储速度变慢。请记住,这是我们的快速格式。我们可以使用打包格式进行长期存储。

    像board[0][0] && board[0][1] && board[0][2] && board[0][3](虽然不是那种语法)这样的东西在编译时间常数位置上是非常快的。按位 AND 只留下可能设置的那些位,然后您可以与掩码进行比较以查看是否设置了 all 位。要测试 || 而不是 &&,请省略第二步。您可以针对 o 或 x 位图或o|x 进行这些测试以检查任何一种类型。但是,如果您必须在运行时从可变位置构建掩码,则效率不高。

    要扫描棋盘是否获胜,您可以检查左列,然后移动掩码以检查下一列。实际上,像这样蛮力检查所有列可能比检查标记的邻居、寻找 2-in-a-row 候选者要慢。

    如果位图是完全 64 位的,代表 8x8 板,则某些操作可能会更容易,但您实际上只使用它的左下角 7x6。这样,单独的列位于 64 位整数的单独字节中。将每一列放在一个单独的字节中可能比行更有用,因为在列中找到最高使用位置是您可能想要做的事情。这只是对列的find first set bit 操作。从位图中提取 8 位块更快(不需要屏蔽)。不过,您可以解压缩 42 位位图以分隔每列的变量。在 x86 上,前 4 个寄存器对于第一个 和第二个 8 位块(AX(RAX 的低 16 位)由 AL 和 AH 组成)是字节可寻址的,您(或编译器,但他们可能没这么聪明)可以在 4 个寄存器中存储 7 列,并且仍然能够分别bsr(位扫描反转)任何列。

// Sample bitboard implementation:
struct game_state {
    struct board_state {
       uint64_t o, x;
    } board;
    enum winlose { GAME_UNDECIDED=0, GAME_WIN_O, GAME_WIN_X, GAME_DRAW } victory;
};
  • 2 位字段数组:不要使用。类似的实现将为每个位置使用 2 位位域。 It's impossible to use with nice board[row][col] syntax in C 和 42*2 位不适合单个寄存器。交错位板没有任何优势,并且使某些事情变得更糟,尤其是。因为整个东西不适合64位。 (如果您想在位板版本中查找未占用的空间,您可以在 o|x 中查找零位。在这里,您必须检查每一对 2 位,而不是能够使用一个位来适应整个问题一个寄存器。不过,您可以制作一个宏来移位/屏蔽代表给定行/列的 2 位。它不会生成有效的代码。

  • 字节数组:使用这种格式循环检查给定棋盘位置的邻居可能会更快。在位板中,测试board[i][j] && board[i][j+1] 可以通过对板进行位移来完成,使感兴趣的两位线,然后按位与,然后位测试该位。至少在 x86 上,存在具有小字节偏移的寻址模式,因此给定一个板位置的地址,与另一个板位置可能只需要一条指令。

    在每个字节中,一位代表x,另一位代表o,如果位置为x或o。这允许通过将它们与在一起来检查多个位置是否都被占用,并检查占用的位。否则,您必须检查是否在每个网格中设置了 x 或 o 位。

// Sample byte-array implementation:
enum boardpos {
    POS_EMPTY = 0,
    POS_O = 1<<0,
    POS_X = 1<<1,
    POS_OCCUPIED = 1<<3
};
// maybe #define for these constants instead?

struct game_state {
    struct board_state {
       uint8_t pos[6][7];
    } board;
    enum winlose { GAME_UNDECIDED=0, GAME_WIN_O, GAME_WIN_X, GAME_DRAW } victory;
    // or maybe stuff the winlose info into the high bits of board.pos[0][0]?
    // Not much point, since the struct will probably be the same size after padding anyway.
};

文件格式表示:

更紧凑但仍然易于使用的格式是xbbb...ooxbbw。然后,您不必将行解析为字符串,只需将其解析为 43 个字符的恒定大小块(如果每条记录由换行符分隔,则为 43 个)。如果您的棋盘位置不是赢、输或平,请使用另一个字符来标记。空格,或'n'。

只需省略逗号即可将您的大小减半。您不希望必须解析逗号和其他内容的输入。像xb2o1xb1w 这样的简单游程编码符号可能会带来进一步的好处。看到一个数字意味着多次重复最后一个字符。也许x 表示一个x,大写X 表示两个x。这已经到了人类难以阅读的地步。 LZOP 或 LZ4 压缩可能会很好地压缩内容。

二进制文件格式:

显然,文本表示是有限制的。固定大小的二进制记录可能非常小,因为没有多少信息需要存储。每个网格位置使用 2 位可能足够紧凑,但仍然存在冗余,因为这可以表示 x 和 o 在同一位置的不可能状态。为了做得更好,您需要将整个电路板或整行或整列的状态映射到多位表示。 Wikipedia says 有 4,531,985,219,092 个合法位置,用于填充 0 到 42 个棋子的所有游戏板。刚刚超过 2^42。所以 43 位应该足以代表任何有效的棋盘状态,包括所有尚未确定的位置。 IDK 如何将游戏编码为 43 位整数,至少没有任何可用的东西(即比实际枚举所有可能的游戏并停在匹配的游戏更快。)

如果您使用位板作为内部快速表示,请将它们打包存储在您的文件格式中,因此 o 和 x 板加上 w/d/d 状态适合 12 个字节,或者如果您使用 16 个字节像整数一样。

// do some pre-processor stuff to choose between GNU C __attribute__ ((__packed__))
// and the MSVC #pragma pack
struct __attribute__ ((__packed__)) compact_game_state {
    struct __attribute__ ((__packed__)) compact_board_state {
       uint64_t o:42, x:42;
    } board; // sizeof = 11
    uint8_t victory;
}; // sizeof = 12

struct semi_compact_game_state {
    struct __attribute__ ((__packed__)) semi_compact_board_state {
       uint64_t o:48, x:48;
    } board; // 96 bits = 12 bytes
    enum winlose victory; // another 4 bytes
};

这些实际上是用 g++ 编译的:see on godbolt。

使用 endian-agnostic code 执行 I/O,这样它就不会在大端机器上中断。它是一种文件格式,因此您如何访问它实际上并不重要,只要正确的字节放在正确的位置即可。 Little-endian 可能是文件格式的不错选择,因此在 little-endian 机器上,加载/存储代码是无操作的。或者只是懒惰并在结构数组上执行二进制 I/O,并且只在与训练数据集具有相同字节顺序的机器上使用您的代码。

如果您不使用位板,最好使用 2 位字段数组。随机访问可能很慢,但将其转换为字节数组可能会比两个单独的位域更快。屏蔽低 2 位,将其用作{ POS_EMPTY, POS_O|POS_OCCUPIED, POS_X|POS_OCCUPIED } 查找表的索引。然后位移两位以将下一个字段置于低位。该板占用 84 位,因此以单独的 32 或 64 位块进行。不需要进行 128 位双移位。输赢/平局信息可以放在最后的 2 位块中。

把它打包成一个 12 字节的结构,包含三个 uint32_t,或者一个 uint64_t 和一个 uint32_t 什么的。或者只是一个 uint8_t 数组,但是这样就很难让编译器完成一个广泛的负载。你可以把东西打包成一个 11 字节的结构,但是对齐就成了一个问题。如果节省 1/12 的内存大小对缓存有用,那就去吧。在 x86 CPU 上,跨缓存行的加载只需要几个额外的周期,而且您不会经常加载。 (第一个块的 64 位加载无论如何都不会与 64b 对齐,使用 12B 结构,但它至少会从中间分裂,这是一种特殊情况,比某些 CPU 上不均匀的高速缓存行分裂更快。 )

我认为,将单独的位板解码为字节数组需要单独移动每个位板。它仍然可以是无分支的,但没有那么好。

游戏数据库的内存存储

表示之间的转换需要 CPU 时间,所以如果没有用,就从文件格式转到内部格式。

只有在您保留文本文件格式并使用非紧凑快速格式时,为此设置单独的格式可能才有用。在这种情况下,将文件解析为压缩的 2 位位置(如该文件格式,见上文)。

如果您的文件格式是二进制文件,请保留它。 (或者甚至只是内存映射文件。)

【讨论】:

  • @asdf:我有另一个有趣的想法:位板的列主要存储意味着您可以使用位扫描函数(变成单个指令)而不是循环来找到最高的在列中占据的位置。我知道你说你正在使用神经网络,所以我不知道有效的位板搜索功能是否有用。
【解决方案2】:

如果你使用bitfields呢?

struct S {
    unsigned w : 3;
    unsigned x : 3;
    unsigned y : 3;
    unsigned z : 3;
};

在大多数系统上,sizeof(struct S) == 2,但您会在其中保存 4 个值!。

现在,您可以执行类似...

struct S myS;
s.w = 0; // Okay
s.x = 6; // Okay
s.y = 8; // Will overflow/wrap-around (why do the standards differentiate between those two?)

但是,如果你这样做......

struct S first;
struct S second;

你会失去你正在寻找的内存效率,因为编译器必须为两个对象提供一个地址,因此它们需要字节对齐,所以它们在一起(通常)保存 32 位,其中您可以保存八个值,但是,如果您有一个保存所有八个变量的结构,则内存使用量通常为 24 位。

请记住,包含使用所有可用空间的位域的结构(例如上面提到的 8 成员:8 * 3 == 24; 24 % 8 == 0)更适合您的目的,因为您可以拥有它们的数组,获得位域的好处,并且在此过程中不浪费内存。因此,第一个结构效率低下,因为它为每个 S 类型的对象浪费了 4 位。

顺便说一句:不要尝试使用&amp;s.x 或sizeof(s.x),因为显而易见的原因,它根本不起作用。

【讨论】:

  • 请注意。这可能(取决于编译器)导致意外扩展,结果 s.y = 6; if (s.y == 6) 将失败,因为 s.y 现在是一个小的负数
  • 我也真诚地希望 sizeof(struct S) 在大多数系统上更像 2。
  • @TomTanner:抱歉,sizeof(struct S) == 16 打错了。
  • @TomTanner,这也许可以通过使用unsigned 来避免位字段。
  • 不,我希望这部分不依赖于实现。如果一个位域是unsigned,它的所有值都应该是正数,并且隐式转换为int 永远不能将其变为负数。
【解决方案3】:

如果机器受到限制并且您必须(按时)竞争,那么您不希望在位域范围的整数上进行 CPU 繁重的操作。最好是使用本机机器字长。下一个最好的方法是使用字节大小的整数,因为许多机器对字节大小的实体都有高效的操作。

优化速度或内存使用总是一个问题。

【讨论】:

  • 这不是真的。您是否注意到 OP 给出的测量值(对于 嵌入式 系统以 兆字节 为单位)?这对他来说根本不可行。
  • 几乎所有 CPU 都具有足够高效的字节操作(或至少能够以零加载将字节扩展至全宽寄存器),建议使用它是没有意义的代表棋盘的 32 位整数数组。 8 位整数数组是处理游戏状态的好选择,但这并不能使其成为存储多个游戏的良好文件格式或内存格式,因为@Kemy 有一个很好的观点。看我的回答。顺便说一句,像狡猾的国际象棋引擎确实大量使用适合 64 位寄存器的位板。按位测试可以检查整个对角线,或者 w/e。
猜你喜欢
  • 2012-07-12
  • 1970-01-01
  • 1970-01-01
  • 2012-02-11
  • 1970-01-01
  • 2011-06-24
  • 2013-09-01
  • 2010-12-19
  • 1970-01-01
相关资源
最近更新 更多