15.2 求值与遍历的开销
玩法机制与可执行规格 那套量的是数值仿真的单位开销——每帧多少毫秒、多少个实体、多少次碰撞;本节量的是叙事引擎的单位开销——一次条件求值多少纳秒、一次完整遍历多少秒、以及这两样东西要重复多少遍。上一节量的是规模涨多少,这一节量的是每次操作多贵。
三样读数与 proxy 口径
一次遍历的总成本可以拆成三样:① 单位成本(单次 cond_ok 求值);② 次数(一次 BFS 里求值被调用多少遍);③ 规模(可达状态数与峰值 frontier)。三者相乘才是总开销,所以必须分开量。本节的脚本对每一档都给出实测值,并把③与②的比值单独列出来。
口径纪律先写在前面:所有耗时数字都是本机单线程的 proxy——受 CPU 频率、缓存命中与 GC 影响,逐轮会波动(本节两次复跑的单次求值中位数是 169.9 ns 与 175.5 ns,BFS 耗时是 60.8 秒与 58.9 秒)。这些数字只用于相对比较(哪一段更贵、哪个表示方式更省),不得读成绝对性能,更不得写成「提升 N 倍」。
S 层:单位成本与一次完整遍历
#!/usr/bin/env python3
# file: sim/ne_15_2_cost.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_15_2_cost.py
"""卷二 15.2 求值与遍历的开销(S 层):量条件求值的单位成本、一次完整 BFS 的代价,
以及求值次数与规格规模的比值。
三段读数:
① 单次 eval_cond 求值耗时——取规格里全部**真实** when 条件,每轮求值 BATCH 次、
共 ROUNDS 轮,报每轮「单次成本」的**中位数**(单次计时会被计时器抖动吃掉)。
② 一次完整 BFS(无深度上限,预算 12000000)——层数、可达状态数、峰值 frontier、
峰值内存 maxrss、耗时。
③ 比值——求值总次数 / 可达状态数、/ 选项总数、/ 节点数。
**口径纪律**:所有耗时数字都是本机单线程的 **proxy**(受缓存与 GC 影响,逐轮波动),
只做**相对比较**,不得读成绝对性能,也不得写成「提升 N 倍」。BFS 与
sim/ne_05_2_reachable_enumeration.py 同口径(同起点、同预算、同打包去重),可交叉核对。
只读 config/story_spec.json。产出 out/ne_15_2_cost.json。exit 0 表示 BFS 未超预算。
"""
from __future__ import annotations
import json
import os
import resource
import statistics
import sys
import time
import ne_05_1_scale as s1
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
STATE_BUDGET = 12000000
BATCH, ROUNDS = 20000, 101
def collect_conds(spec: dict) -> tuple:
"""规格里全部真实 when 条件(去重、按出现顺序),以及没有 when 的选项数。"""
seen, real, n_none = set(), [], 0
for n in spec["dialogue"]:
for c in (n.get("choices") or []):
if c.get("when") is None:
n_none += 1
continue
k = json.dumps(c["when"], ensure_ascii=False, sort_keys=True)
if k not in seen:
seen.add(k)
real.append(c["when"])
return real, n_none
def init_state(spec: dict) -> dict:
"""起点处的初始状态(好感度取 characters[].affinity.init,flag / item 全 0)。"""
VARS = sorted(spec["state"]["vars"])
return {"aff": {v: next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v)
for v in VARS},
"flags": {f: 0 for f in sorted(spec["state"]["flags"])},
"items": {i: 0 for i in sorted(spec["state"]["items"])}}
def bench_eval(conds: list, st: dict) -> dict:
"""单次 eval_cond 的成本:每轮 BATCH 次,取 ROUNDS 轮「单次成本」的中位数。"""
per_round, sink = [], 0
for r in range(ROUNDS):
t0 = time.perf_counter_ns()
for i in range(BATCH):
sink += s1.eval_cond(conds[(r + i) % len(conds)], st)
per_round.append((time.perf_counter_ns() - t0) / BATCH)
per_round.sort()
return {"batch": BATCH, "rounds": ROUNDS, "calls": BATCH * ROUNDS, "sink": sink,
"median_ns": round(statistics.median(per_round), 1),
"p10_ns": round(per_round[ROUNDS // 10], 1),
"p90_ns": round(per_round[ROUNDS * 9 // 10], 1),
"min_ns": round(per_round[0], 1), "max_ns": round(per_round[-1], 1)}
def bfs(spec: dict) -> dict:
"""一次完整 BFS:层数、可达状态数、峰值 frontier、eval_cond 调用次数与耗时。"""
t0 = time.time()
by_id = {n["id"]: n for n in spec["dialogue"]}
VARS = sorted(spec["state"]["vars"])
FLAGS = sorted(spec["state"]["flags"])
ITEMS = sorted(spec["state"]["items"])
def pack(st):
code = 0
for v in VARS:
code = (code << 7) | st["aff"][v]
return (code, sum(1 << i for i, f in enumerate(FLAGS) if st["flags"][f]),
sum(1 << i for i, k in enumerate(ITEMS) if st["items"][k]))
def unpack(code, fb, ib):
return {"aff": {VARS[i]: (code >> (7 * (len(VARS) - 1 - i))) & 127
for i in range(len(VARS))},
"flags": {FLAGS[i]: (fb >> i) & 1 for i in range(len(FLAGS))},
"items": {ITEMS[i]: (ib >> i) & 1 for i in range(len(ITEMS))}}
frontier = [(spec["meta"]["start"],) + pack(init_state(spec))]
seen = set(frontier)
states = evals = ending_states = 0
peak, levels, budget_hit = 1, 0, False
while frontier:
levels += 1
peak = max(peak, len(frontier))
nxt = []
for node, code, fb, ib in frontier:
states += 1
cur = by_id[node]
if cur.get("ending"):
ending_states += 1
continue
st = unpack(code, fb, ib)
for c in (cur.get("choices") or []):
evals += 1
if not s1.eval_cond(c.get("when"), st):
continue
k = (c["goto"],) + pack(s1.apply_effects(c.get("effects") or {}, st))
if k not in seen:
seen.add(k)
nxt.append(k)
if states > STATE_BUDGET:
budget_hit = True
break
frontier = nxt
if budget_hit:
break
return {"levels": levels, "states": states, "peak_frontier": peak, "evals": evals,
"ending_states": ending_states, "dedup": len(seen), "budget_hit": budget_hit,
"elapsed_s": round(time.time() - t0, 1),
"maxrss_mb": round(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1e6, 1)}
def main() -> int:
spec = s1.load_spec()
nodes, choices = len(spec["dialogue"]), spec["meta"]["scale"]["choices"]
real, n_none = collect_conds(spec)
bench = bench_eval(real, init_state(spec))
print(f"规格:{nodes} 节点 / {choices} 选项 / {len(spec['state']['flags'])} flag / "
f"{len(spec['state']['vars'])} 好感度 / {len(spec['state']['items'])} 物品;"
f"真实 when 条件 {len(real)} 条,另有 {n_none} 条选项无 when")
print(f"① 单次 eval_cond 求值:{BATCH} 次/轮 × {ROUNDS} 轮,取每轮单次成本的中位数")
print(" proxy:本机单线程,受缓存与 GC 影响,只做相对比较")
print(f" 中位数 {bench['median_ns']} ns/次 p10 {bench['p10_ns']} p90 {bench['p90_ns']}"
f" min {bench['min_ns']} max {bench['max_ns']} 合计 {bench['calls']} 次调用")
res = bfs(spec)
print(f"② 一次完整 BFS(无深度上限,预算 {STATE_BUDGET})")
print(f" 层数 {res['levels']} 可达状态 {res['states']} 峰值 frontier {res['peak_frontier']}"
f" 超预算={res['budget_hit']}")
print(f" eval_cond 调用 {res['evals']} 次 落在结局节点的状态 {res['ending_states']}"
f"({res['ending_states'] / res['states'] * 100:.1f}%,不产生求值)")
print(f" 耗时 {res['elapsed_s']}s 峰值内存 {res['maxrss_mb']} MB(proxy)")
ratio = {"evals_per_state": res["evals"] / res["states"],
"evals_per_choice": res["evals"] / choices,
"evals_per_node": res["evals"] / nodes,
"states_per_s": res["states"] / res["elapsed_s"],
"evals_per_s": res["evals"] / res["elapsed_s"]}
print("③ 求值次数与规格规模的比值")
print(f" 求值/状态 {ratio['evals_per_state']:.3f} 求值/选项 {ratio['evals_per_choice']:.1f}"
f" 求值/节点 {ratio['evals_per_node']:.1f}")
print(f" 遍历速率(proxy):状态 {ratio['states_per_s']:.0f}/s 求值 {ratio['evals_per_s']:.0f}/s")
assert res["states"] == res["dedup"], "可达状态数应等于去重集大小"
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "ne_15_2_cost.json"), "w", encoding="utf-8") as fh:
json.dump({"nodes": nodes, "choices": choices, "real_conds": len(real),
"choices_without_when": n_none, "bench": bench, "bfs": res,
"ratio": ratio}, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print("wrote out/ne_15_2_cost.json")
print(f"OK ne_15_2_cost:BFS {res['levels']} 层 {res['states']} 状态,"
f"单次求值中位数 {bench['median_ns']} ns(proxy),未超预算")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑
本机实跑 /opt/homebrew/bin/python3 sim/ne_15_2_cost.py(exit 0,整轮约 63 秒):
规格:48 节点 / 115 选项 / 18 flag / 4 好感度 / 9 物品;真实 when 条件 24 条,另有 81 条选项无 when
① 单次 eval_cond 求值:20000 次/轮 × 101 轮,取每轮单次成本的中位数
proxy:本机单线程,受缓存与 GC 影响,只做相对比较
中位数 169.9 ns/次 p10 166.4 p90 180.9 min 165.9 max 546.0 合计 2020000 次调用
② 一次完整 BFS(无深度上限,预算 12000000)
层数 32 可达状态 9895423 峰值 frontier 2498621 超预算=False
eval_cond 调用 19121205 次 落在结局节点的状态 4211116(42.6%,不产生求值)
耗时 60.8s 峰值内存 2153.0 MB(proxy)
③ 求值次数与规格规模的比值
求值/状态 1.932 求值/选项 166271.3 求值/节点 398358.4
遍历速率(proxy):状态 162754/s 求值 314494/s
wrote out/ne_15_2_cost.json
OK ne_15_2_cost:BFS 32 层 9895423 状态,单次求值中位数 169.9 ns(proxy),未超预算
判读:成本藏在次数里,不在单位成本里
先做交叉核对。本节 BFS 与 5.2
是同一口径(同起点 d_pier_1、同预算 12000000、同整数打包去重),三个规模数逐字相同:32 层、9895423 个可达状态、峰值 frontier 2498621。不同的是耗时与内存:
| 读数 | 5.2(规范串去重) | 本节(打包整数元组去重) |
|---|---|---|
| 层数 | 32 | 32 |
| 可达状态 | 9895423 | 9895423 |
| 峰值 frontier | 2498621 | 2498621 |
| 耗时(proxy) | 95.3 s | 60.8 s |
| 峰值内存(proxy) | 3132.8 MB | 2153.0 MB |
第一,算法一样、键不一样,开销差三成。 两节的遍历逻辑逐行对应,唯一区别是去重键:5.2 把每个状态拼成一条规范串(node|yan=..|flag|item),本节存的是 (node, code, fb, ib) 四元组。990 万个状态意味着 990 万条键同时驻留,字符串的构造与哈希把耗时推到 95 秒、内存推到 3.1 GB;换成打包整数元组后同一台机器上是 60.8 秒、2153 MB。这说明遍历成本里,去重键的表示方式比遍历算法本身更贵。
第二,单位成本小到可以忽略,成本全在次数上。 单次 eval_cond 的中位数是 169.9 ns(proxy),而这个规格里 115 条选项只有 24 条不同的真实条件——条件表达式本身极浅,最深的也只有一层 and。但一次完整遍历要调用它 19121205 次:19121205 × 169.9 ns ≈ 3.25 秒,只占 60.8 秒总耗时的 5% 左右。剩下的 95% 花在状态打包、去重与集合查找上。要优化叙事引擎的遍历,先优化去重键,而不是优化条件求值器。
第三,求值/状态 1.932 这个数说明状态分布是偏的。 115 条选项全挂在 43 个非结局节点上(平均出度 2.6744),5 个结局节点没有选项。9895423 个状态里有 4211116 个(42.6%)落在结局节点上——它们不产生任何求值。把结局状态剔掉后,每个非结局状态平均求值 19121205 / 5684307 = 3.364 次,高于非结局节点的平均出度 2.6744(×1.258)。也就是说状态并不是均匀撒在节点上的,而是更多落在高出度的节点上。这条对采样与剪枝有直接含义:按节点均匀采样会低估高出的度节点上的求值次数。
第四,这些数字的用法。 上一节量出「规模涨一档,可达状态数涨 1.13 到 2.10 倍」;本节量出「每涨一个可达状态,就多约 1.93 次求值与一次打包/去重」。两者相乘才是下一节要盯的量——什么时候这套开销该报警。再次强调:本节所有耗时都是本机 proxy,只做相对比较。
自检表
| 检查项 | 合格标准 |
|---|---|
| 单位成本取中位数 | 每轮 20000 次、共 101 轮,报中位数而非单次计时 |
| 与 5.2 同口径 | 同起点、同预算、同打包去重;三个规模数须逐字相同 |
| 不变量成对 | 可达状态数 = 去重集大小,否则 AssertionError 退出 |
| 次数与规模分开 | 求值次数、求值/状态、求值/选项、求值/节点 各自独立落盘 |
| proxy 显式声明 | 脚本、正文、产物 JSON 三处都标注耗时是 proxy |
成本量清楚了,缺的只是一个判据:多贵算贵。这是 15.3 的事。
阅读导航:上一节:15.1 规格规模的增长 · 下一节:15.3 规模告警 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。