跳到主要内容
一千亿条记录,一条都不存

一千亿条记录,一条都不存

原石
原石

· 阅读约 5 分钟

Any Human Ever 这个站,全部逻辑就是三次随机。

y = draw_year();
p = draw_place(y);
l = draw_life(y, p);

struct draw {
    int    year;
    int    x, y;
    char  *life;
};

年份先出,地点拿年份当条件,人生拿前两个当条件。整个站的状态就是这一个 struct,四个字段,里面一个指针。输入是"有史以来活过的所有人",一千亿往上,它一个都不存。

抽签顺序不是随便定的。我见过反着写的:先抽一段人生模板,再往上贴个年份。跑得通。出来的东西全是穿帮的——公元 300 年的埃及人,过着 1990 年郊区的生活。

条件链的方向不能反。它对应的就是真实的依赖:你先落在哪一年,那一年决定你脚下是哪块地,地块和时间一起决定你能过成什么样。代码里的依赖顺序照着现实的依赖顺序摆,反过来一次,出戏一次。

均匀是回答错问题

难的是 draw_year。

第一反应基本都是这样:

int y = 12000 - rand() % 12000;

过去一万两千年,均匀采。三秒写完。然后它给的答案永远是错的——它在回答另一个问题。

均匀采年,"平均的人"落在几千年前。没文字,没名字,什么都没留下。你要一个随机的人,它给你一个史前的随机的人。史前的人占样本多少?极少。

因为人口是指数长的。这句不是背景介绍,这句就是那个数据结构本身。把有史以来活过的人按出生年排成一队,百分之九十几全挤在最后那一小截里。你在均匀轴上抽,等于在采一个不存在的总体。

按人口加权,落到实现上就是拿累计出生人数当分布函数,反采样一次:

/* cdf[i] = 到第 i 年为止累计出生人数,归一化到 [0,1) */
static double cdf[NYEARS];

int draw_year(uint64_t *rng) {
    double u = rand_unit(rng);
    int lo = 0, hi = NYEARS - 1;
    while (lo < hi) {
        int mid = (lo + hi) >> 1;
        if (cdf[mid] < u) lo = mid + 1;
        else              hi = mid;
    }
    return lo;
}

一张表,一次二分,一个随机数。没有采样库,没有概率编程那套"分布对象",没有把分布抽象成一个可以被组合的东西——这玩意儿这辈子只被采样,永远不会被组合。

说句题外话。我总觉得,均匀分布是工程师的一种思维惯性,不是数学结论:凡是不知道概率的地方,一律默认一样大。可现实里几乎没有什么是均匀的。人的分布不均匀,函数的行长不均匀,bug 的密度也不均匀。默认均匀,通常只是懒得去查它到底长什么样。

扯回来。分布本身和分布的展示是两回事。页面上那条年份轴用对数刻度,不是审美问题。线性轴上所有质量堆在最右边一根竖线里,左边看着空空的,你会以为数据错了。换到对数轴,那个驼峰才露出来。采样用分布本身,展示分布用它自己的对数——两个问题,两个工具,谁也不迁就谁。

同一个道理做两遍

地点那一步几乎是年份的翻版。地图上的亮度代表那片区域历史上承载过多少人,亮的抽得中,暗的抽不中。时间和空间都拒绝均匀,抽出来的总体才是同一个。

两步还不能合并。一张静态密度图不行:

/* 不对:一张图管所有年份 */
static const uint8_t density[MAP_H][MAP_W];

/* 对:按时期分层,取离 year 最近的一层 */
static const uint8_t density[NLAYER][MAP_H][MAP_W];
static const int     layer_year[NLAYER];

代价是多占几十倍内存。换来的是罗马不一直有那么多人。这笔账我觉得划得来,但层数该定多少,我心里没底。12 层够不够,120 层是不是在自我感动,这段我没想明白。原则我倒是认:分层的粒度跟着数据本身的精度走,不跟着"以后可能想更细"走。具体几层,先放着。

一台笔记本

人生那一步用的是 FLUX.1-schnell。页脚写着模型名,还写着它跑在作者本人的笔记本上。

这是全篇我最想说的。

一千亿分之一的那个采样点,配一张脸,用一个几步出图的蒸馏模型,在一台笔记本上算出来。整条链路里没有一行代码是为了应付"十万并发"写的。因为不会有十万并发。

而且整个站就三个动词:重抽、确认、读。没有搜索框,没有列表页,没有"浏览更多"。界面的形状跟着数据结构走——状态里只有一个当前位置,那它就只配有两个按钮:"换一个"和"就这样"。你想按年份筛、按地区筛、按人生主题筛,这个站一点面子都不给你留。

把它丢进一次架构评审会开出什么单子,我大概能背出来:预生成多少万条人生落库,省得线上现算;上向量检索,做"和你相似的人生";加用户系统、收藏、榜单、埋点;再套一层缓存,因为出图要几秒。

这几条我一条都不接。不是做不了,是它们服务的需求不存在。

预生成尤其不能接。预先算好的人生是死的,用户拿到的是一份查表结果。现算的人生是活的——同样是"公元前 400 年的某个地方",两次抽出来的不是同一个人。你为了省那几秒钟,把每次不一样的随机性提前兑现掉了。它不会变慢,它会变成另一样东西,而且是更不值钱的那一样。

一个没有搜索框的网站,你给它建索引干什么。

收在哪

规则一条:凡是有人跟你说"先建库、以后好查",先让他回答一句,用户真的会回头找吗。答不上来的库,别建。

说句我最近老琢磨的题外话。一千亿是个能把人压垮的数字。我们在这种数字面前的本能反应是——得先存起来,先管起来,先给它配一套系统。而有人真的面对了这个数字,做出来的东西是:一次抽一个,抽完就扔,跑在一台笔记本上。

我不觉得这叫简陋。面对这个量级,这可能就是唯一诚实的做法。

原石
原石

把代码当文章写的系统工程师,以源码立论、单线程式拒绝复杂度。

查看主页 →