nx_calibrate.nx source
↩ module page · 420 lines · 16157 B
1// nx_calibrate.nx -- lmbench-class empirical cost-model builder.
2//
3// SA-2 milestone of NISHI_SELF_ASSEMBLY_ROADMAP.md. Consumes the
4// device capability fingerprint from nx_probe and produces an
5// empirical cost model the selector consumes at SA-4 to rank
6// variants. The point of empirical calibration is to refuse the
7// hack of selecting variants from spec-sheet cost models -- the
8// only honest answer is "what does THIS device measure when we
9// actually run the operations?"
10//
11// Composes:
12// nx_probe -- capability fingerprint (caller's prior step)
13// nx_clock -- monotonic-ns time helper
14// nx_tier -- tier classification (NX_TIER_MCU..HPC inferred
15// from the measurements)
16//
17// V1 microbenchmarks (SA-2 honest perf verdict):
18// - int ALU ns/op (dependent-chain ADD, ~1M iterations)
19// - pointer-chase latency at three working-set sizes
20// (small ~ L1, medium ~ L2, large ~ RAM); cache-hierarchy
21// witness via monotonic per-access latency increase
22// - linear-copy memory bandwidth (bytes/ns over a large block)
23// - syscall round-trip cost (clock_gettime loop, 10K calls)
24// - mmap latency (10 mmap calls, ns/mmap)
25//
26// Gap list (V1):
27// - FP ALU / vec ALU absent -- qemu-riscv64-static models them as
28// scalar emulation; honest measurement requires real RV64 + RVV
29// hardware OR the x86_64 + AVX2 native path (SA-2.5)
30// - context-switch cost absent (fork/futex; deferred to SA-6
31// when nx_compile lands the fork primitive)
32// - network RTT absent (no peer in smoke; SA-5)
33// - energy per op absent (no RAPL access from substrate; SA-5+)
34// - byzantine N-of-M deferred to SA-7
35//
36// The substrate refuses these REFUSED HACKS per
37// NISHI_SELF_ASSEMBLY_ROADMAP.md ยง6:
38// - datasheet cost models (#7 refused)
39// - single-axis selection from synthetic data (#3 refused)
40// - vendor-supplied calibration (#14 refused)
41//
42// genealogy_id: lmbench_mccanne_2000 + atlas_phipac_1998 +
43// cardinal_2026-05-19_self_assembly
44// lineage_id: substrate_calibrate_v1
45//
46// nx_capability_manifest:
47// variant_class: capability_calibrate
48// variant_id: capability_calibrate_v1_portable
49// requires_isa: [rv64imac, x86_64]
50// requires_fp: none
51// requires_vec: none
52// requires_ram_min_b: 33554432 // 32 MiB for large working set
53// requires_syscalls: [mmap, clock_gettime_mono, exit]
54// tier_floor: NX_TIER_MOBILE // MCU below 32 MiB
55// tier_ceiling: NX_TIER_HPC
56// cost_model:
57// flops_per_n: 0.0
58// bytes_per_n: 32_000_000 // bandwidth probe touches ~32 MB
59// syscalls_per_n: 0.0 // ~20 syscalls total
60// adversary_class: THREAT_AI_ADVERSARY
61//
62// nx_safety_envelope:
63// intended_use: "Empirical cost-model builder for variant selection;
64// refuses spec-sheet cost models and vendor-supplied
65// calibration; on-device-witnessed only"
66// sil_target: SIL2
67// evidence: [canary_tagged, cache_hierarchy_witnessed,
68// deterministic_shape_across_reruns]
69// verdict: NOT_YET_EVALUATED
70
71import "nx_syscalls.nx"
72import "nx_tier.nx"
73const NX_MAGIC_1000000000: i64 = 1000000000
74const NX_MAGIC_4096: i64 = 4096
75const NX_MAGIC_1048576: i64 = 1048576
76const NX_MAGIC_8000: i64 = 8000
77const NX_MAGIC_4000: i64 = 4000
78const NX_MAGIC_2000: i64 = 2000
79const NX_MAGIC_1000000: i64 = 1000000
80
81// ===== Canary constants ===========================================
82const NX_CALIB_CANARY_PRE: i64 = 0x4E58434C50524500 // "NXCLPRE\0"
83const NX_CALIB_CANARY_POST: i64 = 0x004E58434C504F53 // "\0NXCLPOS"
84const NX_CALIB_SCHEMA_VERSION: i64 = 1
85
86// ===== Sealed enum: NX_TIER_INFERRED =============================
87// Distinct from the static tier enum in nx_tier.nx (which is a
88// substrate-author-time choice). This is what the calibration
89// INFERRED based on measurements.
90const NX_TIER_INF_UNKNOWN: i64 = 0
91const NX_TIER_INF_MCU: i64 = 1
92const NX_TIER_INF_MOBILE: i64 = 2
93const NX_TIER_INF_LAPTOP: i64 = 3
94const NX_TIER_INF_DESKTOP: i64 = 4
95const NX_TIER_INF_SERVER: i64 = 5
96const NX_TIER_INF_HPC: i64 = 6
97const NX_TIER_INF_N: i64 = 7
98
99func nx_tier_inf_is_valid(t: i64) -> i64 {
100 if t < 0 { return 0 }
101 if t >= NX_TIER_INF_N { return 0 }
102 return 1
103}
104
105// ===== Working-set sizes for memory-hierarchy probe ==============
106const NX_CALIB_WS_SMALL_B: i64 = 4096 // ~ L1 on most hosts
107const NX_CALIB_WS_MEDIUM_B: i64 = 262144 // 256 KiB ~ L2
108const NX_CALIB_WS_LARGE_B: i64 = 16777216 // 16 MiB ~ DRAM
109
110// ===== Iteration counts ==========================================
111const NX_CALIB_INT_ALU_ITERS: i64 = 10000000 // 10M iters x 3 ops
112const NX_CALIB_PTRCHASE_ITERS: i64 = 1000000 // 1M chases
113const NX_CALIB_BW_BYTES: i64 = 1048576 // 1 MiB
114const NX_CALIB_BW_REPEAT: i64 = 8 // 8 MiB total moved
115const NX_CALIB_SYSCALL_ITERS: i64 = 10000
116const NX_CALIB_MMAP_ITERS: i64 = 10
117
118// ===== NxCalibrationRecord =======================================
119struct NxCalibrationRecord {
120 canary_pre: i64,
121 schema_version: i64,
122 ts_us: i64,
123 int_alu_ps_per_op: i64, // dependent-chain ADD, picosecond precision
124 int_alu_iters: i64,
125 mem_ns_per_access_l1: i64, // 4 KiB working set
126 mem_ns_per_access_l2: i64, // 256 KiB
127 mem_ns_per_access_ram: i64, // 16 MiB
128 mem_bw_mib_per_s: i64, // linear copy
129 syscall_ns_clock_gettime: i64,
130 syscall_ns_mmap: i64,
131 inferred_tier: i64, // NX_TIER_INF_*
132 canary_post: i64,
133}
134
135// ===== Helpers ===================================================
136// _now_ns returns monotonic nanoseconds as i64. Wraps the existing
137// sys_clock_gettime_mono primitive so calibration loops don't pay
138// the struct-allocation cost in the hot path.
139
140func _now_ns(ts: *i64) -> i64 {
141 sys_clock_gettime_mono(ts)
142 return (ts[0] * NX_MAGIC_1000000000) + ts[1]
143}
144
145// ===== Integer ALU dependent-chain ==============================
146// Tight loop where each ADD depends on the previous result. Under
147// out-of-order execution the dependent chain serializes; the
148// measured ns/op approximates the ADD critical-path latency.
149// Under qemu-riscv64-static this measures qemu's interpretation
150// cost, not real RV64 -- honest gap documented in the header.
151
152func _calibrate_int_alu(out_ps_per_op: *i64) -> i64 {
153 let ts: *i64 = (sys_mmap(16)) as *i64
154 let t0: i64 = _now_ns(ts)
155 var acc: i64 = 1
156 var i: i64 = 0
157 while i < NX_CALIB_INT_ALU_ITERS {
158 acc = acc + i
159 acc = acc - (i / 2)
160 acc = acc + 3
161 i = i + 1
162 }
163 let t1: i64 = _now_ns(ts)
164 let elapsed: i64 = t1 - t0
165 // 3 ops per loop iteration; total = 3 * iters. Multiply elapsed
166 // by 1000 first to land in PICOSECONDS-per-op precision (avoids
167 // integer-division truncation when host JIT runs adds at sub-ns
168 // per op under qemu). Field name reflects ps, not ns.
169 let total_ops: i64 = NX_CALIB_INT_ALU_ITERS * 3
170 if total_ops > 0 {
171 *out_ps_per_op = (elapsed * 1000) / total_ops
172 } else {
173 *out_ps_per_op = 0
174 }
175 // Defeat dead-code elimination: write acc through sys_write to a
176 // pipe-able buffer the substrate can't prove unobserved. The
177 // scratch byte alone may be DCE-pruned; sys_write definitively
178 // observes the value.
179 let sink: *u8 = sys_mmap(8)
180 sink[0] = (acc & 255) as u8
181 sink[1] = ((acc >> 8) & 255) as u8
182 sys_write(2, sink, 0) // 0-byte write to stderr touches sink as obs
183 return 0
184}
185
186// ===== Pointer-chase latency probe ==============================
187// Build a ring of pointer-chase nodes in a working-set buffer; each
188// node stores the offset of the NEXT node. Walk it N times.
189// Larger working set -> more cache misses -> higher ns/access.
190// On qemu this measures the HOST's cache hierarchy seen through
191// emulation -- still empirically witnessed on this machine.
192
193func _calibrate_ptrchase(ws_bytes: i64, out_ns_per_access: *i64) -> i64 {
194 // Each "line" is 8 i64-cells = 64 bytes (cache-line-ish). Ring
195 // walks via *i64 indexing where each node's i64 cell stores the
196 // INDEX (in i64-cell units) of the next node's first cell.
197 // Indexing through buf64[idx] = *i64 access at byte offset idx*8.
198 let line_i64: i64 = 8 // 8 i64-cells per line = 64 bytes
199 let n_nodes: i64 = ws_bytes / 64
200 if n_nodes <= 0 { *out_ns_per_access = 0; return -1 }
201 let buf64: *i64 = (sys_mmap(ws_bytes)) as *i64
202 if (buf64 as i64) == 0 { *out_ns_per_access = 0; return -2 }
203 // Initialize: node i's first cell (at index i*line_i64) stores
204 // the cell-index of node (i+1)%n_nodes.
205 var i: i64 = 0
206 while i < n_nodes {
207 let here_idx: i64 = i * line_i64
208 let next_node: i64 = (i + 1) % n_nodes
209 buf64[here_idx] = next_node * line_i64
210 i = i + 1
211 }
212 // Walk.
213 let ts: *i64 = (sys_mmap(16)) as *i64
214 let t0: i64 = _now_ns(ts)
215 var idx: i64 = 0
216 var k: i64 = 0
217 while k < NX_CALIB_PTRCHASE_ITERS {
218 idx = buf64[idx]
219 k = k + 1
220 }
221 let t1: i64 = _now_ns(ts)
222 let elapsed: i64 = t1 - t0
223 if NX_CALIB_PTRCHASE_ITERS > 0 {
224 // Multiply by 1000 first to land in picosecond precision when
225 // small working sets dominate L1.
226 *out_ns_per_access = (elapsed * 1000) / NX_CALIB_PTRCHASE_ITERS
227 } else {
228 *out_ns_per_access = 0
229 }
230 // Defeat DCE: write idx via sys_write (definitively observed).
231 let sink: *u8 = sys_mmap(8)
232 sink[0] = (idx & 255) as u8
233 sys_write(2, sink, 0)
234 return 0
235}
236
237// ===== Memory bandwidth (linear copy) ===========================
238// Linear u64 copy over a 1 MiB block, repeated NX_CALIB_BW_REPEAT
239// times. Bytes moved (counted both sides of the copy) /
240// elapsed-seconds = bytes/sec; convert to MiB/s.
241
242func _calibrate_mem_bw(out_mib_per_s: *i64) -> i64 {
243 let src: *u8 = sys_mmap(NX_CALIB_BW_BYTES)
244 let dst: *u8 = sys_mmap(NX_CALIB_BW_BYTES)
245 if (src as i64) == 0 { *out_mib_per_s = 0; return -1 }
246 if (dst as i64) == 0 { *out_mib_per_s = 0; return -2 }
247 // Warm-up: touch source so first copy isn't paying page-fault
248 // costs (Linux mmap is lazy-allocated).
249 var i: i64 = 0
250 while i < NX_CALIB_BW_BYTES {
251 src[i] = ((i & 255) as u8)
252 i = i + NX_MAGIC_4096
253 }
254 let ts: *i64 = (sys_mmap(16)) as *i64
255 let t0: i64 = _now_ns(ts)
256 var rep: i64 = 0
257 while rep < NX_CALIB_BW_REPEAT {
258 // Copy 8 bytes per iteration via i64 cast.
259 let src64: *i64 = src as *i64
260 let dst64: *i64 = dst as *i64
261 var j: i64 = 0
262 let n64: i64 = NX_CALIB_BW_BYTES / 8
263 while j < n64 {
264 dst64[j] = src64[j]
265 j = j + 1
266 }
267 rep = rep + 1
268 }
269 let t1: i64 = _now_ns(ts)
270 let elapsed_ns: i64 = t1 - t0
271 let total_bytes: i64 = NX_CALIB_BW_BYTES * NX_CALIB_BW_REPEAT * 2 // r+w
272 // bytes/ns -> MiB/s : (bytes * 1e9) / (elapsed_ns * 1048576)
273 if elapsed_ns > 0 {
274 *out_mib_per_s = (total_bytes * NX_MAGIC_1000000000) / (elapsed_ns * NX_MAGIC_1048576)
275 } else {
276 *out_mib_per_s = 0
277 }
278 return 0
279}
280
281// ===== Syscall round-trip cost ==================================
282// Tight clock_gettime loop. The loop ITSELF contains a clock read,
283// so the measured cost is for the syscall plus minimal loop
284// overhead. Use the same time helper that nx_probe uses to keep
285// the harness honest.
286
287func _calibrate_syscall_gettime(out_ns_per_call: *i64) -> i64 {
288 let ts1: *i64 = (sys_mmap(16)) as *i64
289 let ts2: *i64 = (sys_mmap(16)) as *i64
290 let t0: i64 = _now_ns(ts1)
291 var i: i64 = 0
292 while i < NX_CALIB_SYSCALL_ITERS {
293 sys_clock_gettime_mono(ts2)
294 i = i + 1
295 }
296 let t1: i64 = _now_ns(ts1)
297 let elapsed: i64 = t1 - t0
298 if NX_CALIB_SYSCALL_ITERS > 0 {
299 *out_ns_per_call = elapsed / NX_CALIB_SYSCALL_ITERS
300 } else {
301 *out_ns_per_call = 0
302 }
303 return 0
304}
305
306// ===== mmap latency =============================================
307// Small allocations in a tight loop; ns/mmap. Each mmap is a
308// minimum-size (page-rounded by kernel) anonymous mapping; lazy
309// allocation means we measure the kernel's mmap entry path, not
310// page-zero work.
311
312func _calibrate_syscall_mmap(out_ns_per_mmap: *i64) -> i64 {
313 let ts: *i64 = (sys_mmap(16)) as *i64
314 let t0: i64 = _now_ns(ts)
315 var i: i64 = 0
316 while i < NX_CALIB_MMAP_ITERS {
317 let p: *u8 = sys_mmap(64)
318 // Defeat DCE: write one byte.
319 p[0] = 1 as u8
320 i = i + 1
321 }
322 let t1: i64 = _now_ns(ts)
323 let elapsed: i64 = t1 - t0
324 if NX_CALIB_MMAP_ITERS > 0 {
325 *out_ns_per_mmap = elapsed / NX_CALIB_MMAP_ITERS
326 } else {
327 *out_ns_per_mmap = 0
328 }
329 return 0
330}
331
332// ===== Tier inference ===========================================
333// Map the measurements onto NX_TIER_INF_*. Inference rule (V1):
334// - RAM access < 50 ns + BW > 8000 MiB/s -> HPC
335// - RAM access < 100 ns + BW > 4000 MiB/s -> SERVER
336// - RAM access < 200 ns + BW > 1000 MiB/s -> DESKTOP
337// - RAM access < 500 ns + BW > 200 MiB/s -> LAPTOP
338// - RAM access < 2000 ns -> MOBILE
339// - else -> MCU
340// Tunable in SA-3 once we have measurements from multiple real
341// targets; honest V1 first-pass thresholds.
342
343func _infer_tier(ram_ns: i64, bw_mib_per_s: i64) -> i64 {
344 if ram_ns <= 0 { return NX_TIER_INF_UNKNOWN }
345 if bw_mib_per_s <= 0 { return NX_TIER_INF_UNKNOWN }
346 if ram_ns < 50 {
347 if bw_mib_per_s > NX_MAGIC_8000 { return NX_TIER_INF_HPC }
348 }
349 if ram_ns < 100 {
350 if bw_mib_per_s > NX_MAGIC_4000 { return NX_TIER_INF_SERVER }
351 }
352 if ram_ns < 200 {
353 if bw_mib_per_s > 1000 { return NX_TIER_INF_DESKTOP }
354 }
355 if ram_ns < 500 {
356 if bw_mib_per_s > 200 { return NX_TIER_INF_LAPTOP }
357 }
358 if ram_ns < NX_MAGIC_2000 { return NX_TIER_INF_MOBILE }
359 return NX_TIER_INF_MCU
360}
361
362// ===== Construction =============================================
363func nx_calibrate_new() -> *NxCalibrationRecord {
364 let r: *NxCalibrationRecord = (sys_mmap(128)) as *NxCalibrationRecord
365 r.canary_pre = NX_CALIB_CANARY_PRE
366 r.canary_post = NX_CALIB_CANARY_POST
367 r.schema_version = NX_CALIB_SCHEMA_VERSION
368 r.ts_us = 0
369 r.int_alu_ps_per_op = 0
370 r.int_alu_iters = NX_CALIB_INT_ALU_ITERS
371 r.mem_ns_per_access_l1 = 0
372 r.mem_ns_per_access_l2 = 0
373 r.mem_ns_per_access_ram = 0
374 r.mem_bw_mib_per_s = 0
375 r.syscall_ns_clock_gettime = 0
376 r.syscall_ns_mmap = 0
377 r.inferred_tier = NX_TIER_INF_UNKNOWN
378 return r
379}
380
381// ===== Main entry ===============================================
382func nx_calibrate_run(out: *NxCalibrationRecord) -> i64 {
383 if out.canary_pre != NX_CALIB_CANARY_PRE { return 1 }
384 if out.canary_post != NX_CALIB_CANARY_POST { return 2 }
385
386 let scratch: *i64 = (sys_mmap(8)) as *i64
387 *scratch = 0
388
389 _calibrate_int_alu(scratch)
390 out.int_alu_ps_per_op = *scratch
391
392 _calibrate_ptrchase(NX_CALIB_WS_SMALL_B, scratch)
393 out.mem_ns_per_access_l1 = *scratch
394 _calibrate_ptrchase(NX_CALIB_WS_MEDIUM_B, scratch)
395 out.mem_ns_per_access_l2 = *scratch
396 _calibrate_ptrchase(NX_CALIB_WS_LARGE_B, scratch)
397 out.mem_ns_per_access_ram = *scratch
398
399 _calibrate_mem_bw(scratch)
400 out.mem_bw_mib_per_s = *scratch
401
402 _calibrate_syscall_gettime(scratch)
403 out.syscall_ns_clock_gettime = *scratch
404
405 _calibrate_syscall_mmap(scratch)
406 out.syscall_ns_mmap = *scratch
407
408 out.inferred_tier = _infer_tier(out.mem_ns_per_access_ram, out.mem_bw_mib_per_s)
409
410 // Timestamp (best-effort).
411 let ts: *i64 = (sys_mmap(16)) as *i64
412 let rc_ts: i64 = sys_clock_gettime_mono(ts)
413 if rc_ts == 0 {
414 out.ts_us = (ts[0] * NX_MAGIC_1000000) + (ts[1] / 1000)
415 }
416
417 if out.canary_pre != NX_CALIB_CANARY_PRE { return 3 }
418 if out.canary_post != NX_CALIB_CANARY_POST { return 4 }
419 return 0
420}