Files
SoundGame Dev f0f0a9b50e 初始提交:3 款纯音频游戏 + 共享引擎 + 完整文档与资产管线
- 共享引擎(app/src/main):GestureEngine 6 手势识别、AudioEngine 三层音频
  (ExoPlayer BGM 交叉淡入 / 对白 ducking / SoundPool 低延迟)、Game/GameEngine
  状态机接口、MainActivity 全屏封面 + TalkBack
- 《声命线》storyline:末世互动广播剧,3 幕 8 抉择点 4 结局
- 《声东击西》reflex:听声辨位反应,12 波战斗 + BGM 三档 BPM 变速
- 《暗室》escape:3x3 网格密室逃脱,钥匙→铁柜→保险丝→电箱→大门谜题链
- 文档:setup(从零搭建)/ architecture(架构)/ engine-api(引擎 API)/
  asset-pipeline(资产流水线)/ games/*(三款游戏设计文档)
- 资产管线:Qwen3-TTS 对白 + 程序合成音效/BGM + Mureka BGM 的 gen 脚本与台账,
  全部 ogg 资产入库可复现
- env.d 环境脚本:android/tts 入库;sfx/mureka 含密钥不入库,仅提交 .example.sh 模板
2026-08-29 13:00:34 +08:00

482 lines
23 KiB
Python

#!/usr/bin/env python3
"""Bake RoPE's cos/sin tables as ONNX constants, deleting the runtime angle chain.
Why
---
The medium DiT's RoPE angle is `t (x) inv_freq` with `inv_freq[0] == 1.0` exactly,
so the angle equals the position index for the fastest-rotating pair and reaches
~4159 rad at the 6-minute anchor. bf16's ULP up there is 32 rad — five whole
rotations — so a bf16 angle destroys 16/32 frequency pairs, the latent inflates
2.5x over 8 sampling steps, and the decoder clips 2-3%. See ROPE_PRECISION.md.
Every published fix fences that region into an FP32 island. But the tables depend
on NOTHING except position — not activations, not the timestep, not the prompt —
so they are compile-time constants. And the same ablation shows the table VALUES
are bf16-safe (rounding cos/sin outputs to bf16: latent std 0.9161 vs fp32's
0.9162, cos 0.9998) because they live in [-1, 1] where bf16's ULP is ~0.004. Only
the *pre-trig magnitude* needs precision, and precision is free on the host.
So: precompute cos/sin in fp64 on the host, embed as fp32 initializers, slice by
the dynamic sequence length, rewire all 96 trig sites to the two shared slices,
and let dead-code elimination delete the angle chain. No fp32 island is then
needed anywhere in the RoPE region — and bf16 (unlike fp16) needs no fp32 RMSNorm
islands either — so the whole graph can be uniformly bf16: maximum fusion, no
reformat boundaries, and one fewer fencing constraint blocking future fp8/NVFP4.
Graph facts (verified against the real ONNX, not assumed)
--------------------------------------------------------
inv_freq = 1/(10000**(arange(0,32,2)/32)) -> 16 values (NOT 32), inv_freq[0]=1.0
/transformer/Range(0, T, 1) -> Cast(f32) -> Div(/1.0) -> Einsum('i,j->ij')
-> Concat_2(axis=-1, [freqs, freqs]) -> [T, 32] (NOT [T, 64])
per layer i: Cast_14 <- (layer i-1)'s Cast_17, i.e. one long no-op fp32 Cast chain
Cast_14 -> {Cast_17 -> Slice_5 -> {Cos, Sin},
Mul_5(x T_q/T_k) -> Cast_23 -> Slice_8 -> {Cos_1, Sin_1},
Shape_18} Mul_5 -> Shape_24
Slice_5/Slice_8 are `angle[-T_q:]` / `angle[-T_k:]` on a table that already has
exactly T rows, i.e. identity. T_q == T_k == T (self-attention), so the
Mul by T_q/T_k is a multiply by exactly 1.0 and BOTH sites see the SAME
angle -> one shared cos slice + one shared sin slice serves all 96.
Shape_18 / Shape_24 read the angle tensor's SHAPE (to find rot_dim=32 for the
q/k partial-rotary split), so they are rewired to the baked slice, which is
[T, 32] as well. Only then does the angle chain become fully dead.
T = L + 64 (64 prepended global tokens); profile max L=4096 -> max T=4160.
There are 4 further Cos/Sin nodes in the graph (`/Cos`, `/Sin`,
`/timestep_features/Cos`, `/timestep_features/Sin`) for the seconds_total /
timestep Fourier features. Those are NOT RoPE and are left alone; sites are
selected by reachability from /transformer/Einsum, not by op type.
This is the shared RoPE-baking producer for BOTH medium RoPE-baked tiers — it only
touches the RoPE angle chain, so it is agnostic to what else the graph carries:
bf16 : --input dit.onnx (raw fp32; inv_freq is EXTERNAL)
fp8 : --input dit_fp8lin.onnx (fp8 E4M3 Q/DQ already on the Linears; inv_freq INLINE)
Downstream compile: build_from_onnx.py sa3-m-bf16 / sa3-m-fp8.
usage:
python build_dit_bf16.py \
--input /path/to/onnx/sa3-m/dit.onnx \
--output onnx/sa3-m/dit_bf16.onnx [--max-t 4160]
The input's 5.8 GB weights are NEVER loaded or rewritten: initializers keep their
external-data references and the output .onnx is written beside a link to the
original `<input>.onnx.data` sidecar.
"""
import argparse
import os
import time
from collections import defaultdict, deque
from pathlib import Path
import numpy as np
import onnx
from onnx import TensorProto, helper, numpy_helper
FP32 = TensorProto.FLOAT
INT64_MAX = 2 ** 63 - 1
EINSUM = "/transformer/Einsum"
INV_FREQ = "dit.transformer.rotary_pos_emb.inv_freq"
T_SOURCE = "/transformer/Cast_1_output_0" # int64 scalar T; the Range's limit input
def read_external(tensor, base_dir):
"""Read one external initializer's bytes directly.
onnx's own loader rejects this repo's sidecar ("not regular file") on the weka
mount even though it is one, so go to the bytes.
"""
meta = {d.key: d.value for d in tensor.external_data}
path = os.path.join(base_dir, meta["location"])
with open(path, "rb") as f:
f.seek(int(meta.get("offset", 0)))
raw = f.read(int(meta["length"]))
dt = onnx.helper.tensor_dtype_to_np_dtype(tensor.data_type)
return np.frombuffer(raw, dtype=dt).reshape([d for d in tensor.dims]).copy()
def const_of(tname, producer, inits):
"""Resolve a tensor to a numpy constant, or None."""
if tname in inits:
t = inits[tname]
if t.data_location == TensorProto.EXTERNAL:
return None
return numpy_helper.to_array(t)
p = producer.get(tname)
if p is None:
return None
if p.op_type == "Constant":
return numpy_helper.to_array(p.attribute[0].t)
if p.op_type == "Unsqueeze":
a = const_of(p.input[0], producer, inits)
if a is None:
return None
ax = const_of(p.input[1], producer, inits) if len(p.input) > 1 else None
return np.expand_dims(a, int(ax.ravel()[0]) if ax is not None else 0)
return None
def attr(node, name):
for a in node.attribute:
if a.name == name:
return helper.get_attribute_value(a)
return None
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--input", default="/weka2/cj/clod/sa3s/stable-audio-3-optimized/"
"onnx/sa3-m/dit.onnx")
ap.add_argument("--output", required=True)
ap.add_argument("--max-t", type=int, default=4160,
help="rows to bake = max sequence length at self-attention "
"(= profile max L + 64 global tokens)")
ap.add_argument("--link-data", action="store_true", default=True,
help="symlink the input's external-data sidecar next to the output")
args = ap.parse_args()
src = Path(args.input)
out = Path(args.output)
t0 = time.time()
print(f"loading graph metadata from {src} (weights NOT loaded) ...", flush=True)
model = onnx.load(str(src), load_external_data=False)
g = model.graph
print(f" {len(g.node)} nodes · {len(g.initializer)} initializers · "
f"opset {[o.version for o in model.opset_import]} · ir {model.ir_version}",
flush=True)
producer, consumers = {}, defaultdict(list)
for n in g.node:
for o in n.output:
producer[o] = n
for i in n.input:
if i:
consumers[i].append(n)
inits = {i.name: i for i in g.initializer}
graph_outputs = {o.name for o in g.output}
# ── 1. the real inv_freq ────────────────────────────────────────────────
if INV_FREQ not in inits:
raise SystemExit(f"initializer {INV_FREQ} not found")
_ivf = inits[INV_FREQ]
if _ivf.data_location == TensorProto.EXTERNAL:
inv_freq = read_external(_ivf, str(src.parent)) # external in fp32 dit.onnx
else:
inv_freq = numpy_helper.to_array(_ivf) # inline in the fp8-linear ONNX
n_half = inv_freq.size
print(f"\ninv_freq: {n_half} values, dtype={inv_freq.dtype}, "
f"[0]={inv_freq[0]!r}, [-1]={inv_freq[-1]!r}", flush=True)
expect = (1.0 / (10000.0 ** (np.arange(0, 2 * n_half, 2, dtype=np.float32)
/ (2 * n_half)))).astype(np.float32)
print(f" bit-identical to 1/(10000**(arange(0,{2*n_half},2)/{2*n_half})): "
f"{np.array_equal(inv_freq, expect)}", flush=True)
if float(inv_freq[0]) != 1.0:
print(" NOTE: inv_freq[0] != 1.0 — the angle no longer equals the position "
"index; the bf16 hazard is milder than documented.", flush=True)
# ── 2. bake the tables ─────────────────────────────────────────────────
T = args.max_t
rot = 2 * n_half # Concat_2 width
# Reproduce the fp32 graph's angle exactly (Einsum outer product = one exactly
# rounded fp32 multiply; Cast int64->fp32 of i<=4159 is exact; Div by 1.0 is
# exact), then evaluate the trig in float64 and round the RESULT to fp32.
pos32 = np.arange(T, dtype=np.float32)
ang32 = (pos32[:, None] * inv_freq[None, :]).astype(np.float32) # [T, n_half]
ang64 = ang32.astype(np.float64)
cos_t = np.cos(ang64).astype(np.float32)
sin_t = np.sin(ang64).astype(np.float32)
cos_t = np.concatenate([cos_t, cos_t], axis=-1) # [T, rot]
sin_t = np.concatenate([sin_t, sin_t], axis=-1)
assert cos_t.shape == (T, rot)
# how much did fp32 angle rounding cost vs an exact-in-fp64 angle?
ang_exact = np.arange(T, dtype=np.float64)[:, None] * inv_freq.astype(np.float64)[None, :]
d_ang = np.abs(ang64 - ang_exact).max()
d_cos = np.abs(np.cos(ang64) - np.cos(ang_exact)).max()
# what bf16 would have done to the angle, for the record
def to_bf16(a):
u = a.astype(np.float32).view(np.uint32)
u = ((u + 0x8000 + ((u >> 16) & 1)) & 0xFFFF0000).astype(np.uint32)
return u.view(np.float32)
d_bf16 = np.abs(np.cos(to_bf16(ang32).astype(np.float64)) - np.cos(ang64)).max()
print(f"\nbaked tables: cos/sin {cos_t.shape} fp32 "
f"({cos_t.nbytes/1e6:.2f} MB each)", flush=True)
print(f" max angle : {ang32.max():.1f} rad (T={T}, pair 0 uses inv_freq=1)",
flush=True)
print(f" fp32-angle rounding: max |dangle|={d_ang:.2e} rad -> max |dcos|={d_cos:.2e}",
flush=True)
print(f" bf16-angle (avoided): max |dcos|={d_bf16:.4f}", flush=True)
# ── 3. locate the 96 RoPE trig sites by reachability from the Einsum ────
if EINSUM not in {n.name for n in g.node}:
raise SystemExit(f"node {EINSUM} not found")
einsum = next(n for n in g.node if n.name == EINSUM)
reach, q = set(), deque(einsum.output)
while q:
for c in consumers[q.popleft()]:
if c.name in reach:
continue
reach.add(c.name)
q.extend(c.output)
all_trig = [n for n in g.node if n.op_type in ("Cos", "Sin")]
rope_trig = [n for n in all_trig if n.name in reach]
other_trig = [n for n in all_trig if n.name not in reach]
print(f"\ntrig nodes: {len(all_trig)} total · {len(rope_trig)} reachable from "
f"{EINSUM} (RoPE) · {len(other_trig)} not (left alone)", flush=True)
print(f" left alone: {sorted(n.name for n in other_trig)}", flush=True)
if len(rope_trig) != 96:
print(f" WARNING: expected 96 RoPE trig nodes, found {len(rope_trig)}", flush=True)
# ── 4. verify every site really is cos/sin(angle[0:T]) ─────────────────
def is_angle(t, seen=None):
"""tensor carries the [T, rot] angle (identity Casts / x1.0 Mul / identity Slice)"""
seen = seen or set()
if t in einsum.output:
return True
if t in seen:
return False
seen.add(t)
p = producer.get(t)
if p is None:
return False
if p.op_type in ("Cast", "Slice"):
return is_angle(p.input[0], seen)
if p.op_type == "Concat":
return all(is_angle(i, seen) for i in p.input)
if p.op_type == "Mul":
return any(is_angle(i, seen) for i in p.input)
return False
problems = []
for n in rope_trig:
sl = producer.get(n.input[0])
why = []
if sl is None or sl.op_type != "Slice":
why.append(f"input producer is {sl.op_type if sl else None}, not Slice")
else:
ends = const_of(sl.input[2], producer, inits)
axes = const_of(sl.input[3], producer, inits) if len(sl.input) > 3 else None
steps = const_of(sl.input[4], producer, inits) if len(sl.input) > 4 else None
if ends is None or int(ends.ravel()[0]) != INT64_MAX:
why.append(f"ends != INT64_MAX ({ends})")
if axes is None or int(axes.ravel()[0]) != 0:
why.append(f"axes != [0] ({axes})")
if steps is None or int(steps.ravel()[0]) != 1:
why.append(f"steps != [1] ({steps})")
# starts must be -Gather(Shape(q_or_k), 3) == -T, making the slice identity
sp = producer.get(sl.input[1])
ng = producer.get(sp.input[0]) if sp is not None and sp.op_type == "Unsqueeze" else None
if ng is None or ng.op_type != "Neg":
why.append("starts is not Unsqueeze(Neg(...)) i.e. not angle[-T:]")
else:
gt = producer.get(ng.input[0])
if gt is None or gt.op_type != "Gather":
why.append("Neg source is not a Gather")
else:
ix = const_of(gt.input[1], producer, inits)
sh = producer.get(gt.input[0])
if ix is None or int(ix.ravel()[0]) != 3:
why.append(f"Gather index != 3 ({ix}) — not the seq dim of [B,2,H,T,D]")
if sh is None or sh.op_type != "Shape":
why.append("Gather source is not a Shape")
if not is_angle(sl.input[0]):
why.append("sliced tensor does not trace to the RoPE Einsum via "
"identity Cast/Mul/Concat only")
if len(consumers[n.output[0]]) == 0 or n.output[0] in graph_outputs:
why.append("output is a graph output or unused")
if why:
problems.append((n.name, why))
print(f" structural verification: {len(rope_trig)-len(problems)}/{len(rope_trig)} OK",
flush=True)
for nm, why in problems[:8]:
print(f" ✗ {nm}: {why}", flush=True)
if problems:
raise SystemExit("aborting: not every RoPE site matches the verified pattern")
# every Mul on the angle path must be by exactly T_q/T_k == 1.0, else the two
# sites do not share a table
n_ratio_mul = 0
for n in g.node:
if n.op_type != "Mul" or n.name not in reach:
continue
if not any(is_angle(i) for i in n.input):
continue
other = [i for i in n.input if not is_angle(i)]
if len(other) != 1:
raise SystemExit(f"{n.name}: ambiguous angle Mul {list(n.input)}")
d = producer.get(other[0])
ok = False
if d is not None and d.op_type == "Div":
ca, cb = producer.get(d.input[0]), producer.get(d.input[1])
if ca is not None and cb is not None and ca.op_type == cb.op_type == "Cast":
ga, gb = producer.get(ca.input[0]), producer.get(cb.input[0])
if (ga is not None and gb is not None and ga.op_type == gb.op_type == "Gather"
and int(const_of(ga.input[1], producer, inits).ravel()[0]) == 3
and int(const_of(gb.input[1], producer, inits).ravel()[0]) == 3):
sa, sb = producer.get(ga.input[0]), producer.get(gb.input[0])
ok = (sa is not None and sb is not None
and sa.op_type == sb.op_type == "Shape")
if not ok:
raise SystemExit(f"{n.name}: angle scaled by something other than T_q/T_k")
n_ratio_mul += 1
print(f" angle Mul nodes, all by T_q/T_k (== 1.0 in self-attention): {n_ratio_mul}",
flush=True)
# ── 5. build the baked subgraph ────────────────────────────────────────
if T_SOURCE not in producer:
raise SystemExit(f"{T_SOURCE} (dynamic T) not found")
new_inits = [
numpy_helper.from_array(cos_t, "rope_cos_table"),
numpy_helper.from_array(sin_t, "rope_sin_table"),
numpy_helper.from_array(np.array([0], np.int64), "rope_start0"),
numpy_helper.from_array(np.array([0], np.int64), "rope_axis0"),
numpy_helper.from_array(np.array([1], np.int64), "rope_step1"),
numpy_helper.from_array(np.array([0], np.int64), "rope_unsq_axis"),
]
new_nodes = [
helper.make_node("Unsqueeze", [T_SOURCE, "rope_unsq_axis"], ["rope_T_1d"],
name="rope_bake/T_1d"),
helper.make_node("Slice", ["rope_cos_table", "rope_start0", "rope_T_1d",
"rope_axis0", "rope_step1"], ["rope_cos"],
name="rope_bake/cos_slice"),
helper.make_node("Slice", ["rope_sin_table", "rope_start0", "rope_T_1d",
"rope_axis0", "rope_step1"], ["rope_sin"],
name="rope_bake/sin_slice"),
]
# ── 6. rewire ──────────────────────────────────────────────────────────
rename = {}
for n in rope_trig:
rename[n.output[0]] = "rope_cos" if n.op_type == "Cos" else "rope_sin"
drop = {id(n) for n in rope_trig}
# Shape consumers of angle tensors must move to the baked slice (identical
# [T, rot] shape) or the angle chain stays alive purely to be measured.
shape_rewired = 0
for n in g.node:
if n.op_type == "Shape" and n.input[0] not in rename and is_angle(n.input[0]):
n.input[0] = "rope_cos"
shape_rewired += 1
print(f"\nrewired {len(rename)} trig outputs -> rope_cos/rope_sin "
f"({sum(1 for v in rename.values() if v=='rope_cos')} cos / "
f"{sum(1 for v in rename.values() if v=='rope_sin')} sin)", flush=True)
print(f"rewired {shape_rewired} Shape nodes off the angle chain onto rope_cos",
flush=True)
kept = [n for n in g.node if id(n) not in drop]
n_rewired = 0
for n in kept:
for i, t in enumerate(n.input):
if t in rename:
n.input[i] = rename[t]
n_rewired += 1
print(f" {n_rewired} node inputs repointed", flush=True)
# No node outside the angle chain itself may still read an angle tensor —
# otherwise DCE cannot remove the chain. (Chain-internal nodes consume each
# other by construction; those go away together.)
def all_outputs_angle(n):
return all(is_angle(o) for o in n.output)
escapes = [(n.name, n.op_type) for n in kept
if not all_outputs_angle(n)
and any(is_angle(i) for i in n.input if i)]
if escapes:
raise SystemExit(f"angle values still escape the chain at {len(escapes)} nodes: "
f"{escapes[:6]}")
print(f" angle values escape the chain at 0 nodes (chain is now self-contained)",
flush=True)
# ── 7. dead-code elimination from the graph outputs ────────────────────
kept_by_out = {}
for n in kept + new_nodes:
for o in n.output:
kept_by_out[o] = n
live, q = set(), deque(o.name for o in g.output)
while q:
t = q.popleft()
p = kept_by_out.get(t)
if p is None or id(p) in live:
continue
live.add(id(p))
q.extend(i for i in p.input if i)
final = [n for n in kept + new_nodes if id(n) in live]
dead = [n for n in kept if id(n) not in live]
dead_hist = defaultdict(int)
for n in dead:
dead_hist[n.op_type] += 1
print(f"\nDCE: {len(g.node)} -> {len(final)} nodes "
f"({len(rope_trig)} trig removed, {len(dead)} newly dead, "
f"{len(new_nodes)} added)", flush=True)
print(f" dead by op: {dict(sorted(dead_hist.items(), key=lambda kv: -kv[1]))}", flush=True)
for probe in ("/transformer/Range", EINSUM, "/transformer/Concat_2", "/transformer/Cast_2",
"/transformer/Div", "/transformer/layers.0/self_attn/Slice_5",
"/transformer/layers.23/self_attn/Mul_5"):
gone = probe in {n.name for n in dead}
print(f" {'removed' if gone else 'STILL PRESENT'}: {probe}", flush=True)
survivors = [(n.name, n.op_type) for n in final
if not n.name.startswith("rope_bake/")
and any(is_angle(i) for i in n.input if i)]
if survivors:
raise SystemExit(f"angle chain survived DCE at {len(survivors)} nodes: {survivors[:8]}")
print(f" angle chain fully eliminated: 0 surviving nodes touch it", flush=True)
# topological order: the new nodes only depend on T_SOURCE + initializers, so
# splice each immediately before its first consumer.
pending = {n.output[0]: n for n in new_nodes}
ordered, emitted = [], set()
def emit(n):
if id(n) in emitted:
return
emitted.add(id(n))
for i in n.input:
if i in pending:
emit(pending[i])
ordered.append(n)
for n in final:
if id(n) in {id(x) for x in new_nodes}:
continue
for i in n.input:
if i in pending and id(pending[i]) not in emitted:
emit(pending[i])
emit(n)
for n in new_nodes: # any not yet placed (shouldn't happen)
emit(n)
assert len(ordered) == len(final), f"{len(ordered)} != {len(final)}"
del g.node[:]
g.node.extend(ordered)
# ── 8. prune initializers that nothing reads any more ──────────────────
used = {i for n in g.node for i in n.input if i}
keep_i = [i for i in g.initializer if i.name in used]
pruned = [i.name for i in g.initializer if i.name not in used]
del g.initializer[:]
g.initializer.extend(keep_i + new_inits)
print(f"\ninitializers: {len(keep_i)} kept + {len(new_inits)} new "
f"(pruned {len(pruned)}: {pruned})", flush=True)
# ── 9. save: metadata only; external weights stay in the original sidecar
out.parent.mkdir(parents=True, exist_ok=True)
onnx.save(model, str(out)) # new tables ride inline (1.1 MB)
print(f"\nwrote {out} ({out.stat().st_size/1e6:.1f} MB)", flush=True)
locs = {d.value for i in g.initializer if i.data_location == TensorProto.EXTERNAL
for d in i.external_data if d.key == "location"}
for loc in sorted(locs):
link = out.parent / loc
target = src.parent / loc
if link.exists() or link.is_symlink():
print(f" external data {loc}: already present -> "
f"{os.path.realpath(link)}", flush=True)
elif args.link_data:
link.symlink_to(target)
print(f" external data {loc}: symlinked -> {target}", flush=True)
else:
print(f" external data {loc}: MISSING, expected at {link}", flush=True)
print(f"\ndone in {time.time()-t0:.0f}s", flush=True)
if __name__ == "__main__":
main()