21 KiB
从暴力搜索到位运算:寻找 Minecraft 史莱姆区块的优化之旅
这是一份视频/博客共用的叙事提纲。依据当前源码与研究记录整理,性能数字引用已有实验,没有在整理时重新测量。
后续演示代码已放在当前 show/ 目录,见 README.md;
本次重新测量的速度见 BENCHMARK.md。下文保留构思时的研究数字与补测建议,
制作时以新报告取值。主线线程策略对比只包含按 seed 与固定坐标后遍历 seed;展平实现移到实验目录,不列为本阶段优化。
**主线的测试分段已调整:**A 用 65536 seed × 257×257 对比 CPU 与按 seed 的 CUDA; B 用 65536 seed × 4097×4097 对比 02、03,扩大每个 seed 的地图; C 用固定 seed × 8193×8193 对比 03 与后续坐标优化。 A/B、B/C 之间画测试负载分界线,分别重测 02、03,只在每组内部计算速度比。 原来的 64 seed 极端负载用于补充说明并行度,不作为主线“249 倍优化”的依据。
主线:先让相同的工作执行得更快,再利用问题的结构减少需要执行的工作。
建议标题:
- 《为了找一片史莱姆区块,我从 CPU 写到了 CUDA,最后开始拆随机数》
- 《从暴力枚举到解析求解:Minecraft 史莱姆区块搜索优化实录》
1. 开场:我们到底在找什么?
用一个 5×5 区块网格开场:想找一片全是史莱姆区块的区域。 一个区块是否满足条件,由世界种子和区块坐标共同决定。 把这个判断看成一个确定的函数:
isSlime(seed, chunkX, chunkZ) -> true / false
如果粗略把每格看作独立的十分之一概率,一个指定的实心 5×5 图案出现概率约为 10^-25。
这只是直觉估算;同一 seed 下各格共享生成机制,后面的算法恰恰要利用这些关系。
引出三个问题,后续每次切换任务时在画面上注明:
| 任务 | 固定什么 | 搜索什么 | 对应实现 |
|---|---|---|---|
| 原始联合暴力搜索 | seed 区间与坐标范围 | (seed, x, z) |
根目录 slime.cpp、slime.cu |
| 固定 seed 找位置 | 一个世界种子 | (x, z) |
slime_position_cuda.cu、slime_position_residue_cuda.cu |
| 固定位置反查 seed | 图案与锚点坐标 | seed | slime_pattern_cpp.cpp、slime_pattern_cuda.cu |
视频不必一开始列出所有程序。先展示搜索空间像一个三维盒子:seed、X、Z。 后续固定其中一维或两维,就是取这个盒子的一个切片。
贯穿演示的已知样例:世界 seed 61692151016558,5×5 锚点 (51069, 419124)。
固定这个位置,当前求解器找到了 24 个通过精确验证的低 48 位 seed。
2. 第一版:CPU 老老实实枚举
源码入口:../slime.cpp。
按“种子 → 坐标 → 图案中的每一格”解释三层循环:
for seed in seeds:
for (x, z) in positions:
if patternMatches(seed, x, z):
record(seed, x, z)
CPU 多线程把 seed 区间分给不同工作线程。
判断实心图案时,只要一格失败,就能提前退出;一般不会把 25 格全部算完。
根目录还支持“n×n 中至少 d 格满足”的条件,这里为了讲清楚后续原理,统一使用 n=5, d=25。
**画面:**几名工人各自拿一摞 seed,对每个 seed 从头扫描同一张坐标地图。
转场台词:“这些候选互不依赖,很适合并行。既然 CPU 能分给十几个线程,能不能分给 GPU 上更多的线程?”
3. 第二版:搬到 CUDA
源码入口:../slime.cu,关注 checkSeedsKernel 和 checkSingleSeed。
原始 CUDA 实现让一个 GPU thread 负责一个 seed,线程内部继续扫描整个坐标矩形。 这一步改变了执行硬件,但仍然枚举相同类型的候选。
讲解只保留必要概念:thread 是一个工作者,block 是一组工作者,kernel 是交给 GPU 执行的函数。 不必在科普主线中展开 CUDA API。
**画面:**原先几名工人变成很多工人,但每人手上的地图仍然很大。
现有记录中,专用的固定坐标暴力 benchmark,GPU 吞吐约为 16 线程 CPU 的 23.6 倍。
这里必须注明:它来自 slime_bruteforce_*_bench,不是根目录两个程序的同任务计时。
根目录 CPU/CUDA 的直接对比留待补测,不能把这个倍数放在根目录版本的柱子上。
**当前演示对照 A:**使用 show/ 中统一语义后的 01/02,65536 个 seed、257×257 锚点、实心 5×5。
以 CPU 搜索时间与 GPU scan 时间作硬件转换对比,完整命中列表必须一致。新报告已提供这组实测。
测试负载分界:下一章保持相同坐标矩形,把 seed 数增至 65536,重新测量 02,再比较线程策略。跨横线不计算速度比。
4. 第三版:重新分配工作,让 thread 负责坐标
源码入口:03_cuda_position.cuh 的 coordinateKernel。
固定 seed 的研究参考实现另见 slime_position_cuda.cu。
把坐标矩形编号,每个 thread 固定一个锚点坐标,在内部遍历 seed 并检查完整图案。
固定 seed 时,这个循环只运行一次。
每个 thread 先计算图案需要的 F(x+dx) 和 G(z+dz),再将这些多项式结果传给检查函数。
遍历 seed 时只计算两项之和与 Random,不重新计算坐标多项式。这里复用 thread 私有项,下一章才扩展为相邻 thread 共享轴项。
图案大小通过模板展开,私有轴项无需每次动态索引;旧/新 03 的独立样本见
stage3_polynomial_reuse.json,其中同时包含该编译期展开的效果。
当前实现用 grid-stride loop,因此一个 thread 也可能在之后检查其他锚点。
“一个 thread 一个位置”描述的是工作单元,不是永久绑定关系。
之前:thread(seed) -> 顺序扫描所有 (x, z)
之后:thread(x,z) -> 顺序检查所有 seed
好处是把长循环拆成更细的任务,使空间维度也能提供足够并行度;固定坐标项可供多个 seed 使用。 当 seed 数很少、位置很多时,这尤其值得尝试。 它是否更快仍受任务规模、调度成本、寄存器使用等因素影响,不能把“坐标线程化必然更快”当成定理。 坐标线程内部仍然有提前退出,warp 分支差异也仍然存在。
如果希望这一章展示“更快多少”,应补一个受控对比:相同 seed 列表、相同坐标矩形、相同图案,一线程一个 seed 与一线程固定一个 (x,z)、内部遍历 seed 分别运行。
对多个 seed 的总工作量都应是 seed_count × width × height,两边收集相同结果。
目前已有的直接/outer-sum 对比不能证明这个线程粒度转换的独立收益。
**当前演示对照 B:**65536 个 seed、4097×4097 锚点,只运行 02、03。 新版 03 已包含坐标多项式的 thread 内复用与图案大小的模板展开。5 轮 kernel 中位数:02 为 31.599 秒,03 为 17.600 秒,速度比 1.795×;不能再引用仅交换线程策略时的约 1.07×。 03 严格按固定坐标遍历 seed,不引入展平。 只比较同一组输入的 kernel 时间;02/03 的 GPU 完整命中列表必须相同。 独立 CPU 核对同一 seed 列表下的 257×257 窗口,所有 GPU 命中再由独立 Java 算术验证;不把窗口验证写成完整大范围 CPU 重扫。 原先 64 seed 的任务用于解释并行度不足,主线不使用该任务的 249 倍数字。
**画面:**把每个 seed 的大地图拆成小格,工作者各拿一格。
转场台词:“现在能同时检查很多位置。但邻近位置算的东西,有不少其实是重复的。”
测试负载分界:接下来保持地图,固定一个 seed;重新测量 03,再与 outer sum 比较。
5. 第四版:outer sum,把二维坐标项拆成两条轴
共享算术:slime_solver_core.hpp 的 coordinateXTerm、coordinateZTerm。
史莱姆判断的坐标部分可以写成:
T(x,z) = F(x) + G(z)
先展示原式,再按变量分组证明这一点。记 J32(a) 为 Java 有符号 int 回绕,则:
F(x) = J32(4987142*x*x) + J32(5947611*x)
G(z) = 4392871*J32(z*z) + J32(389711*z)
前两项仅依赖 X,后两项仅依赖 Z,所以拆分是精确恒等式。
取 X_i=F(x_i)、Z_j=G(z_j),有 T_ij=X_i+Z_j。
矩阵形式是 T = X·1_n^T + 1_m·Z^T:把 X 列复制到每列,把 Z 行复制到每行,再逐项相加。
动画先用 X=(2,5)、Z=(10,20,30) 展示 2×3 数值表,再引出 block 共享。
完整 LaTeX 推导与整数语义见 video/OUTER_SUM.md。
这里 F、G 必须保留 Java 原本的 int 溢出、long 转换及符号扩展规则。 按共享核心的定义,轴项相加能用于精确判断;不能把它直接替换为无限精度下的普通二次多项式。
outer sum 是“每个 X 值加上每个 Z 值”,不是 outer product。 用一列 X 数值和一行 Z 数值,动画生成二维表格,便能直观解释。 可分离的是进入随机数生成器之前的坐标项;最终的史莱姆布尔图案没有因此直接变成可分离函数。
CUDA 的 outerKernel 用一个 block 覆盖 16×16 个锚点。
5×5 图案需要每条轴上 16+5-1=20 个项,放到 shared memory,让同一个 block 复用。
不要说“每个 tile 算 40 次就完成所有判断”:随机数运算和图案验证仍要做,节省的是重复计算的坐标项。
C 组同任务实测,RTX 3060 Laptop GPU,67,125,249 个锚点,100 轮中位数:
| 实现 | kernel 时间 | 结果 |
|---|---|---|
| 03 固定坐标,线程私有轴项 | 2.715 ms | 唯一命中已知位置 |
| 04 tiled outer-sum,共享轴项 | 1.598 ms | 完整命中列表相同 |
加速约 1.698 倍。更多数据与命令见 BENCHMARK.md。
这个收益适合用来讲提前退出:大多数位置很快就失败,直接版本来就没有计算所有 25 格。 重复计算减少很多,不代表总耗时也按相同比例减少。 outer sum 仍扫描坐标面积,后面的算法才开始减少候选数量。
6. 转折:随机数其实是一个确定的位运算过程
源码入口:slime_solver_core.hpp 的 nextStateFromTerm、exactIsSlimeFromTerm。
前面的 01–04 是实现层面的优化,仍然检查同一批候选。现在画出章节分界,开始探索 Random 的性质: 状态空间有多大?已知低位能推断什么?坐标与 seed 的约束能否分别组织?哪些未知位可以直接求解?
这部分不用“又快了多少”的表格串联,而是按“发现一个性质 → 举例验证 → 推导新搜索策略”展开。 详细推导见 RANDOM_PROPERTIES.md。XOR 合并只是把公式整理成方便分析的形式,不单列优化版本。
合并史莱姆公式与 Java Random 构造器中的两个 XOR,记:
A = 0x5DEECE66D
B = 11
C = 987234911 xor A
u = ((worldSeed + T(x,z)) xor C) mod 2^48
v = (A*u + B) mod 2^48
q = v >> 17 // next(31) 的首次输出
通常 q % 10 == 0 就满足这一格;Java nextInt(10) 还有拒绝后重抽的分支,后文单独交代。
发现一:64 位世界 seed,这个判断只看低 48 位
更准确的说法是“忽略第 48 位及以上的输入位,也就是 64 位中的高 16 位”。 不能说“忽略高 48 位”。内部状态只有 48 位,具有相同低 48 位的 world seed 对史莱姆判断等价。 这不意味着它们生成的整个 Minecraft 世界都相同。
**举一反三:**可以按低 48 位给 world seed 分等价类;验证算法时,也可用相差 2^48 的输入检查等价性,而不是把它写成额外的 kernel 加速。
**画面:**64 个位格子,高 16 位淡出,留下 48 位。
发现二:要整除 10,至少必须是偶数
首次输出 q 的最低位,是内部状态 v 的第 17 位,位编号从 0 开始。
乘法、加法、XOR 在模 2^18 下只依赖输入的低 18 位。
所以不用知道全部 48 位,就可以判断“首次输出是否为偶数”。
对实心 5×5,要求 25 格的这个必要条件同时成立。
已知位置枚举种子低 18 位的 2^18=262144 种取值,只保留 84 种。
这里是首次输出模型中的必要条件,不是完整的模 10 判断;Java 拒绝重抽可能产生例外,后面的候选生成器需要明确这一覆盖边界。
**举一反三:**低位可以先决定部分条件,就可以从“逐个完整 seed 判断”改为“先生成符合条件的低位取值,再补上更高位”。
**画面:**48 位色条分出低 18 位;262144 个小点通过 25 格筛子,剩下 84 个。
7. 坐标侧分支:先查余数,再展开整个世界范围
为了延续前面的“固定 seed 找坐标”,先用新发现优化坐标扫描。
源码入口:slime_position_residue_cuda.cu。
上述首次输出奇偶条件,对 X、Z 都具有 2^18 的坐标周期。
先检查 2^18 × 2^18 = 2^36 个余数对,把通过的类展开成具体坐标,再精确验证。
必须明确:周期属于低位必要条件,完整史莱姆判断不会因此在这些坐标间完全相同。
已知 seed、两轴锚点范围 [-1875000,1874999]:
14,062,500,000,000 个锚点
-> 检查 68,719,476,736 个余数对
-> 保留 180,476 个余数对
-> 展开 36,937,048 个具体候选
-> 精确验证,找到已知位置
余数 kernel 实测 1.931 秒,精确 CPU 验证约 284 ms,连同轴表准备,全流程约 2.5 秒。 直接扫描全范围约 429 秒是根据较大范围实测吞吐量线性外推;约 170 倍加速的分母是这个外推值。 “候选数量减少约 381000 倍”和“总耗时改善约 170 倍”应分别展示。 默认边界约束的是锚点,5×5 图案还会从锚点向外延伸四格。
**画面:**地图由重复的低位坐标类组成;先筛选类,再只点亮类中需要精查的具体位置。
8. 种子侧分支:18 + 17 + 13,解析求解剩余高位
现在明确切换问题:固定 (51069,419124),反过来找世界 seed。
源码入口:slime_pattern_cpp.cpp、slime_pattern_cuda.cu,共享核心的 solveUpper13。
低 18 位筛完后,中间 17 位目前仍然枚举:
84 种通过筛选的低 18 位取值 × 2^17 = 11,010,048 种低 35 位取值
不要把“逐位 lifting”讲成每一位都不断淘汰候选。 对首次输出奇偶条件,低 18 位确定后,后续高位不会改变它;这部分仍是扩展枚举。
最后的高 13 位有更强的结构:
seed = lower35 + m * 2^35, 0 <= m < 8192
合并后的 XOR 常数小于 2^35。固定 lower35,改变 m,所有图案单元的首次输出状态都获得同一个高位位移。
采用 n = 1645*m mod 8192 的可逆重编号后,每格的整除条件变成“某个区间里的 n 应满足哪个模 5 余数”。
每格只有一个回绕阈值,把阈值排序,按区间求约束交集,就能获得可行高位,无需逐个试完 8192 个 m。
候选最终仍用准确的 Java nextInt(10) 检查。
**主视频画面:**25 条长度为 8192 的候选带,阈值前后颜色变化;叠加后只剩少数共同位置。 1645、4965 等常数的推导放博客附录,视频只讲“共同位移、回绕阈值、集合交集”。
固定位置结果,RTX 3060 Laptop GPU/16 线程 CPU:
| 实现 | 时间 | 口径 |
|---|---|---|
| 旧 CPU 暴力 | 约 33.7 小时 | 根据实测 2^32 个 seed 外推 2^48 |
| 新 CPU 求解 | 约 0.61 秒 | 实测搜索,24 个已知结果全部找回 |
| 旧 CUDA 暴力 | 约 85.7 分钟 | 根据实测 2^36 个 seed 外推 2^48 |
| 新 CUDA 求解 | 59.6 ms | 实测 kernel |
| 新 CUDA 全流程 | 约 0.36 秒 | 包含启动、准备、传输等 |
GPU 算法比较约 86300 倍,全流程约 14300 倍。 这组数字属于固定坐标反查,不能接在坐标扫描的 1.21 倍、170 倍后相乘。 另有 64 个位置的性能分布,应在博客中引用,避免只展示一个有利样例。 这里的“搜索完成”限于当前首次输出候选模型,不能据此宣称穷尽含拒绝尾部分支的所有 Java seed。
9. 科研也包括试错:看起来聪明,不一定更快
主视频选一到两个即可,其他放博客侧栏:
- **FFT:**全匹配写成相关运算很诱人,但直接扫描大量候选会在第一两格失败;FFT 还要付准备、变换与内存成本。已有测试比直接扫描慢约 12.5–25.2 倍。
- **缓存:**1024 种互不相同的低 18 位取值,始终开启缓存零命中,反而更慢;该路线已移除,详见试错报告。
- **在十格筛选之后微调:**约 235 亿个 lower35 状态,只有 133815 个进入这个阶段,优化尾部难以改变总时间。
- **错误的逐位剪枝:**低于 18 位时拿不同输出位的奇偶当真实必要条件,会把已知有效 seed 删掉。更高位拆分也不会自动产生新剪枝。
转场可以说:“不仅要看淘汰了多少,还要看为了做这个筛选,花掉了多少工作。”
来源:REJECTED_EXPERIMENTS.md、FFT_EXPERIMENT.md、X/Z 筛轴试错报告。
10. 结尾:哪些问题解决了,哪些还没有?
当前最有成果的两条路线,是固定 seed 的坐标搜索,以及固定坐标的 seed 反查。 X/Z 独立筛轴路线因低位生成和过早配对瓶颈停止维护,见试错报告。 全 seed × 全世界目录仍未完成。
Java nextInt(10) 在首次输出落到 2^31 个可能值中的最后八个值时会拒绝并重抽。
单次概率为 8/2^31。现有候选过滤与解析求解主要描述首次抽取,所以可能遗漏依赖后续抽取才成立的解。
精确最终验证能消除误报,不能补回之前被过滤掉的候选。
博客写清楚这个边界;视频可用一句话和字幕交代,避免称为严格穷尽。
结束台词建议:
一开始,我们让更多线程帮忙试。后来,我们复用了相同的计算。再后来,我们开始问:哪些候选根本不值得试?最显著的加速,来自对随机数结构的理解。
11. 制作安排与需要补齐的实验
视频节奏建议
第一集约 15–20 分钟:开场与 CPU(2 分钟)、CUDA 与线程任务拆分(3 分钟)、outer sum(2 分钟)、Random 与低位过滤(3 分钟)、余数坐标搜索(2 分钟)、高位解析求解(3–5 分钟)、结果与边界(1 分钟)。 试错部分以已停止路线的报告为依据。博客可保留全部章节,用公式与源码链接补充推导。
每次优化都采用相同的叙述节奏:发现重复/浪费 → 改法 → 相同任务验证 → 时间结果 → 剩余瓶颈。
图表口径
使用三组独立图表,图上标出任务、硬件、规模、时间区域和“实测/外推”:
- 联合暴力枚举:CPU、seed-thread CUDA、细粒度候选 CUDA。需要补测。
- 固定 seed 找位置:直接扫描、outer-sum、余数过滤。分别说明小矩形实测与全范围外推。
- 固定位置找 seed:CPU/CUDA 暴力与新求解器,kernel 和全流程分开。
不要把毫秒级扫描、秒级全范围搜索和反查 seed 混在一张“版本越来越快”的图里。 跨度大的 seed 求解时间适合用对数坐标,但柱子上仍标明原始时间。
根目录基线的修正与统一
录制性能对照前,根目录实现存在几处需要处理的可比性问题:
- CPU 分线程时使用从 0 开始的区间,未加命令行 startSeed;非零
-s时实际扫描区间不符。 - CUDA kernel 没有 endSeed 边界判断,批次大小非 block 整数倍时会多查 seed。
- CPU 记录所有满足的位置,CUDA 每个 seed 找到第一个位置就返回;计数和工作量语义不同。
- 根目录坐标计算及 Random 运算使用有符号整数溢出写法;受控基线应采用显式无符号回绕与 Java 符号扩展,避免依赖 C++ 未定义行为。
- 测量应统一收集命中元组,避免循环内 printf 和逐 seed 原子计数掩盖搜索本身。
这些是从源码读取出的基线问题,本提纲没有修改实现。 如果想展示旧程序原貌,可保留旧版本作为历史片段;正式性能图使用明确标注的修正基线。
对照先做小范围命中列表一致性验证,再做预热后的多次交替计时。 固定 seed 数、坐标面积、图案、输出方式,使用 CUDA event 测 kernel,另报全流程时间。 线程粒度实验适合覆盖“少 seed/大地图”和“多 seed/小地图”两种任务,而不是预设一种策略总是胜出。
素材来源
- 当前保留方案与范围:
OPTIMIZATION_SUMMARY.md - 性能、分布与复现命令:
BENCHMARK.md - 算术与高位求解:
slime_solver_core.hpp - 运行入口和已知样例:
README.md
正文中的数字来自这些已有记录,不应写成在此次提纲整理中重新跑出的结果。