Skip to content

Commit beace06

Browse files
vupleaclaude
andcommitted
Add multi-GPU support through a --gpu selector
Every GPU-bound command takes --gpu <index>|all (default 0). Persisted tunings and reference curves are stored per GPU - GPU 0 keeps the plain file names - and the logon re-apply covers every GPU with a persisted tuning, so one card's failure or a removed card doesn't stop the others. 'clear' drops the selected GPUs' files, and the logon task once none is left. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtzmtEDzU3bHpXfMECYb57
1 parent 2368541 commit beace06

18 files changed

Lines changed: 573 additions & 149 deletions

‎CURVE-FILES.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,6 @@ The same documents are how the app stores its own state, under `data\` in the in
119119
`--out-tuning-file` exports). It lives under Program Files deliberately: the file is admin-only
120120
writable, and the elevated logon task must not consume user-writable data. `clear` removes it
121121
along with the task.
122+
123+
Both are per GPU: these plain names are GPU 0's, and another GPU's carry its index as a suffix
124+
(`persisted-tuning.gpu1.json`).

‎README.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,16 @@ A tuning run **persists by default**: it copies the app to Program Files, and re
106106
task that re-applies the same undervolt at logon, elevated. If apply ever fails you get a message box
107107
so you are aware. Pass `--no-persist` to skip persistence.
108108

109+
### Multiple GPUs
110+
111+
Commands act on GPU 0 (the first NVIDIA GPU the driver enumerates) unless `--gpu <index>` picks
112+
another; `status` shows each card's index. `--gpu all` runs the command on every GPU in turn — the
113+
same request planned from each card's own stock curve, so identical cards get the same tuning
114+
without sharing one card's offsets. Persisted tunings and reference curves are kept per GPU, and
115+
the logon task re-applies every GPU's persisted tuning; `clear` removes the selected GPU's, and the
116+
task only once none is left. A saved shortcut carries its `--gpu`. The active checkmark tracks one
117+
live link per folder, so keep per-card profile links in separate folders.
118+
109119
### Saving a shortcut
110120

111121
`--save-shortcut` writes a `.lnk` in the current directory named for the settings (e.g.

‎e2e/ReferenceCurveBackup.cs‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,16 @@ internal sealed class ReferenceCurveBackup
1313

1414
/// <summary>Captures the reference file's content, or null when nothing is saved.</summary>
1515
public static ReferenceCurveBackup Create()
16-
=> new(File.Exists(ReferenceCurve.FilePath()) ? File.ReadAllText(ReferenceCurve.FilePath()) : null);
16+
=> new(File.Exists(ReferenceCurve.FilePath(0)) ? File.ReadAllText(ReferenceCurve.FilePath(0)) : null);
1717

1818
/// <summary>Removes the saved reference, if any. Guarded like <see cref="PersistedTuning.Remove"/>:
1919
/// on a host that never installed anything the data directory itself is missing, and a bare
2020
/// <see cref="File.Delete(string)"/> would throw instead of doing nothing.</summary>
2121
public static void Remove()
2222
{
23-
if (File.Exists(ReferenceCurve.FilePath()))
23+
if (File.Exists(ReferenceCurve.FilePath(0)))
2424
{
25-
File.Delete(ReferenceCurve.FilePath());
25+
File.Delete(ReferenceCurve.FilePath(0));
2626
}
2727
}
2828

@@ -32,8 +32,8 @@ public void Restore()
3232
Remove();
3333
if (_content is not null)
3434
{
35-
Directory.CreateDirectory(Path.GetDirectoryName(ReferenceCurve.FilePath())!);
36-
File.WriteAllText(ReferenceCurve.FilePath(), _content);
35+
Directory.CreateDirectory(Path.GetDirectoryName(ReferenceCurve.FilePath(0))!);
36+
File.WriteAllText(ReferenceCurve.FilePath(0), _content);
3737
}
3838
}
3939
}

‎e2e/ReferenceCurveTests.cs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,9 @@ public void Undervolt_WithAReferenceFromAnotherCard_WarnsAndFallsBackToTheLiveCu
9393
// Re-key the saved reference to a card this isn't: the identity check must reject it
9494
// rather than plan another card's curve onto this one.
9595
ReferenceCurveDoc doc = TuningDocuments.ParseReferenceCurveDoc(
96-
File.ReadAllText(ReferenceCurve.FilePath()), "the saved reference");
96+
File.ReadAllText(ReferenceCurve.FilePath(0)), "the saved reference");
9797
doc.GpuPciIds = "DEADBEEF-DEADBEEF-DEADBEEF-DEADBEEF";
98-
File.WriteAllText(ReferenceCurve.FilePath(), TuningDocuments.Render(doc));
98+
File.WriteAllText(ReferenceCurve.FilePath(0), TuningDocuments.Render(doc));
9999

100100
var (exitCode, output) = App.RunUndervolt("--mv", "900");
101101

‎src/Args.cs‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ internal static class Args
1010
public static readonly Options Global = Options.Empty
1111
.WithBare("--silent");
1212

13+
/// <summary>The options every GPU-bound command accepts: <see cref="Global"/> plus the GPU
14+
/// selector (see <see cref="GpuSelector"/>). 'install' touches no GPU and stays on Global.</summary>
15+
public static readonly Options GpuBound = Global.WithValue(GpuSelector.Flag);
16+
1317
/// <summary>How a flag consumes the token after it.</summary>
1418
private enum Kind
1519
{
@@ -47,8 +51,7 @@ private Options With(Kind kind, string[] flags)
4751

4852
public Parsed Parse(string[] args) => Args.Parse(args, _flags, allowPositionals: false);
4953

50-
public string[] Positionals(string[] args)
51-
=> Args.Parse(args, _flags, allowPositionals: true).Positionals;
54+
public Parsed ParseWithPositionals(string[] args) => Args.Parse(args, _flags, allowPositionals: true);
5255
}
5356

5457
/// <summary>The parsed token stream: each given flag mapped to its value (null for a bare flag, or

‎src/GpuSelector.cs‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
using System.Globalization;
2+
3+
namespace SimpleNvidiaUndervolt;
4+
5+
/// <summary>
6+
/// Which of the enumerated NVIDIA GPUs a command runs against, from <c>--gpu</c>: one index into
7+
/// <see cref="NvApi.EnumeratePhysicalGpus"/> (0 when the flag is absent), or <c>all</c> of them in
8+
/// turn. Every GPU-bound command takes it. The logon re-apply, which names no GPU, instead covers
9+
/// every GPU a tuning was persisted for (see <see cref="PersistedTuning.StoredSelector"/>).
10+
/// </summary>
11+
internal sealed class GpuSelector
12+
{
13+
public const string Flag = "--gpu";
14+
15+
public static readonly GpuSelector Default = new(new[] { 0 }, arg: null, fromStore: false);
16+
17+
/// <summary>The selected indexes, ascending, or null for every enumerated GPU.</summary>
18+
public IReadOnlyList<int>? Indexes { get; }
19+
20+
/// <summary>The flag's value as typed, re-emitted into a saved shortcut; null when the flag was
21+
/// absent (the index-0 default) or the selection isn't from the command line.</summary>
22+
private readonly string? _arg;
23+
24+
/// <summary>Whether the indexes are the persisted set rather than a request — what decides how
25+
/// a missing GPU is explained.</summary>
26+
private readonly bool _fromStore;
27+
28+
private GpuSelector(IReadOnlyList<int>? indexes, string? arg, bool fromStore)
29+
{
30+
Indexes = indexes;
31+
_arg = arg;
32+
_fromStore = fromStore;
33+
}
34+
35+
/// <summary>Whether <c>--gpu</c> was given, as opposed to the index-0 default.</summary>
36+
public bool Given => _arg is not null;
37+
38+
public bool IsAll => Indexes is null;
39+
40+
/// <summary>The GPUs a tuning is persisted for — what the logon re-apply runs against.</summary>
41+
public static GpuSelector Stored(IReadOnlyList<int> indexes) => new(indexes, arg: null, fromStore: true);
42+
43+
public static GpuSelector Parse(Args.Parsed parsed)
44+
{
45+
if (parsed.Value(Flag) is not { } raw)
46+
{
47+
return Default;
48+
}
49+
50+
if (raw.Equals("all", StringComparison.OrdinalIgnoreCase))
51+
{
52+
return new(null, "all", fromStore: false);
53+
}
54+
55+
if (!int.TryParse(raw, NumberStyles.None, CultureInfo.InvariantCulture, out int index))
56+
{
57+
throw new CliError($"{Flag} takes a GPU index (0, 1, ...) or 'all'.");
58+
}
59+
60+
return new(new[] { index }, raw, fromStore: false);
61+
}
62+
63+
/// <summary>The indexes to run against, in order, on a machine with <paramref name="gpuCount"/>
64+
/// GPUs. Not range-checked: the caller reports an index past the enumeration with
65+
/// <see cref="MissingGpu"/> — as that GPU's own failure when others are selected too.</summary>
66+
public IReadOnlyList<int> IndexesOn(int gpuCount)
67+
=> Indexes ?? Enumerable.Range(0, gpuCount).ToArray();
68+
69+
/// <summary>The error for a selected index past the enumeration. A stored index with no card
70+
/// means a persisted tuning outlived its GPU, which <c>clear</c> drops.</summary>
71+
public CliError MissingGpu(int index, int gpuCount)
72+
=> new($"GPU {index} doesn't exist: {gpuCount} NVIDIA GPU(s) found (indexes 0-{gpuCount - 1})."
73+
+ (_fromStore ? $" Its persisted tuning is dropped with 'clear {Flag} {index}'." : string.Empty));
74+
75+
/// <summary>Rejects <c>all</c> alongside an option naming one output file: an export can't
76+
/// come from several GPUs.</summary>
77+
public void RequireOneForExport(string flag, string? file)
78+
{
79+
if (IsAll && file is not null)
80+
{
81+
throw new CliError($"{flag} exports one GPU's document - pass {Flag} <index> instead of 'all'.");
82+
}
83+
}
84+
85+
/// <summary>The flag for a saved shortcut's command line: nothing for the default.</summary>
86+
public string[] ToArgs() => _arg is null ? Array.Empty<string>() : new[] { Flag, _arg };
87+
88+
/// <summary>The selection as a name token: "GPU 1", "all GPUs".</summary>
89+
public string Describe()
90+
=> Indexes is null ? "all GPUs" : "GPU " + string.Join(", ", Indexes);
91+
}
92+
93+
/// <summary>One GPU a command runs against: its NVAPI handle, its index in the enumeration and the
94+
/// enumeration's size. The index keys the GPU's own store files (see
95+
/// <see cref="Persistence.DataFilePath"/>) and, on a multi-GPU machine, names the card.</summary>
96+
internal readonly record struct SelectedGpu(IntPtr Handle, int Index, int Count)
97+
{
98+
/// <summary>The card's name for headers, with its index once the machine has more than one GPU.</summary>
99+
public string Label
100+
=> Count > 1 ? $"{NvApi.SafeFullName(Handle)} (GPU {Index} of {Count})" : NvApi.SafeFullName(Handle);
101+
}

‎src/PersistedTuning.cs‎

Lines changed: 54 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,59 +4,90 @@ namespace SimpleNvidiaUndervolt;
44
/// The tuning document every persisting run stores for the logon task, which re-applies it with
55
/// <c>tune --apply-persisted</c>. Persisting the resolved offsets as data — rather than a command
66
/// line to re-plan — makes the logon re-apply exact: per-anchor deltas are temperature-independent,
7-
/// so nothing is re-derived from a boot-time curve read. Stored as a JSON file in the install
8-
/// directory's data subfolder — the same document <c>--out-tuning-file</c> exports, so it can be
9-
/// read or copied directly — which is under Program Files and thus admin-only writable, so the
10-
/// elevated logon re-apply never consumes user-writable data.
7+
/// so nothing is re-derived from a boot-time curve read. Stored per GPU as a JSON file in the
8+
/// install directory's data subfolder (see <see cref="Persistence.DataFilePath"/>) — the same
9+
/// document <c>--out-tuning-file</c> exports, so it can be read or copied directly — which is
10+
/// under Program Files and thus admin-only writable, so the elevated logon re-apply never
11+
/// consumes user-writable data.
1112
/// </summary>
1213
internal static class PersistedTuning
1314
{
14-
public static string FilePath() => Path.Combine(Persistence.DataDir(), "persisted-tuning.json");
15+
private const string FileStem = "persisted-tuning";
1516

16-
/// <summary>Stores the document, replacing any previous one. Requires administrator (the
17-
/// install directory); the environment refusing the write is reported as the anticipated
17+
public static string FilePath(int gpuIndex) => Persistence.DataFilePath(FileStem, gpuIndex);
18+
19+
/// <summary>The GPUs a tuning is stored for, ascending.</summary>
20+
public static IReadOnlyList<int> StoredGpuIndexes() => Persistence.StoredGpuIndexes(FileStem);
21+
22+
/// <summary>What the logon re-apply runs against: every GPU with a stored tuning. Nothing
23+
/// stored must fail loudly — silence would read as an applied tuning.</summary>
24+
public static GpuSelector StoredSelector()
25+
{
26+
IReadOnlyList<int> stored = StoredGpuIndexes();
27+
return stored.Count > 0 ? GpuSelector.Stored(stored) : throw NothingStored(0);
28+
}
29+
30+
/// <summary>Stores the document, replacing any previous one for the GPU. Requires administrator
31+
/// (the install directory); the environment refusing the write is reported as the anticipated
1832
/// failure it is.</summary>
19-
public static void Save(TuningDoc doc)
20-
=> TuningDocuments.WriteFile(FilePath(), TuningDocuments.Render(doc), "the persisted tuning");
33+
public static void Save(TuningDoc doc, int gpuIndex)
34+
=> TuningDocuments.WriteFile(FilePath(gpuIndex), TuningDocuments.Render(doc), "the persisted tuning");
2135

2236
/// <summary>The stored document. An <c>--apply-persisted</c> run with nothing (usable) stored
2337
/// must fail loudly — it is the logon re-apply, and silence would read as an applied tuning.</summary>
24-
public static TuningDoc Load()
38+
public static TuningDoc Load(int gpuIndex)
2539
{
26-
if (!File.Exists(FilePath()))
40+
if (!File.Exists(FilePath(gpuIndex)))
2741
{
28-
throw new CliError($"No persisted tuning is stored at {FilePath()} - nothing to apply. "
29-
+ "Re-run your tune command to persist one.");
42+
throw NothingStored(gpuIndex);
3043
}
3144

32-
return TuningDocuments.ReadTuningFile(FilePath());
45+
return TuningDocuments.ReadTuningFile(FilePath(gpuIndex));
3346
}
3447

35-
/// <summary>One phrase for status's logon line: the stored document's capture date when it
48+
private static CliError NothingStored(int gpuIndex)
49+
=> new($"No persisted tuning is stored at {FilePath(gpuIndex)} - nothing to apply. "
50+
+ "Re-run your tune command to persist one.");
51+
52+
/// <summary>One phrase for status's logon line: each stored document's capture date when it
3653
/// reads, else the inconsistency — an apply-persisted task with nothing to apply fails at the
37-
/// next logon, and status must say so rather than "yes".</summary>
54+
/// next logon, and status must say so rather than "yes". GPUs are named only past the
55+
/// single-GPU case.</summary>
3856
public static string DescribeForStatus()
57+
{
58+
IReadOnlyList<int> stored = StoredGpuIndexes();
59+
if (stored.Count == 0)
60+
{
61+
return "which is missing - re-run your tune command";
62+
}
63+
64+
return stored is [0]
65+
? Describe(0)
66+
: string.Join("; ", stored.Select(index => $"GPU {index}: {Describe(index)}"));
67+
}
68+
69+
private static string Describe(int gpuIndex)
3970
{
4071
try
4172
{
42-
return TuningDocuments.DescribeCapture(Load().SavedAt);
73+
return TuningDocuments.DescribeCapture(Load(gpuIndex).SavedAt);
4374
}
4475
catch (CliError)
4576
{
46-
return "which is missing or unreadable - re-run your tune command";
77+
return "unreadable - re-run your tune command";
4778
}
4879
}
4980

50-
/// <summary>Deletes the stored document, returning whether one existed. Throws when the delete
51-
/// itself fails, so <c>clear</c> doesn't report persisted state as gone when it isn't.</summary>
52-
public static bool Remove()
81+
/// <summary>Deletes the GPU's stored document, returning whether one existed. Throws when the
82+
/// delete itself fails, so <c>clear</c> doesn't report persisted state as gone when it isn't.</summary>
83+
public static bool Remove(int gpuIndex)
5384
{
54-
if (!File.Exists(FilePath()))
85+
if (!File.Exists(FilePath(gpuIndex)))
5586
{
5687
return false;
5788
}
5889

59-
File.Delete(FilePath());
90+
File.Delete(FilePath(gpuIndex));
6091
return true;
6192
}
6293
}

‎src/Persistence.cs‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Globalization;
12
using System.Security.Cryptography;
23
using System.Xml.Linq;
34

@@ -56,6 +57,50 @@ internal static string InstallDir()
5657
/// install directory, which is what lets the elevated logon re-apply consume it.</summary>
5758
internal static string DataDir() => Path.Combine(InstallDir(), "data");
5859

60+
/// <summary>The data file holding one document kind for one GPU: the plain name for GPU 0, so
61+
/// a single-GPU install never sees a suffix, and a <c>.gpuN</c> suffix for the others.</summary>
62+
internal static string DataFilePath(string stem, int gpuIndex)
63+
=> Path.Combine(DataDir(), gpuIndex == 0 ? $"{stem}.json" : $"{stem}.gpu{gpuIndex}.json");
64+
65+
/// <summary>The GPU indexes a document kind is stored for, ascending.</summary>
66+
internal static IReadOnlyList<int> StoredGpuIndexes(string stem)
67+
{
68+
if (!Directory.Exists(DataDir()))
69+
{
70+
return Array.Empty<int>();
71+
}
72+
73+
return Directory.GetFiles(DataDir(), $"{stem}*.json")
74+
.Select(file => DataFileGpuIndex(stem, Path.GetFileName(file)))
75+
.OfType<int>()
76+
.Order()
77+
.ToArray();
78+
}
79+
80+
/// <summary>The GPU index a file name of the kind encodes (the inverse of
81+
/// <see cref="DataFilePath"/>), or null for a file that isn't one.</summary>
82+
internal static int? DataFileGpuIndex(string stem, string fileName)
83+
{
84+
if (fileName.Equals($"{stem}.json", StringComparison.OrdinalIgnoreCase))
85+
{
86+
return 0;
87+
}
88+
89+
string prefix = $"{stem}.gpu";
90+
const string suffix = ".json";
91+
if (fileName.Length <= prefix.Length + suffix.Length
92+
|| !fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
93+
|| !fileName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
94+
{
95+
return null;
96+
}
97+
98+
string digits = fileName[prefix.Length..^suffix.Length];
99+
return int.TryParse(digits, NumberStyles.None, CultureInfo.InvariantCulture, out int index) && index > 0
100+
? index
101+
: null;
102+
}
103+
59104
private const string NoCmdSuffix = "-nocmd";
60105

61106
/// <summary>The installed executable, copied as-is (console subsystem).</summary>

0 commit comments

Comments
 (0)