Curve Optimizer test bench for AMD Ryzen on Windows. Finds the most aggressive stable per-core undervolt your chip can take, measured and verified against the hardware, then keeps it applied through sleep and reboot.
git clone https://github.com/aJustDev/rycolab && cd rycolab
.\install.ps1 builds and installs (elevates itself); open a new console
rycolab find --quick --cores 0 ten minutes on one core: the whole flow, nothing saved
rycolab find the real campaign: hours, hands off, resumes after a reboot
rycolab on apply the proposed profile and keep it applied
rycolab what is on the cores right now, and what to do next
Needs Windows 10 1809 or newer, the .NET runtime 9 or newer (the SDK to
build) and a Ryzen with per-core Curve Optimizer through the SMU (see
Supported hardware). Commands that touch the hardware need an elevated
console (sudo rycolab ... works if Windows sudo is enabled); rycolab,
status, report and profile show do not.
It writes per-core Curve Optimizer margins through the SMU mailbox (ZenStates.Core, the library Legion Toolkit, ZenTimings and SMUDebugTool use), reads every write back, finds each core's limit with y-cruncher, and runs a guard that re-applies the profile after sleep and at logon, watches for hardware errors and returns to the BIOS baseline when something is wrong.
It does not touch Curve Shaper, PBO limits, all-core offsets, the BIOS, or anything but the per-core margins. Every write is volatile: a reboot returns the CPU to the BIOS state. A profile belongs to one die; never copy one from another machine, not even the same model (the fingerprint cannot tell two chips apart, and the limits are the silicon lottery).
Vendor tools let you write per-core Curve Optimizer margins but not
check what actually got applied. Legion Toolkit even reads the real margin
from the SMU in LoadFromHardwareAsync and overwrites the UI with the JSON on
disk three lines later. Without that read-back, tuning an undervolt is
guessing. First thing the probe showed on the reference machine:
With Legion Toolkit open CCD0 -3 CCD1 -7 (the profile)
After a reboot, without it -5 on all sixteen (the BIOS)
Two different configurations depending on whether an app was open, and no tool said so. Writers do not add up either: they replace each other, last one wins.
- Detect compute errors, not hangs. A hang is a late, terminal symptom. The real risk of an aggressive Curve Optimizer is silent wrong results.
- Never measure without verifying first. Every configuration is read back from the SMU before a single data point is taken. On mismatch, abort.
- Safety limits have no bypass flag. Allowed margin -50..0 (a positive value raises the voltage and is always rejected); every write is read back; a block that writes restores what was there if the process dies before committing; the stress campaigns insist on AC power.
on refuses a profile without a source, from another CPU, or with any core
below its measured limit. The guard re-applies the profile after resume (the
BIOS restores the baseline on wake), retries the SMU write, re-applies a lost
margin (three times an hour, then it gives up), and on any hardware error
restores the baseline and stops with code 10. A profile starts in
validating and becomes steady after 20 h guarded, or 7 days with at least
8 h guarded, without WHEA and without an unexpected reboot (Kernel-Power 41
since the previous guard tick counts as a reset; a hard reset leaves no
WHEA). Bad news (WHEA, reset, margin lost, giveup) raises a Windows toast
with its own chime, one per kind per 10 min; rycolab dev plan set notify false turns it off.
Talking to the SMU needs a kernel driver: inpoutx64.sys (InpOut32),
installed as a system service the first time the SMU is opened and left in
place by uninstall because other tools share it (uninstall --purge asks;
see Build).
rycolab what is on the cores right now and what to do next
rycolab install copy to %LOCALAPPDATA%\rycolab, user PATH, y-cruncher (official zip,
SHA-256 checked), baseline read from the hardware, scheduled task
rycolab find [--quick] find each core's limit and propose the profile (hours; hands off; it may reboot
and resume). --quick: three tests and 180 s per run instead of eight and 360 s
rycolab on apply the profile and keep it: hidden guard, re-applied after sleep and at logon
rycolab off stop the guard, back to the BIOS baseline, task disabled
rycolab status [--once] is the profile on the cores, in what phase, last sample, events, battery;
refreshed every 2 s until Ctrl+C. --once prints and exits; --all adds the
machine, the Lenovo EC (needs sudo) and the Windows scheme; --follow is
the per-core guard view
rycolab report [<campaign>] limits, positives with time to error, telemetry, events; --md writes markdown
rycolab report --power hours guarded and on battery, Wh per battery session, in use vs idle, package W,
by power / GPU mode and overlay, EC and core temps, fans, charging, dGPU, SMU
latency, health (--since 30d|7d|24h or --month 2026-08)
rycolab report --campaigns every campaign and the limit per core side by side
rycolab profile show|from-sweep <campaign> [--margin 5]|export <path>
rycolab db stats|sql "<select>"|export <table> the database: the history of everything (also path, import)
rycolab gpu probe|import|set|show|apply|on|off the NVIDIA V/F curve, kept by the guard (docs/gpu.md)
rycolab legion <command> Lenovo Legion only: fan, mode, power (battery profile), charge (docs/legion.md)
rycolab uninstall [--purge] task, PATH and binaries; --purge also the data
rycolab status on the reference machine, ten hours into validation:
rycolab 0.2.0 2026-09-01 23:31:16
+- Curve Optimizer ---------------------------------------------------------------------------------------------+
| status PROFILE APPLIED validating guard pid 22668 since 23:02 11.1 h guarded 0 WHEA 0 resets |
| profile -35,-35,-30,-35,-35,-40,-45,-25,-45,-30,-40,-30,-40,-45,-45,-45 find-20260828-1232, limit + 5 |
| hardware all 16 cores on profile last sample 23:30:42 CPU 1 % package 13.4 W |
| events 23:02:36 start: profile -35,-35,-30,-35,-35,-40,-45,-25,-45,-30,-40,-30,-40,-45,-45,-45 ... |
| 23:02:36 apply: start: profile applied and verified: -35,-35,-30,-35,-35,-40,-45,-25,-45,... |
| 23:02:51 power: AC line back -> restored the snapshot: no battery profile applied (no snap... |
+---------------------------------------------------------------------------------------------------------------+
+- Battery -----------------------------------------------------------------------------------------------------+
| line AC |
| health 100.0 Wh full charge 100 % of 99.9 Wh design 6 cycles |
| profile not applied |
| auto on battery profile 15 s after unplugging, restored on AC |
+---------------------------------------------------------------------------------------------------------------+
Next: profile in validation: 11.1 h guarded, 1 resumes, 0 WHEA, 0 unexplained resets. Use the machine normally.
(Rounded borders in the terminal.) The first line turns red when the
profile is not on the cores, and the hardware row then lists every core
with the wrong ones in red. The end of
rycolab find on the same machine (86 runs, 26 positives, 3 machine hangs,
four days with interruptions):
Limits found (first clean margin per core):
CCD0 0:-40 1:-40 2:-35 3:-40 4:-40 5:-45 6:-50 7:-30
CCD1 8:-50 9:-35 10:-45 11:-35 12:-45 13:-50 14:-50 15:-50
Proposed profile (limit + 5; cores without a limit stay at -5):
CCD0 0:-35 1:-35 2:-30 3:-35 4:-35 5:-40 6:-45 7:-25
CCD1 8:-45 9:-30 10:-40 11:-30 12:-40 13:-45 14:-45 15:-45
Save it as your profile? [Y/n]
rycolab install(elevated, from the build output the first time). It prints the CPU, its core count, the SMU type and whether per-core Curve Optimizer is available, and reads the current margins as the baseline.rycolab dev probe(elevated): reads every core's margin. Nothing is written. If any core is not readable, stop here.- Optional but recommended on hardware nobody has tried: one harmless write
and back,
rycolab dev apply --core 0 --margin -3,rycolab dev probe(only core 0 changed),rycolab dev reset. rycolab find --quick --cores 0: about ten minutes on one core, shows the whole flow (checks, estimate, confirmation, live table, proposal) without committing to anything; a partial sweep is not saved unless--accept.rycolab find: the real campaign. It prints the estimate and asks before starting; leave the machine plugged in and alone (a too-deep margin can reboot it; the campaign resumes by itself at the next logon). Accept the proposal at the end, thenrycolab on.
If anything looks wrong at any point: a reboot returns the CPU to the BIOS
values, and rycolab off does the same without rebooting.
Data lives in %LOCALAPPDATA%\rycolab (RYCOLAB_HOME overrides): bin\,
tools\y-cruncher\, config.json (baseline, engines, tests, seconds),
profile.json, state.json, validation.json, and rycolab.db, one
SQLite database with the history of everything: campaigns, runs, samples,
limits, guard sessions, ticks (margins, WHEA, load, package W, battery, EC
temperatures and fans, power and GPU mode, panel, hottest core and core
V/GHz, user idle, charging, dGPU on the bus, Windows overlay, SMU read
latency, GPU clock / W / C / utilisation and whether the GPU curve is on),
events, battery health, dev log rows. rycolab report reads it; rycolab db sql "select ..."
runs any read-only query, rycolab db export <table> dumps one as CSV or
JSONL, and rycolab db import brings the JSONL files of 0.2 in once.
guard\positives\ and campaigns\<name>\positives\ keep the raw output of
a positive. docs/profile.reference.json is the reference machine's
profile, kept as an example of the format only; docs/how-it-works.md
lists the tables.
Per core, four stages: a coarse search from -50 upwards in steps of 10 with
the widest vector y-cruncher binary the CPU runs (the engine that finds the
errors), pinned to the core with periodic thread suspension, 360 s per run;
the step of 5 below the first clean margin; a 30 min confirmation at the
limit (a positive moves it one step up); and a 10 min soak with 04-P4P,
the load that reaches fMax, at limit + 5, where the profile will actually
run. The proposed profile is the limit that survived all four plus the
safety margin (5). Telemetry at 1 Hz from the SMU power table. Positives: y-cruncher compute error, dead process, WHEA (18-20, 46, 47; id 17
is PCIe, logged but not counted) or Kernel-Power 41 during the run, and
machine hang (a run left in progress across a reboot; in the same boot
session it is a killed process and the run repeats). Every run restores the
baseline; a run that lost time (sleep) or margin is invalid and repeats. The
details, the engines and what the literature says are in
docs/how-it-works.md; every number in docs/lab-notebook.md.
Low-level commands for diagnostics (elevated): probe, apply, reset,
guard, sweep, watch, sensors, calibrate, plan (config.json), task,
profile import, log, all under rycolab dev. rycolab dev help lists them.
To compare a benchmark before and after a change (a profile, a thermal mod):
rycolab dev log --out before.csv (elevated) while Cinebench or the game
runs, Ctrl+C or --minutes N to stop, the same again for after.csv, then
rycolab report --bench after.csv --vs before.csv. The CSV has package
power, Tctl and CCD temperatures, effective clock (average and per core),
core voltages from the PM table, VID, and on Lenovo Legion machines the fan
speeds and EC temperatures, plus the AC line and the battery's discharge
rate. The summary uses the samples above 100 W of package power
(--min-power); with --battery it uses the samples on battery instead and
adds the runtime a full charge would give at the mean discharge.
rycolab report --health is the battery capacity history the guard samples
once a day, and rycolab report --power what its ticks say about a period:
hours guarded and on battery, each battery session with the Wh it used and
how much of it the machine was in use, package power on AC and on battery,
the split by power mode, GPU mode and Windows overlay, EC and core
temperatures, fans, charging, the dGPU on the bus, the SMU's read latency,
the panel on battery, the events.
rycolab gpu import <Afterburner profile> (or gpu set --lock 2655@875)
and rycolab gpu apply put an undervolt V/F curve on an NVIDIA GPU and the
guard keeps it there: at logon, after sleep, when the dGPU comes back on the
bus; a driver reset withdraws it and sets a safety lock. The curve is yours;
rycolab does not search for the GPU's limit. Ported from Green Curve (MIT),
verified on an RTX 5080 Laptop (Blackwell); see docs/gpu.md.
rycolab legion fan|mode|power|charge drives what only a Legion machine has: the
EC fan switch and power mode, a measured battery profile (quiet mode, iGPU only, 60 Hz) that
the guard can apply on AC line changes, and the charge modes. Not needed for
Curve Optimizer; see docs/legion.md.
- Tested: Ryzen 9 9955HX3D (Legion Pro 7 16AFR10H), 16 cores, two CCDs of eight, SMT on; read path on a Ryzen 7 5800H (one CCD, APU).
- The core map (which physical slot of which CCD each core is, which
logical processor runs it) is derived from the fuses as ZenStates reads
them, the way SMUDebugTool does, so a 6-core CCD (7600X, 7900X, 9900X) or
SMT off gets the right mask;
installanddev probeprint it and refuse when the fuses and CPUID disagree or a mapped core does not answer. Not yet seen on real hardware with fused-off cores: if you have one,rycolab dev probeand an issue would settle it. More than 16 cores is not handled. - The margin floor follows the generation: -30 on Zen 3 (Ryzen 5000,
6000), -50 on Zen 4 and 5;
installraises the sweep start accordingly. - Expected to work: any Ryzen whose SMU exposes
SetDldoPsmMarginin ZenStates.Core (Zen 3 desktop, Cezanne, Phoenix / Hawk Point, Raphael / Dragon Range, Granite Ridge / Fire Range). The core mask is the one Legion Toolkit uses (plain core index on APUs);installprints the SMU type and whether per-core Curve Optimizer is available, and refuses if it is not. - Not supported: Zen 2 and Renoir / Lucienne / Van Gogh (no per-core command).
- Locked by AMD: mobile APUs below Ryzen 9 (Ryzen 5/7 5000H/U, 6000, 7040...).
The SMU accepts the read but answers
FAILEDto every write (RSMU and MP1, 16- and 20-bit margin) - verified on a Ryzen 7 5800H; the same is reported for the 5800H and 6850U in RyzenAdj issue #233, where the UXTU author states Curve Optimizer on 5000+ mobile APUs works only on Ryzen 9 (HX/HS).rycolab dev applysays so when it happens. - The per-core telemetry (voltage, clock, power, temperature from the SMU
table) needs
rycolab dev calibrateonce on any table version other than the reference machine's; without it the sweep still works, with less data. - y-cruncher engines are chosen for the CPU at
install:04-P4Pplus24-ZN5 ~ Komari(AVX-512) on Zen 4/5, or19-ZN2 ~ Kagari(AVX2) on Zen 2/3. On Zen 3 which engine finds the errors has not been measured yet.
What was measured on the reference machine, and what it contradicts in the
usual sensor tools, is in docs/field-notes.md.
Needs a .NET SDK 9 or newer (x64); the project targets
net9.0-windows10.0.17763.0 and newer SDKs build it.
dotnet build -c Release src/Rycolab.Cli
dotnet test -c Release tests/Rycolab.Tests
The tests cover the logic that does not need the hardware (mask encoding, the walk in steps, argument parsing, profile refusal rules, the y-cruncher error criterion against real outputs, the database round trips and the import of the 0.2 journals, the power and campaign reports). CI runs them on every push.
rycolab is not on the PATH until install puts it there, and the
installed copy does not update itself when you rebuild. install.ps1 does
both steps: builds Release and runs install from the build output,
elevating if the console is not (-NoBuild skips the build; -Args "--ycruncher C:\folder" passes options to install). .\uninstall.ps1 is
the reverse (-Purge also deletes the data). By hand it is the same thing,
from an elevated console:
.\src\Rycolab.Cli\bin\Release\net9.0-windows10.0.17763.0\win-x64\rycolab.exe install
Then open a new console (the PATH change does not reach the current one).
To update an existing install: rycolab off, .\install.ps1, rycolab on.
inpoutx64.dll, the port-access layer ZenStates.Core needs, ships in
third_party/inpout (InpOut32, MIT) and is copied next to the executable at
build time. The first time the SMU is opened it installs its kernel driver
(inpoutx64.sys) as a system service. rycolab uninstall leaves it in
place because other tools (ZenTimings, SMUDebugTool) share it;
uninstall --purge asks whether to remove it (--yes answers for you).
ZenStates.Core 1.0.1 does not start without the DLL even though it embeds
PawnIO modules, so PawnIO alone is not an option yet.
Semantic versioning, read for a tool that touches hardware and keeps data:
- Major: something the user has to redo, or something saved that the new version no longer reads. A profile or a database without a migration, a command removed without an alias, a change of methodology that makes earlier limits incomparable. 1.0.0 comes after the first full campaign with the four stages and a week of guard without incident.
- Minor: new functionality, new hardware, or a change of behaviour with
an automatic migration (
db import,profile import). The database schema version moves only with a minor or a major. - Patch: fixes without a change of schema or commands, and docs.
Before 1.0 a minor may break compatibility as long as the migration
exists. A release is an annotated tag vX.Y.Z whose message is the notes;
CI builds the zip and the checksums from it.
GPL-3.0. The core mask encoding and the SMU access sequence derive from Lenovo
Legion Toolkit, also GPL-3.0. See NOTICE.
This writes to your processor's SMU mailbox. A bad undervolt produces wrong results before it produces visible failures. Use it knowing that.
Use at your own risk. Undervolting can crash, reboot or corrupt data on your machine, and an unstable margin can do so days later, at idle. This software is provided "as is", without warranty of any kind; the authors are not liable for any damage to your hardware, data or anything else arising from its use (GPL-3.0, sections 15 and 16). Every write it makes is volatile - a reboot returns the CPU to the BIOS state - but what runs while a margin is unstable is your responsibility.