Compare commits

...

2 commits

25 changed files with 3982 additions and 1084 deletions

View file

@ -1,6 +1,6 @@
# 史莱姆区块搜索与 CUDA 基准
当前可直接使用的入口是 **[bench/slime-bench](bench/README.md)**:指定中心区块、曼哈顿半径和实心 `n×n` 图案,测量全部有效种子的普通候选分支搜索时间,支持单张或多张 NVIDIA GPU。
当前可直接使用的入口是 **[bench/slime-bench](bench/README.md)**:指定中心区块、曼哈顿半径和自定义图案(或实心 `n×n`),测量全部有效种子的普通候选分支搜索时间,支持单张或多张 NVIDIA GPU。
```sh
make bench
@ -8,6 +8,8 @@ make bench
--position-batch 128 --devices all --repeat 3
```
自定义图案用 `--pattern` 或 `--pattern-file`,支持史莱姆、非史莱姆和忽略格;见 [6×6 苦力怕示例](bench/README.md#自定义图案6x6-苦力怕)。
H100 编译:`make -C bench -B CUDA_ARCH=90`。需要 CUDA Toolkit 和 C++17 编译器。普通分支不包含拒绝重抽补扫,不能将它的结果声明为严格完整目录;参数和计时口径见 [CLI 使用说明](bench/README.md)。
## 目录导航

View file

@ -1,6 +1,6 @@
# slime-bench:普通分支全种子搜索基准
指定中心区块、曼哈顿半径和实心 `n×n` 图案,搜索范围内每个锚点的全部有效种子,输出 JSON 结果及计时。支持 NVIDIA CUDA 单卡、多卡。
指定中心区块、曼哈顿半径和自定义图案(或实心 `n×n`),搜索范围内每个锚点的全部有效种子,输出 JSON 结果及计时。支持 NVIDIA CUDA 单卡、多卡。
## 本地编译与运行
@ -16,6 +16,37 @@ mkdir -p artifacts
默认编译 `sm_86` 并附带 PTX,适合本机 RTX 3060 Laptop。换架构时用 `-B` 强制重建;仅改变 Make 变量不会使已有文件自动过期。
## 自定义图案:6×6 苦力怕
`--pattern` 接受 `/` 分隔的行,或用 `--pattern-file` 读取每行一排的文本文件。`#` 必须是史莱姆区块,`.` 必须不是,`?` 不限制。支持非正方形,宽、高分别为 1~64,至少含一个 `#`;不能与 `--size` 同时指定。不自动旋转或镜像。
内置示例 [patterns/creeper-6x6.txt](patterns/creeper-6x6.txt) 将脸部像素设为史莱姆:
```text
##..##
##..##
..##..
.####.
.####.
.#..#.
```
```sh
./bench/bin/slime-bench --radius 512 \
--pattern-file bench/patterns/creeper-6x6.txt \
--position-batch 128 --max-results 100 --output artifacts/creeper.json
# 也可直接输入;必须使用引号保护 #、? 等字符
./bench/bin/slime-bench --radius 512 \
--pattern '##..##/##..##/..##../.####./.####./.#..#.' --max-results 100
```
目标是上面的正色模板:眼睛、鼻子和嘴巴为史莱姆区块,背景必须不是。已检查原点半径 2048、中心 `(51069,419124)` 半径 1024、32 个分散区域各半径 256、另外 128 个分散区域各半径 512;还筛选了 65,536 个坐标,选出低位候选最多的 64 个坐标类,检查这些类在世界边界内的全部 104,538 个实际位置。累计 82,047,228 次锚点检查仍未命中(不同路线可能重叠,见[搜索记录](results/creeper-6x6-search.json))。这不证明正色不存在;不能用反色图案作为该目标的搜索结果。
每行从左到右沿 `+X`,从上到下沿 `+Z`。返回坐标仍是矩形左上角,即使该格是 `.` 或 `?`。将 `.` 改为 `?` 可只要求轮廓、允许背景出现额外史莱姆区块。JSON 包含原始 `pattern` 行、宽高及两类约束数;自定义输入的 `square_size` 为 `null`。
筛选和区间求解只使用 `#` 格,随后精确核对所有 `.` 格;`?` 不参与计算。稀疏图案的候选可能很多,搜索时间不能直接套用实心方阵的基准。搜索口径仍为普通分支,不包含拒绝重抽补扫。
## H100 服务器
将仓库内的 `bench/` 和 `include/` 一起复制到服务器,保持相对路径;CLI 依赖 `include/slime/` 中的共用头文件。
@ -53,7 +84,7 @@ abs(anchor_x - x) + abs(anchor_z - z) <= radius
```
- `x,z,radius` 的单位都是区块,不是方块。
- 锚点是图案最小 X、最小 Z 的角;图案沿 `+X,+Z` 延伸。
- 锚点是图案最小 X、最小 Z 的角;图案沿 `+X,+Z` 延伸。自定义图案的锚点是外接矩形的角。
- 半径限制锚点,图案允许超出菱形范围。
- 不自动按世界边界裁剪;锚点和整块图案须在 int32 坐标范围内,否则报错。
- `--size` 支持 1~6,默认 5。小尺寸匹配种子极多,全种子计数也可能耗时很久,不能用 1×1 推断 5×5 性能。
@ -108,7 +139,7 @@ make -C bench test # 无需 GPU:范围、输入、可变尺寸数学对
make -C bench test-gpu # 需要 GPU:实际搜索与文件输出验证
```
验证内容包括:半径 0~64 的完整菱形枚举,较大半径抽样,1~6 中多个尺寸的组 mask 对照,已知 24 个命中,小根批分拆一致性,4×4 的全部 422 个示例经独立 Java 算法验证,6×6 的已知种子子集对照,以及半径 2 批量结果与全部 13 个位置分别搜索的完整列表一致。
验证内容包括:自定义图案解析和边界、稀疏图案高位枚举对照、带忽略格及非史莱姆格的 GPU 结果与独立 Java 算法筛选对照,以及半径 0~64 的完整菱形枚举,较大半径抽样,1~6 中多个尺寸的组 mask 对照,已知 24 个命中,小根批分拆一致性,4×4 的全部 422 个示例经独立 Java 算法验证,6×6 的已知种子子集对照,以及半径 2 批量结果与全部 13 个位置分别搜索的完整列表一致。
本机实测:RTX 3060 Laptop,中心 `(0,0)`,半径 512,5×5,`--position-batch 128 --repeat 3`。525,313 个锚点,三轮均有 368,935 个低位根任务、0 个命中,扫描墙钟中位数约 **3.990 秒**。原始 JSON 在 [results/local-r512-n5.json](results/local-r512-n5.json)。另有半径 64 的快速环境验证记录;其批大小为默认 32,与上述正式样例不同。

View file

@ -0,0 +1,6 @@
##..##
##..##
..##..
.####.
.####.
.#..#.

File diff suppressed because it is too large Load diff

View file

@ -25,7 +25,25 @@ using Clock=std::chrono::steady_clock;
struct Coord{int32_t x,z;};
struct Job{uint32_t position,root;};
struct Match{uint64_t seed;int32_t x,z;};
struct Config{int32_t x=0,z=0;uint64_t radius=0;int size=5,positionBatch=32,jobBatch=2048,repeats=1;uint64_t maxResults=0;size_t cacheClasses=4096;std::string devices="0",output;bool dry=false,list=false;};
struct PatternCell{int x,z;uint8_t requirement;};
struct Pattern{int width=0,height=0,positive=0;std::vector<std::string> rows;std::vector<PatternCell> cells;};
Pattern parsePattern(std::string text){
std::replace(text.begin(),text.end(),'/','\n');
Pattern p;std::stringstream input(text);std::string row;
while(std::getline(input,row)){if(!row.empty()&&row.back()=='\r')row.pop_back();if(row.empty())throw std::runtime_error("empty pattern row");p.rows.push_back(row);}
if(p.rows.empty())throw std::runtime_error("empty pattern");
p.width=p.rows[0].size();p.height=p.rows.size();
if(p.width>64||p.height>64)throw std::runtime_error("pattern dimensions must be 1..64");
for(auto& r:p.rows)if(r.size()!=size_t(p.width))throw std::runtime_error("pattern rows must have equal width");
for(char wanted:{'#','.'})for(int z=0;z<p.height;++z)for(int x=0;x<p.width;++x){char ch=p.rows[z][x];
if(ch!='#'&&ch!='.'&&ch!='?')throw std::runtime_error("pattern characters must be #, . or ?");
if(ch==wanted)p.cells.push_back({x,z,uint8_t(ch=='#'?sr::kSlime:sr::kNonSlime)});
if(wanted=='#'&&ch=='#')++p.positive;
}
if(!p.positive)throw std::runtime_error("pattern requires at least one # cell");
return p;
}
struct Config{Pattern pattern;int32_t x=0,z=0;uint64_t radius=0;int size=5,positionBatch=32,jobBatch=2048,repeats=1;uint64_t maxResults=0;size_t cacheClasses=4096;std::string devices="0",output;bool dry=false,list=false;};
uint64_t number(const std::string& s){size_t used=0;if(s.empty()||s[0]=='-')throw std::runtime_error("expected nonnegative integer: "+s);auto n=std::stoull(s,&used);if(used!=s.size())throw std::runtime_error("invalid integer: "+s);return n;}
int32_t signed32(const std::string&s){size_t used=0;auto n=std::stoll(s,&used);if(used!=s.size()||n<INT32_MIN||n>INT32_MAX)throw std::runtime_error("invalid int32 coordinate");return int32_t(n);}
uint64_t anchors(uint64_t r){return 1+2*r*(r+1);}
@ -38,15 +56,20 @@ Coord coordinate(const Config& c,uint64_t index){
}
uint64_t classKey(Coord p){return (uint64_t(uint32_t(p.x)&65535)<<17)|(uint32_t(p.z)&131071);}
void terms(Coord p,int n,sr::CellTerm* out){uint64_t anchor=sr::coordinateTerm(p.x,p.z);for(int z=0;z<n;++z)for(int x=0;x<n;++x)out[z*n+x]={(sr::coordinateTerm(p.x+x,p.z+z)-anchor)&sr::kMask48,sr::kSlime};}
__global__ void rootsKernel(const sr::CellTerm* cells,uint32_t positions,int count,Job* out,unsigned long long* total){
void terms(Coord p,const Pattern& pattern,sr::CellTerm* out){
// The grouped filter needs its first positive term to be zero.
auto first=pattern.cells.front();uint64_t anchor=sr::coordinateTerm(p.x+first.x,p.z+first.z);size_t i=0;
for(auto cell:pattern.cells)out[i++]={(sr::coordinateTerm(p.x+cell.x,p.z+cell.z)-anchor)&sr::kMask48,cell.requirement};
}
__global__ void rootsKernel(const sr::CellTerm* cells,uint32_t positions,int count,int positive,Job* out,unsigned long long* total){
uint64_t t=uint64_t(blockIdx.x)*blockDim.x+threadIdx.x;if(t>=(uint64_t(positions)<<18))return;
uint32_t pos=t>>18,root=t&262143;const auto* p=cells+uint64_t(pos)*count;
for(int i=0;i<count;++i)if((((root+uint32_t(p[i].term))^uint32_t(sr::kCombinedXor))*uint32_t(sr::kJavaMultiplier)+11U)&(1U<<17))return;
for(int i=0;i<positive;++i)if((((root+uint32_t(p[i].term))^uint32_t(sr::kCombinedXor))*uint32_t(sr::kJavaMultiplier)+11U)&(1U<<17))return;
out[atomicAdd(total,1ULL)]={pos,root};
}
__global__ void prepareKernel(const sr::CellTerm* cells,const Job* jobs,uint32_t n,int count,uint32_t* prepared){
__global__ void prepareKernel(const sr::CellTerm* cells,const Job* jobs,uint32_t n,int count,int positive,uint32_t* prepared){
uint32_t k=blockIdx.x*blockDim.x+threadIdx.x;if(k>=n)return;const auto job=jobs[k];
for(int i=0;i<count;++i){uint64_t a=job.root+cells[uint64_t(job.position)*count+i].term;
for(int i=0;i<positive;++i){uint64_t a=job.root+cells[uint64_t(job.position)*count+i].term;
prepared[(uint64_t(k)*count+i)*2]=a>>18;
prepared[(uint64_t(k)*count+i)*2+1]=((((a^sr::kCombinedXor)&262143)*sr::kJavaMultiplier+11)>>17);
}
@ -61,22 +84,22 @@ __host__ __device__ uint32_t groupMask(const uint32_t*p,uint32_t base,int count)
return mask;
}
struct Emit{
Coord p;Match* out;uint64_t capacity;unsigned long long* total;
__device__ void operator()(uint64_t seed){uint64_t slot=atomicAdd(total,1ULL);if(slot<capacity)out[slot]={(seed-sr::coordinateTerm(p.x,p.z))&sr::kMask48,p.x,p.z};}
Coord p;uint64_t originTerm;const sr::CellTerm* negative;int negativeCount;Match* out;uint64_t capacity;unsigned long long* total;
__device__ void operator()(uint64_t seed){if(!sr::verifyPattern(seed,negative,negativeCount))return;uint64_t slot=atomicAdd(total,1ULL);if(slot<capacity)out[slot]={(seed-originTerm)&sr::kMask48,p.x,p.z};}
};
__global__ void searchKernel(const sr::CellTerm*cells,const Coord*coords,const Job*jobs,uint32_t n,int count,const uint32_t*prepared,Match*out,uint64_t capacity,unsigned long long*total){
__global__ void searchKernel(const sr::CellTerm*cells,const Coord*coords,const Job*jobs,uint32_t n,int count,int positive,int originX,int originZ,const uint32_t*prepared,Match*out,uint64_t capacity,unsigned long long*total){
uint64_t index=uint64_t(blockIdx.x)*blockDim.x+threadIdx.x;if(index>=(uint64_t(n)<<13))return;
uint32_t g=index&8191,k=index>>13,base=((g>>4)<<8)+(g&15);const uint32_t* p=prepared+uint64_t(k)*count*2;
uint32_t mask=groupMask(p,base,count);
Job job=jobs[k];Emit emit{coords[job.position],out,capacity,total};
while(mask){uint32_t j=__ffs(mask)-1;mask&=mask-1;fs::solveIntervals(job.root+(uint64_t(base+16*j)<<18),cells+uint64_t(job.position)*count,count,emit);}
uint32_t mask=groupMask(p,base,positive);
Job job=jobs[k];Coord origin=coords[job.position];Emit emit{origin,sr::coordinateTerm(origin.x+originX,origin.z+originZ),cells+uint64_t(job.position)*count+positive,count-positive,out,capacity,total};
while(mask){uint32_t j=__ffs(mask)-1;mask&=mask-1;fs::solveIntervals(job.root+(uint64_t(base+16*j)<<18),cells+uint64_t(job.position)*count,positive,emit);}
}
template<class T>struct Buffer{T* p=nullptr;explicit Buffer(size_t n){if(n)CUDA(cudaMalloc(&p,n*sizeof(T)));}~Buffer(){if(p)cudaFree(p);}Buffer(const Buffer&)=delete;};
struct Metrics{int device;std::string name;uint64_t positions=0,classes=0,cacheHits=0,jobs=0,matches=0;double setupMs=0,scanMs=0,rootMs=0,searchMs=0;Clock::time_point start,end;std::vector<Match> sample;};
struct Shared{std::atomic<uint64_t> next{0},done{0};std::atomic<int> finished{0};int ready=0,workerCount=0;std::condition_variable cv;std::atomic<bool> cancel{false};std::mutex mutex;std::string error;};
void worker(const Config& c,int device,Shared& shared,Metrics& result){try{
auto setup=Clock::now();CUDA(cudaSetDevice(device));cudaDeviceProp prop{};CUDA(cudaGetDeviceProperties(&prop,device));result.device=device;result.name=prop.name;
int count=c.size*c.size;size_t rootsCapacity=size_t(c.positionBatch)*(1U<<17);
int count=c.pattern.cells.size(),positive=c.pattern.positive;size_t rootsCapacity=size_t(c.positionBatch)*(1U<<17);
Buffer<sr::CellTerm> dcells(size_t(c.positionBatch)*count);Buffer<Coord>dcoords(c.positionBatch);Buffer<Job>droots(rootsCapacity),djobs(c.jobBatch);
Buffer<uint32_t>prep(size_t(c.jobBatch)*count*2);Buffer<unsigned long long>total(1);Buffer<Match>matches(c.maxResults);
std::map<uint64_t,std::vector<uint32_t>> cache;std::deque<uint64_t> fifo;size_t cachedRoots=0;
@ -95,9 +118,9 @@ void worker(const Config& c,int device,Shared& shared,Metrics& result){try{
if(it!=cache.end()){lists[key]=it->second;++result.cacheHits;}else{lists[key]={};missing.push_back(p);missingKeys.push_back(key);}
}
if(!missing.empty()){
auto start=Clock::now();std::vector<sr::CellTerm> cells(missing.size()*count);for(size_t i=0;i<missing.size();++i)terms(missing[i],c.size,cells.data()+i*count);
auto start=Clock::now();std::vector<sr::CellTerm> cells(missing.size()*count);for(size_t i=0;i<missing.size();++i)terms(missing[i],c.pattern,cells.data()+i*count);
CUDA(cudaMemcpy(dcells.p,cells.data(),cells.size()*sizeof(sr::CellTerm),cudaMemcpyHostToDevice));CUDA(cudaMemset(total.p,0,sizeof(*total.p)));
rootsKernel<<<unsigned((uint64_t(missing.size())<<18)/256),256>>>(dcells.p,missing.size(),count,droots.p,total.p);CUDA(cudaGetLastError());
rootsKernel<<<unsigned((uint64_t(missing.size())<<18)/256),256>>>(dcells.p,missing.size(),count,positive,droots.p,total.p);CUDA(cudaGetLastError());
unsigned long long found;CUDA(cudaMemcpy(&found,total.p,sizeof(found),cudaMemcpyDeviceToHost));if(found>rootsCapacity)throw std::runtime_error("root capacity invariant failed");
std::vector<Job> roots(found);if(found)CUDA(cudaMemcpy(roots.data(),droots.p,found*sizeof(Job),cudaMemcpyDeviceToHost));
for(auto job:roots)lists[missingKeys[job.position]].push_back(job.root);
@ -112,12 +135,12 @@ void worker(const Config& c,int device,Shared& shared,Metrics& result){try{
std::vector<Coord> active;std::vector<sr::CellTerm> cells;std::vector<Job> jobs;jobs.reserve(c.jobBatch);
for(uint32_t i=0;i<num;++i)if(!lists.at(keys[i]).empty())active.push_back(coords[i]);
if(!active.empty()){
cells.resize(active.size()*count);for(size_t i=0;i<active.size();++i)terms(active[i],c.size,cells.data()+i*count);
cells.resize(active.size()*count);for(size_t i=0;i<active.size();++i)terms(active[i],c.pattern,cells.data()+i*count);
CUDA(cudaMemcpy(dcells.p,cells.data(),cells.size()*sizeof(sr::CellTerm),cudaMemcpyHostToDevice));CUDA(cudaMemcpy(dcoords.p,active.data(),active.size()*sizeof(Coord),cudaMemcpyHostToDevice));
auto search=[&](){if(jobs.empty()||shared.cancel)return;auto start=Clock::now();
CUDA(cudaMemcpy(djobs.p,jobs.data(),jobs.size()*sizeof(Job),cudaMemcpyHostToDevice));CUDA(cudaMemset(total.p,0,sizeof(*total.p)));
prepareKernel<<<unsigned((jobs.size()+127)/128),128>>>(dcells.p,djobs.p,jobs.size(),count,prep.p);
searchKernel<<<unsigned((uint64_t(jobs.size())<<13)/256),256>>>(dcells.p,dcoords.p,djobs.p,jobs.size(),count,prep.p,matches.p,c.maxResults,total.p);CUDA(cudaGetLastError());
prepareKernel<<<unsigned((jobs.size()+127)/128),128>>>(dcells.p,djobs.p,jobs.size(),count,positive,prep.p);
searchKernel<<<unsigned((uint64_t(jobs.size())<<13)/256),256>>>(dcells.p,dcoords.p,djobs.p,jobs.size(),count,positive,c.pattern.cells.front().x,c.pattern.cells.front().z,prep.p,matches.p,c.maxResults,total.p);CUDA(cudaGetLastError());
unsigned long long found;CUDA(cudaMemcpy(&found,total.p,sizeof(found),cudaMemcpyDeviceToHost));if(found>UINT64_MAX-result.matches)throw std::runtime_error("match count exceeds uint64");result.matches+=found;result.jobs+=jobs.size();
size_t take=std::min<uint64_t>(found,c.maxResults-result.sample.size());if(take){size_t old=result.sample.size();result.sample.resize(old+take);CUDA(cudaMemcpy(result.sample.data()+old,matches.p,take*sizeof(Match),cudaMemcpyDeviceToHost));}
result.searchMs+=std::chrono::duration<double,std::milli>(Clock::now()-start).count();jobs.clear();};
@ -130,12 +153,15 @@ void worker(const Config& c,int device,Shared& shared,Metrics& result){try{
}catch(const std::exception& e){shared.cancel=true;std::lock_guard<std::mutex> lock(shared.mutex);shared.error="GPU "+std::to_string(device)+": "+e.what();shared.cv.notify_all();}}
std::string escape(const std::string&s){std::ostringstream o;for(unsigned char ch:s){if(ch=='"'||ch=='\\')o<<'\\'<<char(ch);else if(ch<32)o<<"\\u"<<std::hex<<std::setw(4)<<std::setfill('0')<<int(ch)<<std::dec;else o<<char(ch);}return o.str();}
void help(){std::cout<<R"(slime-bench --radius R [options]
Search all effective 48-bit seeds for solid n x n slime chunk squares.
Search effective 48-bit seeds for slime chunk patterns.
Ordinary candidate branch only; rejection-tail supplement is NOT included.
--x X --z Z Center chunk coordinates (default 0,0)
--radius R Manhattan radius in chunks (required, >=0)
--size N Square side, 1..6 (default 5)
--size N Solid square side, 1..6 (default 5)
--pattern TEXT Rows separated by /; # slime, . non-slime, ? ignored
--pattern-file FILE Same pattern, one row per line (dimensions 1..64)
Requires at least one #; exclusive with --size
--devices 0,1,... CUDA device indices, or all (default 0)
--repeat N Complete passes; fresh root cache each pass (default 1)
--position-batch N Anchors per scheduled batch, 1..256 (default 32)
@ -153,20 +179,28 @@ this diamond. Coordinates are not clipped to Minecraft's world border.
Small squares can have enormous numbers of matching seeds and take very long.
)";}
int main(int argc,char**argv){try{
Config c;bool radiusSet=false;for(int i=1;i<argc;++i){std::string a=argv[i];if(a=="--help"||a=="-h"){help();return 0;}if(a=="--dry-run"){c.dry=true;continue;}if(a=="--list-devices"){c.list=true;continue;}
Config c;bool radiusSet=false,sizeSet=false;std::string patternText;bool patternSet=false;for(int i=1;i<argc;++i){std::string a=argv[i];if(a=="--help"||a=="-h"){help();return 0;}if(a=="--dry-run"){c.dry=true;continue;}if(a=="--list-devices"){c.list=true;continue;}
if(i+1>=argc)throw std::runtime_error("missing value for "+a);std::string v=argv[++i];
auto bounded=[&](uint64_t lo,uint64_t hi){auto n=number(v);if(n<lo||n>hi)throw std::runtime_error("out of range: "+a);return n;};
if(a=="--x")c.x=signed32(v);else if(a=="--z")c.z=signed32(v);else if(a=="--radius"){c.radius=bounded(0,INT32_MAX);radiusSet=true;}
else if(a=="--size")c.size=bounded(1,6);else if(a=="--devices")c.devices=v;else if(a=="--repeat")c.repeats=bounded(1,1000);
else if(a=="--size"){c.size=bounded(1,6);sizeSet=true;}
else if(a=="--pattern"||a=="--pattern-file"){if(patternSet)throw std::runtime_error("specify only one pattern");patternSet=true;
if(a=="--pattern")patternText=v;else{std::ifstream file(v);if(!file)throw std::runtime_error("cannot read pattern: "+v);std::ostringstream text;text<<file.rdbuf();patternText=text.str();}}else if(a=="--devices")c.devices=v;else if(a=="--repeat")c.repeats=bounded(1,1000);
else if(a=="--position-batch")c.positionBatch=bounded(1,256);else if(a=="--job-batch")c.jobBatch=bounded(1,65536);
else if(a=="--cache-classes")c.cacheClasses=bounded(0,1000000);else if(a=="--max-results")c.maxResults=bounded(0,1000000);
else if(a=="--output")c.output=v;else throw std::runtime_error("unknown option: "+a);
}
if(c.list){int n;CUDA(cudaGetDeviceCount(&n));for(int i=0;i<n;++i){cudaDeviceProp p{};CUDA(cudaGetDeviceProperties(&p,i));std::cout<<i<<"\t"<<p.name<<"\tsm_"<<p.major<<p.minor<<"\t"<<p.totalGlobalMem/(1ULL<<20)<<" MiB\n";}return 0;}
if(!radiusSet)throw std::runtime_error("--radius is required");
for(int64_t center:{int64_t(c.x),int64_t(c.z)})if(center-int64_t(c.radius)<INT32_MIN||center+int64_t(c.radius)+c.size-1>INT32_MAX)throw std::runtime_error("coordinates including square must fit int32");
if(patternSet&&sizeSet)throw std::runtime_error("--size and pattern options are mutually exclusive");
if(!patternSet){for(int z=0;z<c.size;++z){if(z)patternText+='/';patternText+=std::string(c.size,'#');}}
c.pattern=parsePattern(patternText);
for(auto axis:{std::make_pair(c.x,c.pattern.width),std::make_pair(c.z,c.pattern.height)})
if(int64_t(axis.first)-int64_t(c.radius)<INT32_MIN||int64_t(axis.first)+int64_t(c.radius)+axis.second-1>INT32_MAX)throw std::runtime_error("coordinates including pattern must fit int32");
if(!c.output.empty()&&std::ifstream(c.output).good())throw std::runtime_error("output already exists: "+c.output);
std::ostringstream report;report<<std::setprecision(12)<<"{\"schema_version\":1,\"mode\":\"ordinary_all_effective_seeds\",\"rejection_tail_included\":false,\"center\":["<<c.x<<","<<c.z<<"],\"radius_chunks\":"<<c.radius<<",\"square_size\":"<<c.size<<",\"anchors\":"<<anchors(c.radius)<<",\"effective_seed_bits\":48,\"dry_run\":"<<(c.dry?"true":"false");
std::ostringstream report;report<<std::setprecision(12)<<"{\"schema_version\":1,\"mode\":\"ordinary_all_effective_seeds\",\"rejection_tail_included\":false,\"center\":["<<c.x<<","<<c.z<<"],\"radius_chunks\":"<<c.radius<<",\"square_size\":"<<(patternSet?"null":std::to_string(c.size))<<",\"anchors\":"<<anchors(c.radius)<<",\"effective_seed_bits\":48,\"dry_run\":"<<(c.dry?"true":"false");
report<<",\"pattern_width\":"<<c.pattern.width<<",\"pattern_height\":"<<c.pattern.height<<",\"slime_cells\":"<<c.pattern.positive<<",\"non_slime_cells\":"<<c.pattern.cells.size()-c.pattern.positive<<",\"pattern\":[";
for(size_t i=0;i<c.pattern.rows.size();++i){if(i)report<<",";report<<"\""<<c.pattern.rows[i]<<"\"";}report<<"]";
if(c.dry){auto first=coordinate(c,0),last=coordinate(c,anchors(c.radius)-1);report<<",\"first_anchor\":["<<first.x<<","<<first.z<<"],\"last_anchor\":["<<last.x<<","<<last.z<<"]}";}
else{
int available;CUDA(cudaGetDeviceCount(&available));std::vector<int> devices;if(c.devices=="all"){for(int i=0;i<available;++i)devices.push_back(i);}else{

View file

@ -26,8 +26,31 @@ def main():
assert r['anchors']==1+2*radius*(radius+1)
assert r['first_anchor']==[-123,456-radius] and r['last_anchor']==[-123,456+radius]
for bad in [[],['--radius','-1'],['--radius','1','--size','0'],['--radius','1','--size','7'],['--radius','abc'],['--radius','0','--x','2147483647'],['--radius','1','--repeat','0'],['--radius','1','--position-batch','0']]:run(*bad,'--dry-run',ok=False)
summary={'cli_scope_and_errors':True}
pattern='?##./#..?/..#?'
r=run('--radius',0,'--pattern',pattern,'--dry-run')
assert r['pattern']==pattern.split('/') and r['square_size'] is None
assert (r['pattern_width'],r['pattern_height'],r['slime_cells'],r['non_slime_cells'])==(4,3,4,5)
for bad_pattern in ['', '.', '??', '#/##', '#x', '#//.', '#/'+'#'*65]:
run('--radius',0,'--pattern',bad_pattern,'--dry-run',ok=False)
run('--radius',0,'--size',5,'--pattern','#','--dry-run',ok=False)
run('--radius',0,'--pattern','#','--pattern','#','--dry-run',ok=False)
run('--radius',0,'--pattern-file','/nonexistent-pattern','--dry-run',ok=False)
run('--radius',0,'--x',2147483647,'--pattern','?#','--dry-run',ok=False)
r=run('--radius',0,'--x',2147483647,'--pattern','#/#','--dry-run')
with tempfile.TemporaryDirectory() as tmp:
path=Path(tmp)/'pattern.txt';path.write_bytes(b'?##.\r\n#..?\r\n..#?\r\n')
assert run('--radius',0,'--pattern-file',path,'--dry-run')['pattern']==pattern.split('/')
summary={'cli_scope_and_errors':True,'pattern_inputs':True}
if args.gpu:
# A mixed-mask regression fixture; this is not the target creeper pattern.
fixture=json.loads((HERE/'testdata/mixed-mask-regression.json').read_text())
face=run('--x',fixture['chunk_x'],'--z',fixture['chunk_z'],'--radius',0,
'--pattern','/'.join(fixture['pattern']),'--max-results',1000)
hits=face['rounds'][0]
assert not hits['sample_truncated'] and any(s==fixture['seed48'] for s,x,z in hits['sample'])
for seed,x,z in hits['sample']:
assert all(slime(seed,x+dx,z+dz)==(ch=='#') for dz,row in enumerate(fixture['pattern']) for dx,ch in enumerate(row))
summary['mixed_mask_verified_matches']=hits['matches']
common=['--x',51069,'--z',419124]
reference=run(*common,'--radius',0,'--size',5,'--repeat',2,'--max-results',100)
expected={61692151016558,15959335054446}
@ -45,6 +68,36 @@ def main():
wanted={t for t in samples[0] if all(slime(t[0],t[1]+dx,t[2]+dz) for dx in range(n) for dz in range(n))}
assert set(map(tuple,r['rounds'][0]['sample']))==wanted
summary[f'size_{n}']=r['rounds'][0]['matches']
# The same 5x5 positive constraints, shifted inside a non-square mask.
# Empty anchor exercises normalization independently of the first #.
rows=['?'*7]+['?'+'#'*5+'.' for _ in range(5)]+['?.?????']
pattern='/'.join(rows)
masked=run('--x',51068,'--z',419123,'--radius',0,'--pattern',pattern,'--max-results',100)
wanted={(seed,51068,419123) for seed,x,z in samples[0]
if all(ch=='?' or slime(seed,51068+dx,419123+dz)==(ch=='#')
for dz,row in enumerate(rows) for dx,ch in enumerate(row))}
assert wanted and set(map(tuple,masked['rounds'][0]['sample']))==wanted
assert masked['rounds'][0]['matches']==len(wanted)
# Identical positive subset, but ignored rather than forbidden background.
ignored=pattern.replace('.', '?')
loose=run('--x',51068,'--z',419123,'--radius',0,'--pattern',ignored,'--max-results',100)
assert {s for s,x,z in loose['rounds'][0]['sample']}=={s for s,x,z in samples[0]}
# Force a forbidden cell to reject at least one known positive candidate.
seed=next(iter(samples[0]))[0]
dx,dz=next((dx,dz) for dz in range(16) for dx in range(16)
if not (1<=dx<=5 and 1<=dz<=5) and slime(seed,51068+dx,419123+dz))
rejection=[list('?'*16) for _ in range(16)]
for z in range(1,6):
for x in range(1,6):rejection[z][x]='#'
rejection[dz][dx]='.'
rejected=run('--x',51068,'--z',419123,'--radius',0,'--pattern',
'/'.join(''.join(row) for row in rejection),'--max-results',100)
expected_seeds={s for s,x,z in samples[0] if not slime(s,51068+dx,419123+dz)}
assert len(expected_seeds)<24
assert {s for s,x,z in rejected['rounds'][0]['sample']}==expected_seeds
assert rejected['rounds'][0]['matches']==len(expected_seeds)
summary['mixed_pattern_vs_independent_filter']=len(wanted)
summary['forbidden_cell_excludes_candidates']=True
diamond=run(*common,'--radius',2,'--size',5,'--max-results',1000,'--position-batch',3)
split=[]
for dz in range(-2,3):

View file

@ -2,9 +2,17 @@
#include "slime_bench.cu"
#undef main
#include <random>
struct HostEmit{
const sr::CellTerm* cells;int count;std::set<uint64_t>* result;
__host__ __device__ void operator()(uint64_t seed){
#ifndef __CUDA_ARCH__
if(sr::verifyPattern(seed,cells,count))result->insert(seed);
#endif
}
};
void check(bool ok,const char*msg){if(!ok)throw std::runtime_error(msg);}
int main(){
uint64_t points=0,groups=0;std::mt19937_64 rng(123456);
uint64_t points=0,groups=0,mixed=0;std::mt19937_64 rng(123456);
for(int r=0;r<=64;++r){Config c;c.x=-123;c.z=98;c.radius=r;std::set<std::pair<int32_t,int32_t>> actual;
for(uint64_t i=0;i<anchors(r);++i){auto p=coordinate(c,i);actual.emplace(p.x,p.z);}
check(actual.size()==anchors(r),"duplicate anchor");
@ -21,5 +29,36 @@ int main(){
uint32_t expected=0;for(int j=0;j<16;++j)if(fs::differenceFilter(root+(uint64_t(base+16*j)<<18),cells.data(),count))expected|=1U<<j;
check(groupMask(prepared.data(),base,count)==expected,"variable-size group mask mismatch");++groups;
}
std::cout<<"{\"diamond_points\":"<<points<<",\"variable_size_groups\":"<<groups<<",\"passed\":true}\n";
// Compare arbitrary sparse layouts and forbidden cells against all high bits.
for(int t=0;t<128;++t){
Coord coord{int32_t(rng()),int32_t(rng())};
// Keep offsets away from overflow even for signed boundary samples.
coord.x/=2;coord.z/=2;
uint64_t world=rng()&sr::kMask48;
std::string text;
for(int z=0;z<5;++z){if(z)text+='/';for(int x=0;x<7;++x)
text+=(x==0&&z==0)||rng()%4==0?'?':sr::exactIsSlimeFromTerm(world,sr::coordinateTerm(coord.x+x,coord.z+z))?'#':'.';}
if(text.find('#')==std::string::npos)continue;
auto pattern=parsePattern(text);int count=pattern.cells.size();
std::vector<sr::CellTerm> cells(count);terms(coord,pattern,cells.data());
uint64_t normalized=(world+sr::coordinateTerm(coord.x+pattern.cells.front().x,coord.z+pattern.cells.front().z))&sr::kMask48;
check(sr::verifyPattern(normalized,cells.data(),count),"normalized pattern mismatch");
uint64_t low=normalized&((1ULL<<35)-1);std::set<uint64_t> actual,expected;
HostEmit emit{cells.data()+pattern.positive,count-pattern.positive,&actual};
fs::solveIntervals(low,cells.data(),pattern.positive,emit);
for(uint64_t high=0;high<8192;++high){uint64_t seed=low+(high<<35);
// Ordinary branch requires a successful first draw at positive cells.
bool ordinary=true;for(int i=0;i<pattern.positive;++i)
if((sr::nextStateFromTerm(seed,cells[i].term)>>17)%10!=0)ordinary=false;
if(ordinary&&sr::verifyPattern(seed,cells.data(),count))expected.insert(seed);
}
check(actual==expected,"mixed pattern high-bit solver mismatch");++mixed;
uint32_t root=low&262143,base=uint32_t(low>>18)&~240U;
std::vector<uint32_t> prepared(count*2);
for(int i=0;i<pattern.positive;++i){uint64_t a=root+cells[i].term;
prepared[2*i]=a>>18;prepared[2*i+1]=((((a^sr::kCombinedXor)&262143)*sr::kJavaMultiplier+11)>>17);}
uint32_t wanted=0;for(int j=0;j<16;++j)if(fs::differenceFilter(root+(uint64_t(base+16*j)<<18),cells.data(),pattern.positive))wanted|=1U<<j;
check(groupMask(prepared.data(),base,pattern.positive)==wanted,"sparse pattern group mask mismatch");
}
std::cout<<"{\"diamond_points\":"<<points<<",\"variable_size_groups\":"<<groups<<",\"mixed_pattern_slices\":"<<mixed<<",\"passed\":true}\n";
}

View file

@ -0,0 +1,26 @@
{
"pattern": [
"..##..",
"..##..",
"##..##",
"#....#",
"#....#",
"#.##.#"
],
"seed48": 100568620033461,
"chunk_x": -137,
"chunk_z": -53,
"independent_verification": true,
"search": {
"center": [
0,
0
],
"radius_chunks": 256,
"anchors": 131585,
"matches": 1248,
"scan_wall_ms": 2968.438239,
"gpu": "NVIDIA GeForce RTX 3060 Laptop GPU",
"rejection_tail_included": false
}
}

View file

@ -1,8 +1,8 @@
# 共用算法头文件
- `slime/solver_core.hpp`:Java 整数回绕、48 位随机数、精确史莱姆判定、低位约束和原高位求解器。
- `slime/upper_interval.hpp`:输出差必要条件与高位区间求交,用于实心图案的普通候选分支。
- `slime/upper_interval.hpp`:输出差必要条件与高位区间求交,用于正约束(可稀疏图案)的普通候选分支;CLI 在发出结果前核对非史莱姆约束。
CLI 直接依赖这里。`research/slime_solver_core.hpp` 和原实验目录的 `interval_solver.hpp` 仅转发包含,以便已有研究程序、教学代码和历史复现命令继续工作。
本次只迁移实现位置,保留现有命名空间及算法语义。精确验证支持 Java 重试,不代表普通候选生成已经包含拒绝尾部补扫。
保留现有命名空间及算法语义。精确验证支持 Java 重试,不代表普通候选生成已经包含拒绝尾部补扫。

View file

@ -13,8 +13,9 @@
namespace full_search_experiment {
namespace sr = slime_research;
// Solid patterns only. Each surviving residue has a single interval.
// Keeping just the anchor's two residues makes intersection constant-space.
// Positive (slime) constraints only; positions may be sparse.
// Each surviving residue has a single interval. Keeping the first cell's
// two residues makes intersection constant-space. Check negative cells on emit.
template<class Emit>
FS_HD FS_INLINE void solveIntervals(uint64_t low, const sr::CellTerm* cells,
int count, Emit& emit) {

View file

@ -72,7 +72,7 @@ GPU:NVIDIA GeForce RTX 3060 Laptop GPU。结果完全一致,命中 1 个元
本组改为固定 seed,重新测量 03 后再比较 04。03、04 均保持固定坐标后遍历 seed 的策略。
随机数性质的探索独立成章,见 [RANDOM_PROPERTIES.md](RANDOM_PROPERTIES.md)。从状态空间、低位依赖和模运算约束推导搜索方法,不列为局部计时优化。
随机数性质的探索独立成章,见 [DOCUMENT.md](DOCUMENT.md)。从状态空间、低位依赖和模运算约束推导搜索方法,不列为局部计时优化。
## 2. 固定 seed:全坐标范围余数过滤

881
show/DOCUMENT.md Normal file
View file

@ -0,0 +1,881 @@
# 史莱姆区块搜索:从 CPU 枚举到种子解析求解
本文从单个区块的判定开始,依次介绍 CPU 枚举、CUDA 并行、坐标项复用,再推导当前 [`bench/slime-bench`](../bench/README.md) 搜索算法的数学原理。前四个版本说明如何让同一批检查执行得更快;后面的算法利用 Random 的结构,减少需要检查的候选。
当前算法的核心思路是:**先用输出奇偶筛掉不可能的低位,再成组排除不可能的中间位,最后通过区间求交求出高位。** 给定一个位置,找出让指定区域全部成为史莱姆区块的世界种子;给定位置范围,就对范围内各位置执行这套搜索。
候选生成要求每格“首次随机输出能被 10 整除”。最终执行完整 Java 判断,保证输出的种子确实满足要求;当前入口仍可能漏掉依赖拒绝重抽的解,具体边界见第 14 节。
正文给出一般性的公式和推导;引用块中的“贯穿案例”统一使用同一个实心 5×5 窗口,在有效种子归一化一节引入后逐步展开。性能表引用已有实验,分别注明任务与计时范围。算法固定常量、位数与枚举范围属于一般性内容,符号与常量集中列在[文末总表](#17-符号与常量总表)。
## 0. 史莱姆区块的判定流程
本文研究的是 Java 版的史莱姆区块判定。一个区块在水平方向覆盖 $16\times16$ 个方块;下面的 $(x,z)$ 都是**区块坐标**,不是方块坐标。
判定由**世界种子和区块坐标**共同决定:先计算坐标项 $T(x,z)$,与世界种子组合后初始化 `Random`,再调用 `nextInt(10)`。**返回 0 就是史莱姆区块,返回 1 到 9 则不是。**
先看直接使用 `Random` 的版本:
```java
import java.util.Random;
boolean isSlimeChunk(long worldSeed, int x, int z) {
long coordinateTerm = (long) (x * x * 4987142)
+ (long) (x * 5947611)
+ (long) (z * z) * 4392871L
+ (long) (z * 389711);
long randomSeed = (worldSeed + coordinateTerm) ^ 987234911L;
return new Random(randomSeed).nextInt(10) == 0;
}
```
再展开 `Random` 内部的过程:初始化 48 位状态,按“乘 $A$、加 11、保留低 48 位”更新,取高 31 位,再取余得到 0 到 9;极少数输出需要拒绝重抽。下面的版本与上面的判定等价:
```java
boolean isSlimeChunk(long worldSeed, int x, int z) {
final long A = 0x5DEECE66DL;
final long mask48 = (1L << 48) - 1; // 低 48 位全为 1
// 先计算坐标项 T(x, z),保留原公式的 int 溢出行为。
long coordinateTerm = (long) (x * x * 4987142)
+ (long) (x * 5947611)
+ (long) (z * z) * 4392871L
+ (long) (z * 389711);
// 1. 史莱姆公式先做一次 XOR,Random 初始化再做一次 XOR。
long randomSeed = (worldSeed + coordinateTerm) ^ 987234911L;
long u = (randomSeed ^ A) & mask48;
// 合并写法:u = ((worldSeed + coordinateTerm) ^ C) & mask48
// 其中 C = 987234911L ^ A。
while (true) {
// 2. 更新状态:乘 A、加 11,只保留低 48 位。
long v = (A * u + 11) & mask48;
// 3. 丢掉低 17 位,取高 31 位。
int q = (int) (v >>> 17);
// 4. 取余,并检查这次输出是否可以接受。
int value = q % 10;
if ((long) q - value + 9 <= 0x7FFFFFFFL) {
return value == 0;
}
// 本次输出被拒绝,从刚得到的状态继续取数。
u = v;
}
}
```
代码中的 `^` 是按位异或,`& mask48` 保留低 48 位,`>>> 17` 取高 31 位。
后文沿用代码中的符号:$A$ 是固定乘数,$C$ 是两次 XOR 合并后的常量;$u$、$v$ 是更新前后的状态,$q$ 是原始输出。
### 坐标项与 Java 整数回绕
坐标 $(x,z)$ 指区块坐标。先用一个记号表示 Java 有符号 32 位整数回绕:
$$J_{32}(\xi)=((\xi+2^{31})\bmod2^{32})-2^{31}.$$
它表示乘法溢出后保留低 32 位,再按有符号数解释。
本文的 `mod` 一律取非负余数。模 $2^n$ 相当于只保留低 $n$ 位,其中 $n$ 表示保留的位数。
原始坐标项是:
$$
T(x,z)=J_{32}(4987142x^2)+J_{32}(5947611x)
+4392871J_{32}(z^2)+J_{32}(389711z).
$$
X 的平方与系数相乘后才转换为 long;Z 的平方在乘系数前就转换为 long。因此不能把它随意改成没有回绕的二次多项式。
**相同种子与坐标总会得到相同结果。** 单个区块成为史莱姆区块的概率约为 $1/10$,相邻区块却不能直接视为独立。本项目反向利用这些关系,搜索让给定窗口内所有区块都返回 0 的世界种子。
## 1. CPU:按种子枚举位置
最直接的方法是枚举世界种子和区块坐标,调用第 0 节的判定函数。要找一片全部满足要求的区域,就检查该位置及其周围各格;只要一格失败,这个候选便可提前退出:
```text
for worldSeed in seeds:
for (x, z) in positions:
allSlime = true
for (dx, dz) in requiredOffsets:
if not isSlimeChunk(worldSeed, x + dx, z + dz):
allSlime = false
break
if allSlime:
record(worldSeed, x, z)
```
`requiredOffsets` 列出待检查区域内各格相对 `(x, z)` 的偏移。`break` 只结束当前区域的逐格检查,随后继续检查下一个位置。
CPU 多线程把种子区间分给不同工作线程。每个线程拿到一批种子,逐个扫描同一张坐标地图;不同候选之间互不依赖,适合并行处理。
这一步的主要成本是候选太多。即使绝大多数候选在前几格就失败,也仍要逐个访问“种子数 × 位置数”这么多种组合。
早期入口是根目录的 [`slime.cpp`](../slime.cpp)。用于受控比较的教学版本是 [`01_cpu.cpp`](01_cpu.cpp),它与下面的 CUDA 版本使用相同的 Java 算术、搜索边界及完整命中记录规则。
## 2. CUDA:每个线程负责一个种子
第一版 CUDA 保留上面的循环结构,让一个 GPU 线程负责一个种子,在线程内部扫描整个坐标矩形:
```text
GPU thread(worldSeed):
for (x, z) in positions:
check and record this candidate
```
GPU 可以同时安排大量这样的任务。这里的 thread 是执行一份任务的线程,block 是一组线程,kernel 是 GPU 执行的函数。把种子分给更多线程,便能同时检查更多候选。
这一版改变了执行硬件,搜索的候选集合保持一致,每个线程仍要完成一整张地图的扫描。早期实现见 [`slime.cu`](../slime.cu),统一比较所用的版本见 [`02_cuda_seed.cuh`](02_cuda_seed.cuh)。
> **实测对照 A:CPU 与按种子分工的 CUDA。** 同样搜索 65536 个种子、257×257 个候选位置,目标都是实心 5×5;两版完整命中列表相同。
>
> | 版本 | 搜索时间中位数 |
> | --- | ---: |
> | CPU,16 个逻辑线程 | 3550.398 ms |
> | CUDA,每线程一个种子 | 111.110 ms |
>
> 约加速 31.95 倍。CPU 时间包含线程创建、搜索与结果收集;GPU 使用 scan 时间,含清零、同步及回传,不含一次性的上下文创建和显存分配。GPU 为 RTX 3060 Laptop,详细记录见 [BENCHMARK.md](BENCHMARK.md)。
## 3. CUDA:固定坐标,在线程内遍历种子
还可以交换两层循环的分工:让一个工作单元固定一个坐标,在线程内部遍历种子。实现使用 grid-stride loop,一个线程完成当前坐标后,还可能继续处理其他坐标。
```text
之前:thread(worldSeed) -> 扫描全部坐标
之后:thread(x, z) -> 遍历全部种子
```
这样,位置数量也能提供并行任务。种子较少、地图很大时,尤其能避免只有少量线程各自承担很长的扫描循环。
坐标固定后,还能复用与种子无关的计算。将第 0 节的坐标项按变量分组:
$$F(x)=J_{32}(4987142x^2)+J_{32}(5947611x),$$
$$G(z)=4392871J_{32}(z^2)+J_{32}(389711z),$$
$$T(x,z)=F(x)+G(z).$$
$F$ 只依赖 X,$G$ 只依赖 Z,拆分后仍严格保留原来的整数回绕规则。每个线程先算出待检查区域需要的全部 X 项和 Z 项,再遍历种子,反复使用这些结果。
这一版见 [`03_cuda_position.cuh`](03_cuda_position.cuh)。它还按图案大小展开模板,便于编译器处理私有轴项与检查循环。因此下面的实测同时包含分工调整、坐标项复用和编译期展开的效果。
> **实测对照 B:在更大的地图上比较两种分工。** 同样搜索 65536 个种子、4097×4097 个位置、实心 5×5,重新测量 02 与 03:
>
> | 版本 | GPU kernel 时间中位数 |
> | --- | ---: |
> | 02,每线程一个种子 | 31598.539 ms |
> | 03,固定坐标并复用轴项 | 17600.387 ms |
>
> 约加速 1.795 倍,完整 GPU 命中列表相同。独立 CPU 核对其中的 257×257 范围,所有 GPU 命中再用独立 Java 算术核验。这组地图大于 A 组,两组之间不计算速度比。
交换分工能否获益,还取决于任务规模、寄存器用量和调度成本。两版仍检查相同的种子与位置组合,也都保留逐格提前退出。
## 4. Outer sum:相邻线程共享坐标轴项
上一版的轴项只供当前线程使用。相邻位置需要的 X、Z 坐标大量重叠,还可以让相邻线程共享它们。
由于 $F$ 只依赖 X、$G$ 只依赖 Z,完整坐标项满足:
$$T(x,z)=F(x)+G(z).$$
把所有 X 项排成一列、Z 项排成一行,每个交点就是对应两项之和。这种“每个横轴项加上每个纵轴项”就是 outer sum。能拆分的是进入 Random 之前的坐标项,最终的史莱姆判定仍要逐格执行。
[`04_cuda_outer_sum.cuh`](04_cuda_outer_sum.cuh) 让一个 block 处理 16×16 个候选位置。每条轴需要的项数为“16 + 目标区域的边长 − 1”,这些项放入 shared memory,供整个 block 使用。
当目标为 5×5 时,每条轴需要 20 个项,总共 40 个轴项计算,就能供这块区域的线程复用。后续的轴项相加、Random 运算和图案检查仍然需要执行;节省的是重复的坐标多项式计算。
> **实测对照 C:线程私有轴项与 block 共享轴项。** 固定一个种子,扫描 8193×8193 个位置,目标仍为实心 5×5;重新测量 03,再与 04 比较:
>
> | 版本 | GPU kernel 时间中位数 |
> | --- | ---: |
> | 03,线程私有轴项 | 2.715 ms |
> | 04,outer sum 共享轴项 | 1.598 ms |
>
> 约加速 1.698 倍,完整命中列表相同。这是 100 轮交替测量的中位数。该组只搜索一个种子,不能与 B 组跨任务计算加速比。
CPU、按种子分工、按坐标分工和 outer sum 这四个版本,都在加快同一类枚举检查。接下来转向另一个问题:利用 Random 的确定性,提前判断哪些种子不可能成功,并直接求出部分未知位。
outer sum 的共享轴项实现属于这段演进中的教学版本。当前 `bench` 仍逐格计算完整坐标项;后面的数学推导会利用 $T=F+G$ 的恒等式,但没有把这段 block 共享实现接入最终搜索入口。
## 5. Random 状态更新与 48 位等价关系
### Random 状态更新与首次输出
设 $w$ 是世界种子的低 48 位。合并史莱姆公式和 Random 初始化里的两次 XOR:
$$
A=\mathtt{0x5DEECE66D},\qquad
C=A\mathbin{\mathrm{xor}}987234911=\mathtt{0x5E434E432}.
$$
对单个区块 $(x,z)$,首次输出由三步得到:
$$u=((w+T(x,z))\mathbin{\mathrm{xor}}C)\bmod2^{48},$$
$$v=(Au+11)\bmod2^{48},$$
$$q=v\gg17.$$
$u$ 是初始化状态,$v$ 是推进一次后的状态,$q$ 是取出状态高 31 位得到的整数,范围为 $0\le q<2^{31}$。右移 17 位就是丢掉低 17 位。
如果首次输出被接受,`nextInt(10)` 返回 $q\bmod10$;返回 0 表示史莱姆区块。因此首次输出分支的候选条件是 $q\equiv0\pmod{10}$。
### 世界种子的 48 位等价关系
Random 初始化只保留低 48 位。世界种子相差 $2^{48}$ 的整数倍,在相同坐标上就得到相同的完整 Random 序列,包括重抽。因此可以先搜索 $w\in[0,2^{48})$;一个 $w$ 对应 64 位世界种子的 $2^{16}$ 种高位取值。
这只是史莱姆判断等价,不能据此说整个 Minecraft 世界相同。
## 6. 有效种子归一化
### 从有效种子反推世界种子
先只看一个固定区块 $(x,z)$。上节的公式中,世界种子与坐标项总是以 $w+T(x,z)$ 的形式出现。把这个和记为**有效种子**:
$$s=(w+T(x,z))\bmod2^{48}.$$
于是初始化状态可以直接写成:
$$u=(s\mathbin{\mathrm{xor}}C)\bmod2^{48}.$$
对固定坐标,$T(x,z)$ 已知。我们可以先寻找满足输出条件的 $s$,再反推世界种子:
$$w=(s-T(x,z))\bmod2^{48}.$$
这是一一对应的换元,本身不删掉任何候选。它把搜索顺序改成了“先求有效种子,再减去坐标项恢复世界种子”。
### 从单个区块扩展到窗口
实际要找的是让一整片区块同时满足条件的世界种子。因此指定一个窗口,取最小 X、最小 Z 的格子为锚点,并把刚才定义 $s$ 时使用的 $(x,z)$ 选在这个锚点上。
> **贯穿案例:固定一个实心 5×5 窗口。**
>
> 全文都搜索锚点 $(51069,419124)$ 的窗口:$x=51069,\ldots,51073$,$z=419124,\ldots,419128$,要求全部 25 格都是史莱姆区块。按 X 方向先编号:$i=5\Delta z+\Delta x$,其中 $\Delta x,\Delta z\in\{0,1,2,3,4\}$。
>
> | $z\backslash x$ | 51069 | 51070 | 51071 | 51072 | 51073 |
> | --- | ---: | ---: | ---: | ---: | ---: |
> | 419124 | 0(锚点) | 1 | 2 | 3 | 4 |
> | 419125 | 5 | 6 | 7 | 8 | 9 |
> | 419126 | 10 | 11 | 12 | 13 | 14 |
> | 419127 | 15 | 16 | 17 | 18 | 19 |
> | 419128 | 20 | 21 | 22 | 23 | 24 |
>
> 接下来计算各格相对锚点的坐标项差,再跟随一个低位根及其中一组中间位,最终恢复完整种子。局部展示两三格时,格子仍取自这张表;是否满足窗口要求,始终以全部 25 格为准。
### 各格相对锚点的坐标项
窗口内各格共享同一个 $w$,但坐标项不同。既然 $s$ 已经吸收了锚点的坐标项,计算其他格子时,只需再补上“该格坐标项减去锚点坐标项”的差。把这个差记为 $d_i$,称为**相对坐标项**。
为简写公式,将第 $i$ 格的坐标项记为 $T_i$,锚点编号为 0,坐标项就是 $T_0$。这样:
$$s=(w+T_0)\bmod2^{48},\qquad d_i=(T_i-T_0)\bmod2^{48}.$$
锚点满足 $d_0=0$,各格则满足:
$$w+T_i\equiv s+d_i\pmod{2^{48}}.$$
给状态和输出也加上格子下标后,每格统一按以下公式计算:
$$u_i=((s+d_i)\mathbin{\mathrm{xor}}C)\bmod2^{48},$$
$$v_i=(Au_i+11)\bmod2^{48},\qquad q_i=v_i\gg17.$$
搜索目标就是找到同一个 $s$,让窗口内所有格子都满足首次输出条件。求到后仍按原来的逆变换恢复:
$$w=(s-T_0)\bmod2^{48}.$$
这就是以锚点为基准的**有效种子归一化**。它也让不同位置的低位条件可以按坐标类复用,具体见第 8 节。
> **贯穿案例:锚点的整数回绕。** 锚点 $x=51069$ 的第一项在不考虑溢出时为 $4987142\times51069^2=13006679591179062$。按 Java 的 32 位整数计算,结果是 $J_{32}(13006679591179062)=-1799531722$。这个回绕值才参与后面的坐标项求和。
> **贯穿案例:前两格的坐标项。** 将锚点和右邻格坐标代入,得到:
>
> $$T_0=T(51069,419124)=-1883366190470623,$$
>
> $$T_1=T(51070,419124)=-1883363608967202.$$
>
> 后面所有相对坐标项都从同一组 25 个 $T_i$ 计算。
> **贯穿案例:把锚点吸收到有效种子里。** 由上面的坐标项:
>
> $$s=(w-1883366190470623)\bmod2^{48},$$
>
> $$d_0=0,\qquad d_1=T_1-T_0=2581503421.$$
>
> 因而右邻格统一从 $s+2581503421$ 初始化。现在 $w$、$s$ 都还未知,下一步先求 $s$ 的低 18 位。
## 7. 基于输出奇偶的低 18 位筛选
把待求的有效种子拆成:
```text
高 13 位 h | 中间 17 位 m | 低 18 位 p
```
$$s=p+2^{18}m+2^{35}h,$$
$$0\le p<2^{18},\quad0\le m<2^{17},\quad0\le h<2^{13}.$$
一个数能被 10 整除,必须先是偶数。$q_i=v_i\gg17$,所以 $q_i$ 的最低位就是 $v_i$ 的第 17 位,位编号从 0 开始。
加法、乘法和逐位 XOR 的低 18 位只依赖输入的低 18 位,高位不会反过来影响低位。因此只用 $p$ 就能判断所有格子的首次输出奇偶:计算状态的低 18 位,并要求每格都满足下面的条件:
$$
[A((p+d_i)\mathbin{\mathrm{xor}}C)+11]\bmod2^{18}<2^{17}.
$$
这里可以先用完整整数做加法、XOR 和乘法,最后统一取模:这些运算的低 18 位只依赖输入的低 18 位,所以不必对 $p+d_i$ 和 $C$ 分别取模。末尾的模 $2^{18}$ 仍需保留,条件检查的是状态低 18 位,而不是完整整数的大小。
小于 $2^{17}$ 就表示第 17 位为 0。某格不满足,该 $p$ 对应的全部中间位、高位都可以直接排除。
实际执行时枚举 $p=0,1,\ldots,262143$,通过所有格子的 $p$ 保存为低位根,也就是代码里的 `root`。单格恰好保留一半,因为加法、XOR、奇数乘法在模 $2^{18}$ 下都是一一对应的变换。
这一步保证首次输出都是偶数,还没有保证能被 5 整除。每根仍有 $2^{17}=131072$ 种中间位,随后继续筛选和求解。不同坐标的根数可能不同,不能假设各格条件互相独立、每加一格都恰好减半。
> **实测案例:5×5 窗口的低位候选数量。**
>
> 锚点 $(51069,419124)$ 的实心 5×5 窗口,最后保留 **84 个低位根**,约占原来 262144 个取值的 0.032%。归一化改变根的具体编号,但不改变根的数量。
>
> 每根补上全部中间位后,一共需要考虑:
>
> $$84\times131072=11010048$$
>
> 种低 35 位取值。84 和 11010048 是这个具体窗口的结果,不是所有位置的固定候选数量。
>
> 后面跟随其中的 $p=218767$。代入筛选式,前两格的状态低 18 位分别是 59524、80305,都小于 $2^{17}=131072$;其余 23 格也通过。先保留这个根,再搜索它对应的全部中间位;此时尚不知道哪个 $m,h$ 会成功。
## 8. 坐标类周期与低位根复用
对固定窗口形状,第 $i$ 格相对锚点的偏移记为 $(\alpha_i,\beta_i)$。由坐标项的可分离性:
$$d_i=F(x+\alpha_i)-F(x)+G(z+\beta_i)-G(z)\pmod{2^{48}}.$$
低位筛选只看 $d_i\bmod2^{18}$。Java 回绕与符号扩展不会改变低 18 位,所以推导这部分时可以直接在模 $2^{18}$ 下展开多项式:
$$
\begin{aligned}
d_i\equiv{}&4987142(2x\alpha_i+\alpha_i^2)+5947611\alpha_i\\
&+4392871(2z\beta_i+\beta_i^2)+389711\beta_i\pmod{2^{18}}.
\end{aligned}
$$
将锚点 X 增加 $2^{16}=65536$,相对项的变化为:
$$2\cdot4987142\cdot2^{16}\alpha_i.$$
4987142 是偶数,所以变化是 $2^{18}$ 的整数倍。将 Z 增加 $2^{17}=131072$,变化为:
$$2\cdot4392871\cdot2^{17}\beta_i,$$
也必然是 $2^{18}$ 的整数倍。因此可用:
$$(x\bmod65536,\ z\bmod131072)$$
作为坐标类。同类位置所有 $d_i$ 的低 18 位相同,**有效种子的完整低位根列表也相同**。
程序为每个未缓存的类筛一次根,再把列表用于该类具体位置。无根类的具体位置直接跳过;有根位置才生成后续任务。缓存有大小上限。
共享的是低位根,不是最终种子。完整 $d_i$ 的高位仍可能不同,后面的中间位、高位条件需要为每个具体位置分别计算。这一步属于有效种子低位筛选的复用,没有另加一轮固定世界种子的坐标余数对过滤。
## 9. 高 13 位引起的输出共同平移
先固定 $p,m$。用 $q_i(h)$ 表示高位取 $h$ 时的首次输出,并定义基准输出:
$$\bar q_i=q_i(0).$$
横线表示固定低 35 位、取 $h=0$ 时的基准值,不表示平均值。再记:
$$\ell=p+2^{18}m,\qquad s=\ell+2^{35}h.$$
选择在第 35 位拆分,是因为 $C<2^{35}$,XOR 只翻转低 35 位,不碰高 13 位。
这里不能忽略坐标加法的进位。将 $h=0$ 时的输入写成:
$$(\ell+d_i)\bmod2^{48}=\lambda_i+2^{35}\kappa_i,$$
其中 $0\le \lambda_i<2^{35}$、$0\le \kappa_i<8192$。$\kappa_i$ 已包含坐标项及其进位。加上 $2^{35}h$ 后,$\lambda_i$ 不变,高位变成 $(\kappa_i+h)\bmod8192$。
因为 XOR 不动这段高位,初始化状态满足:
$$u_i(h)=(u_i(0)+2^{35}h)\bmod2^{48}.$$
乘 $A$ 并加 11 后,先把状态更新公式完整写出:
$$
\begin{aligned}
v_i(h)
&=(Au_i(h)+11)\bmod2^{48}\\
&=(Au_i(0)+Ah\,2^{35}+11)\bmod2^{48}.
\end{aligned}
$$
而 $h=0$ 时的基准状态已经是:
$$v_i(0)=(Au_i(0)+11)\bmod2^{48}.$$
所以 **11 已经包含在 $v_i(0)$ 里面**,代入后才简写成:
$$v_i(h)=(v_i(0)+Ah\,2^{35})\bmod2^{48}.$$
这里比较的是两种高位取值带来的变化:两边都加同一个 11,因此变化量只有 $Ah\,2^{35}$;状态本身仍包含加 11 的结果。
新增量是 $2^{35}$ 的整数倍,不会改变右移时丢掉的低 17 位,所以:
$$q_i(h)=(\bar q_i+Ah\,2^{18})\bmod2^{31}.$$
**所有格子的输出都加上同一个量,再在 $2^{31}$ 处回绕。** 这让我们既能提前检查格子之间的输出差,又能解析求高位。
> **贯穿案例:同一窗口的共同平移。** 后面会筛出 $p=218767,m=40302$。固定这两段时,前两格的基准输出为 $\bar q_0=227860608$、$\bar q_1=1920935636$。若高位取 $h=2995$,共同增量取模后为:
>
> $$Ah\,2^{18}\bmod2^{31}=886833152.$$
>
> 因而 $q_0=1114693760$,$q_1=(1920935636+886833152)\bmod2^{31}=660285140$。右邻格发生了回绕,锚点没有,但两格都遵循同一个平移公式。这里先展示这条关系;第 13 节再说明这些中间位和高位如何得到。
这条关系来自 XOR 常量与未知高位分处不同位域,不能理解为一般 XOR 具有加法线性。若切在第 34 位,$C$ 仍会翻转未知部分的一位,上面的平移推导就不成立。
## 10. 基于输出差的低 35 位过滤
共同平移会改变各格输出,却不改变它们的模差。固定位置、低位根 $p$ 和中间位 $m$ 后,模差与高位 $h$ 无关。定义:
$$\delta_i=(\bar q_i-\bar q_0)\bmod2^{31}.$$
如果存在某个高位 $h$,使两格最终输出都是 10 的倍数,那么它们的直接差也是 10 的倍数。若差非负,模差除以 10 余 0;若差为负,取模需要加上 $2^{31}$,而:
$$2^{31}\bmod10=8.$$
所以必要条件是:
$$\delta_i\bmod10\in\{0,8\}.$$
> **贯穿案例:右邻格的模输出差。** 沿用上一节的 $p,m,h$,两格的实际输出差为负:
>
> $$q_1-q_0=660285140-1114693760=-454408620.$$
>
> 加上 $2^{31}$ 后得到 $\delta_1=1693075028$,除以 10 余 8,因此通过差过滤。若反向相减,正差 $454408620$ 除以 10 余 0。
>
> 用基准输出计算也得到 $(1920935636-227860608)\bmod2^{31}=1693075028$。这正是“不必先知道 $h$,也能检查模差”的意义;其余 23 格仍要分别检查。
模差的余数不属于 $\{0,8\}$,就可以断定当前低 35 位无论怎样补高位都不会成功。
通过所有差条件仍不代表存在共同高位,因为这些条件没有决定每格的回绕情况是否能同时满足。还要做第 13 节的区间求交。
当前实现用第 12 节的组 mask 执行这个筛选,结果与逐个检查差条件完全相同。
> **实测案例:输出差过滤的幸存数量。**
>
> 锚点 $(51069,419124)$ 的实心 5×5 窗口,在 11010048 种低 35 位中,只有 **36 种通过全部输出差条件**。
## 11. 每个低位根的公共项预计算
先看单个中间位怎样快速算出 $\bar q_i$。固定位置和根 $p$,令:
$$\sigma_i=p+d_i,\qquad \eta_i=\sigma_i\bmod2^{18},\qquad \gamma_i=\lfloor \sigma_i/2^{18}\rfloor.$$
加入中间位后,输入就是:
$$\ell+d_i=\eta_i+2^{18}(\gamma_i+m).$$
令 $E=C\gg18=\mathtt{0x1790d}$。XOR 可按位拆开:
$$
(\ell+d_i)\mathbin{\mathrm{xor}}C
=(\eta_i\mathbin{\mathrm{xor}}(C\bmod2^{18}))
+2^{18}((\gamma_i+m)\mathbin{\mathrm{xor}}E).
$$
这里只是低 18 位与高位分别做 XOR,不是把 XOR 分配到加法上。超出 48 位的部分最终取模即可。
把低位推进带来的贡献记为:
$$\omega_i=\left\lfloor\frac{A(\eta_i\mathbin{\mathrm{xor}}(C\bmod2^{18}))+11}{2^{17}}\right\rfloor.$$
于是首次输出精确等于:
$$\bar q_i(m)=[\omega_i+2A((\gamma_i+m)\mathbin{\mathrm{xor}}E)]\bmod2^{31}.$$
$\gamma_i,\omega_i$ 都不依赖 $m$,每个 `(位置, 根)` 只需准备一次。后面重复使用它们,减少 131072 个中间位里的重复计算。
实现只需要最终结果的低 31 位,可以把公共项存为 32 位无符号整数,用 32 位回绕计算再保留低 31 位。这与公式中的模运算等价,也减少重复的 64 位乘法。
$\gamma_i$ 表示固定根下的输入高位,$\omega_i$ 表示低位计算产生的输出贡献;它们都会随位置和根改变,但不随中间位 $m$ 改变。
> **贯穿案例:为根 $p=218767$ 准备公共项。** 前两格的值为:
>
> | 格子 $i$ | $d_i$ | $\eta_i$ | $\gamma_i$ | $\omega_i$ |
> | --- | ---: | ---: | ---: | ---: |
> | 0 | 0 | 218767 | 0 | 46624833586 |
> | 1 | 2581503421 | 128076 | 9848 | 13419656886 |
>
> 这些值算一次即可用于这个根的全部中间位。代入 $m=40302$,有 $(40302\mathbin{\mathrm{xor}}96525)=124003$、$((9848+40302)\mathbin{\mathrm{xor}}96525)=113387$,从而:
>
> $$\bar q_0=[46624833586+2A\times124003]\bmod2^{31}=227860608,$$
>
> $$\bar q_1=[13419656886+2A\times113387]\bmod2^{31}=1920935636.$$
>
> $\omega_i$ 是中间贡献项,可以超过 $2^{31}$;最终的 $\bar q_i$ 才取模到 31 位输出范围内。
## 12. 中间位的 16 候选成组筛选
### 中间位分组规则
$E=\mathtt{0x1790d}$ 的第 4~7 位恰好全是 0。让 $m$ 的这四位变化,其余位固定,就得到一组 16 个值:
$$m_j=\mathrm{base}+16j,\qquad j=0,\ldots,15.$$
base 的第 4~7 位必须是 0。全部中间位可以无遗漏、无重复地分成 8192 组:
$$\mathrm{base}=256\lfloor g/16\rfloor+(g\bmod16),\qquad g=0,\ldots,8191.$$
> **贯穿案例:包含 $m=40302$ 的那一组。** 固定根仍为 $p=218767$。取 $g=2526$,得到 $\mathrm{base}=40206$,整组为:
>
> $$m_j=40206+16j=40206,40222,\ldots,40446.$$
>
> 其中 $j=6$ 对应 $m=40302$。这一组的低四位都为 14,第 4~7 位依次取遍 0~15,其余位相同。
### 组内输出差的分段常量性质
这里的“一段”是针对某个格子 $i$ 而言:在一组候选中,$\gamma_i+m_j$ 没有跨过 256 边界的连续部分。不同格子的 $\gamma_i$ 不同,分界位置也可能不同。下面分步说明这一段中的输出差为什么不变。
**输入只在四个 bit 上变化。** 从候选 $j$ 换到 $j+1$,中间位增加 16:
$$m_{j+1}=m_j+16.$$
给一个整数加 16,不会改变它的低四位。如果没有跨过 256 的边界,第 8 位及以上也不变;变化只发生在第 4~7 位。这四位在 $E$ 中全是 0,因此 XOR 不会翻转它们。其余位经过 XOR 后虽然可能改变,但在同一段内仍是固定的。
所以,在两个候选同属这一段的前提下,XOR 后的整数也恰好增加 16:
$$
((\gamma_i+m_{j+1})\mathbin{\mathrm{xor}}E)
=((\gamma_i+m_j)\mathbin{\mathrm{xor}}E)+16.
$$
这里的等式需要“不跨边界”这个前提;一般情况下,XOR 后的增量不一定等于 XOR 前的增量。
**该格的输出每次增加同一个量。** 将上式代入预计算后的输出公式:
$$\bar q_i(m_j)=[\omega_i+2A((\gamma_i+m_j)\mathbin{\mathrm{xor}}E)]\bmod2^{31}.$$
$\omega_i$ 不随 $j$ 改变,XOR 后的输入每次增加 16,所以输出满足:
$$\bar q_i(m_{j+1})\equiv \bar q_i(m_j)+32A \pmod{2^{31}}.$$
也就是输出每次都加 $2A\times16=32A$,再取模。
**锚点输出也增加同一个量。** 锚点 $d_0=0$,所以 $\gamma_0=0$,它的输入就是 $m_j$。base 的第 4~7 位为 0,整组只是将这四位依次取遍,因此锚点输入在整组内都不跨 256 边界。对锚点同样有:
$$\bar q_0(m_{j+1})\equiv \bar q_0(m_j)+32A \pmod{2^{31}}.$$
**相减时,两个增量抵消。** 回到我们实际要筛选的模输出差:
$$
\begin{aligned}
\delta_i(m_{j+1})
&\equiv \bar q_i(m_{j+1})-\bar q_0(m_{j+1})\\
&\equiv [\bar q_i(m_j)+32A]-[\bar q_0(m_j)+32A]\\
&\equiv \bar q_i(m_j)-\bar q_0(m_j)\\
&\equiv \delta_i(m_j) \pmod{2^{31}}.
\end{aligned}
$$
两边的 $\delta_i$ 都取 $[0,2^{31})$ 内的非负余数,因此同余也就意味着数值相等。**同一段里,各格输出都在变化,但该格与锚点的输出增加得一样快,所以两者的模差保持不变。** 即使其中一个输出先在 $2^{31}$ 处回绕,取模后的差仍然相同。
**跨边界时,差值可能切换。** 当 $\gamma_i+m_j$ 跨过 256 边界,第 8 位及以上发生进位,这些位可能被 XOR 翻转。跨界那一步的输出增量不再保证为 $32A$,因此模输出差可能改变。跨界之后,输入又只在第 4~7 位变化,差值便再次保持不变。
一组的跨度只有 $16\times15=240<256$,每格最多跨一次边界。因此,这一格的整组模输出差最多有两个值:边界前一个,边界后一个;也可能两个值相同,或者整组没有跨界。
> **贯穿案例:右邻格在同一组中的两段差值。** 仍用 $\mathrm{base}=40206$。由预计算表,右邻格 $\gamma_1=9848$,锚点 $\gamma_0=0$,且 $E=96525$:
>
> | $j$ | $m_j$ | $9848+m_j$ | 锚点 XOR 后 | 右邻格 XOR 后 | XOR 后两输入之差 |
> | ---: | ---: | ---: | ---: | ---: | ---: |
> | 0 | 40206 | 50054 | 123907 | 113291 | −10616 |
> | 1 | 40222 | 50070 | 123923 | 113307 | −10616 |
> | 7 | 40318 | 50166 | 124019 | 113403 | −10616 |
> | 8 | 40334 | 50182 | 124035 | 113931 | −10104 |
> | 15 | 40446 | 50294 | 124147 | 114043 | −10104 |
>
> $j=0,\ldots,7$ 时,两列 XOR 结果每步都增加 16,差固定为 $-10616$。从 $j=7$ 到 8,右邻格输入跨过 256 的整数倍 $50176$,该格 XOR 结果增加 528,锚点仍只增加 16,所以差切换为 $-10104$;之后又保持不变。
>
> 表格最后一列仍是输入差。代入输出公式,才得到真正要筛选的模输出差:
>
> $$\delta_1(m_j)=\begin{cases}
> (13419656886-46624833586-2A\times10616)\bmod2^{31}=1693075028,&j=0,\ldots,7,\\
> (13419656886-46624833586-2A\times10104)\bmod2^{31}=411302484,&j=8,\ldots,15.
> \end{cases}$$
>
> 前段除以 10 余 8,通过;后段余 4,淘汰。仅检查右邻格,就能一次保留前 8 个、排除后 8 个中间位。
### 16 位掩码的构造与求交
只需算出组首、组尾与锚点的模差,并找到进位分界,就知道整组哪些 $j$ 满足余 0 或 8。
设 $\mathrm{offset}_i=(\mathrm{base}+\gamma_i)\bmod256$,第一个跨边界的索引是:
$$\mathrm{cut}_i=\left\lceil\frac{256-\mathrm{offset}_i}{16}\right\rceil.$$
$\mathrm{cut}_i$ 的范围为 1~16。$j<\mathrm{cut}_i$ 使用前段差值,$j\ge \mathrm{cut}_i$ 使用后段差值;$\mathrm{cut}_i=16$ 表示组内没有跨边界。
程序用一个 16 位 mask 表示通过情况:第 $j$ 位为 1,表示 $m_j$ 还可能成功。每格构造允许 mask,再逐格按位 AND;变成 0 就整组退出。
> **贯穿案例:同一组的掩码逐步求交。** 右邻格允许 $j=0,\ldots,7$,掩码为 `0x00ff`。继续检查窗口内各格,挑出会进一步缩小结果的几步:
>
> | 检查的格子 | 该格允许的 $j$ | 该格掩码 | 累计交集 |
> | --- | --- | --- | --- |
> | $i=1$ | 0~7 | `0x00ff` | 0~7 |
> | $i=3$ | 2~15 | `0xfffc` | 2~7 |
> | $i=4$ | 3~15 | `0xfff8` | 3~7 |
> | $i=8$ | 4~15 | `0xfff0` | 4~7 |
> | $i=9$ | 6~15 | `0xffc0` | 6、7 |
>
> 表中略去的格子也都参与检查,但没有继续缩小累计交集。检查完全部 25 格,最终 mask 是 `0x00c0`,只剩 $m_6=40302$、$m_7=40318$。下一节先沿 $m=40302$ 求高位;$40318$ 也会走相同流程。
最后仅对 mask 中剩余的 $m_j$ 求解高位。
这一步仍覆盖全部 131072 个中间位。它把逐候选差检查改成每根 8192 个组任务,每组工作量更大,不能直接说速度提升了 16 倍。
## 13. 高 13 位的区间求解
对组筛选留下的一个 $m$,固定 $\ell=p+2^{18}m$,计算各格 $h=0$ 时的输出并拆分:
$$\bar q_i=r_i+2^{18}b_i,\qquad 0\le r_i<2^{18},\quad0\le b_i<8192.$$
$r_i$ 是输出的低 18 位,$b_i$ 是输出的高 13 位;它们都是已知数。种子的高位 $h$ 仍是未知数。由共同平移关系:
$$q_i(h)=r_i+2^{18}[(b_i+Ah)\bmod8192].$$
### 高位换元与模逆恢复
令:
$$\theta=Ah\bmod8192.$$
$A$ 是奇数,与 $8192=2^{13}$ 互质,所以乘 $A$ 只是重新排列 8192 个取值,既不重复也不遗漏。具体的逆元是:
$$A\bmod8192=1645,\qquad1645\times4965\equiv1\pmod{8192}.$$
因此求到 $\theta$ 后,可以恢复:
$$h=4965\theta\bmod8192.$$
### 单格的区间与同余条件
记 $\phi_i=(b_i+\theta)\bmod8192$。低位筛选已保证 $r_i$ 为偶数,只需再满足模 5 条件。因为 $2^{18}\equiv-1\pmod5$:
$$q_i=r_i+2^{18}\phi_i\equiv r_i-\phi_i\pmod5.$$
所以要 $\phi_i\equiv r_i\pmod5$。$b_i+\theta$ 最多回绕一次,回绕点为 $\tau_i=8192-b_i$:
| $\theta$ 的范围 | $\phi_i$ 的值 | 要求 |
| --- | --- | --- |
| $[0,\tau_i)$ | $b_i+\theta$ | $\theta\equiv r_i-b_i\pmod5$ |
| $[\tau_i,8192)$ | $b_i+\theta-8192$ | $\theta\equiv r_i-b_i+2\pmod5$ |
第二条多出 2,是因为 $8192\bmod5=2$。区间 $[\mathrm{lo},\mathrm{hi})$ 包含下界、不包含上界;若 $b_i=0$,第二条为空。
### 多格条件的区间求交
从锚点的两条条件开始。对后续格子,余数相同的区间可以求交:
$$[\mathrm{lo},\mathrm{hi})\cap[\mathrm{lo}',\mathrm{hi}')=[\max(\mathrm{lo},\mathrm{lo}'),\min(\mathrm{hi},\mathrm{hi}')).$$
余数不匹配就删除。每格的两个余数相差 2,对一个既定余数最多只有一段能匹配,所以求交不会继续分裂,始终最多保留两条。
区间非空还不够,里面必须有满足模 5 条件的整数。
对于余数 $\rho\in\{0,1,2,3,4\}$,第一个满足条件的数是:
$$\theta_{\min}=\mathrm{lo}+((\rho-\mathrm{lo})\bmod5).$$
若 $\theta_{\min}\ge \mathrm{hi}$,也删除这一条。两条都被删掉,就立即判定这组低 35 位没有解。
最后按 $\theta_{\min},\theta_{\min}+5,\ldots<\mathrm{hi}$ 枚举实际解,换回 $h$。整个过程无需先收集并排序所有格子的回绕点,也无需逐个尝试 8192 种高位。
### 贯穿案例的高位求解与种子恢复
> **贯穿案例:从 $m=40302$ 求出高位。** 固定 $p=218767,m=40302$,前两格的输出拆分为:
>
> | 格子 | $\bar q_i$ | $r_i$ | $b_i$ | 回绕点 $\tau_i$ |
> | --- | ---: | ---: | ---: | ---: |
> | 0 | 227860608 | 57472 | 869 | 7323 |
> | 1 | 1920935636 | 206548 | 7327 | 865 |
>
> 代入单格条件,锚点要求:$[0,7323)$ 中 $\theta\bmod5=3$,或 $[7323,8192)$ 中 $\theta\bmod5=0$。右邻格要求:$[0,865)$ 中 $\theta\bmod5=1$,或 $[865,8192)$ 中 $\theta\bmod5=3$。
>
> 只有余数相同的两段能求交,因此前两格共同留下 $[865,7323)$,余数为 3。继续加入窗口内其余格子,区间逐步缩小:
>
> | 已检查到的格子 | 保留区间 | $\theta\bmod5$ |
> | --- | --- | ---: |
> | $i=1$ | $[865,7323)$ | 3 |
> | $i=2$ | $[865,3544)$ | 3 |
> | $i=3$ | $[3202,3544)$ | 3 |
> | $i=13$ | $[3202,3506)$ | 3 |
> | $i=19$ | $[3382,3506)$ | 3 |
> | $i=22$ | $[3382,3440)$ | 3 |
> | 全部 25 格 | $[3382,3440)$ | 3 |
>
> 其余步骤不再缩小区间。第一个同余解为 $\theta_{\min}=3382+((3-3382)\bmod5)=3383$,所以这一条低 35 位候选共有 12 个高位解:
>
> $$\theta=3383,3388,\ldots,3438.$$
>
> 取第一个 $\theta=3383$,得到:
>
> $$h=4965\times3383\bmod8192=2995,$$
>
> $$s=218767+2^{18}\times40302+2^{35}\times2995=102917981558415,$$
>
> $$w=(s-T_0)\bmod2^{48}=15959335054446.$$
>
> 前两格此时 $\phi_0=4252$、$\phi_1=2518$,输出为 $q_0=1114693760$、$q_1=660285140$,都能被 10 整除。这与第 9、10 节预先展示的数值一致;下一节再核验整个 5×5。
## 14. 种子恢复、精确验证与覆盖边界
对每个高位解拼出:
$$s=p+2^{18}m+2^{35}h.$$
先用 $s$ 和相对坐标项 $d_i$ 逐格执行完整 `nextInt(10)` 判断。通过后再恢复世界种子:
$$w=(s-T_0)\bmod2^{48}.$$
完整判断与单次取模有一个差别:Java 为避免取模偏差,会拒绝首次输出的最后 8 个值,即:
$$q\ge2^{31}-8=2147483640,$$
并继续推进状态、重新取输出,可能不止重抽一次。
因此,首次输出虽能被 10 整除,也可能被拒绝后重抽失败;准确验证会剔除它。反过来,首次输出不满足模 10 条件,却可能在拒绝后重抽成功;这类种子可能在候选阶段已被淘汰,验证不能补回。
所以当前算法**保证记录的种子正确,但不保证找齐完整 Java 语义下的所有解**。独立拒绝补扫尚未接入 `bench`,不属于本文的算法步骤。
> **贯穿案例:核验完整 5×5。** 将刚恢复的 $w=15959335054446$ 代入第 0 节代码,25 格首次原始输出 $q_i$ 如下:
>
> | $z\backslash x$ | 51069 | 51070 | 51071 | 51072 | 51073 |
> | --- | ---: | ---: | ---: | ---: | ---: |
> | 419124 | 1114693760 | 660285140 | 2105382940 | 47575410 | 1796905160 |
> | 419125 | 2074383590 | 1410817530 | 1734889590 | 2009625200 | 440710690 |
> | 419126 | 1664890290 | 537178720 | 1699969350 | 2115254790 | 688097620 |
> | 419127 | 1842255790 | 1387847170 | 1302453480 | 1264531310 | 344660 |
> | 419128 | 1757217670 | 204531470 | 2132545510 | 1606583470 | 569937090 |
>
> 每个数都能被 10 整除,并且都小于拒绝区间起点 $2147483640$,因此全部被接受,`nextInt(10)` 都返回 0。这个世界种子确实让所选窗口的 25 格全是史莱姆区块。
## 15. 完整执行流程与 GPU 任务划分
前文为了先说明成组筛选的依据,提前推导了高位平移;程序并不需要先枚举高位。实际执行顺序如下:
1. 遍历给定范围内的锚点,计算坐标类键。
2. 对未缓存的类生成全部低 18 位根;同类位置复用列表,无根位置跳过。
3. 对有根的具体位置计算完整相对项 $d_i$,展开 `(位置, 根)` 任务。
4. 每个任务预计算 $\gamma_i,\omega_i$,供所有中间位使用。
5. 将中间位分为 8192 组,用输出差的 16 位 mask 筛选;只保留幸存的 $m$。
6. 每个幸存 $m$ 用区间求交求 $\theta$,再恢复高位 $h$。
7. 拼出 $s$,完整验证,恢复并记录 $w$。
GPU 的搜索线程对应一个 `(位置, 根, 中间位组)`,线程内处理 mask 中的幸存者。多卡分配锚点批次,各自维护有限的类根缓存。这些并行安排不改变数学条件。
各步骤省掉的工作不同:归一化统一输入;low18 排除整片种子;类缓存避免重复筛根;公共项避免重复算术;组 mask 共用差过滤;区间求交代替高位穷举;最终验证保证输出符合原始判断。
## 16. 候选数量与优化效果
> **实测案例:已知窗口的候选数量链。**
>
> 已知锚点 $(51069,419124)$、实心 5×5 的候选数量链是:
>
> $$262144\text{ 个低位取值}\longrightarrow84\text{ 个根}$$
>
> $$\longrightarrow11010048\text{ 种低 35 位}\longrightarrow36\text{ 个差过滤幸存者}$$
>
> $$\longrightarrow24\text{ 个最终世界种子低 48 位结果。}$$
>
> 36 统计的是低 35 位候选,24 统计的是最终完整种子。本案例中,36 个差过滤幸存者的低位根都是 $p=218767$;只有 $m=40302$、$40318$ 能继续求出高位,各产生 12 个解,共 24 个。前文沿第一条分支的第一个解展开,其余 23 个结果也经过相同验证。
> **实测案例:同一个 5×5 窗口的性能对照。** 以下只列已有报告中锚点 $(51069,419124)$ 的测量,本次没有重跑 GPU 性能基准:
>
> | 优化 | 同窗口搜索时间 | 对照效果 |
> | --- | --- | --- |
> | 输出差过滤+区间求交 | 63.750 → 约 0.324 ms | 相对旧回绕事件排序版本约 197 倍 |
> | 公共项预计算 | 0.323584 → 0.268288 ms | 约 1.21 倍 |
> | 在预计算基础上增加组 mask | 0.268288 → 0.156672 ms | 再约 1.71 倍;后两项合计约 2.07 倍 |
>
> 这里统计搜索阶段,不含筛根、初始化和传输;后两行包括每轮公共项准备。首行与后两行来自不同轮次的对照实验,不能把倍数连乘成实测整体加速。
类根复用优化的是跨位置的批次任务,单个固定窗口不足以测量其复用收益,因此这里不混入另一批坐标的数值。批次性能及适用条件见[批次流程报告](../research/experiments/normal_search/PIPELINE_REPORT.md)。
代码对应关系:原始算术与验证在 [`solver_core.hpp`](../include/slime/solver_core.hpp);区间求交在 [`upper_interval.hpp`](../include/slime/upper_interval.hpp);类缓存、公共项准备、组 mask 和 GPU 调度在 [`slime_bench.cu`](../bench/slime_bench.cu)。
数量与实测来源:[成组筛选报告](../research/experiments/normal_search/REPORT.md)、[批次流程报告](../research/experiments/normal_search/PIPELINE_REPORT.md)、[输出差与区间对照报告](../research/experiments/full_search_feasibility/REPORT.md)。历史 CPU/GPU 与 outer sum 演进仍见[视频工程](video/README.md),不作为当前算法的额外步骤。
## 17. 符号与常量总表
全文每个符号只承担一种含义,区间端点统一写成 $\mathrm{lo},\mathrm{hi}$。下标 $i$ 表示格子,$i=0$ 表示锚点;下标 $j$ 表示组内候选。以下列出一般推导使用的符号,贯穿案例中的具体赋值不属于固定定义。
### 种子、坐标与随机输出
| 符号 | 含义与定义 | 取值范围或说明 |
| --- | --- | --- |
| $x,z$ | 区块坐标;相对坐标推导中为锚点坐标 | Java 有符号 32 位整数 |
| $i$ | 窗口内格子编号 | 0 为锚点 |
| $\alpha_i,\beta_i$ | 第 $i$ 格相对锚点的 X、Z 偏移 | 由窗口形状确定 |
| $J_{32}(\xi)$ | 将整数 $\xi$ 回绕为有符号 32 位整数 | $[-2^{31},2^{31})$;$\xi$ 仅表示该函数的整数输入 |
| $F(x),G(z)$ | 坐标项的横轴部分、纵轴部分 | 严格保留 Java 回绕规则 |
| $T(x,z),T_i$ | 完整坐标项;$T_i$ 是第 $i$ 格的坐标项 | $T=F+G$;$T_0$ 为锚点坐标项 |
| $d_i$ | 相对坐标项,$(T_i-T_0)\bmod2^{48}$ | $[0,2^{48})$,$d_0=0$ |
| $w$ | 世界种子的低 48 位,最终输出 | $[0,2^{48})$ |
| $s$ | 归一化有效种子,$(w+T_0)\bmod2^{48}$ | $[0,2^{48})$;搜索对象 |
| $p$ | 有效种子的低 18 位;通过筛选后称为根 `root` | $[0,2^{18})$ |
| $m$ | 有效种子的中间 17 位 | $[0,2^{17})$ |
| $h$ | 有效种子的高 13 位 | $[0,2^{13})$;$s=p+2^{18}m+2^{35}h$ |
| $\ell$ | 有效种子的低 35 位,$p+2^{18}m$ | $[0,2^{35})$ |
| $u,v$;$u_i,v_i$ | Random 初始化状态、推进一次后的状态 | $[0,2^{48})$;下标 $i$ 指定窗口格子,参数 $(h)$ 表示随高位变化 |
| $q$;$q_i$ | 首次 31 位输出,$v\gg17$;带下标时为 $v_i\gg17$ | $[0,2^{31})$;$q_i(h)$ 表示高位为 $h$ 时的输出 |
| $\bar q_i$ | 固定低 35 位、取 $h=0$ 时的首次输出 | $\bar q_i=q_i(0)$;横线表示基准值,不表示平均值 |
| $\bar q_i(m)$ | 固定位置与根、随中间位 $m$ 变化的首次输出 | 同样取 $h=0$ |
| $\delta_i$、$\delta_i(m_j)$ | 第 $i$ 格与锚点的模输出差;带参数时明确指定中间位候选 | $(\bar q_i-\bar q_0)\bmod2^{31}$;随位置、$p,m$ 变化,与 $h$ 无关 |
### 公共项、分组与高位求解
| 符号 | 含义与定义 | 取值范围或说明 |
| --- | --- | --- |
| $\lambda_i,\kappa_i$ | $(\ell+d_i)\bmod2^{48}$ 的低 35 位、高 13 位 | 输入拆分为 $\lambda_i+2^{35}\kappa_i$;包含坐标加法进位 |
| $\sigma_i$ | 固定根后,$p+d_i$ 的完整整数和 | 用于预计算 |
| $\eta_i$ | $\sigma_i$ 的低 18 位,$\sigma_i\bmod2^{18}$ | $[0,2^{18})$;固定根后用于公共项预计算 |
| $\gamma_i$ | $\sigma_i$ 除以 $2^{18}$ 的商 | $\lfloor \sigma_i/2^{18}\rfloor$;每根固定 |
| $\omega_i$ | 低 18 位输入经状态更新、右移后的公共贡献 | 第 11 节定义;位置和根固定后不随 $m$ 变化 |
| $g$ | 中间位组编号 | $0,\ldots,8191$ |
| $\mathrm{base}$ | 一组中间位的起始值 | $256\lfloor g/16\rfloor+(g\bmod16)$ |
| $j$ | 组内候选编号,也是 mask 的 bit 编号 | $0,\ldots,15$ |
| $m_j$ | 组内第 $j$ 个中间位候选 | $\mathrm{base}+16j$ |
| $\mathrm{offset}_i$ | 某格组首输入的低 8 位 | $(\mathrm{base}+\gamma_i)\bmod256$ |
| $\mathrm{cut}_i$ | 某格组内首次跨越 256 边界的索引 | $\lceil(256-\mathrm{offset}_i)/16\rceil$;1~16,16 表示组内不跨界 |
| mask | 组内 16 个候选的允许掩码 | 第 $j$ 位为 1 表示 $m_j$ 尚未被淘汰 |
| $r_i,b_i$ | $\bar q_i$ 的低 18 位、高 13 位 | $\bar q_i=r_i+2^{18}b_i$;都由当前低 35 位确定 |
| $\theta$ | 高位换元,$Ah\bmod8192$ | $[0,8192)$;由 $h=4965\theta\bmod8192$ 恢复 |
| $\phi_i$ | 换元后的输出高 13 位 | $(b_i+\theta)\bmod8192$ |
| $\tau_i$ | 第 $i$ 格输出高位的回绕点 | $8192-b_i$,范围 1~8192 |
| $\mathrm{lo},\mathrm{hi}$ | 当前保留区间的下界、上界 | $[\mathrm{lo},\mathrm{hi})$;仅用于高位换元 $\theta$ 的区间 |
| $\mathrm{lo}',\mathrm{hi}'$ | 与当前区间求交的另一段区间端点 | 撇号表示另一段区间;仍然只表示端点 |
| $\rho$ | 区间要求的模 5 余数 | $0,1,2,3,4$ |
| $\theta_{\min}$ | 区间中第一个满足余数要求的整数 | $\mathrm{lo}+((\rho-\mathrm{lo})\bmod5)$;若不小于 $\mathrm{hi}$,则无解 |
### 算法固定常量与记号
| 常量或记号 | 数值或含义 | 用途 |
| --- | --- | --- |
| $A$ | $\mathtt{0x5DEECE66D}$ | Java Random 状态更新的乘数 |
| 加数 | 11 | Java Random 状态更新的固定加数 |
| 史莱姆 XOR 常量 | 987234911 | 原始史莱姆公式的 XOR 常量 |
| $C$ | $A\mathbin{\mathrm{xor}}987234911=\mathtt{0x5E434E432}$ | 合并后的初始化 XOR 常量;只占低 35 位 |
| $E$ | $C\gg18=\mathtt{0x1790d}$ | 公共项公式中的 XOR 常量;第 4~7 位为 0 |
| 坐标系数 | 4987142、5947611、4392871、389711 | 分别对应 X 平方、X 一次、Z 平方、Z 一次项 |
| 1645、4965 | $A\bmod8192$ 及其模 8192 逆元 | 两者乘积模 8192 为 1 |
| $2^{18}=262144$ | 低位取值总数;也是输出低位拆分的模数 | 由算法位数决定,与具体窗口无关 |
| $2^{17}=131072$ | 每个根对应的中间位取值总数 | 分为 8192 组,每组 16 个 |
| $2^{13}=8192$ | 高位取值总数;也是换元 $\theta$ 的模数 | 与具体窗口无关 |
| $2^{31}-8=2147483640$ | `nextInt(10)` 拒绝区间的起点 | 精确验证处理重抽 |
| $n$ | 模 $2^n$ 运算保留的位数 | 一般性位数记号,不是组内分界 |
| $\bmod$、$\equiv$ | 非负取余、同余关系 | 模 $2^n$ 等价于保留低 $n$ 位 |
| $\mathrm{xor}$、$\gg$ | 按位异或、右移 | 本文随机状态和输出均按非负整数解释 |
| $[\mathrm{lo},\mathrm{hi})$ | 含下界、不含上界的区间 | 枚举整数时要求 $\mathrm{lo}\le \theta<\mathrm{hi}$ |

View file

@ -1,371 +0,0 @@
# 从暴力搜索到位运算:寻找 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)
正文中的数字来自这些已有记录,不应写成在此次提纲整理中重新跑出的结果。

View file

@ -1,470 +0,0 @@
# 随机数性质:怎样从坐标图案反推种子候选
前面的 01–04 在做同一件事:给定一批种子和坐标,把它们检查得更快。这一章换一个角度:如果我们已经知道哪些坐标应该是史莱姆区块,能否利用随机数公式,先排除大量种子,再求出值得检查的候选?
我们以一个固定坐标的实心 5×5 图案为例。目标是寻找一个世界种子,让这 25 个格子同时满足要求。我们不知道每格产生的随机数具体是多少,只知道它们应该通过史莱姆判断。因此,这里的“反推”是根据多个条件缩小候选范围,结果可能有多个种子。
当前方法分成三步:**筛低 18 位的可能取值,补上中间 17 位,再求出高 13 位的共同解。** 最后执行完整的 Java Random 验证。
下面解释筛选和求解的过程,以及每一步的数学依据。首次输出筛选还没有完整覆盖拒绝重抽的例外;这个限制在文末说明。
## 1. 先区分种子、内部状态和随机输出
对坐标 $(x_i,z_i)$,原始史莱姆公式先计算坐标项:
$$
T(x_i,z_i)=F(x_i)+G(z_i).
$$
坐标项包含平方、乘法和加法,必须遵循原始 Java 的整数回绕与符号扩展规则,完整说明见 [OUTER_SUM.md](video/OUTER_SUM.md)。下面用 $d_i=T(x_i,z_i)$ 表示第 $i$ 格的坐标项。
设世界种子的低 48 位为 $w$,合并史莱姆公式与 Java Random 初始化中的两个 XOR,得到:
$$
A=\mathtt{0x5DEECE66D},\qquad
C=A\mathbin{\mathrm{xor}}987234911=\mathtt{0x5E434E432}.
$$
每个格子的首次随机输出按下面三步产生:
$$
u_i=((w+d_i)\mathbin{\mathrm{xor}}C)\bmod2^{48},
$$
$$
v_i=(Au_i+11)\bmod2^{48},
$$
$$
q_i=\left\lfloor v_i/2^{17}\right\rfloor.
$$
这三个数的含义不同:
| 符号 | 含义 |
| --- | --- |
| $w$ | 待求的世界种子低 48 位 |
| $d_i$ | 第 $i$ 格的坐标项,坐标确定后就能计算 |
| $u_i$ | Random 初始化后的 48 位内部状态 |
| $v_i$ | 内部状态推进一次后的结果 |
| $q_i$ | 取出 $v_i$ 的高 31 位得到的首次输出 |
其中,`mod` 表示取余数。例如 $8194\bmod8192=2$。模 $2^k$ 相当于只保留一个数的低 $k$ 位。
对被接受的首次输出,`nextInt(10)` 返回 $q_i\bmod10$,返回 0 就表示史莱姆区块。我们先利用“$q_i$ 能被 10 整除”的条件生成候选,随后完整验证。
## 2. 为什么只考虑世界种子的低 48 位
Random 初始化时只保留低 48 位。因此,在同一个坐标上,如果两个原始世界种子 $W_1,W_2$ 相差整数倍的 $2^{48}$,初始化状态就相同,以后的随机数序列也相同:
$$
W_2=W_1+k2^{48}
\quad\Longrightarrow\quad
\mathrm{slime}(W_2,x,z)=\mathrm{slime}(W_1,x,z).
$$
这条性质覆盖完整的随机数序列,包括拒绝重抽。搜索时可以先求低 48 位,原始 64 位输入的高 16 位对应同一组史莱姆判断结果。
这里讨论的是史莱姆判断的等价关系,不能据此断言整个 Minecraft 世界相同。
## 3. 把待求的 48 位分成三部分
按通常从高位到低位的书写顺序,这三部分是:
```text
高 13 位 h | 中间 17 位 m | 低 18 位 p
```
对应数值关系:
$$
w=p+2^{18}m+2^{35}h.
$$
各部分的取值范围是:
$$
0\le p<262144,\qquad
0\le m<131072,\qquad
0\le h<8192.
$$
补上中间 17 位后,得到低 35 位:
$$
\ell=p+2^{18}m,\qquad w=\ell+2^{35}h.
$$
这三个部分的处理方式不同:低 18 位先筛选,中间 17 位目前主要枚举,高 13 位通过分段同余条件求解。
## 4. 低 18 位为什么能提前筛选
首次输出是:
$$
q_i=v_i\gg17.
$$
右移 17 位,就是去掉内部状态的低 17 位。于是,**$q_i$ 的最低位,对应 $v_i$ 的第 17 位**,位编号从 0 开始。
一个数要能被 10 整除,首先必须是偶数。我们只要知道 $v_i$ 的低 18 位,就能知道它的第 17 位,从而判断首次输出的奇偶。
为什么计算这 18 位时,可以暂时不管世界种子的高位?因为加法、乘法和逐位 XOR 的低 $k$ 位,只依赖输入的低 $k$ 位。更高位不会反过来改变低位。
因此,令 $p=w\bmod2^{18}$、$d_{i,18}=d_i\bmod2^{18}$,只需要计算:
$$
a_i=(p+d_{i,18})\bmod2^{18},
$$
$$
u_{i,18}=a_i\mathbin{\mathrm{xor}}(C\bmod2^{18}),
$$
$$
v_{i,18}=(Au_{i,18}+11)\bmod2^{18}.
$$
最后检查:
$$
v_{i,18}<2^{17}=131072.
$$
小于这个分界值,说明第 17 位为 0,首次输出是偶数;否则就是奇数。
**坐标项也参与计算。** 这条性质说的是“种子的低 18 位,加上该格坐标项的低 18 位,足以决定首次输出奇偶”。换坐标后,结果可能改变。
## 5. 怎样从一个图案生成低 18 位候选表
只有一个“5×5”的形状还不够,需要知道锚点在哪。已有样例的锚点是:
$$
(x_0,z_0)=(51069,419124).
$$
图案包含的 25 个坐标为:
$$
(x_i,z_i)=(x_0+\Delta x,z_0+\Delta z),
\qquad\Delta x,\Delta z\in\{0,1,2,3,4\}.
$$
生成表的过程可以按下面的顺序理解:
1. 计算 25 个坐标项,分别保留低 18 位。
2. 尝试一种种子的低 18 位取值 $p$。
3. 用这个 $p$ 检查 25 格的首次输出奇偶。
4. 有一格为奇数,就淘汰这个 $p$;全部为偶数,就保存。
5. 对 $p=0,1,\ldots,262143$ 重复这个过程。
这相当于为每格求出一个允许集合,再对 25 个集合求交。
看两个实际取值,就能理解“保留”和“淘汰”是什么意思:
| 种子的低 18 位 $p$ | 检查的格子 | $v_{i,18}$ | 结果 |
| --- | --- | ---: | --- |
| 0 | $(51069,419124)$ | 53282 | 偶数条件通过 |
| 0 | $(51069,419125)$ | 178468 | 失败,整个 $p=0$ 淘汰 |
| 3948 | $(51069,419124)$ | 1118 | 偶数条件通过 |
| 3948 | $(51069,419125)$ | 73664 | 偶数条件通过 |
$p=3948$ 在其余格子上也通过,所以进入最后的候选表。
固定锚点 $(51069,419124)$,把要求通过的图案从 1 格逐步扩大到 5×5。每次保留已有格子,再加入扩大后新增的一圈格子,剩余取值数量如下。表中的 0 表示尚未加入任何格子条件,1 表示只检查锚点这一格:
| 要求通过的图案 | 剩余低 18 位取值数 |
| ---: | ---: |
| 0 | 262144 |
| 1 | 131072 |
| 2×2 | 20383 |
| 3×3 | 3385 |
| 4×4 | 427 |
| 5×5 | 84 |
单格恰好保留一半取值,是因为加坐标项、XOR 和奇数乘法在模 $2^{18}$ 下都是一一对应的变换。继续加入格子,只会增加条件,所以候选数量只能保持不变或减少。
这 84 种取值就是“该坐标图案允许的世界种子低 18 位候选表”。
对固定图案,这一步可以作为预处理,只做一次。后续直接遍历这 84 种取值,给它们补上更高位,不必让所有完整种子先查一次表。
这里的 84 只适用于样例中的具体坐标。移动锚点或改变形状后,需要重新筛选;如果两个图案对应格子的坐标项低 18 位全部相同,就可以复用这张低位候选表,但完整随机输出仍可能不同。
## 6. 中间 17 位目前怎样处理
取出一个通过筛选的 $p$,再枚举:
$$
m=0,1,\ldots,131071.
$$
每次拼出一种低 35 位取值:
$$
\ell=p+2^{18}m.
$$
然后把这个 $\ell$ 交给高 13 位求解器:是否存在某个 $h$,能让所有格子满足首次输出的模 10 条件?如果没有,就淘汰这个 $\ell$。
因此,当前没有另一张能直接消除中间 17 位枚举的通用表。样例需要处理:
$$
84\times2^{17}=11010048
$$
种低 35 位取值。低 18 位筛选避免了大量无效工作,但中间位的枚举成本仍然存在。
## 7. 为什么固定低 35 位后,高位可以单独求解
现在固定一种 $\ell$,世界种子写成:
$$
w=\ell+2^{35}h.
$$
选择 35 的原因是 XOR 常量 $C$ 恰好需要 35 位:
$$
2^{34}\le C<2^{35}.
$$
它只会翻转低 35 位,不会碰到未知的高 13 位。
这里还要保留坐标加法的进位。对第 $i$ 格,把 $h=0$ 时的输入拆成:
$$
(\ell+d_i)\bmod2^{48}=L_i+2^{35}H_i.
$$
$H_i$ 包含坐标加法产生的进位,也包含负坐标项在模 $2^{48}$ 下的表示。加入 $h$ 后,低位 $L_i$ 不变,高位成为 $(H_i+h)\bmod8192$。
XOR 只改动 $L_i$,因此初始化状态满足:
$$
u_i(h)=(u_i(0)+2^{35}h)\bmod2^{48}.
$$
再推进一次状态:
$$
v_i(h)=(v_i(0)+Ah\,2^{35})\bmod2^{48}.
$$
最后取首次输出,去掉状态低 17 位。新增量本来就是 $2^{35}$ 的整数倍,不会改变被去掉的低位余数,所以:
$$
q_i(h)=(q_i(0)+Ah\,2^{18})\bmod2^{31}.
$$
这里的 $18$ 来自 $35-17$。
这是利用不同位域分离得到的平移关系。一般的 XOR 并不具有这种加法性质。若切在 34 位,$C$ 仍会翻转未知部分的一位,上面的直接平移就不成立;切在 36 位等更高位置也可以分离,但剩下可解析的高位就更少了。
## 8. $r_i,b_i$ 是怎样实际算出来的
它们来自一个确定的计算结果。选定 $\ell$ 和第 $i$ 格的坐标,先暂时令 $h=0$,按照原始公式计算:
$$
u_i(0)=((\ell+d_i)\mathbin{\mathrm{xor}}C)\bmod2^{48},
$$
$$
v_i(0)=(Au_i(0)+11)\bmod2^{48},\qquad
q_i(0)=\left\lfloor v_i(0)/2^{17}\right\rfloor.
$$
再把 $q_i(0)$ 除以 $262144=2^{18}$:
$$
r_i=q_i(0)\bmod262144,\qquad
b_i=\left\lfloor q_i(0)/262144\right\rfloor.
$$
$r_i$ 是余数,也就是输出的低 18 位;$b_i$ 是商,也就是输出的高 13 位。例如,如果某格算出 $q_i(0)=524290$:
$$
524290=2+262144\times2,
$$
那么 $r_i=2,b_i=2$。这只是拆分数字的例子,不是前面样例某格的实测输出。
改变 $h$ 后,该格的首次输出就是:
$$
\boxed{q_i(h)=r_i+2^{18}\bigl((b_i+Ah)\bmod8192\bigr).}
$$
**$b_i$ 是随机输出的一部分,$h$ 是待求种子的一部分,不能把两者当成同一个数。** $r_i,b_i$ 在当前 $\ell$ 和坐标下固定,不同格子通常具有不同的 $r_i,b_i$,但所有格子共用同一个未知 $h$。
## 9. 为什么先求 $t$,再换回 $h$
为了让规则更容易处理,令:
$$
t=Ah\bmod8192.
$$
用 $e_i$ 表示换元后该格输出的高 13 位:
$$
e_i=(b_i+t)\bmod8192,\qquad
q_i=r_i+2^{18}e_i.
$$
$A$ 是奇数,和 $8192=2^{13}$ 互质,所以这个换元只是在重新排列 8192 个编号,不会重复或遗漏。
具体来说:
$$
A\bmod8192=1645,\qquad
1645\times4965=997\times8192+1.
$$
因此,求到 $t$ 后,可以直接换回:
$$
\boxed{h=4965t\bmod8192.}
$$
这里用到了“奇数乘数在模 $2^k$ 下有逆元”的性质。如果完整状态 $v$ 已知,同样可以用模逆恢复上一状态:
$$
u=A^{-1}(v-11)\bmod2^{48}.
$$
但首次输出已经丢掉状态低 17 位,而史莱姆观察只提供一个布尔结果。因此,当前搜索仍需组合多个格子的条件来求候选。
## 10. 每格到底对 $t$ 提出什么要求
我们要让首次输出能被 10 整除。偶数条件已经由低 18 位筛选保证,现在还需要能被 5 整除。
因为:
$$
2^{18}\equiv4\equiv-1\pmod5,
$$
所以:
$$
q_i=r_i+2^{18}e_i\equiv r_i-e_i\pmod5.
$$
于是,每格提出的要求是:**$e_i$ 除以 5 的余数,必须与 $r_i$ 相同。**
完整写法为:
$$
q_i\equiv0\pmod{10}
\iff r_i\equiv0\pmod2\ \text{且}\ e_i\equiv r_i\pmod5.
$$
如果高位求解器单独接收一个没有预先筛过的低 35 位取值,发现某格 $r_i$ 为奇数,就会立即返回无候选。
## 11. 回绕点是什么,为什么它会改变规则
考虑:
$$
e_i=(b_i+t)\bmod8192.
$$
可以把它想成一个从 0 数到 8191 的计数器。到 8192 时,就回到 0。
例如 $b_i=8190$:
| $t$ | $b_i+t$ | 取模后的 $e_i$ |
| ---: | ---: | ---: |
| 0 | 8190 | 8190 |
| 1 | 8191 | 8191 |
| 2 | 8192 | 0 |
| 3 | 8193 | 1 |
| 4 | 8194 | 2 |
$t=2$ 就是回绕点。一般来说,回绕点为:
$$
\tau_i=8192-b_i.
$$
点之前,$e_i=b_i+t$;从这个点开始,$e_i=b_i+t-8192$。由于 $0\le b_i,t<8192$,相加最多回绕一次。
把上一节的要求代入,就得到:
$$
\begin{cases}
t\equiv r_i-b_i\pmod5,&0\le t<\tau_i,\\
t\equiv r_i-b_i+2\pmod5,&\tau_i\le t<8192.
\end{cases}
$$
第二种要求多了 2,是因为 $8192\bmod5=2$。若 $b_i=0$,回绕点在 8192,超出 $t$ 的有效整数范围,所以这个格子在整个范围里都不回绕。
## 12. 用两个格子的数值例子,把求解串起来
下面的 $r_i,b_i$ 是用于讲解的示意数值,不是 5×5 样例的实测参数。
第一格取 $r_1=2,b_1=8190$。它要求 $e_1\bmod5=2$,回绕点在 $t=2$:
- $t<2$ 时,要求 $t\bmod5=2$。
- $t\ge2$ 时,要求 $t\bmod5=4$。
第二格取 $r_2=0,b_2=1$。它要求 $e_2\bmod5=0$,回绕点在 $t=8191$:
- $t<8191$ 时,要求 $t\bmod5=4$。
- $t\ge8191$ 时,要求 $t\bmod5=1$。
把两格条件合并:
| $t$ 的区间 | 第一格要求的余数 | 第二格要求的余数 | 共同解 |
| --- | ---: | ---: | --- |
| $[0,2)$ | 2 | 4 | 无 |
| $[2,8191)$ | 4 | 4 | 区间中除以 5 余 4 的数 |
| $[8191,8192)$ | 4 | 1 | 无 |
因此,允许的 $t$ 是:
$$
t=4,9,14,\ldots,8189.
$$
取 $t=4$,换回种子的高位:
$$
h=4965\times4\bmod8192=3476.
$$
检查两个格子的首次输出:
$$
e_1=(8190+4)\bmod8192=2,\qquad
q_1=2+2^{18}\times2=524290,
$$
$$
e_2=(1+4)\bmod8192=5,\qquad
q_2=2^{18}\times5=1310720.
$$
两个输出都能被 10 整除,所以 $h=3476$ 是这两格共同允许的高位取值。真实图案对 25 格做同样的求交,最后拼成完整种子,再验证。
## 13. 最后说明首次输出模型的边界
上面的状态与输出变换恒等式是精确的。覆盖限制来自用“首次输出能被 10 整除”生成候选。
Java `nextInt(10)` 遇到:
$$
q\ge2147483640=2^{31}-8
$$
时,会拒绝这个输出,继续推进内部状态,再取一个输出。例如 $2147483640$ 虽然能被 10 整除,也不会直接被接受。
因此会有两种情况:
- 首次输出符合我们的候选条件,但被拒绝,重抽后失败。最终精确验证会剔除这种假阳性。
- 首次输出不符合候选条件,例如是奇数,但处在拒绝尾部,重抽后成功。它可能在低位筛选阶段就被淘汰,最终验证无法补回这种漏解。
所以,当前流程准确验证所有输出的候选,但不能宣称已经找齐完整 Java Random 语义下的所有解。
低 18 位的 84 种取值、正文中的筛选数量和两个格子的示意例子,都可以按照上面的计算步骤独立核对。

View file

@ -1,8 +1,12 @@
# 史莱姆区块搜索:逐步演示
这里保存视频/博客草稿、按步骤编号的代码,以及本次实测结果。
演示代码在本目录,研究实现位于 `research/`。草稿见
[POPULAR_SCIENCE_OUTLINE.md](POPULAR_SCIENCE_OUTLINE.md)。
这里保存视频/博客稿、按步骤编号的代码,以及已保存的实测结果。
统一教程见 [DOCUMENT.md](DOCUMENT.md),从 CPU/CUDA 枚举讲到当前算法的数学推导。
视频为 15 章,保留 CPU → CUDA → 按坐标分工 → outer sum,再讲归一化、低位筛根、
类根复用、预计算、16 候选组 mask、高位求解与各项效果。01–08 保留为教学基线;当前入口见
[`bench/slime-bench`](../bench/README.md),共用数学位于 [`include/slime/`](../include/README.md)。
演示代码在本目录,研究实现位于 `research/`。
先读 [BENCHMARK.md](BENCHMARK.md) 看实测结果;命令、原始 stdout、全部计时样本、
编译器与源码 SHA256 保存在 [results/latest.json](results/latest.json)。
@ -23,7 +27,7 @@ Manim 视频工程位于 [video/](video/README.md),使用 uv 管理依赖;
| 02 | `02_cuda_seed.cuh` | 相同循环搬到 CUDA,一 thread 处理一个 seed |
| 03 | `03_cuda_position.cuh` 的 `coordinateKernel` | 一 thread 固定坐标,先算图案的 X/Z 项,再遍历 seed 复用 |
| 04 | `04_cuda_outer_sum.cuh` | 16×16 tile,共享扩展后的 X/Z 轴项 |
| 性质探索 | [RANDOM_PROPERTIES.md](RANDOM_PROPERTIES.md) | 从状态空间、低位依赖与模运算结构推导搜索方法 |
| 完整教程 | [DOCUMENT.md](DOCUMENT.md) | 从状态空间、低位依赖与模运算结构推导搜索方法 |
| 07 | `07_residue.cu` | 固定 seed,完整低位坐标余数过滤后展开 |
| 08 | `08_upper_compare.cu` | 相同 lower35 平面,高 13 位枚举/解析受控比较 |
| 08 完整流程 | `08_seed_cpu.cpp`、`08_seed_cuda.cu` | 固定坐标,筛低 18 位取值 → 补上中间 17 位 → 求高 13 位 |
@ -40,12 +44,12 @@ Manim 视频工程位于 [video/](video/README.md),使用 uv 管理依赖;
这个对照包含多项式复用及编译期图案大小展开,两个版本先后运行;主线 02/03 则轮换顺序测量。
01–04 都记录每一个命中的 `(world_seed,x,z)`,没有“一 seed 找到第一个位置就返回”的差异。
性能主线到 04 结束。随后读 [RANDOM_PROPERTIES.md](RANDOM_PROPERTIES.md),
性能主线到 04 结束。随后读 [DOCUMENT.md](DOCUMENT.md),
探索随机数的状态空间、低位依赖和模运算结构,再引出 07–08 的候选生成与解析求解。
这部分用推导和例子说明性质,不把 XOR 的等价改写列作计时优化。
07、08 完整流程是从对应 `research/` 实现提取的教学快照,文件头注明来源,
继续共享 `../research/slime_solver_core.hpp`。
经 `../research/slime_solver_core.hpp` 兼容转发共享 `../include/slime/solver_core.hpp`。
08 的单独高位比较是新写的受控实验;其他成熟算法没有重新复制一套数学核心。
因此 `show/` 在本仓库内构建,不能只拷贝这个目录后省略 `research` 头文件。
@ -132,14 +136,14 @@ show/bin/08_seed_cuda --x 51069 --z 419124 -n 5 --threads 256 --max-results 1000
只修改前四步的测试参数时,可以运行 `python3 show/benchmark.py --only-mapping --repeats 5`;
它先检查源代码哈希,再重测 A/B/C,保留后面的已有算法实验,在报告中分别注明测量日期。
## 视频里必须交代的边界
## 当前视频的范围
- 固定坐标线程与按 seed 线程是转置关系,哪种快取决于任务规模与循环中的算术复用。展平实现移至 `experiments/`,不属于这一阶段。
- 低 48 位不是新加入的剪枝:JavaRandom 从第一版起就只有 48 位状态。高 16 位等价性是一条性质,不是额外实测加速比。
- 07–08 的全局生成器仍用首次输出候选模型,可能漏掉依赖拒绝后重抽的解;最终准确验证不能补回漏掉的候选。
- 潜在笛卡尔积规模不是实际访问次数。收益不能跨任务相乘,全 seed × 全世界目录仍未解决。
- FFT 等失败方案的代码和数据仍在 `research` 归档,见草稿中的链接;本目录实测保留方案。
可直接用于叙事的结果应从本次报告取值,草稿里原有的研究数字作为历史记录保留。
视频前半段保留 CPU/CUDA 与坐标计算复用的演进;后半段沿当前 `bench/slime-bench` 数据流讲解 Random 优化,不穿插坐标余数对或独立研究分支。
每一步说明为什么成立、减少哪部分工作,以及留下什么候选。
当前入口使用普通候选模型,可能漏掉依赖拒绝重抽的解;最终准确验证保证输出正确,不能补回漏掉的候选。
各项效果页引用冻结的同任务对照,并分别标明 kernel、搜索阶段和批次墙钟,章节与讲稿入口见 [视频工程](video/README.md)。
独立筛轴的教学实现、测速结果和视频章节已移除,历史结论保存在 [X/Z 筛轴试错报告](../research/XZ_FILTER_TRIAL_REPORT.md)。
后续优化的数学、实现状态和新基准均见 [统一教程](DOCUMENT.md),
不把历史 `BENCHMARK.md` 改写成未重新测量的当前性能报告。

View file

@ -214,7 +214,7 @@ def report(data):
else:
text += ['', '本组改为固定 seed,重新测量 03 后再比较 04。03、04 均保持固定坐标后遍历 seed 的策略。']
text += ['']
text += ['随机数性质的探索独立成章,见 [RANDOM_PROPERTIES.md](RANDOM_PROPERTIES.md)。从状态空间、低位依赖和模运算约束推导搜索方法,不列为局部计时优化。', '',
text += ['随机数性质的探索独立成章,见 [DOCUMENT.md](DOCUMENT.md)。从状态空间、低位依赖和模运算约束推导搜索方法,不列为局部计时优化。', '',
'## 2. 固定 seed:全坐标范围余数过滤', '']
r = data['residue']
# Project only from the largest measured rectangle, not from a tiny launch.

View file

@ -1,7 +1,12 @@
# 史莱姆搜索科普视频 · Manim / uv
以 `show/` 的教学实现与实测为基础。中文字幕、深色背景、16:9;绘图使用 Manim Community 的 Cairo 渲染器。
当前版本是带字幕的动画初稿,没有配音或背景音乐;渲染同时输出口播稿和可用于剪辑的 SRT 字幕。
2026-10-16 版依据统一正文 DOCUMENT.md:先讲单区块的 Random 代码与完整坐标公式,
再讲 CPU → CUDA 按 seed 分工 → 按坐标分工 → outer sum。
后半段先建立有效种子的反推关系,再引入唯一的 5×5 窗口,
沿 p = 218767、m = 40302 的路径求出并验证世界种子 15959335054446。
章节顺序见 [STORYBOARD.md](STORYBOARD.md)。
当前输出是带字幕的动画,没有配音或背景音乐;同步生成完整口播稿和 SRT 字幕。
## 环境与运行
@ -20,6 +25,10 @@ FFmpeg 由 `imageio-ffmpeg` 提供,渲染脚本会自动配置。数学公式
公式采用 Computer Modern 数学字体,指数、下标、取整分式与集合符号均由 LaTeX 排版。
渲染优先使用系统的 `latex`、`dvisvgm`;本环境的本地 TeX Live 已解包到 `.native/texlive`,随项目目录保留。
也可通过 `SLIME_TEX_ROOT` 指向相同目录结构的本地 TeX Live,`tex_environment.py` 会配置路径并构建 `latex.fmt`。
本地解包版使用 `ls-R` 数据库查找,避免 WSL 下重复遍历目录;需要保留解包环境中的数据库。
本地转换器跳过 PostScript specials,保留 TeX 字形轮廓和 Manim 的原始 SVG 分组;
本工程公式没有 PostScript 插图,紧边界由字形计算,避免依赖本地 Ghostscript 设备初始化。
系统安装的 TeX / dvisvgm 使用正常转换路径。
换一台机器时,请按上面的系统安装命令补齐工具链。
中文字体优先使用 Microsoft YaHei / Noto Sans CJK SC / WenQuanYi Zen Hei。
WSL 会尝试使用 `/mnt/c/Windows/Fonts/msyh.ttc`,也可设置 `SLIME_FONT_PATH` 为已有中文字体路径。
@ -31,9 +40,9 @@ WSL 会尝试使用 `/mnt/c/Windows/Fonts/msyh.ttc`,也可设置 `SLIME_FONT_P
## 场景与输出
- `SlimeFilm`:七个章节的完整动画。
- `ParallelEpisode`:问题、CPU、CUDA、固定坐标、outer sum。
- `RandomEpisode`:Random 性质、高位解析、结尾。
- `SlimeFilm`:15 个章节的完整动画。
- `ParallelEpisode`:任务、CPU、CUDA 按 seed 分工、按坐标分工、outer sum。
- `RandomEpisode`:Random 状态公式、归一化与窗口、low18、类缓存、预计算、组 mask、θ 高位求解、完整验证与效果。
```bash
uv run python render.py --scene ParallelEpisode --quality preview
@ -50,16 +59,27 @@ uv run python render.py --scene RandomEpisode --quality final
`scenes.py` 定义动画、字幕与停留时间。每个 `say()` 同时记录字幕时间轴,修改口播时请在这里修改。
字幕支持中文与 LaTeX 行内公式混排:`say(caption, seconds, display=r"中文 $2^{48}$")`。中文使用 Pango,`$...$` 内的公式使用 `MathTex`;`caption` 保留口播文字,供 SRT 与配音稿使用。时间轴同时保存实际画面用的 `display`,便于核对。
固定坐标与 outer sum 两章包含原始坐标公式分组、Java 回绕语义、广播相加的矩阵恒等式及 2×3 数值示例;完整推导见 [OUTER_SUM.md](OUTER_SUM.md)。
Random 章节逐步推导 XOR 常量的 35 位边界、输出高位平移、模 5 条件与回绕区间,并用数值例子解释 upper13 求解。完整数学与 effective seed 的区别见 [RANDOM_PROPERTIES.md](../RANDOM_PROPERTIES.md)。公式独立核验:`uv run python check_lower35.py`(逐个核对全部 8192 个高位,另核对完整 25 格区间求交)。
`data/benchmarks.json` 是渲染所用的固定数据快照,保留来源文件与 SHA256,避免一次视频渲染混入不同批次数据。
A/B/C 来自 `../results/latest.json`。
前半段保留 CPU/CUDA 与计算复用的演进,A/B/C 各自比较相同任务。
后半段沿当前算法的数据流讲解,每章先说明解决的问题,再解释条件与实现,最后展示效果。
不加入固定 seed 的坐标余数对分支、未接入的补扫算法或全世界成本外推。
数学与当前实现说明见 [最终算法数学原理](../DOCUMENT.md)。
当前候选生成只覆盖普通分支;完整 Java 验证保证输出正确,尚未补回依赖拒绝重抽的全部解。
- A:65536 seed ×257²,CPU 搜索时间 / GPU scan。
- B:65536 seed ×4097²,只比较相同工作量的 02/03 kernel。
- C:1 seed ×8193²,重新测量 03,再比较 04 kernel。
后半段与 [统一正文](../DOCUMENT.md) 使用同一条数值路径。公式与案例分开展示,案例使用黄色标签与引用线;不再使用临时假设的坐标项、输出差或高位参数。θ 与坐标项 T 分开,11 在基准状态中的去向单独展开。
不同组不连乘加速比。GPU diagram 简化线程数量;outer sum 图示为 5×5,实际 block 为 16×16。
Random 的高 16 位等价性质覆盖重抽路径,但后续首次输出候选模型仍可能漏掉依赖拒绝重抽的解。
独立筛轴章节已删除,历史结果见 [试错报告](../../research/XZ_FILTER_TRIAL_REPORT.md)。含旧章节的渲染视频、字幕与口播稿已删除;需用当前源码重新渲染。
最终视频须在配音、节奏审核后再作为发布版本。
`data/benchmarks.json` 冻结测量数据和本次实际使用的来源 SHA256:
- A/B/C:CPU → GPU、按坐标分工、outer sum;不同负载不连乘。
- ROOT:已知 5×5 窗口,262144 → 84 个低位根,11010048 → 36 个输出差幸存者。
- P/G:同一窗口预计算 0.323584 → 0.268288 ms,成组筛选再降到 0.156672 ms;两步合计约 2.07×。
- D:同窗口的输出差过滤+区间求交,63.750 → 约 0.324 ms;这是组合效果。
数据文件保留 E/F/CLI 等历史测量,当前影片不展示另一批坐标或 CLI 案例。窗口演算最终展示 25 格完整原始输出表,与开头 Java 代码相互对应。
P/G 包含每轮公共项准备,不包含低位根生成、初始化和传输。
不能把不同输入、版本或计时范围的倍数连乘为应用总加速。
`verify.py` 核对章节顺序、口播导出、字幕时间、来源哈希和各项实测数据,并逐帧解码 MP4。
数学式与同窗口演算另用 `uv run python check_lower35.py` 核验,包含完整 25 格输出、组掩码及全部 8192 个高位对照。
正式输出为 `output/SlimeFilm-final.mp4`;同名 `.srt`、`.narration.md`、`.timeline.json`、
`.render.json` 与 `-contact.png` 同步生成。

View file

@ -1,14 +1,29 @@
# 分镜
# 单区块判定、CPU/CUDA 演进与最终算法(2026-10-16)
| 章节 | 画面 | 讲解目的 | 对应材料 |
| --- | --- | --- | --- |
| 00 问题开场 | 网格中点亮 5×5 区域 | 明确 seed、坐标、图案三个维度 | POPULAR_SCIENCE_OUTLINE.md |
| 01 CPU | 逐格检查与失败退出,配合循环结构 | 建立正确基线;CPU 已使用多线程 | 01_cpu.cpp |
| 02 CUDA | seed 分给线程,线程各扫坐标图 | 解释独立任务并行;A 组图表 | 02_cuda_seed.cuh |
| 03 固定坐标 | 从原式按 X/Z 分组,标记 Java 回绕,再对照两种循环 | 证明 T=F+G;seed 循环外复用轴项;B 组图表 | 03_cuda_position.cuh、common.hpp |
| 04 outer sum | 列向量/行向量广播的矩阵恒等式、2×3 数值例子,再点亮行列 | 解释 outer sum 的数学依据与计算量边界;halo;C 组图表 | 04_cuda_outer_sum.cuh、OUTER_SUM.md |
| 05 Random | 64 位拆成 16+48;XOR 位宽→高位平移→35−17→模 5→回绕区间及数值例子 | 推导 lower35/upper13,解释 18+17+13 与首次输出模型边界 | RANDOM_PROPERTIES.md |
| 06 结尾 | 两条优化路线和实验原则 | 把实现优化与结构化搜索串起来 | BENCHMARK.md |
本版依据统一正文 [DOCUMENT.md](../DOCUMENT.md)。先解释单个区块及 Random,再保留 CPU → CUDA 按 seed → 固定坐标 → outer sum 的演进。反推有效种子的关系建立后才引入具体窗口,后续所有数值演算统一使用锚点 `(51069,419124)` 的实心 5×5。
画面以字幕节拍编排,口播稿与 SRT 在渲染时生成,避免维护三份不同步的时间轴。
没有把历史外推数据、删除的 05/06 性能分支或坐标范围趋势实验重新带入视频。
| 章 | 内容 | 对应正文与效果 |
| --- | --- | --- |
| 01 | 直接调用 Random 的代码 → 内部展开 → 完整 T 公式与回绕 | 第 0 节;u、v、q、A、C 的含义 |
| 02 | CPU 枚举种子和位置;一格失败立即退出 | 第 1 节;正确性基线 |
| 03 | CUDA 每线程一个 seed,线程内扫描位置 | 第 2 节;A:3550.398 → 111.110 ms |
| 04 | 固定坐标后遍历 seed,线程内复用 F/G | 第 3 节;B:31598.539 → 17600.387 ms |
| 05 | 相邻线程共享轴项,outer sum | 第 4 节;C:2.715 → 1.598 ms;实现范围说明留在此处 |
| 06 | 单区块状态公式与低 48 位等价关系 | 第 5 节;不提前使用 Tᵢ、T₀ 或窗口编号 |
| 07 | 先求 s 再恢复 w → 窗口编号 → dᵢ 与 Tᵢ | 第 6 节;完整引入唯一的 5×5 贯穿案例 |
| 08 | low18 输出奇偶筛根 | 第 7 节;262144 → 84;跟随 p = 218767 |
| 09 | 坐标类根复用 | 第 8 节;案例类键 (51069,25908);不引入另一批次演算 |
| 10 | 每根公共项预计算,数值表核算基准输出 | 第 11 节;同窗口 0.323584 → 0.268288 ms |
| 11 | 共同高位平移 → 输出差 → 16 候选两段 → mask | 第 9、10、12 节;明确 11 包含于 vᵢ(0),base = 40206,γ₁ = 9848,最后只剩 m = 40302、40318;全部 lower35 过滤剩 36;组筛同窗口再快 1.71× |
| 12 | θ 高位换元、两段模 5 条件、全部 25 格区间交集 | 第 13 节;[3382,3440),余 3;12 个高位解;θ = 3383 → h = 2995 → w = 15959335054446 |
| 13 | 完整 5×5 原始输出表与 Java 拒绝重抽 | 第 14 节;25 格全部返回 0;说明正确输出与完整覆盖的区别 |
| 14 | 当前 GPU 批次、每线程一候选组、多卡调度 | 第 15 节;不混入额外 CLI 案例 |
| 15 | 同窗口完整数量链、符号总表与优化效果 | 第 16、17 节;两条 m 分支各 12 个解,共 24 个种子 |
推导页、贯穿案例页、实测页分开展示;案例用黄色标签和引用线。高位换元统一用 θ,坐标项仍用 T;输出高位为 φᵢ,基准输出用 q 横线,区间端点用 lo、hi。
A/B/C 使用不同负载,只在各组内部比较。Random 后半段的预计算、成组过滤及差过滤+区间求交的效果,统一引用同一已知窗口的历史对照;不把不同轮次的倍数连乘。类缓存的收益取决于跨位置的重复率,单窗口不展示另一个批次的速度比。
不加入坐标余数对、未接入的拒绝补扫、失败实验或全世界外推。`SlimeFilm` 完整十五章;`ParallelEpisode` 为 01–05;`RandomEpisode` 为 06–15。
输出为无音轨的中文字幕动画,口播稿、SRT、时间轴和来源摘要随本次渲染同步生成。

View file

@ -75,8 +75,36 @@ def main():
direct = {h for h in range(M)
if all(first_output(lower+(h << 35), d) % 10 == 0 for d in terms)}
assert analytic == direct and direct
assert 2 + (1 << 18)*((8190+4) % M) == 524290
assert 4965*4 % M == 3476
assert sorted(ts) == list(range(3383, 3440, 5))
assert 4965*3383 % M == 2995
assert lower == 218767 + (40302 << 18)
world = (lower + (2995 << 35) - coordinate_term(51069, 419124)) & MASK48
assert world == 15959335054446
# Use the film's X-first indexing and check all 25 accepted outputs.
terms = [(coordinate_term(51069+i%5, 419124+i//5)-coordinate_term(51069, 419124)) & MASK48
for i in range(25)]
expected = [1114693760,660285140,2105382940,47575410,1796905160,
2074383590,1410817530,1734889590,2009625200,440710690,
1664890290,537178720,1699969350,2115254790,688097620,
1842255790,1387847170,1302453480,1264531310,344660,
1757217670,204531470,2132545510,1606583470,569937090]
for i, term in enumerate(terms):
output = first_output(lower+(2995 << 35), term)
assert output == expected[i] and output % 10 == 0 and output < (1 << 31)-8
# Check every row of the selected candidate group and its two constant pieces.
root, base = 218767, 40206
masks = []
for i, term in enumerate(terms[1:], 1):
diffs = [(first_output(root+((base+16*j) << 18), term) -
first_output(root+((base+16*j) << 18), 0)) % (1 << 31) for j in range(16)]
masks.append(sum(1 << j for j, value in enumerate(diffs) if value % 10 in (0, 8)))
if i == 1:
assert diffs == [1693075028]*8 + [411302484]*8
assert [masks[i-1] for i in (1,3,4,8,9)] == [0xff,0xfffc,0xfff8,0xfff0,0xffc0]
combined = 65535
for mask in masks:
combined &= mask
assert combined == 0xc0
print(f'OK: {len(cases)*M} single-cell identities; '
f'25-cell interval intersection equals all {M} high-bit trials ({len(direct)} candidates).')

View file

@ -19,5 +19,117 @@
"metric": "GPU kernel",
"before_ms": 2.714623928,
"after_ms": 1.598464012
},
"D": {
"before_ms": 63.75014496,
"after_ms": 0.3235839903,
"workload": "已知坐标 · 11,010,048 lower35",
"metric": "CUDA event kernel · 中位数 / 5 轮 · 不含根生成与传输"
},
"E": {
"before_ms": 130.1360626,
"after_ms": 19.98233604,
"workload": "65536 随机坐标 · 同一完整根列表",
"metric": "CUDA event 搜索含预计算 · 中位数 / 7 轮 · 不含根生成"
},
"F": {
"before_ms": 141.138417,
"after_ms": 9.585776,
"workload": "32768 位置 / 512 类 · 同批完整根任务一致",
"metric": "host 批次墙钟 · 中位数 / 3 轮 · 排除一次初始化"
},
"CLI": {
"anchors": 525313,
"root_jobs": 368935,
"matches": 0,
"scan_ms": 3989.615391
},
"sources": [
{
"path": "../../show/results/latest.json",
"sha256": "044fed3620ae3e6b421948126c6a05614f60bead24997996ca68c76666431232"
},
{
"path": "../../show/BENCHMARK.md",
"sha256": "6eae6f123fe0c53f231f73f8b879078ae90ea747417dab693550e35d6e6c94b9"
},
{
"path": "../../show/DOCUMENT.md",
"sha256": "765340f78b6bca00097363f5c7be087703f8181f9a2d3c250db20bb9611c83b4"
},
{
"path": "../../include/slime/solver_core.hpp",
"sha256": "feb1200518716b4d796fb60f60a83b6b798155bbd4e795531793181a5c7f9eba"
},
{
"path": "../../include/slime/upper_interval.hpp",
"sha256": "fe2ca1ed5a11a2d7914410549003738b42315c7e61d819f7c109c2d339a01c48"
},
{
"path": "../../bench/README.md",
"sha256": "e2bca364899a8c77b4d519f5068b3b906eabd50911ff7f541fd1275970e05745"
},
{
"path": "../../bench/results/local-r512-n5.json",
"sha256": "736b329a3b2df479d22b4b14260d2a07ea7e7a07ee1f092ca7cd5eef1b2a4409"
},
{
"path": "../../research/OPTIMIZATION_SUMMARY.md",
"sha256": "2e3a9cb48e8a099f7c487c9a0e131f34c49d6f57f4ccfa669f2499aaacf3cc1a"
},
{
"path": "../../research/experiments/full_search_feasibility/REPORT.md",
"sha256": "0d0ddee7a5a022c806955a7095c8adad6620d93d5589085539d59cc9fc5d26c0"
},
{
"path": "../../research/experiments/full_search_feasibility/results/summary.json",
"sha256": "34d8205bb221d1e97effd4f43e4416321a31905315a59abc25ce80ca4abfbce4"
},
{
"path": "../../research/experiments/full_search_feasibility/results/complete65536.jsonl",
"sha256": "60af282ec9ee1b9d3044e838782f23c2275c37c6a9927fcafcf621b7a7f576cf"
},
{
"path": "../../research/experiments/normal_search/REPORT.md",
"sha256": "43ca60cbcf1a808bf04dad2a50f332d609bd3205f583d0f2ab88985c50a8a5e6"
},
{
"path": "../../research/experiments/normal_search/results/summary.json",
"sha256": "eab9db6035745e5570c7a6d858330d699629f4bc3b5a1c12877a4fb214405a3e"
},
{
"path": "../../research/experiments/normal_search/PIPELINE_REPORT.md",
"sha256": "e4e479f7109a9da10dbd36ecc551ade3c8cc5c9c56eaffb899014ad2ebfdf9c2"
},
{
"path": "../../research/experiments/normal_search/pipeline_results/summary.json",
"sha256": "64ef8b5f3ab74f0d99406c4bdae7bf5015c1289434acb06d0978d938be5cc850"
},
{
"path": "../../show/video/STORYBOARD.md",
"sha256": "6fa54f5429218894b2b55f0128fa569060c7a0a619550238a5654f430cc91717"
}
],
"updated": "2026-10-16",
"P": {
"before_ms": 0.3235839903,
"after_ms": 0.2682879865,
"workload": "锚点 (51069,419124) · 同一个实心 5×5 窗口",
"metric": "搜索 CUDA event · 7 轮中位数,含每轮公共项准备;不含筛根与传输"
},
"G": {
"before_ms": 0.2682879865,
"after_ms": 0.1566720009,
"workload": "锚点 (51069,419124) · 同一个实心 5×5 窗口",
"metric": "搜索 CUDA event · 7 轮中位数,含每轮公共项准备;不含筛根与传输"
},
"ROOT": {
"position": [
51069,
419124
],
"roots": 84,
"lower35": 11010048,
"difference_survivors": 36
}
}

View file

@ -6,3 +6,4 @@ pixel_height = 1080
frame_rate = 30
media_dir = media
verbosity = WARNING
max_files_cached = 1000

View file

@ -30,7 +30,7 @@ env = dict(os.environ, PATH=str(tools)+os.pathsep+os.environ['PATH'])
env = prepare_tex(env)
quality = '-ql' if args.quality == 'preview' else '-qh'
fps = '15' if args.quality == 'preview' else '30'
subprocess.run([sys.executable, '-m', 'manim', quality, '--fps', fps, '--disable_caching',
subprocess.run([sys.executable, '-m', 'manim', quality, '--fps', fps,
'--format', 'mp4', '-o', args.scene, 'scenes.py', args.scene],
env=env, check=True)
source = next(p for p in (HERE / 'media/videos/scenes').rglob(f'{args.scene}.mp4')
@ -46,7 +46,7 @@ with av.open(str(target)) as video:
'audio_streams': len(video.streams.audio), 'chapters': timeline['chapters'],
'caption_count': len(timeline['cues'])}
report['source_sha256'] = {p: hashlib.sha256((HERE / p).read_bytes()).hexdigest()
for p in ('scenes.py', 'render.py', 'tex_environment.py', 'manim.cfg', 'uv.lock', 'data/benchmarks.json')}
for p in ('scenes.py', 'render.py', 'tex_environment.py', 'check_lower35.py', 'manim.cfg', 'uv.lock', 'data/benchmarks.json')}
samples = [chapter['start']+3 for chapter in timeline['chapters']]
samples += [cue['start']+1 for cue in timeline['cues']
if cue['text'].startswith(('A 组', 'B 组', 'C 组', '同一 4096'))]

View file

@ -5,6 +5,7 @@ import os
import re
import manimpango
import numpy as np
from manim import *
HERE = Path(__file__).resolve().parent
@ -81,6 +82,20 @@ class FilmBase(Scene):
self.cues = []
self.chapters = []
self.subtitle = None
self.timeline_frames = 0
def play(self, *args, **kwargs):
super().play(*args, **kwargs)
# Cached Cairo segments advance by requested seconds, while fresh
# segments advance by encoded frames. Keep exports on the frame clock
# in both cases, including a timeline-only replay of the same scene.
dt = 1 / self.camera.frame_rate
if self.is_current_animation_frozen_frame():
frames = int(self.duration / dt)
else:
frames = len(np.arange(0, self.duration, dt))
self.timeline_frames += frames
self.renderer.time = self.timeline_frames * dt
def chapter(self, number, title, tag):
if self.mobjects:
@ -91,6 +106,7 @@ class FilmBase(Scene):
badge = text(number, 20, MINT).to_corner(UR, buff=.4)
footer = text(tag, 17, MUTED).move_to([0, -2.93, 0])
line = Line([-6.5, -3.16, 0], [6.5, -3.16, 0], color=PANEL)
self.chrome = (heading, badge, footer, line)
self.play(FadeIn(heading), FadeIn(badge), FadeIn(footer), Create(line), run_time=.6)
def say(self, caption, seconds=5, *, display=None):
@ -101,6 +117,40 @@ class FilmBase(Scene):
self.cues.append({'start': float(self.time), 'end': float(self.time + seconds), 'text': caption, 'display': display if display is not None else caption})
self.wait(seconds)
def page(self, title, equations=(), notes=(), *, kind='general'):
heading = text(title, 28, CYAN).move_to([0, 2.05, 0])
rows = VGroup(*[formula_tex(eq, 32, color, 11.8) for eq, color in equations],
*[text(note, 26, color, 11.8) for note, color in notes])
rows.arrange(DOWN, buff=.38).move_to([0, .05, 0])
if rows.height > 3.5:
rows.scale_to_fit_height(3.5)
objects = VGroup(heading, rows)
if kind != 'general':
label = {'example': '贯穿案例 · 同一个 5×5 窗口',
'measurement': '实测案例 · 仅对应指定输入'}[kind]
marker = text(label, 18, AMBER).move_to([0, 2.65, 0])
quote_line = Line([-6.3, 1.65, 0], [-6.3, -2.05, 0], color=AMBER, stroke_width=4)
objects.add(marker, quote_line)
self.play(FadeIn(objects), run_time=.6)
return objects
def code_page(self, title, source, notes=()):
heading = text(title, 28, CYAN).move_to([0, 2.25, 0])
source_lines = source.splitlines()
lines = VGroup(*[Text(line.lstrip() or ' ', font='DejaVu Sans Mono', font_size=21, color=INK)
for line in source_lines])
lines.arrange(DOWN, aligned_edge=LEFT, buff=.10)
for row, line in zip(lines, source_lines):
row.shift(RIGHT * .13 * (len(line) - len(line.lstrip())))
if lines.width > 11.5:
lines.scale_to_fit_width(11.5)
if lines.height > 3.65:
lines.scale_to_fit_height(3.65)
lines.move_to([0, .10, 0])
footer = text(' · '.join(notes), 20, MUTED).move_to([0, -2.25, 0])
self.play(FadeIn(VGroup(heading, lines, footer)), run_time=.6)
def chart(self, key, labels):
d = DATA[key]
before, after = d['before_ms'], d['after_ms']
@ -128,24 +178,26 @@ class FilmBase(Scene):
FadeIn(ratio), FadeIn(metric), run_time=1.8)
return VGroup(condition, items, ratio, metric)
def opening(self):
self.chapter('00', '寻找史莱姆区块:从暴力搜索到结构化计算', '示意图用于解释工作分配;测速来自实际程序')
g = grid(9, .40).move_to([-3.3, -.15, 0])
title = VGroup(text('同一个问题', 38, CYAN), text('换一种计算方式', 38, MINT),
text('CPU → CUDA → 复用 → 数学性质', 24, MUTED)).arrange(DOWN, buff=.4).move_to([2.3, 0, 0])
self.play(LaggedStart(*[FadeIn(c) for c in g], lag_ratio=.008), FadeIn(title), run_time=1.5)
target = [g[i*9+j] for i in range(2, 7) for j in range(2, 7)]
self.play(*[c.animate.set_fill(MINT, opacity=.75) for c in target], run_time=.8)
self.say('我们想找一个 seed,以及一片连续 5×5 的史莱姆区块。', 6, display='我们想找一个 seed,以及一片连续 $5\\times5$ 的史莱姆区块。')
self.say('先让程序算得更快,再研究随机数本身的结构。', 6)
def problem(self):
self.chapter('01', 'CPU:先把正确答案算出来', '源码:01_cpu.cpp · 原始 Java Random 语义 · 不通过便立即退出')
g = grid(5, .62).move_to([-3.2, .15, 0])
code = VGroup(*[text(s, 25, c, 6.1) for s,c in [
('for seed in seeds:', CYAN), (' for (x, z) in anchors:', INK),
(' check 5 × 5 cells', INK), (' stop on first failure', AMBER),
(' collect complete hits', MINT)]]).arrange(DOWN, aligned_edge=LEFT, buff=.26).move_to([2.5, .25, 0])
self.chapter('02', 'CPU:先把正确答案算出来', '源码:01_cpu.cpp · 原始 Java Random 语义 · 不通过便立即退出')
g = grid(5, .48).move_to([-4.7, .15, 0])
source = '''for worldSeed in seeds:
for (x, z) in positions:
allSlime = true
for (dx, dz) in requiredOffsets:
if not isSlimeChunk(worldSeed, x + dx, z + dz):
allSlime = false
break
if allSlime:
record(worldSeed, x, z)'''
code = VGroup(*[Text(line.lstrip(), font='DejaVu Sans Mono', font_size=17,
color=AMBER if 'break' in line else INK)
for line in source.splitlines()])
code.arrange(DOWN, aligned_edge=LEFT, buff=.15)
for row, line in zip(code, source.splitlines()):
row.shift(RIGHT * .105 * (len(line) - len(line.lstrip())))
code.scale_to_fit_width(8.6).move_to([2.15, .25, 0])
self.play(FadeIn(g), FadeIn(code), run_time=1)
self.play(g[0].animate.set_fill(MINT, opacity=.6), g[1].animate.set_fill(AMBER, opacity=.8), run_time=.8)
self.say('对每个 seed,遍历锚点,再判断锚点周围的 25 格。', 6)
@ -154,8 +206,9 @@ class FilmBase(Scene):
self.play(FadeIn(note))
self.say('保留 CPU 实现,后面的优化都要核对相同搜索结果。', 6)
def cuda(self):
self.chapter('02', 'CUDA:每个 thread 负责一个 seed', '源码:02_cuda_seed.cuh · 图中只展示少量线程,实际启动更多线程')
self.chapter('03', 'CUDA:每个 thread 负责一个 seed', '源码:02_cuda_seed.cuh · 图中只展示少量线程,实际启动更多线程')
workers = VGroup(*[box(f'thread {i}', 1.45, .65) for i in range(6)]).arrange(RIGHT, buff=.3).move_to([0, 1.3, 0])
seeds = VGroup(*[text(f'seed {i}', 23, MINT) for i in range(6)]).arrange(RIGHT, buff=.75).move_to([0, .2, 0])
maps = VGroup(*[grid(3, .24) for _ in range(6)]).arrange(RIGHT, buff=1.0).move_to([0, -1.1, 0])
@ -170,8 +223,9 @@ class FilmBase(Scene):
self.say('A 组:CPU 搜索 3550 毫秒,GPU scan 111 毫秒,约快 32 倍。', 8)
self.say('这里比较的是搜索时间和 GPU scan,不能改拿 kernel 时间计算这个倍数。', 7)
def mapping(self):
self.chapter('03', '改变分工:固定坐标,遍历 seed', '源码:03_cuda_position.cuh · 坐标项存在线程私有数组中 · 不引入全局坐标表')
self.chapter('04', '改变分工:固定坐标,遍历 seed', '源码:03_cuda_position.cuh · 坐标项存在线程私有数组中 · 不引入全局坐标表')
self.coordinate_identity()
left = VGroup(box('thread → seed', 3.2, color=AMBER),
text('for each (x, z)', 25), text('compute F(x+dx), G(z+dz)', 22, AMBER)).arrange(DOWN, buff=.35).move_to([-3.4, .6, 0])
@ -189,17 +243,18 @@ class FilmBase(Scene):
self.say('B 组扩大到 4097×4097;相同任务下,从 31.599 秒降到 17.600 秒。', 8, display='B 组扩大到 $4097\\times4097$;相同任务下,从 31.599 秒降到 17.600 秒。')
self.say('这一组重新测量两个版本,只在组内比较:约快 1.80 倍。', 7)
def outer(self):
self.chapter('04', 'Outer sum:邻居也可以共享坐标项', '源码:04_cuda_outer_sum.cuh · 实际 block 为 16×16 · 示意图简化为 5×5')
self.chapter('05', 'Outer sum:邻居也可以共享坐标项', '源码:04_cuda_outer_sum.cuh · 实际 block 为 16×16 · 示意图简化为 5×5')
self.outer_identity()
g = grid(5, .54).move_to([0, .1, 0])
xs = VGroup(*[formula_tex(rf'F_{{{i}}}', 28, CYAN).move_to([g[i*5].get_x()-.75, g[i*5].get_y(), 0]) for i in range(5)])
zs = VGroup(*[formula_tex(rf'G_{{{i}}}', 28, MINT).move_to([g[i].get_x(), g[i].get_y()+.65, 0]) for i in range(5)])
xs = VGroup(*[formula_tex(rf'F(x+{i})', 28, CYAN).move_to([g[i*5].get_x()-.75, g[i*5].get_y(), 0]) for i in range(5)])
zs = VGroup(*[formula_tex(rf'G(z+{i})', 28, MINT).move_to([g[i].get_x(), g[i].get_y()+.65, 0]) for i in range(5)])
self.play(FadeIn(g), FadeIn(xs), FadeIn(zs), run_time=1)
self.play(*[g[2*5+j].animate.set_fill(CYAN, opacity=.4) for j in range(5)], run_time=.7)
self.say('固定 X 的坐标项沿一整行复用;固定 Z 的项沿一整列复用。', 7, display='固定 $X$ 的坐标项沿一整行复用;固定 $Z$ 的项沿一整列复用。')
self.play(*[g[i*5+3].animate.set_fill(MINT, opacity=.4) for i in range(5)], run_time=.7)
formula = formula_tex(r'T_{ij}=X_i+Z_j', 36, MINT, 4.5).move_to([3.9, -.1, 0])
formula = formula_tex(r'T(x,z)=F(x)+G(z)', 36, MINT, 4.5).move_to([3.9, -.1, 0])
self.play(FadeIn(formula))
self.say('把两条轴放进 shared memory,再用 outer sum 组合每格的输入。', 7)
self.play(FadeOut(VGroup(g, xs, zs, formula)))
@ -208,15 +263,17 @@ class FilmBase(Scene):
self.chart('C', ('fixed coordinate', 'tiled outer sum'))
self.say('C 组固定一个 seed;kernel 从 2.715 毫秒降到 1.598 毫秒。', 8)
self.say('重新测量 03 再比较 04,约快 1.70 倍;不同负载的倍数不能连乘。', 7)
self.say('这段共享轴项属于前四版的演进。当前最终搜索入口逐格计算坐标项,后面只使用坐标可分离的数学关系。', 12)
def coordinate_identity(self):
intro = text('先看送入 Random 之前的坐标加和项', 25, MUTED).move_to([0,2.05,0])
intro = text('回到坐标项:按 X、Z 分组', 25, MUTED).move_to([0,2.05,0])
original = formula_tex(
r'T(x,z)=\operatorname{J}_{32}(4987142x^2)+\operatorname{J}_{32}(5947611x)'
r'+4392871\operatorname{J}_{32}(z^2)+\operatorname{J}_{32}(389711z)',
34).move_to([0,1.25,0])
wrap = VGroup(
formula_tex(r'\operatorname{J}_{32}(a)=((a+2^{31})\bmod 2^{32})-2^{31}', 30, MUTED),
formula_tex(r'\operatorname{J}_{32}(\xi)=((\xi+2^{31})\bmod 2^{32})-2^{31}', 30, MUTED),
text('32 位回绕 → 有符号解释 → 提升到 64 位', 21, MUTED),
).arrange(DOWN, buff=.15).move_to([0,-1.9,0])
self.play(FadeIn(intro), FadeIn(original), FadeIn(wrap), run_time=1.1)
@ -235,150 +292,424 @@ class FilmBase(Scene):
self.say('这只是按变量重新分组,不是近似;每一项的整数回绕规则都保持原样。', 7)
self.play(FadeOut(VGroup(intro,original,x_part,z_part,wrap)), run_time=.6)
def outer_identity(self):
entry = formula_tex(r'T_{ij}=F(x_i)+G(z_j)=X_i+Z_j', 42, CYAN).move_to([0,1.8,0])
vectors = formula_tex(
r'\mathbf X=(X_0,\ldots,X_{m-1})^{\mathsf T},\qquad'
r'\mathbf Z=(Z_0,\ldots,Z_{n-1})^{\mathsf T}', 32, MUTED).move_to([0,.9,0])
identity = formula_tex(
r'\mathbf T=\underbrace{\mathbf X\mathbf 1_n^{\mathsf T}}_{\text{repeat X across columns}}'
r'+\underbrace{\mathbf 1_m\mathbf Z^{\mathsf T}}_{\text{repeat Z across rows}}',
40, MINT).move_to([0,-.2,0])
self.play(FadeIn(entry), FadeIn(vectors), FadeIn(identity), run_time=1.3)
self.say('对所有 X 和所有 Z 配对,第 i 行、第 j 列就是 X 的第 i 项加 Z 的第 j 项。', 8, display='对所有 $X$ 和所有 $Z$ 配对,第 $i$ 行、第 $j$ 列就是 $X_i+Z_j$。')
self.say('矩阵写法是:X 列向量复制到每列,Z 行向量复制到每行,再逐项相加。', 8, display='矩阵写法是:$X$ 列向量复制到每列,$Z$ 行向量复制到每行,再逐项相加。')
self.play(FadeOut(vectors), FadeOut(identity), run_time=.5)
example = formula_tex(
r'\begin{pmatrix}2&2&2\\5&5&5\end{pmatrix}'
r'+\begin{pmatrix}10&20&30\\10&20&30\end{pmatrix}'
r'=\begin{pmatrix}12&22&32\\15&25&35\end{pmatrix}',
40).move_to([0,.1,0])
note = text('数值仅用于演示广播相加;不是实测坐标项', 21, MUTED).move_to([0,-1.25,0])
contrast = text('outer sum:两条轴相加 · outer product:两条轴相乘', 24, AMBER).move_to([0,-2.0,0])
self.play(FadeIn(example), FadeIn(note), FadeIn(contrast), run_time=1)
self.say('例如 X 是二和五,Z 是十、二十、三十,就得到这张二乘三的加法表。', 8, display='例如 $X=(2,5)^{\\mathsf T}$,$Z=(10,20,30)^{\\mathsf T}$,就得到这张 $2\\times3$ 的加法表。')
self.say('两项 X 加三项 Z,只需五次轴函数计算;六个格子仍需六次相加。', 8, display='两项 $X$ 加三项 $Z$,只需五次轴函数计算;六个格子仍需六次相加。')
self.say('这就是 outer sum:所有交叉组合都相加。外积则是相乘,运算不同。', 7)
self.say('我们复用的是 Random 之前的坐标项,仍要逐格执行随机数判断。', 8)
self.play(FadeOut(VGroup(entry,example,note,contrast)), run_time=.6)
self.page('两条轴的交点就是两项之和', [
(r'T(x,z)=F(x)+G(z)', CYAN),
(r'\begin{array}{c|ccc}&G(z)&G(z+1)&G(z+2)\\\hline F(x)&F(x)+G(z)&F(x)+G(z+1)&F(x)+G(z+2)\\ F(x+1)&F(x+1)+G(z)&F(x+1)+G(z+1)&F(x+1)+G(z+2)\end{array}', INK),
], [('先准备两条轴,再按需要组合;随机数判断仍逐格执行', MINT)])
self.say('横轴和纵轴各算一次。表中每个交点都是对应两项之和,相邻线程不必重新计算相同的坐标多项式。', 12)
self.clear_body()
def random(self):
self.chapter('05', '研究 Random:从计算速度转向状态空间', '性质章节独立于 01–04 的测速 · seed 均指有效的 48-bit 状态等价类')
bits = VGroup(*[Rectangle(width=.19, height=.55, stroke_width=.7,
stroke_color=BG, fill_color=AMBER if i<16 else CYAN,
fill_opacity=1) for i in range(64)]).arrange(RIGHT, buff=.015).move_to([0, 1.25, 0])
labels = VGroup(text('高 16 位:不影响 Random 结果', 24, AMBER).move_to([-4.0,.3,0]),
text('低 48 位:决定内部状态', 24, CYAN).move_to([2,.3,0]))
self.play(FadeIn(bits), FadeIn(labels), run_time=1)
formula = VGroup(formula_tex(r'u=\bigl((\mathrm{seed}+T)\mathbin{\mathtt{xor}}C\bigr)\bmod 2^{48}', 34, CYAN),
formula_tex(r'v=(Au+11)\bmod 2^{48}', 34),
formula_tex(r'q=\left\lfloor\frac{v}{2^{17}}\right\rfloor', 34, MINT)).arrange(DOWN, buff=.25).move_to([0, -1.4, 0])
self.play(FadeIn(formula))
self.say('输入是 64 位,但 Java Random 的状态只有 48 位。', 7)
self.say('相差 2^48 的 seed 给出相同结果,包括拒绝重抽的路径。', 7, display='相差 $2^{48}$ 的 seed 给出相同结果,包括拒绝重抽的路径。')
self.say('这不是一个微小的计时优化,而是搜索空间里的等价关系。', 7)
self.play(FadeOut(VGroup(bits,labels,formula)))
self.lower35_proof()
def clear_body(self):
body = [m for m in self.mobjects if m not in self.chrome and m is not self.subtitle]
if body:
self.play(*[FadeOut(m) for m in body], run_time=.4)
def lower35_proof(self):
def page(title, equations, note=None):
heading = text(title, 27, CYAN).move_to([0, 2.15, 0])
rows = VGroup(*[formula_tex(eq, 32, color) for eq, color in equations])
rows.arrange(DOWN, buff=.32).move_to([0, .25, 0])
objects = VGroup(heading, rows)
if note:
objects.add(text(note, 21, MUTED).move_to([0, -2.25, 0]))
self.play(FadeIn(objects), run_time=.7)
return objects
def measured_effect(self, key, title, notes=()):
self.clear_body()
d = DATA[key]
return self.page(title, [
(rf'{d["before_ms"]:.3f}\,\mathrm{{ms}}\ \longrightarrow\ {d["after_ms"]:.3f}\,\mathrm{{ms}}', MINT),
(rf'{d["before_ms"]/d["after_ms"]:.2f}\times', AMBER),
], [(d['workload'], INK), (d['metric'], MUTED), *notes], kind='measurement')
first = page('先明确未知量:不是把高 13 位忽略', [
(r's=\ell+2^{35}h,\quad 0\le\ell<2^{35},\quad0\le h<8192', CYAN),
(r'u_i(h)=\bigl((s+d_i)\mathbin{\mathtt{xor}}C\bigr)\bmod2^{48}', INK),
(r'C=\mathtt{0x5E434E432},\qquad 2^{34}\le C<2^{35}', MINT),
], '固定坐标求解:s 是世界种子')
self.say('低 35 位是待求输入的一部分;固定它之后,还要求出高 13 位。', 8, display='$\\ell=s\\bmod2^{35}$ 是待求输入的低 35 位;固定它之后,还要求出高 13 位。')
self.say('为什么选 35?合并后的 XOR 常量恰好占 35 位,不会翻转更高位。', 9)
self.play(FadeOut(first))
def opening(self):
self.chapter('01', '史莱姆区块:先看单个区块如何判定', 'DOCUMENT.md 第 0 节 · 坐标单位为区块,每块水平覆盖 16×16 方块')
self.code_page('直接使用 Java Random', '''boolean isSlimeChunk(long worldSeed, int x, int z) {
long coordinateTerm = (long) (x * x * 4987142)
+ (long) (x * 5947611)
+ (long) (z * z) * 4392871L
+ (long) (z * 389711);
long randomSeed = (worldSeed + coordinateTerm) ^ 987234911L;
return new Random(randomSeed).nextInt(10) == 0;
}''', ['世界种子 + 区块坐标 → 返回 0 才是史莱姆区块'])
self.say('先看一个区块。坐标项与世界种子相加,异或后初始化 Random,再调用 nextInt 十。返回零就是史莱姆区块。', 13)
self.say('同一个世界种子、同一个坐标,总得到相同结果。我们先加快这种检查,再研究如何反向搜索种子。', 11)
self.clear_body()
self.code_page('展开 Random:先初始化 48 位状态', '''final long A = 0x5DEECE66DL;
final long mask48 = (1L << 48) - 1;
long randomSeed = (worldSeed + coordinateTerm) ^ 987234911L;
long u = (randomSeed ^ A) & mask48;
// C = 987234911L ^ A:两次 XOR 合并后的常量''', ['A:固定乘数;u:初始状态;& mask48:只留低 48 位'])
self.say('Random 初始化还会再异或一次固定常量 A,并保留低四十八位,得到初始状态 u。两次异或合并后的常量记为 C。', 13,
display=r'初始化得到 $u$;两次 XOR 合并为 $C=987234911\mathbin{\mathrm{xor}}A$。')
self.clear_body()
self.code_page('展开 Random:更新、取数、必要时重抽', '''while (true) {
long v = (A * u + 11) & mask48;
int q = (int) (v >>> 17);
int value = q % 10;
if ((long) q - value + 9 <= 0x7FFFFFFFL) {
return value == 0;
}
u = v; // 本次输出被拒绝,从当前状态继续取数
}''', ['v:更新后的状态;q:高 31 位原始输出'])
self.say('状态乘 A 加十一,截成四十八位,再取高三十一位。通常取除以十的余数;极少数过大的输出会被拒绝,再更新一次。', 14)
self.clear_body()
self.page('坐标项的完整公式与整数回绕', [
(r'J_{32}(\xi)=((\xi+2^{31})\bmod2^{32})-2^{31}', MUTED),
(r'T(x,z)=J_{32}(4987142x^2)+J_{32}(5947611x)', CYAN),
(r'\qquad+4392871J_{32}(z^2)+J_{32}(389711z)', CYAN),
], [('J₃₂:保留低 32 位,再按有符号整数解释', INK),
('X 的平方乘系数后转 long;Z 的平方先转 long 再乘系数', AMBER)])
self.say('坐标项的数学公式放在这里。J 三十二保留 Java 的整数回绕;X 和 Z 转成长整数的位置不同,必须保留。', 13)
carry = page('保留坐标进位,再分离 XOR', [
(r'(\ell+d_i)\bmod2^{48}=L_i+2^{35}H_i', INK),
(r'(s+d_i)\bmod2^{48}=L_i+2^{35}\bigl((H_i+h)\bmod8192\bigr)', CYAN),
(r'u_i(h)=\bigl(u_i(0)+2^{35}h\bigr)\bmod2^{48}', MINT),
], 'Hᵢ 包含坐标加法的进位;C 只 XOR 低位 Lᵢ')
self.say('坐标项加到低位上可能进位,我们把进位保留在 H 里面。', 8, display='坐标项加到低位上可能进位,我们把进位保留在 $H_i$ 里面。')
self.say('XOR 只改变低位,所以改变 h,相当于让初始化状态增加 h 乘 2^35。', 10, display='XOR 只改变低位,所以改变 $h$,相当于让初始化状态增加 $h\\,2^{35}$。')
self.say('这个平移来自位域分离,不能把一般的 XOR 当成线性运算。', 8)
self.play(FadeOut(carry))
def random_bridge(self):
self.chapter('06', 'Random:状态更新与 48 位等价关系', 'DOCUMENT.md 第 5 节 · 从加快枚举转向减少候选')
self.page('先把单个区块的随机数过程写成公式', [
(r'A=\mathtt{0x5DEECE66D},\quad C=A\mathbin{\mathrm{xor}}987234911', MUTED),
(r'u=((w+T(x,z))\mathbin{\mathrm{xor}}C)\bmod2^{48}', CYAN),
(r'v=(Au+11)\bmod2^{48},\qquad q=v\gg17', MINT),
], [('w:世界种子的低 48 位;u、v:更新前后的状态', INK),
('q:首次 31 位输出;普通候选要求 q 能被 10 整除', AMBER)])
self.say('坐标公式已经解释过。现在只看 Random:先初始化 u,乘 A 加十一得到 v,再取高三十一位 q。', 11)
self.say('前面四个版本仍检查同一批候选。后面利用这些确定的运算,排除不可能的种子,并直接求出部分未知位。', 12)
self.clear_body()
self.page('史莱姆判断只看世界种子的低 48 位', [
(r'w\in[0,2^{48}),\qquad 2^{48}=281474976710656', CYAN),
(r'\mathrm{worldSeed}\mapsto\mathrm{worldSeed}+k2^{48}', INK),
], [('初始化只保留低 48 位,因此同一坐标上的完整随机序列相同', MINT),
('这是史莱姆判定等价,不表示整个 Minecraft 世界相同', MUTED)])
self.say('初始化只保留四十八位。因此世界种子相差二的四十八次方的整数倍,在同一坐标上得到相同随机序列。', 12)
self.say('这只是史莱姆判断等价。候选生成暂按首次输出分析,完整 Java 验证和覆盖边界在最后交代。', 11)
output = page('一次状态推进:35 − 17 = 18', [
(r'v_i(h)=\bigl(v_i(0)+Ah\,2^{35}\bigr)\bmod2^{48}', INK),
(r'q_i(h)=\bigl(q_i(0)+Ah\,2^{18}\bigr)\bmod2^{31}', CYAN),
(r'q_i(0)=r_i+2^{18}b_i', INK),
(r'q_i(h)=r_i+2^{18}\bigl((b_i+Ah)\bmod8192\bigr)', MINT),
], '0 ≤ rᵢ < 2¹⁸;0 ≤ bᵢ < 8192;所有格子共用同一个 h')
self.say('LCG 先乘 A。next(31) 再去掉状态低 17 位,因此 2^35 变成 2^18。', 11, display='LCG 先乘 $A$。next(31) 再去掉状态低 17 位,因此 $2^{35}$ 变成 $2^{18}$。')
self.say('把首次输出拆成 r 和 b:高位 h 只改变 b,输出的低 18 位 r 保持不变。', 10, display='把首次输出拆成 $r_i$ 和 $b_i$:高位 $h$ 只改变高位部分,低 18 位 $r_i$ 保持不变。')
self.play(FadeOut(output))
def normalize(self):
self.chapter('07', '有效种子:先反推关系,再引入窗口', 'DOCUMENT.md 第 6 节 · 此处开始统一的 5×5 贯穿案例')
self.page('先看一个固定区块:求 s,再恢复 w', [
(r's=(w+T(x,z))\bmod2^{48}', CYAN),
(r'u=(s\mathbin{\mathrm{xor}}C)\bmod2^{48}', INK),
(r'w=(s-T(x,z))\bmod2^{48}', MINT),
], [('坐标固定后,T(x,z) 已知;先搜索有效种子 s', INK),
('可逆换元,不删除候选', AMBER)])
self.say('先只看一个固定区块。把世界种子加坐标项记成有效种子 s。求到 s 后,再减去已知坐标项就能反推 w。', 13)
self.clear_body()
self.page('现在扩展到同一个实心 5×5 窗口', [
(r'51069\le x\le51073,\qquad419124\le z\le419128', CYAN),
(r'\begin{array}{c|rrrrr}z\backslash x&51069&51070&51071&51072&51073\\\hline419124&0&1&2&3&4\\419125&5&6&7&8&9\\419126&10&11&12&13&14\\419127&15&16&17&18&19\\419128&20&21&22&23&24\end{array}', INK),
], [('0 号格是最小 X、最小 Z 的锚点;要求全部 25 格通过', MINT)], kind='example')
self.say('反推关系建立后,再引入窗口。锚点是五万一千零六十九、四十一万九千一百二十四,沿 X 优先编号零到二十四。', 13)
self.say('后面每个数值例子都使用这一个窗口。只展示部分格子时,仍然来自这张表。', 10)
self.clear_body()
self.page('其他格子只需补上相对坐标项', [
(r'd_i=(T_i-T_0)\bmod2^{48},\qquad d_0=0', MINT),
(r's=(w+T_0)\bmod2^{48}', CYAN),
(r'w+T_i\equiv s+d_i\pmod{2^{48}}', INK),
(r'u_i=((s+d_i)\mathbin{\mathrm{xor}}C)\bmod2^{48}', INK),
], [('dᵢ:该格坐标项与锚点的差;Tᵢ:第 i 格的坐标项', MUTED)])
self.say('窗口各格共享同一个世界种子。有效种子已经吸收锚点项,其他格只需补上坐标项的差,记作 d 下标 i。', 13)
self.say('把各格坐标项简记为 T 下标 i,锚点项为 T 零。以后统一从 s 加 d 计算,求到 s 后减回 T 零。', 12)
self.clear_body()
self.page('贯穿案例:计算坐标项,再求相对项', [
(r'J_{32}(4987142\cdot51069^2)=-1799531722', MUTED),
(r'T_0=-1883366190470623,\quad T_1=-1883363608967202', CYAN),
(r'd_1=T_1-T_0=2581503421', MINT),
(r's=(w-1883366190470623)\bmod2^{48}', INK),
], [('现在 s、w 尚未知;接下来先找 s 的低 18 位', AMBER)], kind='example')
self.say('前两格坐标项按 Java 回绕算出,相减得到右邻格的相对项二十五亿八千一百五十万三千四百二十一。', 12)
self.say('现在仍不知道 s 和 w。下一步先筛出可能的低十八位,逐步求回完整有效种子。', 10)
modular = page('模 10 条件 = 奇偶条件 + 模 5 条件', [
(r'y_i=(b_i+Ah)\bmod8192,\qquad q_i=r_i+2^{18}y_i', INK),
(r'2^{18}\equiv0\pmod2,\qquad2^{18}\equiv-1\pmod5', CYAN),
(r'q_i\equiv0\pmod{10}\iff r_i\equiv0\pmod2\ \land\ y_i\equiv r_i\pmod5', MINT),
], '这里先求 q mod 10 = 0 的候选;Java 拒绝重抽在后面单独说明')
self.say('如果 r 是奇数,所有 h 都无法让首次输出被 10 整除。', 8, display='如果 $r_i$ 是奇数,所有 $h$ 都无法让首次输出被 10 整除。')
self.say('r 为偶数时,因为 2^18 模 5 等于负一,剩下的要求就是 y 与 r 模 5 相同。', 11, display='$r_i$ 为偶数时,因为 $2^{18}\\equiv-1\\pmod5$,剩下的要求就是 $y_i\\equiv r_i\\pmod5$。')
self.play(FadeOut(modular))
def low_roots(self):
self.chapter('08', '第一次筛选:只枚举有效种子的低 18 位', '本章输出:低位根 p;代码中称为 root')
self.page('偶数条件只依赖状态的第 17 位', [
(r's=p+2^{18}m+2^{35}h', CYAN),
(r'q_i=v_i\gg17', INK),
(r'\operatorname{bit}_0(q_i)=\operatorname{bit}_{17}(v_i)', MINT),
], [('p:低 18 位;m:中间 17 位;h:高 13 位', CYAN),
('能被 10 整除,首先必须是偶数', AMBER)])
self.say('把有效种子拆成低十八位 p、中间十七位 m 和高十三位 h。', 8,
display=r'有效种子拆成低 18 位 $p$、中间 17 位 $m$、高 13 位 $h$。')
self.say('首次输出是状态右移十七位,因此输出的最低位,对应状态的第十七位。只需算状态低十八位,就能判断奇偶。', 12)
self.clear_body()
self.page('只在计算末尾取模,逐格检查偶数条件', [
(r'[A((p+d_i)\mathbin{\mathrm{xor}}C)+11]\bmod2^{18}<2^{17}', MINT),
(r'0\le p<2^{18}=262144', CYAN),
], [('加法、乘法与 XOR 的高位不会反过来影响低 18 位', INK),
('有一格失败:排除整个 p;全部通过:保存为根', AMBER),
('每根仍有 131072 种中间位;模 5 条件尚未检查', MUTED)])
self.say('先做完整整数运算,末尾统一保留低十八位。没有必要对加法输入和异或常量分别取模。', 10)
self.say('结果小于二的十七次方,说明第十七位为零。枚举全部 p,有一格失败就排除它,全部通过才保存为根。', 12)
self.say('这一轮只保证首次输出都是偶数。不同位置的根数不同,不能假设每增加一格就恰好减半。', 10)
self.clear_body()
d = DATA['ROOT']
self.page('已知 5×5 窗口的低位候选数量', [
(rf'262144\longrightarrow {d["roots"]}\ \mathrm{{roots}}', MINT),
(rf'{d["roots"]}\times131072={d["lower35"]}', CYAN),
], [('锚点 (51069,419124),全部 25 格要求为史莱姆区块', INK),
('84 是这个窗口的实测结果,不是固定根数', MUTED)], kind='measurement')
self.say('这个已知窗口留下八十四个根。每根再补全部中间位,一共仍有一千一百零一万零四十八种低三十五位。', 12)
intervals = page('换变量:每个格子最多回绕一次', [
(r't=Ah\bmod8192,\qquad h=4965t\bmod8192', CYAN),
(r'y_i=(b_i+t)\bmod8192', INK),
(r'\begin{cases}t\equiv r_i-b_i\pmod5,&t<8192-b_i,\\'
r't\equiv r_i-b_i+2\pmod5,&t\ge8192-b_i.\end{cases}', MINT),
], 'A 是奇数,模 8192 可逆;回绕后 +2 来自 8192 mod 5 = 2')
self.say('把 A 乘 h 换成 t。A 是奇数,这个换元一一对应,不会丢掉高位解。', 10, display='令 $t=Ah\\bmod8192$。$A$ 是奇数,这个换元一一对应,不会丢掉高位解。')
self.say('b 加 t 最多回绕一次,所以每格只有两段固定的模 5 要求。', 9, display='$b_i+t$ 最多回绕一次,所以每格只有两段固定的模 5 要求。')
self.play(FadeOut(intervals))
self.clear_body()
self.page('贯穿案例:沿其中的根 p = 218767 继续', [
(r'p=218767', CYAN),
(r'v_0\bmod2^{18}=59524<131072', INK),
(r'v_1\bmod2^{18}=80305<131072', MINT),
], [('其余 23 格也通过奇偶条件;m、h 仍未知', AMBER)], kind='example')
self.say('八十四个根中,我们跟随二十一万八千七百六十七。前两格的低位结果都小于二的十七次方,其余格子也通过。', 12)
example = page('数值例子:看见回绕怎样改变要求', [
(r'r=2,\quad b=8190,\quad8192-b=2', CYAN),
(r'0\le t<2:\ t\equiv2\pmod5\quad\Longrightarrow\quad\varnothing', INK),
(r'2\le t<8192:\ t\equiv4\pmod5\quad\Longrightarrow\quad t=4,9,14,\ldots', MINT),
(r't=4:\quad y=2,\quad q=2+2^{18}\cdot2=524290', AMBER),
], 'r、b 为示意数值;取 t=4 时恢复 h=3476')
self.say('例如 r 等于二、b 等于 8190,t 到二就回绕,允许的余数从二变成四。', 11, display='例如 $r_i=2$、$b_i=8190$,$t=2$ 时回绕,允许的余数从二变成四。')
self.say('取 t 等于四,输出为 524290,确实能被十整除。', 8, display='取 $t=4$,输出为 $q=524290$,确实能被十整除。')
self.play(FadeOut(example))
def class_roots(self):
self.chapter('09', '复用筛选结果:同一坐标类只生成一次根', '本章输出:有根位置与 p 的任务;每张 GPU 使用有限缓存')
self.page('先展开相对坐标项的低 18 位', [
(r'd_i=F(x+\alpha_i)-F(x)+G(z+\beta_i)-G(z)\pmod{2^{48}}', CYAN),
(r'd_i\equiv4987142(2x\alpha_i+\alpha_i^2)+5947611\alpha_i', INK),
(r'\qquad+4392871(2z\beta_i+\beta_i^2)+389711\beta_i\pmod{2^{18}}', INK),
], [('αᵢ、βᵢ 是第 i 格相对锚点的坐标偏移', MUTED),
('Java 回绕与符号扩展不会改变这里的低 18 位', MINT)])
self.say('相对坐标项是两格坐标项的差。只看低十八位时,可以展开原来的二次多项式。', 10)
self.say('注意与锚点位置相关的项:横轴系数是二乘四百九十八万七千一百四十二,纵轴系数是二乘四百三十九万二千八百七十一。', 13)
self.clear_body()
self.page('移动一个周期,相对项变化为 2¹⁸ 的倍数', [
(r'x\mapsto x+2^{16}:\quad \Delta d_i=2\cdot4987142\cdot2^{16}\alpha_i', CYAN),
(r'z\mapsto z+2^{17}:\quad \Delta d_i=2\cdot4392871\cdot2^{17}\beta_i', MINT),
(r'\mathrm{class}(x,z)=(x\bmod65536,\ z\bmod131072)', INK),
], [('横轴系数 4987142 是偶数,多提供一个因子 2', AMBER),
('同类位置的相对项低 18 位相同,完整根列表相同', MINT)])
self.say('横轴系数是偶数,加上前面的二,所以移动二的十六次方,相对项就改变二的十八次方的整数倍。', 11)
self.say('纵轴移动二的十七次方,也得到同样的整倍数变化。因此用这两个坐标余数分类,同类可以共享完整根列表。', 12)
self.clear_body()
self.page('先复用根,再展开具体位置', notes=[
('位置归类 → 查缓存;未缓存的类才筛根', CYAN),
('无根类直接跳过;有根类展开具体位置与根', MINT),
('只共享低位条件,完整相对项和高位约束仍按位置计算', AMBER),
('根 p 是有效种子的低位,世界种子还要减回锚点项', MUTED)])
self.say('缓存省的是重复筛根。无根类直接跳过,有根类才展开具体位置。共享根不代表最终世界种子相同。', 11)
self.clear_body()
self.page('贯穿案例:这个窗口属于哪个坐标类', [
(r'(51069\bmod65536,\ 419124\bmod131072)=(51069,25908)', CYAN),
(r'\mathrm{cached\ roots}:\quad84', MINT),
], [('同类位置复用这 84 个根;完整高位约束仍需各自求解', INK),
('单个窗口不测缓存复用收益;范围任务取决于类的重复率', MUTED)], kind='example')
self.say('这个窗口的类键为五万一千零六十九、二万五千九百零八。同类位置可以复用相同的八十四个根。', 11)
self.say('缓存减少重复筛根,不减少当前窗口的根。单窗口无法测量跨位置的复用收益,因此这里不混入另一个批次的测速。', 12)
joint = page('多个格子:按回绕点分段,再求交', [
(r't\in[a_j,a_{j+1}),\qquad t\equiv c_i\pmod5\ \ (\forall i)', INK),
(r'c_i\ne c_k\ \Longrightarrow\ \varnothing', AMBER),
(r'c_i=c\ \ (\forall i)\ \Longrightarrow\ t=c+5k\in[a_j,a_{j+1})', MINT),
(r'\underbrace{18}_{\mathrm{parity}}+\underbrace{17}_{\mathrm{enumerate}}'
r'+\underbrace{13}_{\mathrm{solve}}=48', CYAN),
], '输入低 18 位决定首次输出奇偶;再补上中间 17 位得到低 35 位;最后求高 13 位')
self.say('排序所有格子的回绕点。每段内,只要两格要求不同余数,就能丢掉整段。', 10)
self.say('要求一致时,直接按间隔五生成 t,再恢复 h,不必逐个检查 8192 个高位。', 11, display='要求一致时,直接按间隔五生成 $t$,再恢复 $h$,不必逐个检查 8192 个高位。')
self.say('低 18 位先筛奇偶,中间 17 位目前仍需扩展;低 35 位候选的生成成本没有消失。', 10, display='低 18 位先筛奇偶,中间 17 位仍需扩展;$\\ell=s\\bmod2^{35}$ 的供给成本没有消失。')
self.play(FadeOut(joint))
def prepare(self):
self.chapter('10', '每个根预计算一次,避免中间位循环重复计算', '本章输出:公共项 γᵢ、ωᵢ;E 是固定 XOR 常量的高位部分')
self.page('固定根,暂取 h = 0,定义基准输出', [
(r'\ell=p+2^{18}m,\qquad \bar q_i=q_i(0)', CYAN),
(r'\sigma_i=p+d_i=\eta_i+2^{18}\gamma_i', INK),
(r'\eta_i=\sigma_i\bmod2^{18},\quad \gamma_i=\lfloor\sigma_i/2^{18}\rfloor', MINT),
(r'\ell+d_i=\eta_i+2^{18}(\gamma_i+m)', INK),
], [('横线表示 h = 0 的基准输出,不表示平均值', MUTED)])
self.say('先暂时取高位 h 为零,把这个输出记为 q 上面加横线。横线表示基准值,不表示平均值。', 10,
display=r'暂取 $h=0$,定义基准输出 $\bar q_i=q_i(0)$;横线不表示平均值。')
self.say('根加相对项得到 sigma。拆成低十八位 eta,以及剩余部分 gamma。加入 m 后,只有高位部分变成 gamma 加 m。', 12,
display=r'$\sigma_i=p+d_i=\eta_i+2^{18}\gamma_i$;加入 $m$ 后,高位变成 $\gamma_i+m$。')
self.clear_body()
self.page('低位和高位分别 XOR,固定贡献只算一次', [
(r'E=C\gg18=\mathtt{0x1790d}', CYAN),
(r'\omega_i=\left\lfloor\frac{A(\eta_i\mathbin{\mathrm{xor}}(C\bmod2^{18}))+11}{2^{17}}\right\rfloor', INK),
(r'\bar q_i(m)=[\omega_i+2A((\gamma_i+m)\mathbin{\mathrm{xor}}E)]\bmod2^{31}', MINT),
], [('γᵢ:根加相对项后的高位;ωᵢ:低位推进贡献', CYAN),
('位置与根固定后,γᵢ、ωᵢ 均不随 m 改变', AMBER)])
self.say('把初始化常量的高位部分记为大写 E。低十八位推进产生的固定输出贡献记为 omega。', 10,
display=r'固定常量 $E=C\gg18$;低位推进的贡献记为 $\omega_i$。')
self.say('剩下的输入项乘 A 后右移十七位,二的十八次方变成系数二,得到下面的基准输出公式。', 11)
self.say('每个位置与根只准备一次 gamma 和 omega。后续把 m 代入,还能用三十二位整数计算最终回绕。', 11,
display=r'每个位置与根只准备一次 $\gamma_i,\omega_i$,随后代入 $m$。')
self.clear_body()
self.page('贯穿案例:前两格的公共项', [
(r'\begin{array}{c|r|r|r}i&\eta_i&\gamma_i&\omega_i\\\hline0&218767&0&46624833586\\1&128076&9848&13419656886\end{array}', INK),
(r'\bar q_0(40302)=227860608,\quad\bar q_1(40302)=1920935636', MINT),
], [('根仍为 218767;m = 40302 是后面筛出的成功分支', CYAN),
('ωᵢ 是中间贡献,可以大于 31 位;最终输出才取模', MUTED)], kind='example')
self.say('这个根的前两格公共项如表。把后来筛出的中间位四万零三百零二代入,就得到这两个基准输出。', 12)
self.measured_effect('P', '同一个 5×5 窗口:公共项预计算')
self.say('同窗口搜索从零点三二三五八四毫秒降到零点二六八二八八毫秒,约快一点二一倍。含每轮公共项准备,不含筛根和传输。', 14)
boundary = page('公式精确,但候选条件有适用边界', [
(r'q<2^{31}-8:\quad\mathrm{nextInt}(10)=q\bmod10', MINT),
(r'q\ge2^{31}-8:\quad\mathrm{reject\ and\ redraw}', AMBER),
(r'2147483640\bmod10=0\quad\text{but this output is rejected}', INK),
], '精确验证能去掉假阳性,不能补回首次输出筛选漏掉的重抽成功解')
self.say('Java 会拒绝最高八个首次输出并重抽;此时不能只看首次输出模十。', 10)
self.say('所以这里是首次输出候选模型,还不能宣称穷尽了所有 Java Random 解。', 9)
def group_filter(self):
self.chapter('11', '中间位:输出差早筛与 16 候选掩码', 'DOCUMENT.md 第 9、10、12 节 · 固定位置与 p,先不枚举 h')
self.page('未知高位不进入 XOR 翻转的低 35 位', [
(r'(\ell+d_i)\bmod2^{48}=\lambda_i+2^{35}\kappa_i,\quad C<2^{35}', CYAN),
(r'u_i(h)\equiv u_i(0)+2^{35}h\pmod{2^{48}}', INK),
(r'v_i(h)=(Au_i(0)+11+Ah\,2^{35})\bmod2^{48}', MINT),
(r'v_i(0)=(Au_i(0)+11)\bmod2^{48}', INK),
], [('κᵢ 已包含坐标加法的进位;11 已包含在基准状态 vᵢ(0) 中', AMBER)])
self.say('坐标进位已经包含在 kappa 中。C 只翻转低三十五位,因此高位 h 给初始状态增加二的三十五次方乘 h。', 12)
self.say('再乘 A 加十一。十一没有消失,它已经包含在基准状态 v 下标 i 的零参数里,所以新增量只有 A 乘 h 乘二的三十五次方。', 14)
self.clear_body()
self.page('共同平移在输出差中抵消', [
(r'q_i(h)=(\bar q_i+Ah\,2^{18})\bmod2^{31}', CYAN),
(r'\delta_i=(\bar q_i-\bar q_0)\bmod2^{31}', MINT),
(r'(q_i(h)-q_0(h))\bmod2^{31}=\delta_i', INK),
(r'2^{31}\bmod10=8,\quad\delta_i\bmod10\in\{0,8\}', AMBER),
], [('位置、p、m 固定后,δᵢ 与 h 无关;通过仍只是必要条件', MUTED)])
self.say('取高三十一位后,各格输出加上同一个量。相减可以消掉未知高位,所以先检查输出差。', 11)
self.say('两个输出都被十整除,直接差也被十整除。负差取模要加二的三十一次方,故模差只能除以十余零或八。', 12)
self.clear_body()
self.page('贯穿案例:两格输出与模差', [
(r'p=218767,\quad m=40302,\quad h=2995', CYAN),
(r'Ah\,2^{18}\bmod2^{31}=886833152', INK),
(r'q_0=1114693760,\quad q_1=660285140', MINT),
(r'q_1-q_0=-454408620,\quad\delta_1=1693075028', INK),
], [('δ₁ 除以 10 余 8;由基准输出相减得到完全相同的值', AMBER),
('这里先展示成功分支;高位如何得到,下一章展开', MUTED)], kind='example')
self.say('这个窗口的成功分支给两格相同的平移。右邻格先回绕,直接差为负四亿五千四百四十万八千六百二十。', 12)
self.say('取模后是十六亿九千三百零七万五千零二十八,除以十余八。直接用基准输出相减也得到同一结果。', 12)
self.clear_body()
self.page('16 候选成组:只改变 E 不翻转的四个位', [
(r'E=\mathtt{0x1790d},\quad\operatorname{bits}_{4:7}(E)=0000', AMBER),
(r'm_j=\mathrm{base}+16j,\quad0\le j<16', MINT),
(r'\mathrm{base}=256\lfloor g/16\rfloor+(g\bmod16),\quad0\le g<8192', CYAN),
], [('同一段:γᵢ + mⱼ 没跨过 256 的整数倍', INK),
('加 16 只改变第 4~7 位,因此 XOR 后也加 16', MINT)])
self.say('E 的第四到第七位为零。让中间位只改变这四位,得到间隔十六的一组十六个候选,总共八千一百九十二组。', 13)
self.say('同一段是指当前格的 gamma 加 m 没跨过二百五十六的整数倍。这时加十六只改变那四位,异或后也增加十六。', 13)
self.clear_body()
self.page('同步增加,模输出差保持不变', [
(r'\bar q_i(m_{j+1})\equiv\bar q_i(m_j)+32A\pmod{2^{31}}', CYAN),
(r'\bar q_0(m_{j+1})\equiv\bar q_0(m_j)+32A\pmod{2^{31}}', INK),
(r'\delta_i(m_{j+1})\equiv(\bar q_i+32A)-(\bar q_0+32A)', MINT),
(r'\qquad\equiv\delta_i(m_j)\pmod{2^{31}}', MINT),
], [('锚点 γ₀ = 0,整组不跨界;当前格在同一段内不跨界', MUTED),
('整组跨度 240 < 256,每格最多跨界一次,最多两段', AMBER)])
self.say('异或后每增加十六,输出增加三十二 A。锚点也增加同一个量,相减后增量抵消,同一段的模输出差完全相同。', 14)
self.say('跨界时进位进入可能被异或翻转的高位,差值可能切换。之后又保持不变。一组跨度只有二百四十,因此最多两段。', 13)
self.clear_body()
self.page('贯穿案例:选中包含 m = 40302 的组', [
(r'\mathrm{base}=40206,\quad\gamma_1=9848,\quad E=96525', CYAN),
(r'm_j=40206+16j,\quad j=0,\ldots,15', MINT),
(r'\begin{array}{r|r|r|r|r|r}j&m_j&\gamma_1+m_j&m_j\mathbin{\mathrm{xor}}E&(\gamma_1+m_j)\mathbin{\mathrm{xor}}E&\mathrm{diff}\\\hline0&40206&50054&123907&113291&-10616\\1&40222&50070&123923&113307&-10616\\7&40318&50166&124019&113403&-10616\\8&40334&50182&124035&113931&-10104\\15&40446&50294&124147&114043&-10104\end{array}', INK),
], [('j = 7 → 8:右邻格输入跨过 50176,这是 256 的整数倍', AMBER)], kind='example')
self.say('仍是这个窗口和这个根。组首为四万零二百零六,右邻格 gamma 为九千八百四十八。前八个候选的输入差都是负一万零六百一十六。', 14)
self.say('从七号到八号候选,右邻格输入跨过五万零一百七十六,异或后的输入差变成负一万零一百零四。后半组又保持相同。', 14)
self.clear_body()
self.page('贯穿案例:输入差变成真正的输出差', [
(r'\delta_1=\begin{cases}1693075028,&j=0,\ldots,7,\\411302484,&j=8,\ldots,15.\end{cases}', MINT),
(r'\delta_1=(\omega_1-\omega_0+2A\cdot\mathrm{input\ difference})\bmod2^{31}', INK),
], [('前段余 8:保留 j = 0~7;后段余 4:整段排除', AMBER),
('右邻格的允许掩码为 0x00ff', CYAN)], kind='example')
self.say('输入差还要乘二 A,加上公共项之差,再取模,才是输出差。前段余八、后段余四,因此一次保留前八个、排除后八个。', 14)
self.clear_body()
self.page('贯穿案例:全部 25 格的允许掩码求交', [
(r'\begin{array}{c|c|c}i&\mathrm{allowed\ mask}&\mathrm{remaining}\ j\\\hline1&\mathtt{00ff}&0\ldots7\\3&\mathtt{fffc}&2\ldots7\\4&\mathtt{fff8}&3\ldots7\\8&\mathtt{fff0}&4\ldots7\\9&\mathtt{ffc0}&6,7\end{array}', INK),
(r'\mathrm{final\ mask}=\mathtt{00c0}\quad\Longrightarrow\quad m=40302,40318', MINT),
], [('表中略去的格子也参与检查,但不再缩小累计交集', MUTED),
('只对幸存中间位求高位;下一章先沿 40302 继续', AMBER)], kind='example')
self.say('所有格子的掩码按位求交,逐步缩小到六号、七号候选。检查完全部二十五格,留下中间位四万零三百零二和四万零三百一十八。', 14)
self.clear_body()
self.page('同窗口的候选数量与成组筛选效果', [
(r'11010048\ \mathrm{lower35}\longrightarrow36\ \mathrm{survivors}', CYAN),
(r'0.268288\ \mathrm{ms}\longrightarrow0.156672\ \mathrm{ms}', MINT),
], [('预计算后再增加组 mask:约 1.71×;两项合计约 2.07×', AMBER),
('搜索 CUDA event 中位数;含公共项准备,不含筛根与传输', MUTED)], kind='measurement')
self.say('全部八十四个根展开后,输出差过滤留下三十六种低三十五位。同窗口成组筛选再快约一点七一倍,与预计算合计约二点零七倍。', 14)
def upper(self):
self.chapter('12', '高位:用 θ 区间求交恢复 h', 'DOCUMENT.md 第 13 节 · 不逐个尝试 8192 种高位')
self.page('拆分基准输出,高位换元为 θ', [
(r'\bar q_i=r_i+2^{18}b_i,\quad0\le b_i<8192', CYAN),
(r'\theta=Ah\bmod8192,\qquad h=4965\theta\bmod8192', MINT),
(r'\phi_i=(b_i+\theta)\bmod8192,\quad q_i(h)=r_i+2^{18}\phi_i', INK),
], [('A mod 8192 = 1645,1645 × 4965 mod 8192 = 1', MUTED),
('rᵢ、bᵢ 已知,θ 未知;换元不重不漏', AMBER)])
self.say('把基准输出拆成低十八位 r 和高十三位 b。令 theta 等于 A 乘 h 模八千一百九十二,再用模逆恢复 h。', 12,
display=r'$\theta=Ah\bmod8192$,由 $h=4965\theta\bmod8192$ 恢复高位。')
self.clear_body()
self.page('单格变成两段区间与模 5 条件', [
(r'2^{18}\equiv-1\pmod5,\quad q_i\equiv r_i-\phi_i\pmod5', CYAN),
(r'\tau_i=8192-b_i', INK),
(r'\begin{array}{c|c}\theta\text{ interval}&\text{required residue mod }5\\\hline[0,\tau_i)&r_i-b_i\\{}[\tau_i,8192)&r_i-b_i+2\end{array}', MINT),
], [('低位筛选已保证偶数;这里只需补模 5 条件', AMBER),
('多出的 2 来自 8192 mod 5 = 2', MUTED)])
self.say('奇偶已经通过,只需再满足模五条件。二的十八次方除以五余负一,所以要求平移后的高位 phi 与 r 同余。', 12)
self.say('b 加 theta 最多回绕一次。回绕前要求一个余数,回绕后多出二,形成两段区间条件。', 11)
self.clear_body()
self.page('逐格求交,只枚举实际共同解', [
(r"[\mathrm{lo},\mathrm{hi})\cap[\mathrm{lo}',\mathrm{hi}')=[\max(\mathrm{lo},\mathrm{lo}'),\min(\mathrm{hi},\mathrm{hi}'))", INK),
(r'\theta_{\min}=\mathrm{lo}+((\rho-\mathrm{lo})\bmod5)', MINT),
(r'\theta_{\min},\theta_{\min}+5,\ldots<\mathrm{hi}', CYAN),
], [('余数不同就删除;区间内没有同余整数也删除', AMBER),
('每个既定余数最多匹配一段,始终最多保留两条', MUTED)])
self.say('相同余数的区间求交,不同余数直接删除。区间非空还不够,必须确实包含满足余数的整数。', 11)
self.say('每格对一个既定余数最多匹配一段,因此始终最多保留两条。最后按步长五枚举共同解,再恢复 h。', 12)
self.clear_body()
self.page('贯穿案例:固定 p = 218767、m = 40302', [
(r'\begin{array}{c|r|r|r|r}i&\bar q_i&r_i&b_i&\tau_i\\\hline0&227860608&57472&869&7323\\1&1920935636&206548&7327&865\end{array}', INK),
(r'i=0:\ [0,7323):\rho=3;\ [7323,8192):\rho=0', CYAN),
(r'i=1:\ [0,865):\rho=1;\ [865,8192):\rho=3', MINT),
], [('前两格共同留下 [865,7323),要求 θ mod 5 = 3', AMBER)], kind='example')
self.say('还是同一条成功分支。前两格的 r、b 和回绕点都由基准输出算出。只有余三的两段能匹配,交集是八百六十五到七千三百二十三。', 14)
self.clear_body()
self.page('贯穿案例:继续加入其余格子', [
(r'\begin{array}{c|c|c}\text{through cell}&\text{interval}&\rho\\\hline1&[865,7323)&3\\2&[865,3544)&3\\3&[3202,3544)&3\\13&[3202,3506)&3\\19&[3382,3506)&3\\22&[3382,3440)&3\\24&[3382,3440)&3\end{array}', INK),
(r'\theta=3383,3388,\ldots,3438\quad(12\ \mathrm{solutions})', MINT),
], [('全部 25 格共同允许 [3382,3440) 中除以 5 余 3 的整数', AMBER)], kind='example')
self.say('逐格收紧后,整个窗口共同允许的区间是三千三百八十二到三千四百四十,余数为三。', 11)
self.say('第一个整数解是三千三百八十三,之后每次加五,直到三千四百三十八,共十二个高位解。', 11)
self.clear_body()
self.page('贯穿案例:恢复第一个完整世界种子', [
(r'\theta=3383\quad\Longrightarrow\quad h=4965\cdot3383\bmod8192=2995', CYAN),
(r's=218767+2^{18}\cdot40302+2^{35}\cdot2995=102917981558415', INK),
(r'w=(s-T_0)\bmod2^{48}=15959335054446', MINT),
], [('m = 40318 的另一条分支同样得到 12 个解,共 24 个', AMBER)], kind='example')
self.say('取第一个 theta 解,恢复 h 为二千九百九十五,拼回 s,再减去锚点项,得到世界种子十五万九千五百九十三亿三千五百零五万四千四百四十六。', 15,
display=r'$\theta=3383\Rightarrow h=2995\Rightarrow s=102917981558415\Rightarrow w=15959335054446$。')
self.measured_effect('D', '同窗口:输出差与区间求交的组合效果')
self.say('同窗口旧排序流程六十三点七五零毫秒,差过滤与区间求交组合约零点三二四毫秒,约快一百九十七倍。', 13)
self.say('这是组合效果,不是区间单项收益,也不包含筛根与传输。', 8)
def verify_output(self):
self.chapter('13', '完整验证:恢复的种子让全部 25 格通过', 'DOCUMENT.md 第 14 节 · 验证输出正确,覆盖边界另行说明')
self.page('贯穿案例:世界种子 15959335054446 的原始输出', [
(r'\begin{array}{rrrrr}1114693760&660285140&2105382940&47575410&1796905160\\2074383590&1410817530&1734889590&2009625200&440710690\\1664890290&537178720&1699969350&2115254790&688097620\\1842255790&1387847170&1302453480&1264531310&344660\\1757217670&204531470&2132545510&1606583470&569937090\end{array}', MINT),
], [('按窗口编号排列;每个输出都能被 10 整除', CYAN),
('全部小于 2147483640,不会拒绝;25 次 nextInt(10) 均返回 0', AMBER)], kind='example')
self.say('把恢复的世界种子代回最开始的代码,二十五格原始输出如表。每个数都被十整除,也都在接受范围内。', 12)
self.say('因此这个种子确实让整个五乘五成为史莱姆区块。其余二十三个种子也逐格执行完整 Java 判断。', 11)
self.clear_body()
self.page('完整验证与候选覆盖的边界', [
(r'q\ge2^{31}-8=2147483640\quad\Longrightarrow\quad\mathrm{redraw}', AMBER),
(r's=p+2^{18}m+2^{35}h,\quad w=(s-T_0)\bmod2^{48}', CYAN),
], [('实际流程先用 s、dᵢ 完整验证,通过后恢复并记录 w', MINT),
('拒绝后重抽可能改变结果,准确验证会排除误报', INK),
('依赖重抽才成立的种子可能未生成;验证不能补回遗漏', AMBER)])
self.say('Java 会拒绝最后八个原始输出并重抽。准确验证可以消除候选中的误报。', 10)
self.say('但依赖重抽才成功的种子,可能在首次输出候选阶段已被排除。所以当前算法保证输出正确,不保证完整覆盖所有 Java 解。', 13)
def execution(self):
self.chapter('14', 'GPU 执行:按位置与根供给任务,按组搜索', 'DOCUMENT.md 第 15 节 · 并行安排不改变数学条件')
self.page('当前入口的执行顺序', notes=[
('位置归类 → 查低位根缓存 → 未缓存类才筛根', CYAN),
('有根位置展开(位置、root)任务 → 每根准备公共项', INK),
('GPU 线程处理(位置、root、16 候选组)', MINT),
('mask 幸存者求高位 → 完整验证 → 恢复世界种子', AMBER),
('多卡动态领取位置批次,各自维护有限根缓存', MUTED)])
self.say('CPU 分批提供位置。GPU 先筛根、准备公共项,再让每个搜索线程负责一个位置、一个根和一组十六个中间位。', 13)
self.say('组内幸存者继续求高位并完整验证。多张卡动态领取位置批次,各自维护根缓存,最后合并结果。', 11)
def ending(self):
self.chapter('06', '优化的两条线:复用计算,利用结构', DATA['hardware'])
rows = VGroup(*[text(s, 29, c) for s,c in [
('CPU → CUDA:把独立任务同时做', CYAN),
('固定坐标 → outer sum:减少重复的坐标计算', MINT),
('Random → 高位解析:利用输出的模运算结构', AMBER),
('每个加速比,都要有同任务、同输入的对照', INK)]]).arrange(DOWN, aligned_edge=LEFT, buff=.55).move_to([0,.2,0])
self.play(LaggedStart(*[FadeIn(r) for r in rows], lag_ratio=.2), run_time=1.8)
self.say('先理解重复工作在哪里,再决定并行、复用,还是改变搜索方法。', 7)
self.say('源码、完整测试口径和原始测速记录,都放在 show 目录里。', 6)
self.chapter('15', '整条流程:同一个窗口,每一步省下什么', 'DOCUMENT.md 第 16、17 节 · 符号与效果回顾')
self.page('贯穿案例的完整候选链', [
(r'262144\ \mathrm{low18}\longrightarrow84\ \mathrm{roots}', CYAN),
(r'84\cdot131072=11010048\ \mathrm{lower35}\longrightarrow36', MINT),
(r'm=40302,40318\quad\Longrightarrow\quad12+12=24\ \mathrm{seeds}', AMBER),
], [('36 个差过滤幸存者的根都为 p = 218767', INK),
('完整演算的一个结果:w = 15959335054446', CYAN)], kind='example')
self.say('同一个窗口,八十四个根展开中间位,差过滤留下三十六种低三十五位。只有两种中间位有高位解,各十二个,共二十四个世界种子。', 14)
self.clear_body()
self.page('符号:种子、基准输出与公共项', [
(r's=p+2^{18}m+2^{35}h,\quad w=(s-T_0)\bmod2^{48}', CYAN),
(r'\bar q_i=q_i(0),\quad\delta_i=(\bar q_i-\bar q_0)\bmod2^{31}', MINT),
(r'\bar q_i(m)=[\omega_i+2A((\gamma_i+m)\mathbin{\mathrm{xor}}E)]\bmod2^{31}', INK),
], [('p、m、h 属于种子;γᵢ、ωᵢ 每根准备一次', MUTED),
('δᵢ 随位置、p、m 变化,与 h 无关', AMBER)])
self.say('p、m、h 是有效种子的三段。横线表示基准输出,gamma 和 omega 是公共项。delta 是模输出差,与未知高位无关。', 13)
self.clear_body()
self.page('符号:高位换元与区间', [
(r'\bar q_i=r_i+2^{18}b_i,\quad\theta=Ah\bmod8192', CYAN),
(r'\phi_i=(b_i+\theta)\bmod8192', MINT),
(r'\theta\in[\mathrm{lo},\mathrm{hi}),\quad\theta\equiv\rho\pmod5', INK),
], [('T:坐标项;θ:种子高位的换元,二者含义不同', AMBER),
('rᵢ、bᵢ、φᵢ 属于输出;lo、hi 只表示区间端点', MUTED)])
self.say('大写 T 是坐标项,theta 是高位换元。r、b、phi 属于输出;lo、hi 是区间端点。符号在正文末尾有完整总表。', 13)
self.clear_body()
self.page('同窗口优化的实测效果', notes=[
('输出差+区间求交:63.750 → 约 0.324 ms(197×)', CYAN),
('公共项预计算:0.323584 → 0.268288 ms(1.21×)', INK),
('成组 mask:0.268288 → 0.156672 ms(再快 1.71×)', MINT),
('后两项同轮对照合计 2.07×;不跨实验连乘倍数', AMBER),
('均为搜索阶段;不含筛根、初始化与传输', MUTED)], kind='measurement')
self.say('效果也使用同一个窗口。预计算减少重复算术,成组掩码共用差过滤,区间求交减少高位尝试。不同轮次的倍数不能连乘。', 14)
self.say('从 CPU 到 GPU,我们先并行和复用计算;理解 Random 后,再排除不可能的候选,并直接求出剩余高位。', 12)
def tear_down(self):
output = HERE / 'output'
@ -398,8 +729,10 @@ class FilmBase(Scene):
class SlimeFilm(FilmBase):
def construct(self):
for part in (self.opening, self.problem, self.cuda, self.mapping,
self.outer, self.random, self.ending):
for part in (self.opening, self.problem, self.cuda, self.mapping, self.outer,
self.random_bridge, self.normalize, self.low_roots, self.class_roots,
self.prepare, self.group_filter, self.upper, self.verify_output,
self.execution, self.ending):
part()
@ -411,5 +744,7 @@ class ParallelEpisode(FilmBase):
class RandomEpisode(FilmBase):
def construct(self):
for part in (self.random, self.ending):
for part in (self.random_bridge, self.normalize, self.low_roots, self.class_roots,
self.prepare, self.group_filter, self.upper, self.verify_output,
self.execution, self.ending):
part()

View file

@ -2,6 +2,7 @@
from pathlib import Path
import os
import shutil
import shlex
import subprocess
HERE = Path(__file__).resolve().parent
@ -23,7 +24,9 @@ def prepare_tex(env):
'TEXMFCNF': str(dist/'web2c'),
'TEXMFROOT': str(root/'usr/share/texlive'),
'TEXMFDIST': str(dist),
'TEXMF': '{'+str(dist)+','+str(root/'usr/share/texmf')+'}',
# Database-only lookup avoids recursive misses on the WSL filesystem.
'TEXMF': '{!!'+str(dist)+',!!'+str(root/'usr/share/texmf')+'}',
'TEXMFDBS': '{'+str(dist)+','+str(root/'usr/share/texmf')+'}',
'TEXMFVAR': str(root/'var'),
'TEXMFCONFIG': str(root/'config'),
'TEXMFHOME': str(root/'texmf-home'),
@ -40,4 +43,14 @@ def prepare_tex(env):
subprocess.run(['pdftex', '-ini', '-etex', '-interaction=nonstopmode', '-halt-on-error',
'-jobname=latex', '-progname=latex', 'latex.ini'],
cwd=formats, env=env, check=True)
# These scenes use TeX glyphs and dvisvgm raw SVG groups, without PS artwork.
# The unpacked Ghostscript cannot initialize its device in this environment;
# skip only PS specials while preserving raw SVG and vector glyph outlines.
wrappers = root / 'tools'
wrappers.mkdir(exist_ok=True)
converter = wrappers / 'dvisvgm'
converter.write_text('#!/bin/sh\nexec '+shlex.quote(str(root/'usr/bin/dvisvgm'))+
' --no-specials=ps "$@"\n')
converter.chmod(0o755)
env['PATH'] = str(wrappers)+os.pathsep+env['PATH']
return env

View file

@ -17,9 +17,71 @@ timeline = json.loads((HERE / f'output/{name}.timeline.json').read_text())
for source, digest in report['source_sha256'].items():
assert hashlib.sha256((HERE/source).read_bytes()).hexdigest() == digest, f'changed after render: {source}'
data = json.loads((HERE/'data/benchmarks.json').read_text())
for record in (data,):
path = HERE/record['source']
assert hashlib.sha256(path.read_bytes()).hexdigest() == record['source_sha256'], f'benchmark source changed: {path}'
for record in data['sources']:
path = HERE/record['path']
assert hashlib.sha256(path.read_bytes()).hexdigest() == record['sha256'], f'benchmark source changed: {path}'
# Compare frozen figure values to the original machine-readable measurements.
def read_source(path):
return json.loads((HERE/path).read_text())
mapping = read_source('../../show/results/latest.json')['mapping']
assert data['A']['before_ms'] == mapping[0]['cpu']['median_ms']
assert data['A']['after_ms'] == mapping[0]['gpu']['rows'][0]['scan_median_ms']
for key, item, modes in (('B', mapping[1], (2, 3)), ('C', mapping[2], (3, 4))):
rows = {r['mode']: r for r in item['gpu']['rows']}
assert data[key]['before_ms'] == rows[modes[0]]['kernel_median_ms']
assert data[key]['after_ms'] == rows[modes[1]]['kernel_median_ms']
f = read_source('../../research/experiments/full_search_feasibility/results/summary.json')
e = read_source('../../research/experiments/normal_search/results/summary.json')
g = read_source('../../research/experiments/normal_search/pipeline_results/summary.json')
for key, before, after in (
('D', f['known']['kernel_median_ms']['0'], f['known']['kernel_median_ms']['3']),
('E', e['random65536']['3'], e['random65536']['7']),
('P', e['known']['3'], e['known']['4']),
('G', e['known']['4'], e['known']['7']),
('F', g['mixed512']['1']['host_ms'], g['mixed512']['3']['host_ms']),
):
assert data[key]['before_ms'] == before and data[key]['after_ms'] == after, key
assert data['ROOT']['roots'] == e['known']['info']['jobs']
assert data['ROOT']['lower35'] == e['known']['info']['lower35']
assert data['ROOT']['difference_survivors'] == e['metadata']['cpu_validation']['known_filter_survivors']
assert len(e['known']['info']['matches']) == 24, 'known-window final seed count'
cli = read_source('../../bench/results/local-r512-n5.json')
assert data['CLI']['scan_ms'] == cli['median_scan_wall_ms']
assert data['CLI']['anchors'] == cli['anchors']
assert all(r['root_jobs'] == data['CLI']['root_jobs'] and r['matches'] == data['CLI']['matches']
for r in cli['rounds'])
# Ensure each cue is also present, in order, in the editable exports.
subtitles = (HERE/f'output/{name}.srt').read_text()
narration = (HERE/f'output/{name}.narration.md').read_text()
assert [block.split('\n', 2)[2] for block in subtitles.strip().split('\n\n')] == [c['text'] for c in timeline['cues']]
for cue in timeline['cues']:
assert cue['text'] in narration
if args.scene == 'SlimeFilm':
assert [c['title'] for c in timeline['chapters']] == [
'史莱姆区块:先看单个区块如何判定',
'CPU:先把正确答案算出来',
'CUDA:每个 thread 负责一个 seed',
'改变分工:固定坐标,遍历 seed',
'Outer sum:邻居也可以共享坐标项',
'Random:状态更新与 48 位等价关系',
'有效种子:先反推关系,再引入窗口',
'第一次筛选:只枚举有效种子的低 18 位',
'复用筛选结果:同一坐标类只生成一次根',
'每个根预计算一次,避免中间位循环重复计算',
'中间位:输出差早筛与 16 候选掩码',
'高位:用 θ 区间求交恢复 h',
'完整验证:恢复的种子让全部 25 格通过',
'GPU 执行:按位置与根供给任务,按组搜索',
'整条流程:同一个窗口,每一步省下什么',
]
captions = '\n'.join(c['text'] for c in timeline['cues'])
for explanation in ('同一段的模输出差完全相同', '末尾统一保留低十八位',
'没有消失', '三千三百八十三', '二十四个世界种子'):
assert explanation in captions, f'missing updated explanation: {explanation}'
displays = '\n'.join(c.get('display', '') for c in timeline['cues'])
assert r'\theta=3383' in displays and '15959335054446' in displays
assert captions.index('先只看一个固定区块') < captions.index('再引入窗口')
assert all(token not in captions for token in ('假设 base 为零', '两组示意输出参数', '一百三十点一三六'))
for cue in timeline['cues']:
assert 0 <= cue['start'] < cue['end'] <= timeline['duration']+.1
for a,b in zip(timeline['cues'],timeline['cues'][1:]):