nx_unet_block.nx source
↩ module page · 209 lines · 8072 B
1// nx_unet_block.nx -- diffusion-model ResBlock composer.
2//
3// L4 composer for the UNet building block used by Stable Diffusion
4// 1.x/2.x/3, Flux, Z-Image. Pure composition -- no new math.
5//
6// ===== Block shape (canonical SD ResBlock) =======================
7//
8// x: [N, C, H, W] input
9//
10// 1. h = GroupNorm(x, num_groups=32, gamma_1, beta_1)
11// 2. h = SiLU(h)
12// 3. h = Conv2D(h, W_1) : [N, C, H, W]
13// 4. h = GroupNorm(h, num_groups=32, gamma_2, beta_2)
14// 5. h = SiLU(h)
15// 6. h = Conv2D(h, W_2) : [N, C, H, W]
16// 7. Skip: residual = x (v1: assumes C_in = C_out)
17// 8. Output = h + residual
18//
19// ===== v1 scope ==================================================
20//
21// Assumes C_in == C_out throughout (skip-connection is identity).
22// For "down" blocks in a U-shape (C_in != C_out): caller passes
23// a separate 1x1 skip-conv layer and we'd extend this composer
24// in v2. v1 satisfies the same-channel-count case, which is the
25// majority of ResBlocks within a stage.
26//
27// Time-embedding injection (diffusion-step conditioning):
28// canonical UNets add a per-block timestep-embedding vector to h
29// between steps 3 and 4 (after first conv, before second norm).
30// v1 omits this -- caller is expected to add it post-hoc or use the
31// queued v2 of this primitive that accepts a time_emb tensor.
32//
33// Bits-up composition (pure, no new math):
34// NxTensor (L1)
35// nx_conv2d (L3)
36// nx_groupnorm (L3, shipped 40d6e1c2)
37// nx_silu (L3)
38// nx_loop.LoopVerdict (control)
39//
40// genealogy_id: ho_2020_ddpm_unet + ronneberger_2015_u_net +
41// rombach_2022_stable_diffusion_unet +
42// wu_he_2018_group_norm
43// lineage_id: substrate_unet_block_v1_same_channel
44
45// nx_safety_envelope:
46// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
47// sil_target: SIL1
48// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
49// verdict: NOT_YET_EVALUATED
50
51import "nx_syscalls.nx"
52import "nx_tier.nx"
53import "nx_loop.nx"
54import "nx_tensor.nx"
55import "nx_conv2d.nx"
56import "nx_groupnorm.nx"
57import "nx_silu.nx"
58
59const NX_UB_Q10: nx_int = 1024
60const NX_UB_DEFAULT_GROUPS: nx_int = 32
61
62// ===== Sealed-enum: UnetBlockVerdict ==============================
63
64const NX_UB_OK: nx_int = 0
65const NX_UB_ERR_BAD_DIMS: nx_int = 1
66const NX_UB_ERR_SHAPE_MISMATCH: nx_int = 2
67const NX_UB_ERR_INTERNAL: nx_int = 3
68const NX_UB_N_VERDICTS: nx_int = 4
69
70func nx_ub_verdict_is_valid(v: nx_int) -> nx_int {
71 if v < 0 { return 0 }
72 if v >= NX_UB_N_VERDICTS { return 0 }
73 return 1
74}
75
76// ===== Forward pass ==============================================
77//
78// x: *NxTensor [N, C, H, W] Q10 input (preserved; not modified)
79// W_1: *NxTensor [C, C, 3, 3] first conv weights
80// gamma_1: *i64 [C] first GroupNorm scale
81// beta_1: *i64 [C] first GroupNorm bias (nullable)
82// W_2: *NxTensor [C, C, 3, 3] second conv weights
83// gamma_2: *i64 [C] second GroupNorm scale
84// beta_2: *i64 [C] second GroupNorm bias (nullable)
85// n_groups: nx_int GroupNorm group count (typically 32)
86// out: *NxTensor [N, C, H, W] Q10 output
87// scratch: *NxTensor [N, C, H, W] caller-owned scratch (size matches)
88
89func nx_unet_block_forward(
90 x: *NxTensor,
91 W_1: *NxTensor, gamma_1: *i64, beta_1: *i64,
92 W_2: *NxTensor, gamma_2: *i64, beta_2: *i64,
93 n_groups: nx_int,
94 out: *NxTensor, scratch: *NxTensor) -> nx_int {
95
96 if x.dtype != NX_DT_I64 { return NX_UB_ERR_BAD_DIMS }
97 if out.dtype != NX_DT_I64 { return NX_UB_ERR_BAD_DIMS }
98 if scratch.dtype != NX_DT_I64 { return NX_UB_ERR_BAD_DIMS }
99 if x.ndim != 4 { return NX_UB_ERR_BAD_DIMS }
100 if x.shape[0] != out.shape[0] { return NX_UB_ERR_SHAPE_MISMATCH }
101 if x.shape[1] != out.shape[1] { return NX_UB_ERR_SHAPE_MISMATCH }
102 if x.shape[2] != out.shape[2] { return NX_UB_ERR_SHAPE_MISMATCH }
103 if x.shape[3] != out.shape[3] { return NX_UB_ERR_SHAPE_MISMATCH }
104
105 // Step 1+2: scratch = SiLU(GroupNorm(x, gamma_1, beta_1)).
106 let v_gn1: nx_int = nx_groupnorm_forward(x, n_groups, gamma_1, beta_1, scratch)
107 if v_gn1 != NX_GN_OK { return NX_UB_ERR_INTERNAL }
108 let v_silu1: nx_int = nx_silu_forward(scratch, scratch)
109 if v_silu1 != NX_SILU_OK { return NX_UB_ERR_INTERNAL }
110
111 // Step 3: out = Conv2D(scratch, W_1).
112 let v_cv1: nx_int = nx_conv2d_forward(scratch, W_1, 0 as *i64, out)
113 if v_cv1 != NX_CV2_OK { return NX_UB_ERR_INTERNAL }
114
115 // Step 4+5: scratch = SiLU(GroupNorm(out, gamma_2, beta_2)).
116 let v_gn2: nx_int = nx_groupnorm_forward(out, n_groups, gamma_2, beta_2, scratch)
117 if v_gn2 != NX_GN_OK { return NX_UB_ERR_INTERNAL }
118 let v_silu2: nx_int = nx_silu_forward(scratch, scratch)
119 if v_silu2 != NX_SILU_OK { return NX_UB_ERR_INTERNAL }
120
121 // Step 6: out = Conv2D(scratch, W_2).
122 let v_cv2: nx_int = nx_conv2d_forward(scratch, W_2, 0 as *i64, out)
123 if v_cv2 != NX_CV2_OK { return NX_UB_ERR_INTERNAL }
124
125 // Step 7+8: out = out + x (residual skip; v1 assumes C_in = C_out).
126 let n_elem: nx_int = x.shape[0] * x.shape[1] * x.shape[2] * x.shape[3]
127 let pi: *i64 = x.storage as *i64
128 let po: *i64 = out.storage as *i64
129 var i: nx_int = 0
130 var iter: nx_int = 0
131 var verdict: nx_int = NX_LOOP_RUNNING
132 let BUDGET: nx_int = n_elem
133 while verdict == NX_LOOP_RUNNING && iter < BUDGET {
134 po[i] = po[i] + pi[i]
135 i = i + 1
136 iter = iter + 1
137 }
138 return NX_UB_OK
139}
140
141// ===== Self-test ==================================================
142//
143// Builds a tiny UNet block: [N=1, C=4, H=2, W=2] with zero weights +
144// identity gammas + zero betas. Verifies the function COMPLETES OK
145// (structural-acceptance gate). Numerical correctness against a
146// reference model is the next workstream (needs a real
147// stable-diffusion UNet block + golden activation snapshot).
148//
149// Closed-form invariant: zero-weight forward leaves the residual
150// path unchanged. x + 0 = x. We verify this.
151
152func main() -> i64 {
153 let N: nx_int = 1
154 let C: nx_int = 4
155 let H: nx_int = 2
156 let W: nx_int = 2
157
158 let sh: *nx_int = sys_mmap(4 * 8) as *nx_int
159 sh[0]=N; sh[1]=C; sh[2]=H; sh[3]=W
160
161 let wt_sh: *nx_int = sys_mmap(4 * 8) as *nx_int
162 wt_sh[0]=C; wt_sh[1]=C; wt_sh[2]=3; wt_sh[3]=3
163
164 let err: *nx_int = sys_mmap(8) as *nx_int
165 err[0] = 0
166 let x: *NxTensor = nx_t_alloc(NX_DT_I64, sh, 4, err)
167 let out: *NxTensor = nx_t_alloc(NX_DT_I64, sh, 4, err)
168 let scratch: *NxTensor = nx_t_alloc(NX_DT_I64, sh, 4, err)
169 let W_1: *NxTensor = nx_t_alloc(NX_DT_I64, wt_sh, 4, err)
170 let W_2: *NxTensor = nx_t_alloc(NX_DT_I64, wt_sh, 4, err)
171 if err[0] != 0 { return 5 }
172
173 // Fill x with a known pattern.
174 let px: *i64 = x.storage as *i64
175 var i: nx_int = 0
176 while i < N * C * H * W { px[i] = (i + 1) * 100; i = i + 1 }
177
178 // Zero W_1 and W_2 (already zero from sys_mmap).
179 // gamma = Q10 unit, beta = 0.
180 let gamma_1: *i64 = nx_groupnorm_gamma_unit(C)
181 let beta_1: *i64 = nx_groupnorm_beta_zero(C)
182 let gamma_2: *i64 = nx_groupnorm_gamma_unit(C)
183 let beta_2: *i64 = nx_groupnorm_beta_zero(C)
184
185 // 2 groups (C=4 / 2 channels each).
186 let v: nx_int = nx_unet_block_forward(
187 x, W_1, gamma_1, beta_1,
188 W_2, gamma_2, beta_2,
189 2, out, scratch)
190 if v != NX_UB_OK { return 10 + v }
191
192 // With zero weights, conv steps produce zero output. Residual
193 // path adds back x. So out should equal x bit-exact.
194 let po: *i64 = out.storage as *i64
195 var j: nx_int = 0
196 while j < N * C * H * W {
197 if po[j] != px[j] { return 20 }
198 j = j + 1
199 }
200
201 // --- Verdict gate ---
202 var vi: nx_int = 0
203 while vi < NX_UB_N_VERDICTS {
204 if nx_ub_verdict_is_valid(vi) != 1 { return 30 + vi }
205 vi = vi + 1
206 }
207
208 return 0
209}