← 揭开黑盒:理解系统的调查课堂

第二章 · 计算机选择的数学 / 综合代码解析Ⅱ · 数学怎样成为机器上的程序

综合代码解析Ⅱ:数学怎样成为机器上的程序

本篇覆盖第五至第七单元的实现:有限表示与求和、矩阵循环与缓存模型、Worker 分工与实际测量。从实现者角度,把数学关系、数据结构、调用过程与验收接成一条链。

预计用时:75 分钟,可分两次。教材为 assets/math-lab/ v1.0.0;下载后用 py -3 lab.py 打开可视化。已有 Node.js 22+ 时,可以运行 node trace.cjs 和 node --test test_algorithms.cjs。下面节选均来自该教材,单独摘出的函数依赖其文件内的辅助函数。

一、一个界面,几种不同的证据

文件职责输入与产物
algorithms.js数值、矩阵、模型与核对函数参数或数组 → 结果、轨迹、计数
app.js控件、图形、计时与报告读者选择 → 调用算法 → 呈现和导出
worker.js独立执行环境入口矩阵与行范围 → 对应结果块
lab.py本地静态服务浏览器请求 → 同目录页面和脚本
trace.cjs可读的确定输入跟踪调用真实算法 → 控制台观察
test_algorithms.cjs数值与行为边界验收小型独立参考 → 正常与反例检查

algorithms.js 同时暴露浏览器中的 MathLab 与 Node 中的模块接口。这让图形、跟踪和自测调用同一份算法。模型输出与真实计时却没有混在一起:缓存装入次数来自 cacheTrace,毫秒样本来自实际调用旁边的计时器。

二、一个 1 沿哪些变量走失

precision(exponent) 计算 x = 2 ** exponent,随后计算 rounded = x + 1 与 recovered = (x + 1) - x;精确整数目标则使用 2n ** BigInt(exponent) + 1n,最后转成字符串。

BigInt 不能直接进入普通 JSON 序列化,所以精确目标作为文本提供给界面。这里保留的是整数含义,页面用 textContent 显示它,没有再把长整数转回 Number 而丢失同一位信息。

调用 precision(53) 时,目标是 9007199254740993,Number 结果是 9007199254740992,相减得到 0。renderNumber 根据相邻间隔放置刻度与两种标记。SVG 坐标使用相对于 x 的小增量,避免在图形布局时再次对两个巨大值相减来猜目标位置。

leftSum 用一个累加器;pairSum 将数组区间递归分开后合并;compensatedSum 另保留 correction。它们对同一组 [1e16,1,-1e16,1] 返回 1、0、2。测试保留这个反例,防止日后为了“统一结果”悄悄改变算法或示例。

三、同一个坐标,三种执行顺序

输入 A、B 与输出 C 都用 Float64Array,位置约定为 i*n+j。multiply 先验证 n、数组类型、长度和有限值,再创建已清零的 C,根据 order 进入相应循环。

以 A=[1,2,3,4]、B=[5,6,7,8]、n=2 为例:逐格实现先生成 19,再生成 22、43、50。沿行实现则先用 A 的第一个值更新第一行两个格子,再用第二个值继续更新,最终也得到同样结果。

分块实现增加块起点 ii、kk、jj,每块内部按 i、k、j 推进。每个上限取 Math.min(起点+tile,n),所以 n=5、tile=2 的尾部仍被覆盖。

这三个实现没有改变每个 C 格子中 k 的递增顺序,但更换成别的并行归约时可能改变。小整数测试中的精确相等,也不能推广为任意实数输入下所有数学等价算法逐位相同。

exactReference 将小整数转成 BigInt,逐项生成参考;checkExact 要求实际值为安全整数,再转成 BigInt 比较。参考使用不同的数值表示,避免仅凭两段同样舍入的代码相互印证。它仅适用于本实验的整数输入,不是通用浮点比较器。

四、可视化的轨迹怎样产生

cacheTrace 一边按模型中的循环计算 C,一边在数组读写处调用 touch。缓存用 Map 保存行标识,顺序从最久未使用到最近使用。一次命中会删除原位置再放到末尾;一次未命中先在容量已满时移出最旧行,再加入新行。

行标识是 数组名:行编号,例如 B:3。每个数组单独对齐到缓存行边界,这属于模型假设。一个事件保存数组、元素索引、读写类型、是否命中、移出项以及当前缓存列表。C 的写事件另保存新值。

renderTrace 按滑块位置重放已发生的写事件,形成此刻的 C,再根据事件里的缓存行给格子着色。因此滑到中途看到的是这条计算路径的中间状态;点到最后应当与独立参考一致。

保留每次事件的缓存快照会占空间,所以模型限制最大边长 16,页面动画只提供 4 与 8。只取统计时可设置 keepEvents=false。真实计时中的矩阵函数没有这种轨迹记录,否则我们测到的主要可能是记录与绘图开销。

五、从控件到一次计时记录

benchmark 先固定 n、tile 和预测文本,禁用本轮相关参数,生成输入与 BigInt 参考。对于顺序比较,它先预热,再把实现的次序按轮次旋转。每次调用前取 performance.now(),返回后计算耗时;随后核对结果,只有通过才保留为有效样本。

statistics 从原始样本计算中位数与范围,并保留样本本身。完成的记录含计时范围、参数、时间与浏览器环境。再次修改预测输入框,不会改写已经保存在记录中的原预测。

失败也会保留 error 字段。最后恢复按钮,使读者可以修正运行条件再试。记录与解释可以导出为 JSON;导出是复核材料,不是防篡改认证。

六、Worker 的输入、所有权与汇合

实际执行链是:

benchmark("workers")
  → workerProduct → rowRanges
  → 每个新 Worker 接收完整 A/B 与自己的行范围
  → worker.js → multiplyRows
  → 转移结果缓冲 → 主页面检查范围与长度
  → result.set(结果块, start*n)
  → 计时停止 → BigInt 逐项核对 → 记录

rowRanges 用整除后的边界切分结果行,n 小于人数时减少实际执行者数。multiplyRows 只创建自己负责的结果区;索引写成 (i-start)*n+j,把全局行坐标转换为局部结果位置。

输入发送未使用转移列表,因此各 Worker 接收自己的输入副本。输出发送使用 [result.buffer],把该缓冲所有权交回主页面。这样避免额外复制输出,但仍然保留重复传输完整输入的成本。

每个 Worker 有超时、错误与结果结构检查;所有路径最终终止本轮的 Worker 并清理定时器。Promise.all 负责等待全部行块,而不是在某一块先到后就宣布整张矩阵完成。

七、改造一项真正会改变行为的需求

任选一项,在自己的下载副本中完成,保留原参考实现。

需求主要修改处独立验收
按行实现也支持分块multiplyRowsn=1/5/17;不完整尾块;不同 start/end;逐项正确
缓存模型改成 FIFO 替换cacheTrace/touch构造 A、B、A、C 等访问序列,能区分 FIFO 与 LRU;结果矩阵相同
复用已启动的 WorkerworkerProduct 与生命周期管理明确请求编号;连续两轮不同输入;失败重建;不会把旧结果拼进新矩阵

每项交付代码、一次输入到输出的跟踪、正常与失败路径检查,以及计时边界说明。不要把“修改后某次更快”作为唯一完成标准。

前三个单元已解释了数怎样留下、数据怎样到达、工作怎样分开。但算法还有更大的选择空间:如果同一个查询要执行很多次,是否该先做准备?如果大部分输入都是零,是否还该逐项遍历?进入 规模与表示的选择。