Skip to content

Commit 79439bd

Browse files
committed
refactor(helper): hardcode GPU throttle; refuse integrated GPUs
Two related cleanups around the helper's GPU-sharing model. Drop the GpuLimitPercent setting (5..75, default 25). The knob name read as "max % of GPU the helper uses," but the implementation actually controlled when to yield based on overall GPU-busy %. Everyone who saw "25" reasonably worried the helper would constantly pin a quarter of their GPU, which it never did. The dedicated encoder block (NVENC, AMD VCN) runs in parallel with the game's render pipeline, so a low yield threshold cost helper throughput for no measurable game-FPS benefit. Helper now uses a hardcoded 95% back-off threshold internally and exposes no equivalent knob. Refuse Intel QSV and Windows MediaFoundation at encoder selection. Both run on integrated graphics on the vast majority of installs, where the encoder shares silicon with the render pipeline and accepting a lease costs real frames in-headset. Helper opts out of co-watching entirely when only integrated encoders are available; the rest of WKVRCProxy (relay, watchdog, mesh client) continues to run normally. A helper_encoder_refused log line lists the rejected encoders so operators see why their iGPU machine isn't sharing. Wire field gpu_limit_percent retained on HelperStatusFrame for backward compat with older servers that still log it; always emitted as 0 from this build (no user override).
1 parent d98ff7e commit 79439bd

11 files changed

Lines changed: 254 additions & 49 deletions

src/WKVRCProxy.Tests/AppSettingsTests.cs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,24 @@ public void Defaults_EnableSharingWithConservativeLimits()
1313
var settings = new AppSettings().Normalize();
1414

1515
Assert.True(settings.Helper.GpuSharing);
16-
Assert.Equal(25, settings.Helper.GpuLimitPercent);
1716
Assert.Equal(0, settings.Helper.UploadLimitMbps);
1817
Assert.False(settings.Helper.AllowOnBattery);
1918
Assert.Equal("auto", settings.Helper.EncodingQuality);
2019
Assert.True(settings.Terminal.StatusLine);
2120
Assert.True(settings.Terminal.Animations);
2221
}
2322

23+
[Fact]
24+
public void GpuLimitSetting_NoLongerExposed()
25+
{
26+
// Removed 2026-05-22: GpuLimitPercent was confusingly named (read as
27+
// "max % the helper uses" but actually a back-off sensitivity knob).
28+
// Helper now uses a hardcoded internal threshold; no user-facing knob.
29+
Assert.False(AppSettingsRegistry.TryFind("gpu-limit", out _));
30+
Assert.False(AppSettingsRegistry.TryFind("gpu", out _));
31+
Assert.False(AppSettingsRegistry.TryFind("gpu-percent", out _));
32+
}
33+
2434
[Fact]
2535
public void Clone_RepairsMissingSections()
2636
{
@@ -42,8 +52,6 @@ public void Clone_RepairsMissingSections()
4252
}
4353

4454
[Theory]
45-
[InlineData("gpu-limit", "5", "5%")]
46-
[InlineData("gpu-limit", "37", "37%")]
4755
[InlineData("upload-limit", "0", "automatic")]
4856
[InlineData("upload-limit", "5", "5 MB/s")]
4957
[InlineData("upload-limit", "12", "12 MB/s")]
@@ -58,10 +66,6 @@ public void Settings_AcceptNumericInputsAndRenderUnits(string key, string input,
5866
}
5967

6068
[Theory]
61-
[InlineData("gpu-limit", "4")]
62-
[InlineData("gpu-limit", "76")]
63-
[InlineData("gpu-limit", "37%")]
64-
[InlineData("gpu-limit", "a lot")]
6569
[InlineData("upload-limit", "-1")]
6670
[InlineData("upload-limit", "501")]
6771
[InlineData("upload-limit", "12 MB/s")]

src/WKVRCProxy.Tests/FfmpegCapabilityProbeTests.cs

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,21 @@ namespace WKVRCProxy.Tests;
66
public class FfmpegCapabilityProbeTests
77
{
88
// Simulate a smoke runner where the first encoder (nvenc) always fails,
9-
// second (qsv) always passes. Verifies the demotion + retry logic.
9+
// second (amf) always passes. Verifies the demotion + retry logic
10+
// across the two discrete-GPU backends. Integrated backends (qsv, mf)
11+
// are no longer eligible at all, so they don't participate in the
12+
// demote-and-retry chain.
1013
[Fact]
1114
public async Task SmokeTest_DemotesFailingEncoderAndTriesNext()
1215
{
1316
var nvenc = new HardwareEncoderCapability("h264_nvenc", HardwareEncoderBackend.Nvenc, "NVIDIA NVENC");
14-
var qsv = new HardwareEncoderCapability("h264_qsv", HardwareEncoderBackend.Qsv, "Intel QSV");
17+
var amf = new HardwareEncoderCapability("h264_amf", HardwareEncoderBackend.Amf, "AMD AMF");
1518
var location = new FfmpegLocation("ffmpeg.exe", FfmpegLocationKind.Bundled);
1619

1720
var base_ = new FfmpegCapabilityProbeResult(
1821
location,
1922
new FfmpegVersionInfo("ffmpeg version 7.1", "7.1"),
20-
new[] { nvenc, qsv },
23+
new[] { nvenc, amf },
2124
nvenc,
2225
FfmpegCapabilityProbeStatus.Ready,
2326
"ok");
@@ -30,21 +33,60 @@ public async Task SmokeTest_DemotesFailingEncoderAndTriesNext()
3033
int cvIdx = argList.IndexOf("-c:v");
3134
string enc = cvIdx >= 0 ? argList[cvIdx + 1] : "?";
3235
attemptLog.Add(enc);
33-
bool pass = enc == "h264_qsv";
36+
bool pass = enc == "h264_amf";
3437
return Task.FromResult(new SmokeTestRunResult(pass ? 0 : 1, false, pass ? "" : "driver error"));
3538
};
3639

3740
FfmpegCapabilityProbeResult result = await FfmpegCapabilityProbe.RunSmokeTestAsync(
3841
base_, runner, CancellationToken.None);
3942

4043
Assert.True(result.SmokeTestPassed);
41-
Assert.Equal("h264_qsv", result.SmokeTestEncoder);
44+
Assert.Equal("h264_amf", result.SmokeTestEncoder);
4245
Assert.NotNull(result.PreferredEncoder);
43-
Assert.Equal("h264_qsv", result.PreferredEncoder!.Value.EncoderName);
46+
Assert.Equal("h264_amf", result.PreferredEncoder!.Value.EncoderName);
4447

4548
Assert.Equal(2, attemptLog.Count);
4649
Assert.Equal("h264_nvenc", attemptLog[0]);
47-
Assert.Equal("h264_qsv", attemptLog[1]);
50+
Assert.Equal("h264_amf", attemptLog[1]);
51+
}
52+
53+
// When the only fallback candidate after a smoke failure is an integrated
54+
// backend, the smoke loop must stop -- not retry on Qsv/MediaFoundation
55+
// (which we refuse) and not silently report success.
56+
[Fact]
57+
public async Task SmokeTest_RefusesIntegratedFallbackWhenDiscreteFails()
58+
{
59+
var nvenc = new HardwareEncoderCapability("h264_nvenc", HardwareEncoderBackend.Nvenc, "NVIDIA NVENC");
60+
var qsv = new HardwareEncoderCapability("h264_qsv", HardwareEncoderBackend.Qsv, "Intel QSV");
61+
var location = new FfmpegLocation("ffmpeg.exe", FfmpegLocationKind.Bundled);
62+
63+
var base_ = new FfmpegCapabilityProbeResult(
64+
location,
65+
new FfmpegVersionInfo("ffmpeg version 7.1", "7.1"),
66+
new[] { nvenc, qsv },
67+
nvenc,
68+
FfmpegCapabilityProbeStatus.Ready,
69+
"ok");
70+
71+
var attemptLog = new List<string>();
72+
FfmpegSmokeRunner runner = (_, args, _, _) =>
73+
{
74+
var argList = args.ToList();
75+
int cvIdx = argList.IndexOf("-c:v");
76+
string enc = cvIdx >= 0 ? argList[cvIdx + 1] : "?";
77+
attemptLog.Add(enc);
78+
return Task.FromResult(new SmokeTestRunResult(1, false, "driver error"));
79+
};
80+
81+
FfmpegCapabilityProbeResult result = await FfmpegCapabilityProbe.RunSmokeTestAsync(
82+
base_, runner, CancellationToken.None);
83+
84+
Assert.False(result.SmokeTestPassed);
85+
Assert.Null(result.SmokeTestEncoder);
86+
// Only nvenc was tried -- qsv was filtered out of the candidate pool
87+
// before the retry loop reached it.
88+
Assert.Single(attemptLog);
89+
Assert.Equal("h264_nvenc", attemptLog[0]);
4890
}
4991

5092
[Fact]

src/WKVRCProxy.Tests/FfmpegHelperTests.cs

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,14 @@ V....D h264_amf AMD AMF H.264 Encoder
3333
}
3434

3535
[Fact]
36-
public void HardwareEncoderProbe_PrefersQsvWhenAvailableToReduceDiscreteGpuContention()
36+
public void ChoosePreferred_PicksNvencOverEverything()
3737
{
3838
var encoders = new[]
3939
{
40+
new HardwareEncoderCapability("h264_amf", HardwareEncoderBackend.Amf, "AMD AMF"),
4041
new HardwareEncoderCapability("h264_nvenc", HardwareEncoderBackend.Nvenc, "NVIDIA NVENC"),
4142
new HardwareEncoderCapability("h264_qsv", HardwareEncoderBackend.Qsv, "Intel QSV"),
43+
new HardwareEncoderCapability("h264_mf", HardwareEncoderBackend.MediaFoundation, "Windows MF"),
4244
};
4345

4446
HardwareEncoderCapability? selected = HardwareEncoderProbe.ChoosePreferred(encoders);
@@ -47,6 +49,69 @@ public void HardwareEncoderProbe_PrefersQsvWhenAvailableToReduceDiscreteGpuConte
4749
Assert.Equal(HardwareEncoderBackend.Nvenc, selected!.Value.Backend);
4850
}
4951

52+
[Fact]
53+
public void ChoosePreferred_PicksAmfWhenNvencAbsent()
54+
{
55+
var encoders = new[]
56+
{
57+
new HardwareEncoderCapability("h264_amf", HardwareEncoderBackend.Amf, "AMD AMF"),
58+
new HardwareEncoderCapability("h264_qsv", HardwareEncoderBackend.Qsv, "Intel QSV"),
59+
};
60+
61+
HardwareEncoderCapability? selected = HardwareEncoderProbe.ChoosePreferred(encoders);
62+
63+
Assert.NotNull(selected);
64+
Assert.Equal(HardwareEncoderBackend.Amf, selected!.Value.Backend);
65+
}
66+
67+
// Integrated-GPU-only systems (Intel iGPU via QSV, or generic MediaFoundation
68+
// fallback) refuse to participate as helpers: the encoder block shares
69+
// silicon with the user's game render pipeline and accepting work would
70+
// cost real frames in-headset. The helper opts out of co-watching entirely
71+
// on these systems rather than offering a degraded share.
72+
[Fact]
73+
public void ChoosePreferred_ReturnsNull_WhenOnlyIntelQsvAvailable()
74+
{
75+
var encoders = new[]
76+
{
77+
new HardwareEncoderCapability("h264_qsv", HardwareEncoderBackend.Qsv, "Intel QSV"),
78+
};
79+
80+
Assert.Null(HardwareEncoderProbe.ChoosePreferred(encoders));
81+
}
82+
83+
[Fact]
84+
public void ChoosePreferred_ReturnsNull_WhenOnlyMediaFoundationAvailable()
85+
{
86+
var encoders = new[]
87+
{
88+
new HardwareEncoderCapability("h264_mf", HardwareEncoderBackend.MediaFoundation, "Windows MF"),
89+
};
90+
91+
Assert.Null(HardwareEncoderProbe.ChoosePreferred(encoders));
92+
}
93+
94+
[Fact]
95+
public void ChoosePreferred_ReturnsNull_WhenOnlyQsvAndMfAvailable()
96+
{
97+
var encoders = new[]
98+
{
99+
new HardwareEncoderCapability("h264_qsv", HardwareEncoderBackend.Qsv, "Intel QSV"),
100+
new HardwareEncoderCapability("h264_mf", HardwareEncoderBackend.MediaFoundation, "Windows MF"),
101+
};
102+
103+
Assert.Null(HardwareEncoderProbe.ChoosePreferred(encoders));
104+
}
105+
106+
[Fact]
107+
public void IsDedicatedGpuBackend_AcceptsDiscreteEncodersOnly()
108+
{
109+
Assert.True(HardwareEncoderProbe.IsDedicatedGpuBackend(HardwareEncoderBackend.Nvenc));
110+
Assert.True(HardwareEncoderProbe.IsDedicatedGpuBackend(HardwareEncoderBackend.Amf));
111+
Assert.False(HardwareEncoderProbe.IsDedicatedGpuBackend(HardwareEncoderBackend.Qsv));
112+
Assert.False(HardwareEncoderProbe.IsDedicatedGpuBackend(HardwareEncoderBackend.MediaFoundation));
113+
}
114+
50115
[Fact]
51116
public void FfmpegLocator_PrefersBundledBinary()
52117
{
@@ -89,6 +154,29 @@ V..... h264_qsv Intel Quick Sync Video H.264 encoder
89154
Assert.Equal("7.1.1-full_build-www.gyan.dev", result.Version!.Value.Version);
90155
}
91156

157+
[Fact]
158+
public void FfmpegCapabilityProbe_FromOutputsRefusesIntegratedOnlySystem()
159+
{
160+
var location = new FfmpegLocation("ffmpeg.exe", FfmpegLocationKind.Path);
161+
FfmpegCapabilityProbeResult result = FfmpegCapabilityProbe.FromOutputs(
162+
location,
163+
"ffmpeg version 7.1.1",
164+
"""
165+
Encoders:
166+
V..... h264_qsv Intel Quick Sync Video H.264 encoder
167+
V..... h264_mf Windows MediaFoundation H.264 encoder
168+
""");
169+
170+
Assert.True(result.HasFfmpeg);
171+
Assert.False(result.CanUseHardwareH264);
172+
Assert.Equal(FfmpegCapabilityProbeStatus.NoHardwareEncoder, result.Status);
173+
Assert.Null(result.PreferredEncoder);
174+
Assert.Contains("integrated-GPU", result.Message);
175+
// The encoders list is still populated so logs / telemetry see what
176+
// was found before it was refused.
177+
Assert.Equal(2, result.Encoders.Count);
178+
}
179+
92180
[Fact]
93181
public async Task FfmpegCapabilityProbe_ReportsMissingWhenExecutableIsUnavailable()
94182
{
@@ -445,6 +533,50 @@ public void HelperSelfThrottle_AllowsWorkWhenSignalsAreHealthy()
445533
Assert.Equal("idle", decision.State);
446534
}
447535

536+
// The previous configurable GpuLimitPercent (5..75) is gone -- helper now
537+
// uses a hardcoded back-off threshold. Pin both the runs-at-typical-load
538+
// case (was previously rejected at default 25 / 88% threshold) and the
539+
// pauses-at-near-saturation case so future tweaks to the constant remain
540+
// visible in CI.
541+
[Fact]
542+
public void HelperSelfThrottle_RunsAtModerateGpuLoad()
543+
{
544+
var settings = new AppSettings().Normalize();
545+
546+
HelperThrottleDecision decision = HelperSelfThrottle.Evaluate(
547+
settings,
548+
new HelperRuntimeSignals(
549+
OnBattery: false,
550+
VrChatRunning: true,
551+
GpuBusyPercent: 80, // game using most of the GPU; helper still allowed
552+
CpuBusyPercent: 30,
553+
ThermalHeadroomPercent: 50,
554+
UploadQueueBytes: 0,
555+
ConsecutiveFailures: 0));
556+
557+
Assert.True(decision.CanAcceptWork);
558+
}
559+
560+
[Fact]
561+
public void HelperSelfThrottle_PausesWhenGpuNearSaturation()
562+
{
563+
var settings = new AppSettings().Normalize();
564+
565+
HelperThrottleDecision decision = HelperSelfThrottle.Evaluate(
566+
settings,
567+
new HelperRuntimeSignals(
568+
OnBattery: false,
569+
VrChatRunning: true,
570+
GpuBusyPercent: 96, // system genuinely saturated -- helper yields
571+
CpuBusyPercent: 30,
572+
ThermalHeadroomPercent: 50,
573+
UploadQueueBytes: 0,
574+
ConsecutiveFailures: 0));
575+
576+
Assert.False(decision.CanAcceptWork);
577+
Assert.Equal("GPU busy", decision.Reason);
578+
}
579+
448580
private static TranscodeLease NewLease()
449581
{
450582
return new TranscodeLease(

src/WKVRCProxy.Tests/ProtocolRoundTripTests.cs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -278,9 +278,9 @@ public void HelperStatus_frame_matches_mesh_contract()
278278
CanEncodeH264 = true,
279279
Status = "idle",
280280
FfmpegVersion = "7.1.1",
281-
Encoder = "h264_qsv",
282-
EncoderBackend = "qsv",
283-
GpuLimitPercent = 25,
281+
Encoder = "h264_nvenc",
282+
EncoderBackend = "nvenc",
283+
GpuLimitPercent = 0,
284284
UploadLimitMbps = 0,
285285
AllowOnBattery = false,
286286
};
@@ -291,8 +291,10 @@ public void HelperStatus_frame_matches_mesh_contract()
291291
Assert.Equal("helper_status", doc.RootElement.GetProperty("action").GetString());
292292
Assert.True(doc.RootElement.GetProperty("sharing").GetBoolean());
293293
Assert.True(doc.RootElement.GetProperty("can_encode_h264").GetBoolean());
294-
Assert.Equal("h264_qsv", doc.RootElement.GetProperty("encoder").GetString());
295-
Assert.Equal(25, doc.RootElement.GetProperty("gpu_limit_percent").GetInt32());
294+
Assert.Equal("h264_nvenc", doc.RootElement.GetProperty("encoder").GetString());
295+
// Wire field retained (older servers still read it). Always 0 now;
296+
// the client no longer exposes a user-tunable GPU limit.
297+
Assert.Equal(0, doc.RootElement.GetProperty("gpu_limit_percent").GetInt32());
296298
}
297299

298300
[Fact]

src/WKVRCProxy/AppSettings.cs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -127,9 +127,6 @@ internal sealed class HelperAppSettings
127127
[JsonPropertyName("gpu_sharing")]
128128
public bool GpuSharing { get; set; } = true;
129129

130-
[JsonPropertyName("gpu_limit_percent")]
131-
public int GpuLimitPercent { get; set; } = 25;
132-
133130
[JsonPropertyName("upload_limit_mbps")]
134131
public int UploadLimitMbps { get; set; }
135132

@@ -148,7 +145,6 @@ internal sealed class HelperAppSettings
148145
public HelperAppSettings Clone() => new()
149146
{
150147
GpuSharing = GpuSharing,
151-
GpuLimitPercent = GpuLimitPercent,
152148
UploadLimitMbps = UploadLimitMbps,
153149
AllowOnBattery = AllowOnBattery,
154150
EncodingQuality = EncodingQuality,
@@ -157,7 +153,6 @@ internal sealed class HelperAppSettings
157153

158154
public void Normalize()
159155
{
160-
GpuLimitPercent = Math.Clamp(GpuLimitPercent, 5, 75);
161156
UploadLimitMbps = Math.Clamp(UploadLimitMbps, 0, 500);
162157
EncodingQuality = HelperEncodingQualityNames.Format(
163158
HelperEncodingQualityNames.ParseOrAuto(EncodingQuality));

src/WKVRCProxy/AppSettingsRegistry.cs

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -23,21 +23,6 @@ internal static class AppSettingsRegistry
2323
completionValues: ["on", "off"],
2424
aliases: ["gpu-sharing", "helper.gpu-sharing"]),
2525

26-
new AppSettingDefinition(
27-
"gpu-limit",
28-
"Maximum GPU share used for video repair.",
29-
["number from 5 to 75; shown as %"],
30-
static s => s.Helper.GpuLimitPercent.ToString(CultureInfo.InvariantCulture) + "%",
31-
static (AppSettings s, string value, out string error) =>
32-
{
33-
if (!TryParsePercent(value, 5, 75, out int parsed, out error)) return false;
34-
s.Helper.GpuLimitPercent = parsed;
35-
return true;
36-
},
37-
static s => s.Helper.GpuLimitPercent = s_defaults.Helper.GpuLimitPercent,
38-
completionValues: ["5", "10", "25", "50", "75"],
39-
aliases: ["gpu", "gpu-percent", "helper.gpu-limit"]),
40-
4126
new AppSettingDefinition(
4227
"upload-limit",
4328
"Maximum upload speed used for sharing.",

0 commit comments

Comments
 (0)