Added niceness / thread priority management.

This commit is contained in:
Jeremy Karst 2026-08-23 18:39:43 -04:00
parent 3d6c7251e9
commit 47bc23cd16

View file

@ -20,6 +20,7 @@ header -- see capture.py.
import argparse import argparse
import csv import csv
import os
import queue import queue
import sys import sys
import threading import threading
@ -51,6 +52,53 @@ FLUSH_INTERVAL_S = 0.5
# inside the bracket budget. # inside the bracket budget.
GIL_SWITCH_INTERVAL_S = 0.0005 GIL_SWITCH_INTERVAL_S = 0.0005
# Thread priority. The sampling loop runs on the main thread and is the only
# latency-sensitive part; the writer merely formats a few fields per sample.
#
# Lowering the writer needs no privilege and is always done: only the *relative*
# priority matters, and on Linux nice is per-thread, so it does not touch the
# sampler. Raising the sampler needs CAP_SYS_NICE and is opt-in via
# --high-priority, which fails loudly rather than degrading silently -- asking
# for it and not getting it is worth knowing about.
SAMPLER_NICE = -10
WRITER_NICE = 10
def restore_ownership(path):
"""Hand a file created under sudo back to the invoking user.
--high-priority needs root, and anything root writes stays root-owned --
which earlier in this project produced captures the normal user could not
rewrite. Undo that here so privilege is needed for scheduling and nothing
else leaks from it.
"""
if os.geteuid() != 0:
return
uid, gid = os.environ.get("SUDO_UID"), os.environ.get("SUDO_GID")
if uid is None:
return
try:
os.chown(path, int(uid), int(gid) if gid else -1)
except OSError as exc:
print(f"WARNING: could not hand {path} back to uid {uid}: {exc}",
file=sys.stderr)
def set_thread_nice(value):
"""Renice the calling thread. Returns the nice actually in effect, or None.
Linux threads are tasks, so PRIO_PROCESS with a native thread id applies to
just this thread. Elsewhere this may be a no-op or affect the process, which
is why failure is tolerated rather than fatal -- priority is an optimisation,
not a correctness requirement.
"""
try:
tid = threading.get_native_id()
os.setpriority(os.PRIO_PROCESS, tid, value)
return os.getpriority(os.PRIO_PROCESS, tid)
except (AttributeError, OSError):
return None
# Miss detection uses the DRDY bracket -- the span between the last poll showing # Miss detection uses the DRDY bracket -- the span between the last poll showing
# DRDY clear and the poll showing it set -- rather than the read-to-read # DRDY clear and the poll showing it set -- rather than the read-to-read
# interval. # interval.
@ -142,12 +190,17 @@ def parse_args():
help="free-text label recorded in the capture header, e.g. " help="free-text label recorded in the capture header, e.g. "
"the supply under test. Keeps the configuration with the " "the supply under test. Keeps the configuration with the "
"data instead of only in the filename") "data instead of only in the filename")
p.add_argument("--high-priority", action="store_true",
help=f"raise the sampling thread to nice {SAMPLER_NICE}. "
"Needs CAP_SYS_NICE, so run under sudo; the run aborts "
"if the priority cannot be set. Output files are handed "
"back to the invoking user afterwards")
p.add_argument("--scan-only", action="store_true", p.add_argument("--scan-only", action="store_true",
help="scan the bus, report what responded, and exit") help="scan the bus, report what responded, and exit")
return p.parse_args() return p.parse_args()
def print_plan(cfg, bus_speed, requested_rate): def print_plan(cfg, bus_speed, requested_rate, sampler_nice=None):
"""Show how the configuration was derived, so it can be checked not trusted.""" """Show how the configuration was derived, so it can be checked not trusted."""
lsb = rm3100.tesla_per_count(cfg.cycle_count) * rm3100.NT_PER_TESLA lsb = rm3100.tesla_per_count(cfg.cycle_count) * rm3100.NT_PER_TESLA
dither = rm3100.expected_noise_nt(cfg.cycle_count) / lsb dither = rm3100.expected_noise_nt(cfg.cycle_count) / lsb
@ -172,6 +225,12 @@ def print_plan(cfg, bus_speed, requested_rate):
print(f" duty {cfg.duty:.1%} integration / period") print(f" duty {cfg.duty:.1%} integration / period")
print(f" bus {bus_speed} kHz {bus * 1e3:.3f} ms/sample " print(f" bus {bus_speed} kHz {bus * 1e3:.3f} ms/sample "
f"{share:.1%} of the period") f"{share:.1%} of the period")
if sampler_nice is not None:
detail = (f"nice {sampler_nice}, writer at {WRITER_NICE}"
if sampler_nice < 0 else
f"nice {sampler_nice}, writer at {WRITER_NICE} "
"(--high-priority raises the sampler, needs sudo)")
print(f" priority {detail}")
for note in cfg.notes: for note in cfg.notes:
print(f" note: {note}") print(f" note: {note}")
# Bus speed is validated but never changed silently -- swapping it would # Bus speed is validated but never changed silently -- swapping it would
@ -249,8 +308,11 @@ def write_header(handle, meta):
def writer_thread(q, path, meta, stats): def writer_thread(q, path, meta, stats):
"""Drain samples to CSV and drive the console, off the sampling thread.""" """Drain samples to CSV and drive the console, off the sampling thread."""
# Stand aside for the sampler: this thread is not latency-sensitive.
set_thread_nice(WRITER_NICE)
lsb_nt = float(meta["tesla_per_count"]) * rm3100.NT_PER_TESLA lsb_nt = float(meta["tesla_per_count"]) * rm3100.NT_PER_TESLA
with open(path, "w", newline="") as handle: with open(path, "w", newline="") as handle:
restore_ownership(path)
write_header(handle, meta) write_header(handle, meta)
out = csv.writer(handle) out = csv.writer(handle)
out.writerow(CSV_FIELDS) out.writerow(CSV_FIELDS)
@ -504,6 +566,24 @@ def sample_loop(sensor, q, duration, dt_nominal, stats):
def main(): def main():
args = parse_args() args = parse_args()
sys.setswitchinterval(GIL_SWITCH_INTERVAL_S) sys.setswitchinterval(GIL_SWITCH_INTERVAL_S)
sampler_nice = None
if args.high_priority:
sampler_nice = set_thread_nice(SAMPLER_NICE)
if sampler_nice is None:
sys.exit(
f"--high-priority needs CAP_SYS_NICE to set nice "
f"{SAMPLER_NICE}, and this process does not have it.\n"
"Re-run under sudo, using the venv interpreter by absolute "
"path:\n"
f" sudo {sys.executable} {' '.join(sys.argv)}\n"
"Or drop the flag -- the writer thread already steps aside, "
f"which is the half that needs no privilege.")
else:
try:
sampler_nice = os.getpriority(os.PRIO_PROCESS,
threading.get_native_id())
except (AttributeError, OSError):
pass
if args.cycle_count is not None and not 1 <= args.cycle_count <= 0xFFFF: if args.cycle_count is not None and not 1 <= args.cycle_count <= 0xFFFF:
sys.exit(f"--cycle-count {args.cycle_count} outside 1..65535") sys.exit(f"--cycle-count {args.cycle_count} outside 1..65535")
@ -538,7 +618,7 @@ def main():
sys.exit(str(exc)) sys.exit(str(exc))
print() print()
print_plan(cfg, args.bus_speed, args.rate) print_plan(cfg, args.bus_speed, args.rate, sampler_nice)
print() print()
nominal_rate = cfg.predicted_hz nominal_rate = cfg.predicted_hz