Skip to content

Commit 15a0f0e

Browse files
committed
feat(overclock): flujo de tuning guiado con perfiles validados
- StartTuning en la 05 ESTABILIDAD: biseccion con techo anunciado (preset Performance), journal real, checkpoint y perfil validado que desbloquea juegos - WmiClockStallProbe (EffectiveClockStall por WMI, resto SensorUnavailable, corte de carga 50), TransactionOverclockJournal, AmdTuningCeilingSource, StoreBackedGameProfileRepository - Fix catalogo: presets escalares con literal int caian en FromIndex (Scalar=0); 17 presets a sufijo d + guard - Suite 1692 passed, 0 failed; build 0W/0E
1 parent 4eaffc3 commit 15a0f0e

18 files changed

Lines changed: 1316 additions & 23 deletions

‎src/CA-O.Core/Overclock/Catalog/OverclockCatalog.cs‎

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,19 @@ public static class OverclockCatalog
2525
"knob_nvidia_power_limit", "knob_nvidia_power_limit_desc", requiresReboot: false,
2626
presets:
2727
[
28-
(OverclockProfile.Conservative, 100),
29-
(OverclockProfile.Balanced, 110),
30-
(OverclockProfile.Performance, 120),
28+
(OverclockProfile.Conservative, 100d),
29+
(OverclockProfile.Balanced, 110d),
30+
(OverclockProfile.Performance, 120d),
3131
]),
3232

3333
Build(new KnobId("nvidia.gpu.thermalLimit"), OverclockComponent.Gpu,
3434
KnobRange.Scalar(60, 95), 1, KnobUnit.Celsius, RiskLevel.High, EvidenceLevel.Vendor,
3535
"knob_nvidia_thermal_limit", "knob_nvidia_thermal_limit_desc", requiresReboot: false,
3636
presets:
3737
[
38-
(OverclockProfile.Conservative, 83),
39-
(OverclockProfile.Balanced, 86),
40-
(OverclockProfile.Performance, 90),
38+
(OverclockProfile.Conservative, 83d),
39+
(OverclockProfile.Balanced, 86d),
40+
(OverclockProfile.Performance, 90d),
4141
],
4242
// CA-O never raises this past the driver's own default for the profile
4343
// Conservative; the ceiling is what the engine negotiates downward.
@@ -53,7 +53,7 @@ public static class OverclockCatalog
5353
"knob_nvidia_core_offset", "knob_nvidia_core_offset_desc", requiresReboot: false,
5454
presets:
5555
[
56-
(OverclockProfile.Conservative, 0),
56+
(OverclockProfile.Conservative, 0d),
5757
(OverclockProfile.Balanced, 60_000_000d),
5858
(OverclockProfile.Performance, 120_000_000d),
5959
],
@@ -65,7 +65,7 @@ public static class OverclockCatalog
6565
"knob_nvidia_mem_offset", "knob_nvidia_mem_offset_desc", requiresReboot: false,
6666
presets:
6767
[
68-
(OverclockProfile.Conservative, 0),
68+
(OverclockProfile.Conservative, 0d),
6969
(OverclockProfile.Balanced, 250_000_000d),
7070
(OverclockProfile.Performance, 500_000_000d),
7171
],
@@ -76,9 +76,9 @@ public static class OverclockCatalog
7676
"knob_nvidia_fan_target", "knob_nvidia_fan_target_desc", requiresReboot: false,
7777
presets:
7878
[
79-
(OverclockProfile.Conservative, 0),
80-
(OverclockProfile.Balanced, 0),
81-
(OverclockProfile.Performance, 15),
79+
(OverclockProfile.Conservative, 0d),
80+
(OverclockProfile.Balanced, 0d),
81+
(OverclockProfile.Performance, 15d),
8282
]),
8383

8484
// Read-only reporting knob: CA-O measures the thermal ceiling and shows it,
@@ -103,9 +103,9 @@ public static class OverclockCatalog
103103
"knob_amd_power_limit", "knob_amd_power_limit_desc", requiresReboot: false,
104104
presets:
105105
[
106-
(OverclockProfile.Conservative, 100),
107-
(OverclockProfile.Balanced, 110),
108-
(OverclockProfile.Performance, 120),
106+
(OverclockProfile.Conservative, 100d),
107+
(OverclockProfile.Balanced, 110d),
108+
(OverclockProfile.Performance, 120d),
109109
],
110110
backendId: AmdAdlxBackendId),
111111
];
@@ -133,9 +133,9 @@ public static class OverclockCatalog
133133
"knob_cpu_max_freq", "knob_cpu_max_freq_desc", requiresReboot: false,
134134
presets:
135135
[
136-
(OverclockProfile.Conservative, 100),
137-
(OverclockProfile.Balanced, 100),
138-
(OverclockProfile.Performance, 100),
136+
(OverclockProfile.Conservative, 100d),
137+
(OverclockProfile.Balanced, 100d),
138+
(OverclockProfile.Performance, 100d),
139139
]),
140140
];
141141

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
using System.Globalization;
2+
using System.Text;
3+
using System.Text.Json;
4+
using CAO.Shared;
5+
6+
namespace CAO.Core.Overclock.Reporting;
7+
8+
/// <summary>
9+
/// File-backed validated profiles. Same discipline as the checkpoint store: atomic
10+
/// writes, unreadable files quarantined and never deleted, transient I/O skipped.
11+
/// One file per knob, so saving twice keeps the latest run instead of history.
12+
/// </summary>
13+
public sealed class FileValidatedProfileStore : IValidatedProfileStore
14+
{
15+
private static readonly JsonSerializerOptions SerializerOptions = new()
16+
{
17+
WriteIndented = false,
18+
PropertyNameCaseInsensitive = true,
19+
};
20+
21+
private readonly string _directory;
22+
23+
public FileValidatedProfileStore(string? directory = null) =>
24+
_directory = directory ?? CaOPaths.OverclockProfilesDirectory;
25+
26+
public async Task<IReadOnlyList<ValidatedOverclockProfile>> ListAsync(CancellationToken cancellationToken)
27+
{
28+
var result = new List<ValidatedOverclockProfile>();
29+
if (!Directory.Exists(_directory))
30+
{
31+
return result;
32+
}
33+
34+
foreach (var file in Directory.EnumerateFiles(_directory, "*.json"))
35+
{
36+
cancellationToken.ThrowIfCancellationRequested();
37+
38+
ValidatedOverclockProfile? profile;
39+
try
40+
{
41+
var json = await File.ReadAllTextAsync(file, cancellationToken).ConfigureAwait(false);
42+
profile = JsonSerializer.Deserialize<ValidatedOverclockProfile>(json, SerializerOptions);
43+
}
44+
catch (JsonException)
45+
{
46+
profile = null;
47+
}
48+
catch (IOException)
49+
{
50+
continue;
51+
}
52+
53+
if (profile is null)
54+
{
55+
Quarantine(file);
56+
continue;
57+
}
58+
59+
result.Add(profile);
60+
}
61+
62+
return result;
63+
}
64+
65+
public async Task SaveAsync(ValidatedOverclockProfile profile, CancellationToken cancellationToken)
66+
{
67+
ArgumentNullException.ThrowIfNull(profile);
68+
69+
Directory.CreateDirectory(_directory);
70+
var path = Path.Combine(_directory, profile.Knob.Value + ".json");
71+
72+
var temp = path + ".tmp";
73+
var json = JsonSerializer.Serialize(profile, SerializerOptions);
74+
await File.WriteAllTextAsync(temp, json, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
75+
File.Move(temp, path, overwrite: true);
76+
}
77+
78+
private static void Quarantine(string file)
79+
{
80+
var stamp = DateTimeOffset.UtcNow.ToString("yyyyMMdd'T'HHmmss'Z'", CultureInfo.InvariantCulture);
81+
File.Move(file, $"{file}.corrupt-{stamp}-unparseable");
82+
}
83+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace CAO.Core.Overclock.Reporting;
4+
5+
/// <summary>
6+
/// A tuning run that reached its ceiling without a fault, kept as the machine's
7+
/// validated answer for one knob. This is the only thing a game profile may
8+
/// reference: a value no run has produced is a guess, and CA-O does not ship
9+
/// guesses (design 6.9).
10+
/// </summary>
11+
public sealed record ValidatedOverclockProfile(
12+
[property: JsonPropertyName("knob")] KnobId Knob,
13+
[property: JsonPropertyName("component")] OverclockComponent Component,
14+
[property: JsonPropertyName("stableValue")] double StableValue,
15+
[property: JsonPropertyName("stableCeiling")] double? StableCeiling,
16+
[property: JsonPropertyName("validatedUtc")] DateTimeOffset ValidatedAtUtc,
17+
[property: JsonPropertyName("driverVersion")] string? DriverVersion,
18+
[property: JsonPropertyName("pointsPassed")] int PointsPassed);
19+
20+
/// <summary>Where validated profiles live. One file per knob; saving twice keeps the latest.</summary>
21+
public interface IValidatedProfileStore
22+
{
23+
Task<IReadOnlyList<ValidatedOverclockProfile>> ListAsync(CancellationToken cancellationToken);
24+
25+
Task SaveAsync(ValidatedOverclockProfile profile, CancellationToken cancellationToken);
26+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
using CAO.Core.Overclock;
2+
using CAO.Core.Overclock.Reporting;
3+
4+
namespace CAO.Infrastructure.Overclock;
5+
6+
/// <summary>
7+
/// The real bounds ADLX reports for the one AMD knob with a public setter.
8+
/// AMD power limits already are percent of TDP, so there is no anchor to
9+
/// resolve and no conversion to get wrong: the driver range is the ceiling.
10+
/// A range the driver would not report gets a row with its reason, not a
11+
/// missing row that would read as "nothing bounded it".
12+
/// </summary>
13+
public sealed class AmdTuningCeilingSource : IOverclockCeilingSource
14+
{
15+
private readonly IAmdTuningApi _tuning;
16+
17+
public AmdTuningCeilingSource(IAmdTuningApi tuning) =>
18+
_tuning = tuning ?? throw new ArgumentNullException(nameof(tuning));
19+
20+
public ValueTask<IReadOnlyList<TuningCeiling>> ReadAsync(CancellationToken ct)
21+
{
22+
ct.ThrowIfCancellationRequested();
23+
24+
if (!_tuning.IsAvailable)
25+
{
26+
return new ValueTask<IReadOnlyList<TuningCeiling>>([]);
27+
}
28+
29+
if (!_tuning.TryDescribe(out var device) || device is null)
30+
{
31+
TuningCeiling unknown = new(
32+
AmdKnobs.PowerLimit,
33+
NvidiaTuningCeilingSource.PercentUnitKey,
34+
null,
35+
null,
36+
_tuning.UnavailableReasonKey ?? NvidiaTuningCeilingSource.UnavailableReasonKey);
37+
return new ValueTask<IReadOnlyList<TuningCeiling>>([unknown]);
38+
}
39+
40+
if (device.MinPowerLimitPercent is not int min || device.MaxPowerLimitPercent is not int max)
41+
{
42+
TuningCeiling noRange = new(
43+
AmdKnobs.PowerLimit,
44+
NvidiaTuningCeilingSource.PercentUnitKey,
45+
null,
46+
null,
47+
AmdAdlxBackend.RangeUnavailableReasonKey);
48+
return new ValueTask<IReadOnlyList<TuningCeiling>>([noRange]);
49+
}
50+
51+
TuningCeiling ceiling = new(
52+
AmdKnobs.PowerLimit,
53+
NvidiaTuningCeilingSource.PercentUnitKey,
54+
min,
55+
max,
56+
null);
57+
return new ValueTask<IReadOnlyList<TuningCeiling>>([ceiling]);
58+
}
59+
}

‎src/CA-O.Infrastructure/Overclock/Gaming/GameOverclockSource.cs‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,14 @@ public interface IAntiCheatScanner
6767
public interface IGameProfileRepository
6868
{
6969
IReadOnlyList<GameOverclockProfile> List();
70+
71+
/// <summary>
72+
/// Async over the same answer. The default keeps the sync one so existing
73+
/// repositories do not change; stores backed by disk override it so the
74+
/// page never blocks the UI thread on file I/O.
75+
/// </summary>
76+
Task<IReadOnlyList<GameOverclockProfile>> ListAsync(CancellationToken ct) =>
77+
Task.FromResult(List());
7078
}
7179

7280
/// <summary>The repository that has no validated profile, because none has been produced yet.</summary>
@@ -85,7 +93,10 @@ public sealed class SystemGameOverclockSource : IGameOverclockSource
8593
private readonly IGameProfileRepository _profiles;
8694

8795
public SystemGameOverclockSource()
88-
: this(new GameDetectionProvider(), new AntiCheatScanProvider(), new EmptyGameProfileRepository())
96+
: this(
97+
new GameDetectionProvider(),
98+
new AntiCheatScanProvider(),
99+
new StoreBackedGameProfileRepository(new CAO.Core.Overclock.Reporting.FileValidatedProfileStore()))
89100
{
90101
}
91102

@@ -122,7 +133,7 @@ public async ValueTask<GameOverclockSnapshot> ReadAsync(CancellationToken ct)
122133
detected = [];
123134
}
124135

125-
var profiles = _profiles.List();
136+
var profiles = await _profiles.ListAsync(ct).ConfigureAwait(false);
126137
var entries = new List<GameOverclockEntry>(detected.Count);
127138
foreach (var game in detected)
128139
{
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
using CAO.Core.Gaming;
2+
using CAO.Core.Overclock;
3+
using CAO.Core.Overclock.Gaming;
4+
using CAO.Core.Overclock.Reporting;
5+
using CAO.Shared;
6+
7+
namespace CAO.Infrastructure.Overclock.Gaming;
8+
9+
/// <summary>
10+
/// Game profiles built from tuning runs on this machine. A validated GPU answer
11+
/// unlocks one profile per known title, referencing the Custom preset: the value
12+
/// was measured here, not taken from a vendor slide. No validated answer means
13+
/// no profile, which is why every row says "no validated profile" on a fresh
14+
/// machine instead of offering a number nobody ran.
15+
/// </summary>
16+
public sealed class StoreBackedGameProfileRepository : IGameProfileRepository
17+
{
18+
public const string CustomPresetLabelKey = "preset_custom";
19+
20+
private readonly IValidatedProfileStore _profiles;
21+
22+
public StoreBackedGameProfileRepository(IValidatedProfileStore profiles) =>
23+
_profiles = profiles ?? throw new ArgumentNullException(nameof(profiles));
24+
25+
public IReadOnlyList<GameOverclockProfile> List() =>
26+
ListAsync(CancellationToken.None).GetAwaiter().GetResult();
27+
28+
public async Task<IReadOnlyList<GameOverclockProfile>> ListAsync(CancellationToken ct)
29+
{
30+
IReadOnlyList<ValidatedOverclockProfile> validated;
31+
try
32+
{
33+
validated = await _profiles.ListAsync(ct).ConfigureAwait(false);
34+
}
35+
catch (OperationCanceledException)
36+
{
37+
throw;
38+
}
39+
catch (Exception)
40+
{
41+
// An unreadable profile store is not "no validated profile": it is
42+
// unknown, and the gate reports that through ScanUnavailable rows
43+
// only when the scan itself failed. Here the honest answer is to
44+
// offer nothing rather than a profile whose backing file is gone.
45+
return [];
46+
}
47+
48+
if (!validated.Any(v => v.Component == OverclockComponent.Gpu))
49+
{
50+
return [];
51+
}
52+
53+
return GameProfileCatalog.All
54+
.Select(g => new GameOverclockProfile(
55+
g.GameId,
56+
g.DisplayName,
57+
OverclockProfile.Custom,
58+
CustomPresetLabelKey,
59+
PreferHighPerformanceGpu: true,
60+
GpuPreferenceEvidence: EvidenceLevel.Empirical,
61+
g.GuidanceEs))
62+
.ToList();
63+
}
64+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using CAO.Core.Overclock.Stability;
2+
using CAO.Infrastructure.Persistence;
3+
using CAO.Shared;
4+
5+
namespace CAO.Infrastructure.Overclock;
6+
7+
/// <summary>
8+
/// The tuning engine's journal, backed by the same append-only transaction file
9+
/// the rest of the app uses. The run id is the transaction id, so a tuning run
10+
/// shows up in the journal next to every optimization, and it never closes:
11+
/// closing is the caller's job, and a tuning that closed its own transaction
12+
/// could report a terminal state nobody verified.
13+
/// </summary>
14+
public sealed class TransactionOverclockJournal : IOverclockJournal
15+
{
16+
public const string OptimizationId = "overclock-tune";
17+
18+
private readonly FileTransactionJournal _inner;
19+
private readonly Guid _runId;
20+
21+
public TransactionOverclockJournal(FileTransactionJournal inner, Guid runId)
22+
{
23+
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
24+
_runId = runId;
25+
}
26+
27+
public ValueTask WriteAsync(TransactionPhase phase, string? detail, CancellationToken cancellationToken)
28+
{
29+
// The engine calls from a worker thread; Append flushes to disk inline.
30+
_inner.Append(new TransactionEvent(
31+
_runId,
32+
OptimizationId,
33+
DateTime.UtcNow,
34+
phase,
35+
Terminal: false,
36+
detail));
37+
return ValueTask.CompletedTask;
38+
}
39+
}

0 commit comments

Comments
 (0)