acdream/src/AcDream.App/Streaming/StreamingWorkBudgetOptions.cs
Erik a1d15a82dd perf #418: widen the destination-lane budget only while the reveal hold is active
While a destination reservation hides the world behind the authored
tunnel, the streaming frame meter now runs a hold-widened profile
(StreamingWorkBudget.WidenForDestinationHold): the time ceiling rises
from the authored 2 ms to an absolute 8 ms default
(ACDREAM_STREAM_WORK_HOLD_DEST_MS is a measurement-only override), every
count/byte dimension scales by the same factor so elapsed time stays the
authoritative guard (the measured binder is Time at both ceilings), and
the reserve fraction is re-derived (0.75 -> 0.9375) so the
NON-destination lane's absolute per-frame caps are unchanged. The
widening keys off the existing BeginDestinationReservation/
EndDestinationReservation bracket only, is derived per-Tick from the
CURRENT budget (mid-hold quality swaps compose), and a frame with no
reservation uses the authored budget verbatim (test-pinned). Portal
holds ride the same bracket as login holds by construction - intended,
and pinned by a kind-parity test through the real coordinator plus a
live @telepoi portal hold (kind=portal gate-ready 3589 ms).

Why: issue #418's next-hypothesis (1). Measured result: the ~5 s
publication drip collapsed to ~2 s (loaded 625/625 at ~3.0 s, tunnel at
64-66 fps), the portal-hold gate-ready fell to ~3.6 s - and login
gate-ready/total stayed at 8.4-8.8 s / 12.6-12.7 s, exposing the real
remaining pacer: the login-cold render-thread upload/registration
barrier behind GpuWorldState.IsRenderReady, which ran concurrently under
the old drip. Full attribution appended to docs/ISSUES.md #418; no
divergence-register row (the streamed result and reveal gate are
byte-identical; only the scheduling rate during a hidden hold changed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 20:55:43 +02:00

188 lines
7.1 KiB
C#

using System.Globalization;
namespace AcDream.App.Streaming;
/// <summary>
/// Startup-time scheduling ceilings for update-thread streaming work.
/// These values limit work per frame; they never reduce the amount or
/// quality of content that eventually becomes resident.
/// <para><paramref name="HoldDestinationCeilingMilliseconds"/> (#418) is the
/// hold-scoped destination-lane time ceiling: while a destination
/// reservation hides the world behind the authored tunnel, the frame meter
/// runs on <see cref="StreamingWorkBudget.WidenForDestinationHold"/> of the
/// current profile instead of the profile itself. It is an absolute ceiling
/// (not scaled by the legacy quality selector), it never shrinks a profile
/// whose own ceiling is already at least as large, and it is NOT a user
/// setting — the environment override
/// (<c>ACDREAM_STREAM_WORK_HOLD_DEST_MS</c>) exists for A/B measurement
/// only, matching the rest of the <c>ACDREAM_STREAM_WORK_*</c> family.</para>
/// </summary>
public sealed record StreamingWorkBudgetOptions(
double MaxUpdateMilliseconds,
int MaxCompletionAdmissions,
long MaxAdoptedCpuBytes,
int MaxEntityOperations,
long MaxGpuUploadBytes,
int MaxGlRetireOperations,
float DestinationReserveFraction,
double HoldDestinationCeilingMilliseconds = 8.0)
{
public const long MiB = 1024L * 1024L;
public static StreamingWorkBudgetOptions Default { get; } = new(
MaxUpdateMilliseconds: 2.0,
MaxCompletionAdmissions: 64,
MaxAdoptedCpuBytes: 8 * MiB,
// Entity cursors are intentionally very small operations (often one
// dictionary/index write). The elapsed-time ceiling remains the
// authoritative CPU guard; 256 left more than 90% of that budget
// unused and stretched destination publication past retail's
// five-second wait-notice edge.
MaxEntityOperations: 4_096,
MaxGpuUploadBytes: 8 * MiB,
MaxGlRetireOperations: 64,
DestinationReserveFraction: 0.75f,
HoldDestinationCeilingMilliseconds: 8.0);
internal static StreamingWorkBudgetOptions Parse(
Func<string, string?> env)
{
ArgumentNullException.ThrowIfNull(env);
StreamingWorkBudgetOptions defaults = Default;
return new StreamingWorkBudgetOptions(
MaxUpdateMilliseconds: ParsePositiveDouble(
env("ACDREAM_STREAM_WORK_MS"),
defaults.MaxUpdateMilliseconds),
MaxCompletionAdmissions: ParsePositiveInt(
env("ACDREAM_STREAM_WORK_COMPLETIONS"),
defaults.MaxCompletionAdmissions),
MaxAdoptedCpuBytes: ParseMiB(
env("ACDREAM_STREAM_WORK_CPU_MIB"),
defaults.MaxAdoptedCpuBytes),
MaxEntityOperations: ParsePositiveInt(
env("ACDREAM_STREAM_WORK_ENTITY_OPS"),
defaults.MaxEntityOperations),
MaxGpuUploadBytes: ParseMiB(
env("ACDREAM_STREAM_WORK_GPU_MIB"),
defaults.MaxGpuUploadBytes),
MaxGlRetireOperations: ParsePositiveInt(
env("ACDREAM_STREAM_WORK_GL_RETIRE_OPS"),
defaults.MaxGlRetireOperations),
DestinationReserveFraction: ParseReservePercent(
env("ACDREAM_STREAM_WORK_DEST_RESERVE_PERCENT"),
defaults.DestinationReserveFraction),
HoldDestinationCeilingMilliseconds: ParsePositiveDouble(
env("ACDREAM_STREAM_WORK_HOLD_DEST_MS"),
defaults.HoldDestinationCeilingMilliseconds));
}
public StreamingWorkBudget ToBudget() => new(
TimeSpan.FromMilliseconds(MaxUpdateMilliseconds),
MaxCompletionAdmissions,
MaxAdoptedCpuBytes,
MaxEntityOperations,
MaxGpuUploadBytes,
MaxGlRetireOperations,
DestinationReserveFraction);
/// <summary>
/// Compatibility bridge for the pre-Slice-E quality setting. The former
/// landblock count now selects a complete work profile instead of acting
/// as a second hidden execution throttle. Four is the historical High
/// profile and therefore preserves this configured profile exactly.
/// <see cref="HoldDestinationCeilingMilliseconds"/> deliberately rides
/// through unscaled: it is an absolute hold-scoped ceiling, so a smaller
/// quality profile widens further toward the same ceiling during a hold
/// and a profile already at or above it widens not at all.
/// </summary>
public StreamingWorkBudgetOptions ScaleForLegacyCompletionCount(int count)
{
if (count <= 0)
throw new ArgumentOutOfRangeException(nameof(count));
double scale = count / 4.0;
return this with
{
MaxUpdateMilliseconds = Math.Max(
0.25,
MaxUpdateMilliseconds * scale),
MaxCompletionAdmissions = Scale(MaxCompletionAdmissions, scale),
MaxAdoptedCpuBytes = Scale(MaxAdoptedCpuBytes, scale),
MaxEntityOperations = Scale(MaxEntityOperations, scale),
MaxGpuUploadBytes = Scale(MaxGpuUploadBytes, scale),
MaxGlRetireOperations = Scale(MaxGlRetireOperations, scale),
};
}
private static double ParsePositiveDouble(string? value, double fallback) =>
double.TryParse(
value,
NumberStyles.Float,
CultureInfo.InvariantCulture,
out double parsed)
&& double.IsFinite(parsed)
&& parsed > 0
? parsed
: fallback;
private static int ParsePositiveInt(string? value, int fallback) =>
int.TryParse(
value,
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out int parsed)
&& parsed > 0
? parsed
: fallback;
private static long ParseMiB(string? value, long fallback)
{
if (!long.TryParse(
value,
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out long mebibytes)
|| mebibytes <= 0
|| mebibytes > long.MaxValue / MiB)
{
return fallback;
}
return checked(mebibytes * MiB);
}
private static float ParseReservePercent(string? value, float fallback)
{
if (!float.TryParse(
value,
NumberStyles.Float,
CultureInfo.InvariantCulture,
out float percent)
|| !float.IsFinite(percent)
|| percent <= 0f
|| percent >= 100f)
{
return fallback;
}
return percent / 100f;
}
internal static int Scale(int value, double scale)
{
if (scale >= int.MaxValue / (double)value)
return int.MaxValue;
return Math.Max(
1,
(int)Math.Round(value * scale, MidpointRounding.AwayFromZero));
}
internal static long Scale(long value, double scale)
{
if (scale >= long.MaxValue / (double)value)
return long.MaxValue;
return Math.Max(
1L,
(long)Math.Round(value * scale, MidpointRounding.AwayFromZero));
}
}