code wiki / (root) / nx_calibrate.nx

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}