Skip to content

Commit d9b08dd

Browse files
committed
v0.2.1: add profile/autostart/perf-json/logging/CI, remove crack globals
1 parent 16b84bf commit d9b08dd

6 files changed

Lines changed: 229 additions & 44 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
jobs:
8+
test:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v4
12+
- uses: actions/setup-python@v5
13+
with:
14+
python-version: "3.12"
15+
- run: pip install -e .[dev]
16+
- run: pytest -q

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ All notable changes to this project are documented here.
1212
- Basic unit tests in `tests/` (safety, prop_rels).
1313
- Crack matching now uses dynamic layout read paths (less false-positive risk).
1414
- XBAR domain index discovery from live control buffer (profile/API fallback).
15+
- `perf --json` output and full PERF entry parsing.
16+
- `autostart-install` / `autostart-remove` commands.
17+
- `profile-save` / `profile-apply` / `profile-list` commands.
18+
- Basic logging infrastructure (`--verbose`, `--quiet`, `--log-file`).
19+
- GitHub Actions CI workflow.
1520
- Unified user-facing stable combo to +205 / VF 224..253 (README, USAGE, wizard).
1621
- Cross-version static validation for R572.16..R610.88 (desktop + selected notebook).
1722
- Fully decoded PropRels GET_INFO descriptor (type/src/dst/bidir).

‎docs/RELEASE_NOTES_v0.2.1.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,13 @@
1414
- ClkDomains entry base/stride discovery.
1515
- VF STATUS/CONTROL record base/stride discovery.
1616
- XBAR bank fallback to `driver_profile.json`.
17-
- **JSON output** for `status` and `vfp-status` (`--json`).
17+
- **JSON output** for `status`, `vfp-status`, and `perf` (`--json`).
1818
- **Write cooldown** between write commands.
19-
- **Crack matching improvements** using dynamic read paths.
19+
- **Crack matching improvements** using dynamic read paths (globals removed).
20+
- **Profile system**: `profile-save`, `profile-apply`, `profile-list`.
21+
- **Autostart**: `autostart-install`, `autostart-remove`.
22+
- **Logging infrastructure**: `--verbose`, `--quiet`, `--log-file`.
23+
- **GitHub Actions CI**.
2024
- **Unit tests** for safety and prop_rels.
2125
- **Documentation**
2226
- `docs/TECHNICAL_NOTES.md` with complete LACT #1147 / PR #1158 / issue #1159 / NVIDIA #1266 context.

‎src/xbar5090/cli.py‎

Lines changed: 170 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@
33
from __future__ import annotations
44

55
import argparse
6+
import base64
67
import json
8+
import logging
79
import os
10+
import subprocess
811
import sys
912
import time
1013

@@ -21,29 +24,14 @@
2124
else:
2225
APP_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
2326
BACKUP_DIR = os.path.join(APP_DIR, "backups")
27+
PROFILES_DIR = os.path.join(APP_DIR, "profiles")
2428
VF_CTRL_SIZE = vf_points.VF_CTRL_VER
2529

2630

2731
def _api(gpu_index: int = 0) -> NvApi:
2832
return NvApi(gpu_index=gpu_index)
2933

3034

31-
def _ensure_supported(force: bool = False) -> bool:
32-
if force:
33-
print("WARNING: --force-driver set, skipping driver version check.", file=sys.stderr)
34-
print("WARNING: This is dangerous if the NvAPI layout changed.", file=sys.stderr)
35-
return True
36-
ok, msg = driver_check.ensure_supported_driver()
37-
if not ok:
38-
print(f"ERROR: {msg}", file=sys.stderr)
39-
return False
40-
ok, msg = driver_check.ensure_supported_gpu()
41-
if not ok:
42-
print(f"ERROR: {msg}", file=sys.stderr)
43-
return False
44-
return True
45-
46-
4735
def _ensure_supported_auto(api: NvApi, args) -> bool:
4836
"""Like _ensure_supported, but automatically tries crack for unknown drivers."""
4937
if getattr(args, "force_driver", False):
@@ -131,6 +119,21 @@ def _safe_restore(restore_func, label: str, original_exc: Exception | None = Non
131119

132120
_last_write_time = 0.0
133121
WRITE_COOLDOWN_SECONDS = 1.0
122+
LOG = logging.getLogger("xbar5090")
123+
124+
125+
def _setup_logging(args) -> None:
126+
level = logging.WARNING
127+
if getattr(args, "verbose", False):
128+
level = logging.DEBUG
129+
elif getattr(args, "quiet", False):
130+
level = logging.ERROR
131+
handlers = [logging.StreamHandler()]
132+
log_file = getattr(args, "log_file", None)
133+
if log_file:
134+
handlers.append(logging.FileHandler(log_file, encoding="utf-8"))
135+
logging.basicConfig(level=level, handlers=handlers,
136+
format="%(asctime)s %(levelname)s %(message)s")
134137

135138

136139
def _write_cooldown() -> None:
@@ -565,15 +568,142 @@ def cmd_restore_snapshot(args, api: NvApi) -> int:
565568
return 0
566569

567570

571+
def _profile_path(name: str) -> str:
572+
return os.path.join(PROFILES_DIR, f"{name}.json")
573+
574+
575+
def cmd_profile_save(args, api: NvApi) -> int:
576+
name = args.name
577+
clk_buf, _, _ = clk_domains.read_clock_domains(api)
578+
prop_buf, _ = prop_rels.read_prop_rels(api)
579+
active = vf_points.active_mask(api)
580+
vf_buf = vf_points.get_control(api, active)
581+
os.makedirs(PROFILES_DIR, exist_ok=True)
582+
path = _profile_path(name)
583+
payload = {
584+
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
585+
"clk_b64": base64.b64encode(buf_to_bytes(clk_buf)).decode(),
586+
"prop_b64": base64.b64encode(buf_to_bytes(prop_buf)).decode(),
587+
"vf_b64": base64.b64encode(buf_to_bytes(vf_buf)).decode(),
588+
"gpu_index": api.gpu_index,
589+
}
590+
with open(path, "w", encoding="utf-8") as f:
591+
json.dump(payload, f, ensure_ascii=False, indent=2)
592+
print(f"Profile saved: {path}")
593+
return 0
594+
595+
596+
def cmd_profile_apply(args, api: NvApi) -> int:
597+
if not is_admin():
598+
print("ERROR: profile apply requires administrator.", file=sys.stderr)
599+
return 2
600+
if not _ensure_supported_auto(api, args):
601+
return 2
602+
path = _profile_path(args.name)
603+
if not os.path.isfile(path):
604+
print(f"ERROR: profile not found: {path}", file=sys.stderr)
605+
return 2
606+
snap = backup_mod.load_snapshot(path)
607+
clk_buf = bytes_to_buf(snap["clk_bytes"], clk_domains.CLK_DOMAINS_BUFSIZE)
608+
prop_buf = bytes_to_buf(snap["prop_bytes"], prop_rels.PROP_RELS_BUFSIZE)
609+
vf_buf = bytes_to_buf(snap["vf_bytes"], vf_points.VF_CTRL_VER)
610+
611+
cur_clk, _, _ = clk_domains.read_clock_domains(api)
612+
cur_prop, _ = prop_rels.read_prop_rels(api)
613+
cur_vf = vf_points.get_control(api, vf_points.active_mask(api))
614+
backup_mod.save_snapshot(BACKUP_DIR, "pre_profile_apply", cur_clk, cur_prop, cur_vf,
615+
{"gpu_index": api.gpu_index})
616+
617+
try:
618+
clk_domains.restore_from_buf(api, clk_buf)
619+
prop_rels.restore_from_buf(api, prop_buf)
620+
vf_points.set_control(api, vf_buf)
621+
except Exception as e:
622+
_safe_restore(lambda: clk_domains.restore_from_buf(api, cur_clk), "clk_domains", e)
623+
_safe_restore(lambda: prop_rels.restore_from_buf(api, cur_prop), "prop_rels", e)
624+
_safe_restore(lambda: vf_points.set_control(api, cur_vf), "vf_control", e)
625+
print(f"ERROR: profile apply failed, rolled back: {e}", file=sys.stderr)
626+
return 3
627+
628+
_, f, m = clk_domains.read_clock_domains(api)
629+
_, r = prop_rels.read_prop_rels(api)
630+
print(f"Profile applied: {args.name} (XBAR={f} kHz, MSVDD={m} uV, ratio_raw={r})")
631+
return 0
632+
633+
634+
def cmd_profile_list(args, api: NvApi) -> int:
635+
if not os.path.isdir(PROFILES_DIR):
636+
print("No profiles saved.")
637+
return 0
638+
for name in sorted(os.listdir(PROFILES_DIR)):
639+
if name.endswith(".json"):
640+
print(name[:-5])
641+
return 0
642+
643+
568644
def cmd_perf(args, api: NvApi) -> int:
569645
buf = perf_limits.get_perf_limits(api)
570-
for uid in (perf_limits.XBAR_MAX_USER_ID, perf_limits.XBAR_MIN_USER_ID):
571-
base = perf_limits.find_entry_by_user_id(buf, uid)
572-
if base is None:
573-
print(f"user_id {uid:#x}: not found")
574-
continue
575-
vals = [get_u32(buf, base + o) for o in range(0, 0x40, 4)]
576-
print(f"user_id {uid:#x} base={base:#x} first8={[hex(v) for v in vals[:8]]}")
646+
entries = perf_limits.parse_entries(buf)
647+
if getattr(args, "json", False):
648+
print(json.dumps({"entries": entries}, indent=2))
649+
return 0
650+
for e in entries:
651+
print(f"user_id {e['user_id']:#x} base={e['base']:#x} first8={[hex(v) for v in e['values'][:8]]}")
652+
return 0
653+
654+
655+
def _autostart_script_path() -> str:
656+
return os.path.join(APP_DIR, "autostart_xbar.ps1")
657+
658+
659+
def _write_autostart_script() -> str:
660+
if getattr(sys, "frozen", False):
661+
cmd = f"& '{sys.executable}'"
662+
else:
663+
py = sys.executable
664+
run = os.path.join(APP_DIR, "run.py")
665+
cmd = f"& '{py}' '{run}'"
666+
script = f"""$ErrorActionPreference = 'Continue'
667+
{cmd} vfp-auto-range --msvdd-mv 1150 --freq-khz 88000 --yes *> $env:TEMP\\xbar5090_autostart.log 2>&1
668+
{cmd} set-xbar --freq-khz 205000 --msvdd-uv 10000 --yes *>> $env:TEMP\\xbar5090_autostart.log 2>&1
669+
{cmd} set-ratio --ratio 1.2 --yes *>> $env:TEMP\\xbar5090_autostart.log 2>&1
670+
"""
671+
path = _autostart_script_path()
672+
with open(path, "w", encoding="utf-8") as f:
673+
f.write(script)
674+
return path
675+
676+
677+
def cmd_autostart_install(args, api: NvApi) -> int:
678+
if not is_admin():
679+
print("ERROR: autostart-install requires administrator.", file=sys.stderr)
680+
return 2
681+
script = _write_autostart_script()
682+
task_name = "xbar5090 Autostart"
683+
cmd = (
684+
f'schtasks /Create /TN "{task_name}" '
685+
f'/TR "powershell -NoProfile -ExecutionPolicy Bypass -File \\"{script}\\"" '
686+
f'/SC ONLOGON /RL HIGHEST /F'
687+
)
688+
r = subprocess.run(cmd, shell=True, capture_output=True, text=True)
689+
if r.returncode != 0:
690+
print(f"ERROR: failed to create scheduled task: {r.stderr.strip()}", file=sys.stderr)
691+
return 1
692+
print(f"Autostart installed: {task_name}")
693+
print(f"Script: {script}")
694+
return 0
695+
696+
697+
def cmd_autostart_remove(args, api: NvApi) -> int:
698+
if not is_admin():
699+
print("ERROR: autostart-remove requires administrator.", file=sys.stderr)
700+
return 2
701+
task_name = "xbar5090 Autostart"
702+
r = subprocess.run(f'schtasks /Delete /TN "{task_name}" /F', shell=True, capture_output=True, text=True)
703+
if r.returncode != 0:
704+
print(f"ERROR: failed to remove scheduled task: {r.stderr.strip()}", file=sys.stderr)
705+
return 1
706+
print(f"Autostart removed: {task_name}")
577707
return 0
578708

579709

@@ -811,8 +941,11 @@ def cmd_l2_test(args, api: NvApi) -> int:
811941

812942

813943
def main(argv=None) -> int:
814-
parser = argparse.ArgumentParser(prog="xbar5090", description="RTX 5090 XBAR controls via private NvAPI")
944+
parser = argparse.ArgumentParser(prog="xbar5090", description="RTX 50-series XBAR controls via private NvAPI")
815945
parser.add_argument("--gpu", type=int, default=0, help="GPU index (default 0)")
946+
parser.add_argument("--verbose", action="store_true", help="enable debug logging")
947+
parser.add_argument("--quiet", action="store_true", help="only show errors")
948+
parser.add_argument("--log-file", help="write logs to this file")
816949
sub = parser.add_subparsers(dest="cmd")
817950

818951
write_common = argparse.ArgumentParser(add_help=False)
@@ -849,7 +982,11 @@ def main(argv=None) -> int:
849982
p_rest = sub.add_parser("vfp-restore", parents=[write_common])
850983
p_rest.add_argument("--backup", required=True)
851984
p_rest.set_defaults(func=cmd_vfp_restore)
852-
sub.add_parser("perf").set_defaults(func=cmd_perf)
985+
p_perf = sub.add_parser("perf")
986+
p_perf.add_argument("--json", action="store_true", help="output JSON")
987+
p_perf.set_defaults(func=cmd_perf)
988+
sub.add_parser("autostart-install").set_defaults(func=cmd_autostart_install)
989+
sub.add_parser("autostart-remove").set_defaults(func=cmd_autostart_remove)
853990
sub.add_parser("doctor").set_defaults(func=cmd_doctor)
854991
sub.add_parser("probe", help="auto-verify driver layout after a driver update (read-only)").set_defaults(func=cmd_probe)
855992
sub.add_parser("crack", help="auto-match driver function IDs from candidates.json (read-only probing)").set_defaults(func=cmd_crack)
@@ -866,12 +1003,20 @@ def main(argv=None) -> int:
8661003
p_rs = sub.add_parser("restore-snapshot", parents=[write_common], help="restore clk+prop+vf snapshot")
8671004
p_rs.add_argument("--snapshot", required=True)
8681005
p_rs.set_defaults(func=cmd_restore_snapshot)
1006+
p_ps = sub.add_parser("profile-save", help="save current settings as a named profile")
1007+
p_ps.add_argument("name")
1008+
p_ps.set_defaults(func=cmd_profile_save)
1009+
p_pa = sub.add_parser("profile-apply", parents=[write_common], help="apply a named profile")
1010+
p_pa.add_argument("name")
1011+
p_pa.set_defaults(func=cmd_profile_apply)
1012+
sub.add_parser("profile-list", help="list saved profiles").set_defaults(func=cmd_profile_list)
8691013

8701014
# If no subcommand is given, launch the interactive wizard directly.
8711015
# This makes double-click / "Run as administrator" on the exe work.
8721016
parser.set_defaults(func=cmd_wizard)
8731017

8741018
args = parser.parse_args(argv)
1019+
_setup_logging(args)
8751020
if getattr(args, "cmd", None) in {
8761021
"set-xbar", "set-ratio", "vfp-set-range", "vfp-auto-range",
8771022
"vfp-restore", "reset", "restore-snapshot", "wizard",

‎src/xbar5090/crack.py‎

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -112,13 +112,9 @@ def _try_vf_info(api: NvApi, fid: int) -> bool:
112112
return False
113113

114114

115-
_CURRENT_VF_INFO = vf_points.VF_INFO
116-
_CURRENT_VF_STATUS = vf_points.VF_STATUS
117-
118-
119-
def _try_vf_status(api: NvApi, fid: int) -> bool:
115+
def _try_vf_status(api: NvApi, fid: int, vf_info_id: int = vf_points.VF_INFO) -> bool:
120116
try:
121-
active = vf_points.active_mask(api, info_id=_CURRENT_VF_INFO)
117+
active = vf_points.active_mask(api, info_id=vf_info_id)
122118
buf = make_buffer(vf_points.VF_STATUS_VER)
123119
set_u32(buf, 0, vf_points.VF_STATUS_VER)
124120
for i in active:
@@ -130,9 +126,11 @@ def _try_vf_status(api: NvApi, fid: int) -> bool:
130126
return False
131127

132128

133-
def _try_vf_get(api: NvApi, fid: int) -> bool:
129+
def _try_vf_get(api: NvApi, fid: int,
130+
vf_info_id: int = vf_points.VF_INFO,
131+
vf_status_id: int = vf_points.VF_STATUS) -> bool:
134132
try:
135-
active = vf_points.active_mask(api, info_id=_CURRENT_VF_INFO)
133+
active = vf_points.active_mask(api, info_id=vf_info_id)
136134
buf = make_buffer(vf_points.VF_CTRL_VER)
137135
set_u32(buf, 0, vf_points.VF_CTRL_VER)
138136
for i in active:
@@ -141,7 +139,7 @@ def _try_vf_get(api: NvApi, fid: int) -> bool:
141139
rc = api.call(fid, buf)
142140
if rc != 0:
143141
return False
144-
bank = vf_points.detect_xbar_bank(api, info_id=_CURRENT_VF_INFO, status_id=_CURRENT_VF_STATUS)
142+
bank = vf_points.detect_xbar_bank(api, info_id=vf_info_id, status_id=vf_status_id)
145143
return bank is not None
146144
except Exception:
147145
return False
@@ -188,14 +186,14 @@ def crack_driver(api: NvApi, candidates_path: str | None = None) -> bool:
188186
found["prop_get"] = _pick(api, "PropRelsGet", cands.get("prop_get", []), _try_prop_get)
189187
found["prop_set"] = _pick(api, "PropRelsSet", cands.get("prop_set", []), None, is_set=True)
190188
found["vf_info"] = _pick(api, "VF_INFO", cands.get("vf_info", []), _try_vf_info)
191-
if found["vf_info"] is not None:
192-
global _CURRENT_VF_INFO
193-
_CURRENT_VF_INFO = found["vf_info"]
194-
found["vf_status"] = _pick(api, "VF_STATUS", cands.get("vf_status", []), _try_vf_status)
195-
if found["vf_status"] is not None:
196-
global _CURRENT_VF_STATUS
197-
_CURRENT_VF_STATUS = found["vf_status"]
198-
found["vf_get"] = _pick(api, "VF_GET", cands.get("vf_get", []), _try_vf_get)
189+
vf_info_id = found["vf_info"] or vf_points.VF_INFO
190+
found["vf_status"] = _pick(
191+
api, "VF_STATUS", cands.get("vf_status", []),
192+
lambda api, fid: _try_vf_status(api, fid, vf_info_id))
193+
vf_status_id = found["vf_status"] or vf_points.VF_STATUS
194+
found["vf_get"] = _pick(
195+
api, "VF_GET", cands.get("vf_get", []),
196+
lambda api, fid: _try_vf_get(api, fid, vf_info_id, vf_status_id))
199197
found["vf_set"] = _pick(api, "VF_SET", cands.get("vf_set", []), None, is_set=True)
200198
found["perf_get"] = _pick(api, "PERF_GET", cands.get("perf_get", []), _try_perf_get)
201199

‎src/xbar5090/perf_limits.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,20 @@ def find_entry_by_user_id(buf, uid: int):
4545
if get_u32(buf, base) == uid:
4646
return base
4747
return None
48+
49+
50+
def parse_entries(buf):
51+
"""Parse all PERF limit entries into a list of dicts."""
52+
entries = []
53+
for i in range(0, (PERF_SIZE - 0xC) // 0x338):
54+
base = 0xC + i * 0x338
55+
uid = get_u32(buf, base)
56+
if uid == 0:
57+
continue
58+
entries.append({
59+
"index": i,
60+
"user_id": uid,
61+
"base": base,
62+
"values": [get_u32(buf, base + o) for o in range(0, 0x40, 4)],
63+
})
64+
return entries

0 commit comments

Comments
 (0)