code wiki / _hdl_build / nx_ctxtop_lib.nx

nx_ctxtop_lib.nx source

↩ module page · 190 lines · 10080 B

1// nx_ctxtop_lib.nx -- PURE core of nx_ctxtop: per-process context-switch ATTRIBUTION. 2// 3// WHY: nx_procchurn proved the box takes 81k-93k context switches/sec in EVERY sample across 45 minutes 4// (1.6-1.9x the RED line) -- the one CONSTANT in the perf lane. But a box-level rate names no culprit, 5// and the fork-rate hypothesis was DISPROVEN as the driver (seq1340: forks swing 15->179->10 while ctxsw 6// stays flat, so the switches are NOT mostly fork/exec). /proc/<pid>/status carries 7// voluntary_ctxt_switches and nonvoluntary_ctxt_switches PER PROCESS, so the producer is directly 8// measurable instead of guessed. LAW (rising-debt-means-find-the-producer): a rate with no owner is a 9// symptom, not a diagnosis. 10// 11// ★THE VOLUNTARY/NONVOLUNTARY SPLIT IS THE DIAGNOSIS, not a detail: 12// voluntary = the process BLOCKED on its own (read/poll/sleep) -- an I/O or poll-loop design, 13// fixable by batching, longer polls, or event-driven waits. 14// nonvoluntary = the SCHEDULER preempted it -- CPU contention / too many runnable threads, 15// fixed by reducing concurrency, not by batching. 16// The same total means opposite remedies, so a tool reporting only the sum would send the fix the wrong 17// way. This lib keeps them separate all the way to the output. 18// 19// Rule 15: rate arithmetic is REUSED from nx_procchurn_lib (pc_rate), generic IO/print from 20// nx_resmon_lib -- no new copies of the emit family (D001/seq389: 9495 duplicate bodies). 21// license_tier: ORIGINAL Read-only. No hw writes (Rule 26). 22import "nx_procchurn_lib.nx" 23 24const CT_NOTFOUND: i64 = 0 - 1 25 26// ---- ADMISSION, IN THE LIB SO EVERY HEAVY ORGAN ASKS THE SAME QUESTION THE SAME WAY ---------------- 27// ★LAW (nx_ctxtop): A DIAGNOSTIC THAT CANNOT REFUSE TO RUN IS A LOAD GENERATOR WITH GOOD INTENTIONS. 28// The law was already written and only nx_ctxtop and nx_memvel obeyed it, because the SEQUENCE that 29// implements it -- read conf, read loadavg, parse, admit -- lived inline in the caller. MEASURED 30// 2026-08-15: _drv_proto_gate and _drv_bind_gate fork a full rv64 EMULATOR, far heavier than a /proc 31// walk, and cannot refuse; under host saturation the long virtio-blk transcript was cut short and BOTH 32// gates reported the DRIVER as broken. Same binaries returned 4/4 and 3/3 GREEN once load fell. 33// ★★★A LAW THAT EACH CALLER MUST RE-IMPLEMENT IS OBEYED AT RE-IMPLEMENTATION RATES; THE SAME LAW BEHIND 34// ONE CALL IS OBEYED BY WHOEVER CALLS IT. 35// CT_ADMIT_DEFAULT MOVED here from nx_ctxtop.nx rather than copied: a second 800 in the driver gates 36// would be one calibration living in two files that can never be tuned together, which is the defect -- 37// not the number. One home, one operator-tunable key, every caller in agreement. 38const CT_ADMIT_DEFAULT: i64 = 800 39// procfs reports size 0 to lseek END, so sys_read_file cannot size /proc/loadavg and this ONE read is 40// legitimately bounded. Named for that single purpose. A truncated loadavg still yields the 1-minute 41// figure correctly because it is the FIRST field, and ct_load_centi refuses anything it cannot parse. 42const CT_ADMIT_LOADBUF: i64 = 128 43 44// 1 = the host can afford this work, 0 = REFUSE. Fail-closed at every step: an unreadable conf falls 45// back to the named default, and an unreadable loadavg refuses outright, because not knowing how loaded 46// the box is has never earned permission to add to it. 47// The conf is a REAL file with a real size, so it composes sys_read_file and needs no ceiling at all. 48func ct_admit_now() -> i64 { 49 let cl: *i64 = sys_mmap(16) as *i64 50 var maxload: i64 = CT_ADMIT_DEFAULT 51 let cbuf: *u8 = sys_read_file("knowledge/status/procchurn.conf" as *u8, cl) 52 if (cbuf as i64) != 0 { maxload = rm_conf(cbuf, cl[0], "admit-max-load-centi" as *u8, CT_ADMIT_DEFAULT) } 53 let lbuf: *u8 = sys_mmap(CT_ADMIT_LOADBUF) 54 let ln: i64 = rm_read("/proc/loadavg" as *u8, lbuf, CT_ADMIT_LOADBUF) 55 if ln <= 0 { return 0 } 56 return ct_admit(ct_load_centi(lbuf, ln), maxload) 57} 58 59// Linear find of a pid in a parallel-array sample. Returns index or -1. O(n) per lookup, O(n^2) overall 60// at ~1000 pids = ~1e6 compares -- deliberately simple: a hash table here would be unprovable complexity 61// for a one-shot diagnostic, and the cost is far below one scheduling quantum. 62func ct_find(pids: *i64, n: i64, pid: i64) -> i64 { 63 var i: i64 = 0 64 while i < n { 65 if pids[i] == pid { return i } 66 i = i + 1 67 } 68 return CT_NOTFOUND 69} 70 71// Insert (pid,rate) into a DESCENDING top-k kept in parallel arrays. Returns the new used count. 72// FAIL-CLOSED ON TRUNCATION BY DESIGN: when the table is full the SMALLEST entry is evicted, never the 73// incoming one -- a top-k that silently drops the biggest producer because it arrived last is exactly 74// the silent-truncation class (seq1319/L011). Ties keep the incumbent (stable, so output does not 75// churn between runs on equal rates). 76func ct_topk_insert(pids: *i64, rates: *i64, k: i64, used: i64, pid: i64, rate: i64) -> i64 { 77 if k <= 0 { return 0 } 78 if rate < 0 { return used } 79 // find insertion point: first slot whose rate is strictly smaller 80 var pos: i64 = used 81 var i: i64 = 0 82 var found: i64 = 0 83 while i < used { 84 if found == 0 { 85 if rates[i] < rate { pos = i; found = 1 } 86 } 87 i = i + 1 88 } 89 if pos >= k { return used } 90 var nu: i64 = used 91 if nu < k { nu = nu + 1 } 92 // shift down from the end toward pos (drops the smallest when the table was full) 93 var j: i64 = nu - 1 94 while j > pos { 95 pids[j] = pids[j - 1] 96 rates[j] = rates[j - 1] 97 j = j - 1 98 } 99 pids[pos] = pid 100 rates[pos] = rate 101 return nu 102} 103 104// Share of a total, in permil. Guards a zero/absent total rather than dividing by it. 105func ct_share_permil(part: i64, total: i64) -> i64 { 106 if total <= 0 { return 0 - 1 } 107 if part < 0 { return 0 - 1 } 108 return (part * 1000) / total 109} 110 111// ---- ADMISSION CONTROL: an instrument must not become the load it measures ---- 112// WHY (seq341, and nearly re-learned 2026-07-30): nx_ctxtop and nx_memvel each walk ALL of /proc and 113// openat+read+close every /proc/<pid>/status -- ~2700 syscalls per nx_memvel run at 5 rounds x ~450 114// procs. That is the SAME O(processes) syscall storm seq1318 indicts the supervisor for doing ~25x per 115// poll. Running it on an already-saturated box is how seq341's loadgen tipped the NAS into an outage. 116// ★LAW: A DIAGNOSTIC THAT CANNOT REFUSE TO RUN IS A LOAD GENERATOR WITH GOOD INTENTIONS. 117// The check is deliberately CHEAP (one small file read, no fork) so the guard can never cost more than 118// the thing it guards. 119const CT_LOADAVG_SCALE: i64 = 100 120const CT_ASCII_DOT: i64 = 46 121 122// Parse the FIRST field of /proc/loadavg ("4.02 3.75 ...") into centi-load (402). -1 if unparseable. 123// Two decimals is the kernel's fixed format; a third digit is ignored rather than silently scaling by 10. 124func ct_load_centi(buf: *u8, n: i64) -> i64 { 125 if n <= 0 { return 0 - 1 } 126 var i: i64 = 0 127 var whole: i64 = 0 128 var seen: i64 = 0 129 while i < n { 130 let c: i64 = buf[i] as i64 131 var isdig: i64 = 0 132 if c >= 48 { if c <= 57 { isdig = 1 } } 133 if isdig == 1 { whole = whole * 10 + (c - 48); seen = 1; i = i + 1 } 134 if isdig == 0 { i = n } 135 } 136 if seen == 0 { return 0 - 1 } 137 // resume at the '.' to collect exactly two fractional digits 138 var p: i64 = 0 139 while p < n { if buf[p] == (CT_ASCII_DOT as u8) { p = n } else { p = p + 1 } } 140 var frac: i64 = 0 141 var got: i64 = 0 142 var q: i64 = 0 143 while q < n { 144 if buf[q] == (CT_ASCII_DOT as u8) { 145 var k: i64 = q + 1 146 while got < 2 { 147 if k >= n { got = 2 } else { 148 let d: i64 = buf[k] as i64 149 if d >= 48 { if d <= 57 { frac = frac * 10 + (d - 48); got = got + 1 } } 150 if d < 48 { got = 2 } 151 if d > 57 { got = 2 } 152 k = k + 1 153 } 154 } 155 q = n 156 } else { q = q + 1 } 157 } 158 return whole * CT_LOADAVG_SCALE + frac 159} 160 161// 1 = admitted, 0 = REFUSED. Fail-CLOSED on an unreadable loadavg: if we cannot tell how loaded the box 162// is, we do not get to add to it. 163// PURE. THE RUN-QUEUE ADMISSION RULER (2026-08-21). 1 = admit, 0 = REFUSE. 164// WHY THIS EXISTS BESIDE ct_admit: ct_admit gates on /proc/loadavg, which on Linux counts tasks in R 165// *AND* D (uninterruptible disk wait). On this Synology loadavg sits at 1000-2000 centi while 166// procs_running is 1-3 of 8 -- i.e. the CPU is 75-88% IDLE and the number is dominated by the RAID. 167// nx_build_admit was corrected off exactly this axis on 2026-07-30 ("gated a CPU-bound compile on a 168// DISK-WAIT metric ... 43% of the CPU idle"); its siblings were not. MEASURED COST OF THE OMISSION: 169// nx_memvel -- the LEAK detector, the LEADING indicator -- was refused continuously for 7.4 h while 170// swap climbed to 812 permil past its 700 bar, because its bar was 800 and the load never went under. 171// A procfs walker performs ~2700 openat+read+close against a VIRTUAL filesystem: it consumes CPU and 172// syscalls and touches NO BLOCK DEVICE, so disk wait is a cost it can neither cause nor deepen. 173// *GATE AN ORGAN ON THE RESOURCE IT ACTUALLY CONSUMES. A GUARD KEYED TO THE WRONG RESOURCE IS NOT 174// CONSERVATIVE -- IT IS BLIND IN ONE DIRECTION AND DEAF IN THE OTHER, AND IT SHEDS THE LEADING 175// INDICATOR FIRST, WHICH IS THE ONE MEASUREMENT YOU CANNOT AFFORD TO LOSE UNDER PRESSURE. 176// ncpu is DERIVED from /proc/stat by the one shared parser (ioa_ncpu) -- no baked core count. 177// Fail-CLOSED on either sensor being unreadable: not knowing is not permission. 178func ct_admit_runq(procs_run: i64, ncpu: i64) -> i64 { 179 if ncpu <= 0 { return 0 } 180 if procs_run < 0 { return 0 } 181 if procs_run >= ncpu { return 0 } 182 return 1 183} 184 185func ct_admit(load_centi: i64, max_centi: i64) -> i64 { 186 if load_centi < 0 { return 0 } 187 if max_centi <= 0 { return 0 } 188 if load_centi > max_centi { return 0 } 189 return 1 190}