材质参数不是「声明了就行」。同样 10 个参数,声明顺序不同,占用的常量寄存器数就可能不同——这不是风格问题,而是硬件层面的对齐约束。本节把 6.1 的参数表往前推一步:给定每个参数的分量数,算清楚它到底要几个寄存器、利用率是多少。全篇是整数账,命令行可复现。
6.2 参数对齐与打包
6.2.1 一个寄存器 = float4,参数不许跨边界
在 GPU 里,常量寄存器的最小单位是 16 字节 = 一个 float4,也就是 4 个分量。一个 float3 参数占 3 个分量,剩下的 1 个分量不能给别人用——除非下一个参数正好放得下。于是声明顺序是 float3 / float2 / float3 时,第二个 float3 因为 4 个分量放不下而顺延到下一个寄存器,白白空出 2 个分量。
参数不能跨寄存器边界,这是硬约束:一个参数要么整块落在某个寄存器内,要么整体顺延。所以「对齐」不是把参数排整齐,而是让参数之间的空隙被填满。
6.2.2 整数账:寄存器数 / 已用 / 作废 / 千分比
把它写成整数账:设寄存器容量 cpr = 4 个分量,按声明顺序贪心首适配——能塞进当前寄存器就塞,塞不下就顺延到下一个(余量作废)。于是有四个整数:寄存器数 registers、已用分量 used、作废分量 wasted = registers × cpr − used、利用率千分比 permille = used × 1000 // (registers × cpr)。全是整数运算,没有浮点,两侧逐位一致。
6.2.3 打包配置(D 层)
下面这个块是 D 层:10 个参数的分量数(来自 6.1 的参数类型),以及两个声明顺序——naive(按材质逻辑顺序,两个 3 分量参数夹着一个 2 分量参数)与 packed(按寄存器边界重排,3 分量后紧跟 1 分量补满):
{
"project": "stardust-inn",
"ruleSet": "param-packing",
"specRef": "config/visual_spec.json",
"note": "参数对齐与打包:一个常量寄存器是 16 字节 = 4 个分量(float4)。每个参数占若干分量,必须整块落在某个寄存器内、不许跨寄存器边界;放不下就顺延到下一个寄存器(余量作废)。本表把「按逻辑顺序声明」与「按寄存器边界重排」两种顺序各算一遍,产出整数账:寄存器数 / 已用分量 / 作废分量 / 利用率千分比。全部整数,两侧逐位一致。",
"registerBytes": 16,
"componentsPerRegister": 4,
"params": [
{ "name": "_BaseColor", "components": 3 },
{ "name": "_EmissionColor", "components": 3 },
{ "name": "_TilingScale", "components": 2 },
{ "name": "_Roughness", "components": 1 },
{ "name": "_Metallic", "components": 1 },
{ "name": "_EmissionStrength", "components": 1 },
{ "name": "_FlickerSpeed", "components": 1 },
{ "name": "_OutlineWidth", "components": 1 },
{ "name": "_AlphaCutoff", "components": 1 },
{ "name": "_SoftEdge", "components": 1 }
],
"variants": [
{
"id": "naive",
"label": "反例:按材质逻辑顺序声明,两个 3 分量参数夹着 2 分量参数",
"order": ["_BaseColor", "_TilingScale", "_EmissionColor", "_Roughness", "_Metallic", "_EmissionStrength", "_FlickerSpeed", "_OutlineWidth", "_AlphaCutoff", "_SoftEdge"]
},
{
"id": "packed",
"label": "正例:按寄存器边界重排,3 分量参数后紧跟 1 分量参数补满",
"order": ["_EmissionColor", "_Roughness", "_BaseColor", "_Metallic", "_TilingScale", "_EmissionStrength", "_FlickerSpeed", "_OutlineWidth", "_AlphaCutoff", "_SoftEdge"]
}
]
}
_BaseColor 是 3 分量的 color、_TilingScale 是 2 分量的 float2——分量数不是随便定的,它由参数类型决定(见 6.1 材质参数表与默认值
)。同一个参数集,换个声明顺序就少占一个寄存器。
6.2.4 S 层:两个变体的整数账
S 层脚本对两个变体各算一遍,落 out/ta06_packing.json:
#!/usr/bin/env python3
# file: sim/ta06_packing.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta06_packing.py
"""6.2 参数对齐与打包的整数账(S 层)。一个常量寄存器 = 16 字节 = 4 个分量(float4),规则三条:
参数整块落在某个寄存器内、不许跨寄存器边界;放不下就顺延到下一个寄存器(余量作废);声明顺序
不同寄存器数就不同。对 naive(逻辑顺序)与 packed(重排)两个变体各算一遍,落 out/ta06_packing.json,
断言任何参数都不跨寄存器边界(boundaryCrossings == 0)。
"""
import hashlib
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CONFIG = os.path.join(ROOT, "config/ta06_packing.json")
OUT = os.path.join(ROOT, "out")
def pack(order, comp, cpr):
"""贪心首适配:按 order 顺序放参数,不跨寄存器边界,放不下就顺延。"""
cursor = 0
placed = []
crossings = 0
for name in order:
c = comp[name]
if c > cpr:
crossings += 1 # 单个参数比一个寄存器还大:无法打包
if cursor % cpr + c > cpr:
cursor = ((cursor + cpr - 1) // cpr) * cpr # 顺延到下一个寄存器
placed.append({"name": name, "register": cursor // cpr, "offset": cursor % cpr, "components": c})
cursor += c
registers = (cursor + cpr - 1) // cpr
used = sum(p["components"] for p in placed)
capacity = registers * cpr
return {"registers": registers, "used": used, "capacity": capacity,
"wasted": capacity - used, "permille": used * 1000 // capacity,
"boundaryCrossings": crossings, "placed": placed}
def main():
cfg = json.load(open(CONFIG, encoding="utf-8"))
os.makedirs(OUT, exist_ok=True)
cpr = cfg["componentsPerRegister"]
comp = {p["name"]: p["components"] for p in cfg["params"]}
total_comp = sum(comp.values())
variants = []
for v in cfg["variants"]:
assert set(v["order"]) == set(comp), f"{v['id']} 顺序与参数集不符"
r = pack(v["order"], comp, cpr)
r.update({"id": v["id"], "label": v["label"]})
variants.append(r)
lines = [f"{v['id']}|{v['registers']}|{v['used']}|{v['wasted']}|{v['permille']}" for v in variants]
digest = hashlib.sha256("\n".join(sorted(lines)).encode("utf-8")).hexdigest()[:16]
naive = next(v for v in variants if v["id"] == "naive")
packed = next(v for v in variants if v["id"] == "packed")
summary = {
"project": cfg["project"], "ruleSet": cfg["ruleSet"],
"componentsPerRegister": cpr, "registerBytes": cfg["registerBytes"],
"paramCount": len(cfg["params"]), "totalComponents": total_comp,
"variants": variants, "digest": digest,
"savedRegisters": naive["registers"] - packed["registers"],
"permilleGain": packed["permille"] - naive["permille"],
"allBoundarySafe": all(v["boundaryCrossings"] == 0 for v in variants),
}
with open(os.path.join(OUT, "ta06_packing.json"), "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"componentsPerRegister={cpr} params={len(cfg['params'])} totalComponents={total_comp}")
for v in variants:
print(f" [{v['id']}] registers={v['registers']} used={v['used']} wasted={v['wasted']} "
f"permille={v['permille']} boundaryCrossings={v['boundaryCrossings']}")
print(" [packed] 参数落位:")
for p in packed["placed"]:
print(f" {p['name']:<18} reg{p['register']} +{p['offset']} ({p['components']} comp)")
print(f"savedRegisters={summary['savedRegisters']} permilleGain={summary['permilleGain']} "
f"allBoundarySafe={summary['allBoundarySafe']}")
print(f"digest={digest}")
print("wrote out/ta06_packing.json")
return 0 if summary["allBoundarySafe"] else 1
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/ta06_packing.py:
componentsPerRegister=4 params=10 totalComponents=15
[naive] registers=5 used=15 wasted=5 permille=750 boundaryCrossings=0
[packed] registers=4 used=15 wasted=1 permille=937 boundaryCrossings=0
[packed] 参数落位:
_EmissionColor reg0 +0 (3 comp)
_Roughness reg0 +3 (1 comp)
_BaseColor reg1 +0 (3 comp)
_Metallic reg1 +3 (1 comp)
_TilingScale reg2 +0 (2 comp)
_EmissionStrength reg2 +2 (1 comp)
_FlickerSpeed reg2 +3 (1 comp)
_OutlineWidth reg3 +0 (1 comp)
_AlphaCutoff reg3 +1 (1 comp)
_SoftEdge reg3 +2 (1 comp)
savedRegisters=1 permilleGain=187 allBoundarySafe=True
digest=f2c86825df9d692d
wrote out/ta06_packing.json
naive 占 5 个寄存器、利用率 750‰;packed 占 4 个、937‰——只是把 3 分量参数和 1 分量参数配成对,就省下一个寄存器。落位读法:reg0 装 _EmissionColor(3) + _Roughness(1),reg1 装 _BaseColor(3) + _Metallic(1),reg2 装 _TilingScale(2) + _EmissionStrength(1) + _FlickerSpeed(1),reg3 只剩 3 个分量。boundaryCrossings=0:没有任何参数跨边界。
6.2.5 P 层内核
同一套贪心首适配,TypeScript 侧独立实现一遍,只读同一份配置:
// file: core/src/ta06/packing.ts
// tier: P
// verify: node --test 'core/test/ta06_*.test.ts'
// 6.2 参数对齐与打包的 TS 内核。与 sim/ta06_packing.py 同一套规则:一个寄存器 4 个分量,
// 参数整块落在寄存器内、不跨边界,放不下就顺延到下一个寄存器(余量作废)。全部整数,
// 除法一律整数地板除,两侧逐位一致。只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
export type Placement = { name: string; register: number; offset: number; components: number };
export type PackResult = {
registers: number; used: number; capacity: number; wasted: number;
permille: number; boundaryCrossings: number; placed: Placement[];
};
export function pack(order: string[], comp: Record<string, number>, cpr: number): PackResult {
let cursor = 0;
const placed: Placement[] = [];
let crossings = 0;
for (const name of order) {
const c = comp[name];
if (c > cpr) crossings += 1;
if (cursor % cpr + c > cpr) cursor = Math.floor((cursor + cpr - 1) / cpr) * cpr;
placed.push({ name, register: Math.floor(cursor / cpr), offset: cursor % cpr, components: c });
cursor += c;
}
const registers = Math.floor((cursor + cpr - 1) / cpr);
const used = placed.reduce((a, p) => a + p.components, 0);
const capacity = registers * cpr;
return {
registers, used, capacity, wasted: capacity - used,
permille: Math.floor((used * 1000) / capacity),
boundaryCrossings: crossings, placed,
};
}
/** 断言:没有任何参数跨寄存器边界(offset + components <= cpr)。 */
export function boundarySafe(r: PackResult, cpr: number): boolean {
return r.placed.every((p) => p.offset + p.components <= cpr);
}
6.2.6 P 层测试:跨语言对照与负例
P 层测试读 Python 落盘的 out/ta06_packing.json,逐字段对照两个变体的排布,并断言「naive 必须比 packed 多占寄存器」这个负例:
// file: core/test/ta06_packing.test.ts
// tier: P
// verify: node --test 'core/test/ta06_*.test.ts'
// 跨语言对照:读 sim/ta06_packing.py 落盘的 out/ta06_packing.json,断言 TS 侧的寄存器排布账
// 逐位一致。负例:naive 顺序必须比 packed 多占寄存器;任何参数都不得跨寄存器边界。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { pack, boundarySafe } from "../src/ta06/packing.ts";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/ta06_packing.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta06_packing.json"), "utf8"));
const comp: Record<string, number> = Object.fromEntries(cfg.params.map((p: any) => [p.name, p.components]));
const cpr = cfg.componentsPerRegister;
const digest = (ls: string[]): string => createHash("sha256").update([...ls].sort().join("\n")).digest("hex").slice(0, 16);
test("跨语言:TS 寄存器排布账与 Python 产物逐位一致", () => {
const lines: string[] = [];
cfg.variants.forEach((v: any, i: number) => {
const mine = pack(v.order, comp, cpr);
const theirs = py.variants[i];
for (const k of ["registers", "used", "capacity", "wasted", "permille", "boundaryCrossings"]) {
assert.equal(mine[k], theirs[k], `${v.id} ${k}`);
}
assert.deepEqual(mine.placed, theirs.placed, `${v.id} placed`);
lines.push(`${v.id}|${mine.registers}|${mine.used}|${mine.wasted}|${mine.permille}`);
});
assert.equal(digest(lines), py.digest);
assert.equal(py.digest, "f2c86825df9d692d");
console.log(`packing digest = ${digest(lines)}`);
});
test("负例:naive 逻辑顺序必须比 packed 重排多占寄存器", () => {
const naive = py.variants.find((v: any) => v.id === "naive");
const packed = py.variants.find((v: any) => v.id === "packed");
assert.equal(naive.registers, 5);
assert.equal(packed.registers, 4);
assert.ok(naive.registers > packed.registers);
assert.equal(py.savedRegisters, 1);
assert.equal(naive.permille, 750);
assert.equal(packed.permille, 937);
assert.ok(packed.permille > naive.permille);
});
test("不变量:任何参数都不跨寄存器边界,分量总数守恒", () => {
assert.equal(py.allBoundarySafe, true);
for (const v of cfg.variants) {
const r = pack(v.order, comp, cpr);
assert.ok(boundarySafe(r, cpr), `${v.id} 有参数跨边界`);
assert.equal(r.boundaryCrossings, 0);
assert.equal(r.used, py.totalComponents);
}
});
test("正例:一个 3 分量后紧跟 1 分量能填满同一寄存器;越界必须顺延", () => {
const r = pack(["_EmissionColor", "_Roughness", "_TilingScale"], { _EmissionColor: 3, _Roughness: 1, _TilingScale: 2 }, 4);
assert.equal(r.registers, 2);
assert.deepEqual(r.placed[1], { name: "_Roughness", register: 0, offset: 3, components: 1 });
assert.deepEqual(r.placed[2], { name: "_TilingScale", register: 1, offset: 0, components: 2 });
});
实跑 node --test 'core/test/ta06_*.test.ts'(ta06_* 三节共 13 条,此处只列本节相关的 4 条与汇总):
packing digest = f2c86825df9d692d
✔ 跨语言:TS 寄存器排布账与 Python 产物逐位一致
✔ 负例:naive 逻辑顺序必须比 packed 重排多占寄存器
✔ 不变量:任何参数都不跨寄存器边界,分量总数守恒
✔ 正例:一个 3 分量后紧跟 1 分量能填满同一寄存器;越界必须顺延
ℹ tests 13
ℹ pass 13
ℹ fail 0
packing digest = f2c86825df9d692d,与 Python 侧逐位一致。边界:真实 GPU 的常量缓冲区规则——编译器是否自动重排、驱动是否补齐、不同图形 API 的寄存器粒度差异——需真实环境,命令行不可复现,标 X;本节算的是可复现的整数账:给定「一个寄存器 4 个分量、参数不许跨边界」这个模型,寄存器数与利用率就是确定的。另外,本节只算布局账,不算「省一个寄存器能提升多少帧率」——那是性能预算专题的事。
阅读导航:上一节:6.1 材质参数表与默认值 · 下一节:6.3 划界:编译与渲染原理外链 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。