这里有两三个不同的东西要混为一谈。
- 训练文件格式
- 解析后训练集的内存存储格式(如果您需要保留已解析的状态以供将来参考)
- 单板状态的解压表示 + 可选? 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 位位置(如该文件格式,见上文)。
如果您的文件格式是二进制文件,请保留它。 (或者甚至只是内存映射文件。)