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}