slime/show/POPULAR_SCIENCE_OUTLINE.md

371 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 从暴力搜索到位运算:寻找 Minecraft 史莱姆区块的优化之旅
这是一份视频/博客共用的叙事提纲。依据当前源码与研究记录整理,性能数字引用已有实验,没有在整理时重新测量。
后续演示代码已放在当前 `show/` 目录,见 [README.md](README.md);
本次重新测量的速度见 [BENCHMARK.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 区块网格开场:想找一片全是史莱姆区块的区域。
一个区块是否满足条件,由世界种子和区块坐标共同决定。
把这个判断看成一个确定的函数:
```text
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`](../slime.cpp)。
按“种子 → 坐标 → 图案中的每一格”解释三层循环:
```text
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`](../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`](03_cuda_position.cuh) 的 `coordinateKernel`。
固定 seed 的研究参考实现另见 [`slime_position_cuda.cu`](../research/slime_position_cuda.cu)。
把坐标矩形编号,每个 thread 固定一个锚点坐标,在内部遍历 seed 并检查完整图案。
固定 seed 时,这个循环只运行一次。
每个 thread 先计算图案需要的 `F(x+dx)` 和 `G(z+dz)`,再将这些多项式结果传给检查函数。
遍历 seed 时只计算两项之和与 Random,不重新计算坐标多项式。这里复用 thread 私有项,下一章才扩展为相邻 thread 共享轴项。
图案大小通过模板展开,私有轴项无需每次动态索引;旧/新 03 的独立样本见
[stage3_polynomial_reuse.json](results/stage3_polynomial_reuse.json),其中同时包含该编译期展开的效果。
当前实现用 grid-stride loop,因此一个 thread 也可能在之后检查其他锚点。
“一个 thread 一个位置”描述的是工作单元,不是永久绑定关系。
```text
之前: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`](../research/slime_solver_core.hpp) 的 `coordinateXTerm`、`coordinateZTerm`。
史莱姆判断的坐标部分可以写成:
```text
T(x,z) = F(x) + G(z)
```
先展示原式,再按变量分组证明这一点。记 `J32(a)` 为 Java 有符号 int 回绕,则:
```text
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`](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`](BENCHMARK.md)。
这个收益适合用来讲提前退出:大多数位置很快就失败,直接版本来就没有计算所有 25 格。
重复计算减少很多,不代表总耗时也按相同比例减少。
outer sum 仍扫描坐标面积,后面的算法才开始减少候选数量。
## 6. 转折:随机数其实是一个确定的位运算过程
源码入口:[`slime_solver_core.hpp`](../research/slime_solver_core.hpp) 的 `nextStateFromTerm`、`exactIsSlimeFromTerm`。
前面的 01–04 是实现层面的优化,仍然检查同一批候选。现在画出章节分界,开始探索 Random 的性质:
状态空间有多大?已知低位能推断什么?坐标与 seed 的约束能否分别组织?哪些未知位可以直接求解?
这部分不用“又快了多少”的表格串联,而是按“发现一个性质 → 举例验证 → 推导新搜索策略”展开。
详细推导见 [RANDOM_PROPERTIES.md](RANDOM_PROPERTIES.md)。XOR 合并只是把公式整理成方便分析的形式,不单列优化版本。
合并史莱姆公式与 Java Random 构造器中的两个 XOR,记:
```text
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`](../research/slime_position_residue_cuda.cu)。
上述首次输出奇偶条件,对 X、Z 都具有 `2^18` 的坐标周期。
先检查 `2^18 × 2^18 = 2^36` 个余数对,把通过的类展开成具体坐标,再精确验证。
必须明确:周期属于低位必要条件,完整史莱姆判断不会因此在这些坐标间完全相同。
已知 seed、两轴锚点范围 `[-1875000,1874999]`:
```text
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 位目前仍然枚举:
```text
84 种通过筛选的低 18 位取值 × 2^17 = 11,010,048 种低 35 位取值
```
不要把“逐位 lifting”讲成每一位都不断淘汰候选。
对首次输出奇偶条件,低 18 位确定后,后续高位不会改变它;这部分仍是扩展枚举。
最后的高 13 位有更强的结构:
```text
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`](../research/REJECTED_EXPERIMENTS.md)、[`FFT_EXPERIMENT.md`](../research/FFT_EXPERIMENT.md)、[X/Z 筛轴试错报告](../research/XZ_FILTER_TRIAL_REPORT.md)。
## 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 分钟)。
试错部分以已停止路线的报告为依据。博客可保留全部章节,用公式与源码链接补充推导。
每次优化都采用相同的叙述节奏:**发现重复/浪费 → 改法 → 相同任务验证 → 时间结果 → 剩余瓶颈**。
### 图表口径
使用三组独立图表,图上标出任务、硬件、规模、时间区域和“实测/外推”:
1. 联合暴力枚举:CPU、seed-thread CUDA、细粒度候选 CUDA。需要补测。
2. 固定 seed 找位置:直接扫描、outer-sum、余数过滤。分别说明小矩形实测与全范围外推。
3. 固定位置找 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`](../research/OPTIMIZATION_SUMMARY.md)
- 性能、分布与复现命令:[`BENCHMARK.md`](../research/BENCHMARK.md)
- 算术与高位求解:[`slime_solver_core.hpp`](../research/slime_solver_core.hpp)
- 运行入口和已知样例:[`README.md`](../research/README.md)
正文中的数字来自这些已有记录,不应写成在此次提纲整理中重新跑出的结果。