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>
188 lines
7.1 KiB
C#
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));
|
|
}
|
|
}
|