From 22c88668b70ab04a6695f8325bb20ee773819f0e Mon Sep 17 00:00:00 2001 From: DO0OG Date: Sat, 14 Nov 2026 08:38:10 +0900 Subject: [PATCH] =?UTF-8?q?Fix:=20=EB=B6=80=EB=B6=84=20=EC=98=88=EC=97=B4?= =?UTF-8?q?=20=EA=B2=8C=EC=9E=84=EC=9D=B4=20=EC=9E=88=EC=9C=BC=EB=A9=B4=20?= =?UTF-8?q?30=EC=B4=88=EB=A7=88=EB=8B=A4=20=EA=B3=84=ED=9A=8D=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EC=9D=84=20=ED=86=B5=EC=A7=B8=EB=A1=9C=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EC=9D=BD=EC=96=B4=20CPU=20=EB=A5=BC=20=EA=B3=84?= =?UTF-8?q?=EC=86=8D=20=EC=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 부분 예열 게임이 남아 있는 동안 30초 틱마다 예산 계산이 예열 대상 게임 전부의 계획·목록 파일을 캐시 없이 다시 읽어 해석하고, 목록의 게임 파일을 다시 열었습니다. 읽을 것이 없는 틱에도 되풀이돼 평균 코어 하나의 약 22% 를 썼습니다. - 예산 계산이 쓰는 게임별 추정값(전체 크기, 관찰량)을 입력 파일의 길이·수정 시각과 관련 설정이 같고 10분 이내이면 재사용함 - 보충이 기다릴지 정하는 판정은 추정값으로 하고, 읽기로 정해진 뒤 읽기 직전에만 계획을 새로 읽음 - 새로 읽은 값이 추정과 다르면 그 값을 기억하고 이번 회는 읽지 않으며, 다음 회가 새 값으로 판정함 - 예열이 실제로 읽을 범위를 정하는 계획 읽기는 이전처럼 매번 새로 읽음 - 입력 파일 서명을 만드는 코드를 기존 서명과 한 곳으로 모음 - 등록에서 빠진 게임의 추정값은 지움 Issues #189 --- csharp/src/AsterCache.Service/LoadTiers.cs | 6 + .../src/AsterCache.Service/Prewarm.Games.cs | 1 + .../src/AsterCache.Service/Prewarm.Memory.cs | 25 +- .../AsterCache.Service/Prewarm.Supplement.cs | 40 +- csharp/src/AsterCache.Service/Prewarm.cs | 2 + csharp/src/AsterCache.Service/PrewarmEnv.cs | 5 + csharp/src/AsterCache.Service/Streaming.cs | 27 +- .../AsterCache.Service/WarmEstimateCache.cs | 79 ++++ .../Service/WarmEstimateCacheTests.cs | 393 ++++++++++++++++++ 9 files changed, 568 insertions(+), 10 deletions(-) create mode 100644 csharp/src/AsterCache.Service/WarmEstimateCache.cs create mode 100644 csharp/tests/AsterCache.Tests/Service/WarmEstimateCacheTests.cs diff --git a/csharp/src/AsterCache.Service/LoadTiers.cs b/csharp/src/AsterCache.Service/LoadTiers.cs index c10635c..48ae00a 100644 --- a/csharp/src/AsterCache.Service/LoadTiers.cs +++ b/csharp/src/AsterCache.Service/LoadTiers.cs @@ -11,6 +11,11 @@ public static class LoadTiers { private static readonly ConcurrentDictionary Cache = new(StringComparer.OrdinalIgnoreCase); + private static int _generation; + + /// 캐시가 새 경계로 바뀔 때마다 늘어남 (캐시에서만 찾는 예산 추정의 재사용 키). + internal static int Generation => Volatile.Read(ref _generation); + public static LoadBoundary? For(GameSettings game) => For(game, default); // allowParse 가 거짓이면 이미 캐시에 있는 경계만 돌려줍니다 (없으면 null, 원시 트레이스를 파싱하지 않음). @@ -59,6 +64,7 @@ public static class LoadTiers return new FileInfo(p).Length; }, cancellationToken)); Cache[raw] = (written, length, b); + _ = Interlocked.Increment(ref _generation); return b; } catch (Exception e) when (e is IOException or UnauthorizedAccessException or PyValueException) diff --git a/csharp/src/AsterCache.Service/Prewarm.Games.cs b/csharp/src/AsterCache.Service/Prewarm.Games.cs index 0fa72a7..6876bac 100644 --- a/csharp/src/AsterCache.Service/Prewarm.Games.cs +++ b/csharp/src/AsterCache.Service/Prewarm.Games.cs @@ -195,6 +195,7 @@ public void SetGames(IReadOnlyList games) } } + _estimates.Retain(live); _rewarm.SetGames(games); _pins.Reconcile(games); bool budgetWaiting; diff --git a/csharp/src/AsterCache.Service/Prewarm.Memory.cs b/csharp/src/AsterCache.Service/Prewarm.Memory.cs index aa86ea3..13edf49 100644 --- a/csharp/src/AsterCache.Service/Prewarm.Memory.cs +++ b/csharp/src/AsterCache.Service/Prewarm.Memory.cs @@ -94,16 +94,31 @@ internal static IReadOnlyList OrderByValuePerByte(IReadOnlyList (G: g, V: 1.0 / (keep + n + 1) / (zoneMib(g) is > 0 and long z ? z : DefaultFullPrewarmMib))).OrderByDescending(x => x.V).Select(x => x.G)]; } - private long ZoneMib(GameSettings game) => ((_env.ZoneBytes(game) ?? 0) + (1 << 20) - 1) >> 20; + // estimate: 예산 계산의 추정으로만 쓰는 호출 (입력 파일·설정이 같고 MaxAge 안이면 이전 값 재사용). 읽을 범위·양을 정하는 호출은 false 로 매번 새로 읽음. + private long ZoneMib(GameSettings game, bool estimate = false) => + (((estimate ? EstimatedZoneBytes(game) : _env.ZoneBytes(game)) ?? 0) + (1 << 20) - 1) >> 20; - private long ObservedMib(GameSettings game, long totalMib, bool allowSourceRead = true) + private long? EstimatedZoneBytes(GameSettings game) => Estimated("zone", game, false, () => _env.ZoneBytes(game), true); + + // 추정의 재사용 키: 입력 파일 서명 + ZoneBytes·ObservedBytes 가 쓰는 게임 설정. 서명을 만들 수 없으면 (시험의 가짜 env 포함) null = 재사용 안 함. + private string? EstimateStampOf(GameSettings game, bool fromMemoryCaches) => + _env.EstimateStamp?.Invoke(game, fromMemoryCaches) is { } files + ? $"{files}|{game.Plan}|{game.Trace}|{game.OriginRoot}|{game.MaxStreamSet}|{game.MaxStreamMib}" + : null; + + private long? Estimated(string kind, GameSettings game, bool fromMemoryCaches, Func compute, bool estimate) => + _estimates.Get(kind, game.Name, estimate ? EstimateStampOf(game, fromMemoryCaches) : null, compute); + + private long ObservedMib(GameSettings game, long totalMib, bool allowSourceRead = true, bool estimate = false) { if (totalMib <= 0) { return totalMib; } - long? observedBytes = allowSourceRead ? _env.ObservedBytes(game) : _env.ObservedBytesIfCached(game); + long? observedBytes = allowSourceRead + ? Estimated("observed", game, false, () => _env.ObservedBytes(game), estimate) + : Estimated("observed-cached", game, true, () => _env.ObservedBytesIfCached(game), estimate); if (observedBytes is not { } bytes) { return totalMib; @@ -195,7 +210,7 @@ internal IReadOnlyList Warmed() var observedMib = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (GameSettings g in ordered) { - long total = ZoneMib(g); + long total = ZoneMib(g, estimate: true); bool hasZoneList = total > 0; long coldHeld; bool pending; @@ -219,7 +234,7 @@ internal IReadOnlyList Warmed() total = Math.Min(total, cap); long observed = unknownPendingSize ? 0 : total <= 0 || !hasZoneList ? total - : ObservedMib(g, total, allowSourceRead: !DriveHasRunningGame(g)); // 같은 디스크에서 게임이 실행 중이면 출처를 읽지 않고, 출처를 모르면 전부 관찰된 것으로 셈 + : ObservedMib(g, total, allowSourceRead: !DriveHasRunningGame(g), estimate: true); // 같은 디스크에서 게임이 실행 중이면 출처를 읽지 않고, 출처를 모르면 전부 관찰된 것으로 셈 totalMib[g.Name] = total; observedMib[g.Name] = observed; } diff --git a/csharp/src/AsterCache.Service/Prewarm.Supplement.cs b/csharp/src/AsterCache.Service/Prewarm.Supplement.cs index 3ca71df..cd59bf4 100644 --- a/csharp/src/AsterCache.Service/Prewarm.Supplement.cs +++ b/csharp/src/AsterCache.Service/Prewarm.Supplement.cs @@ -470,8 +470,11 @@ internal bool Supplement(GameSettings game, CancellationToken ct, long? allocate return false; } - long? zoneBytes = _env.ZoneBytes(game); - p = RaiseHotMib(game.Name, p, zoneBytes); // 기억한 목록 크기가 현재 목록보다 작으면 올림: 첫 관문·창 상한·표시가 현재 목록을 따름 + // 읽을지 기다릴지 정하는 판정까지는 예산 계산과 같은 추정 (읽지 않고 돌아가는 틱은 계획을 해석하지 않음). 추정으로는 지역 값만 고치고 지속되는 상태는 새로 읽은 값으로만 고침. + string? zoneStamp = EstimateStampOf(game, false); + long? zoneBytes = _estimates.Get("zone", game.Name, zoneStamp, () => _env.ZoneBytes(game)); + (long WarmedMib, long HotMib) remembered = p; + p = (p.WarmedMib, Math.Max(p.HotMib, ZoneListMib(zoneBytes))); // 기억한 목록 크기가 현재 목록보다 작으면 올림 (_partial 은 읽기 직전에) long avail = _env.AvailableRamMib(); long physCap = PhysCapMib(); long budgetCap = allocatedMib is { } allocated ? Math.Min(p.HotMib, allocated) : p.HotMib; @@ -490,6 +493,12 @@ internal bool Supplement(GameSettings game, CancellationToken ct, long? allocate long plannedCapMib = partialCap ?? p.HotMib; string? inputStamp = PrewarmRun.InputStamp(game); long progressBefore = SupplementProgressBytes(game, p, inputStamp); + SupplementRetry? retryBefore; + lock (_gate) + { + _supplementRetry.TryGetValue(game.Name, out retryBefore); + } + if (SupplementDeferred(game, plannedCapMib, p.HotMib, progressBefore, inputStamp)) { return false; @@ -557,6 +566,29 @@ internal bool Supplement(GameSettings game, CancellationToken ct, long? allocate return false; } + long? freshZone = _env.ZoneBytes(game); // 읽기로 정해진 뒤에는 계획을 새로 읽음 + if (ZoneListMib(freshZone) != ZoneListMib(zoneBytes)) + { + // 추정이 낡았음: 새 값을 추정에 넣고 이번 회는 읽지 않음 (재시도 기한 상태도 되돌림), 다음 보충이 새 값으로 판정 + _estimates.Set("zone", game.Name, zoneStamp, freshZone); + lock (_gate) + { + if (retryBefore is null) + { + _supplementRetry.Remove(game.Name); + } + else + { + _supplementRetry[game.Name] = retryBefore; + } + } + + return false; + } + + zoneBytes = freshZone; + p = RaiseHotMib(game.Name, remembered, zoneBytes); // 기억한 목록 크기가 현재 목록보다 작으면 올림: 창 상한·표시가 현재 목록을 따름 + using PrewarmRun run = PrewarmRun.Begin(); long? finalReadBytes = ReadSetBytes(warm, partial: windowMib is not null || skipBytes > 0); avail = _env.AvailableRamMib(); @@ -883,10 +915,12 @@ private void LogSupplementWaitsForMemory(string game, long fitMib, long warmedMi _log.Info($"{game}: supplement waits for memory (read {warmedMib} MiB, room for {fitMib} MiB)"); } + private static long ZoneListMib(long? zoneBytes) => zoneBytes is > 0 ? (zoneBytes.Value + (1 << 20) - 1) >> 20 : 0; + // 기억한 목록 크기 (HotMib) 를 현재 목록 (zoneBytes, MiB 올림) 이 더 크면 올려 돌려주고 _partial·_memoryPartial 도 같게 고침. 늘어날 때만: 줄이면 끝난 것으로 잘못 칠 수 있음. private (long WarmedMib, long HotMib) RaiseHotMib(string game, (long WarmedMib, long HotMib) p, long? zoneBytes) { - long listMib = zoneBytes is > 0 ? (zoneBytes.Value + (1 << 20) - 1) >> 20 : 0; + long listMib = ZoneListMib(zoneBytes); if (listMib <= p.HotMib) { return p; diff --git a/csharp/src/AsterCache.Service/Prewarm.cs b/csharp/src/AsterCache.Service/Prewarm.cs index ba5ed9b..015c49b 100644 --- a/csharp/src/AsterCache.Service/Prewarm.cs +++ b/csharp/src/AsterCache.Service/Prewarm.cs @@ -30,6 +30,7 @@ public sealed partial class Prewarmer private readonly ILog _log; private readonly string? _statusPath; private readonly Func _now; + private readonly WarmEstimateCache _estimates; // 예산 계산 (WarmBudget) 의 게임별 추정 재사용, 읽을 범위를 정하는 호출은 거치지 않음 private readonly object _gate = new(); // 짧은 메모리 상태만 보호: 스트림이 도는 동안에도 LastWarmed·Warmed 가 막히지 않게 private readonly object _run = new(); // 예열을 한 번에 하나로 private readonly Dictionary _activeWork = new(StringComparer.OrdinalIgnoreCase); // 설정 재적재 때 진행 중인 게임 상태를 보존, _gate 로 보호 @@ -123,6 +124,7 @@ public Prewarmer(IReadOnlyList games, IGameStreamer streamer, Prew _statusPath = statusPath; _now = now ?? (() => DateTimeOffset.UtcNow); _coordinator = coordinator; + _estimates = new WarmEstimateCache(_now); _pins = new PinKeeper(env, log, _now) { Coordinator = coordinator }; _solar = new Solar(env, log, _now); _rewarm = new Rewarmer(this, env, log, _now); diff --git a/csharp/src/AsterCache.Service/PrewarmEnv.cs b/csharp/src/AsterCache.Service/PrewarmEnv.cs index f76f459..cdbd97e 100644 --- a/csharp/src/AsterCache.Service/PrewarmEnv.cs +++ b/csharp/src/AsterCache.Service/PrewarmEnv.cs @@ -39,6 +39,10 @@ public sealed class PrewarmEnv /// 게임이 실행 중일 때는 이미 캐시에 있는 출처 정보만 사용합니다 (원시 트레이스 파일을 새로 읽지 않음). public Func ObservedBytesIfCached { get; init; } = _ => null; + /// ZoneBytes·ObservedBytes 가 읽는 입력 파일들의 서명 (두 번째 인자: 캐시에서만 출처를 찾는 ObservedBytesIfCached 용인가), 만들 수 없으면 null. + /// 예산 계산 (WarmBudget) 의 추정 재사용 키입니다. 시험 기본값은 null (재사용 안 함). + public Func? EstimateStamp { get; init; } + public required Func TraceExists { get; init; } /// 계획을 만들 때의 유효 존 상한 (바이트), 모르면 null. 틈 메우기 예산의 상한이며 현재 존 크기와 무관합니다. @@ -291,6 +295,7 @@ public static PrewarmEnv Create(OrbitCalibration? orbitCalibration = null, Orbit ZoneBytes = g => PrefetchStreamer.WarmBytes(g) is { } z ? Math.Min(z, g.StreamCapBytes(z)) : null, ObservedBytes = PrefetchStreamer.ObservedBytes, ObservedBytesIfCached = g => PrefetchStreamer.ObservedBytes(g, allowSourceRead: false), + EstimateStamp = PrefetchStreamer.EstimateStamp, TraceExists = g => File.Exists(g.Trace), EffectiveZoneBytes = EffectiveZoneFromPlan, }; diff --git a/csharp/src/AsterCache.Service/Streaming.cs b/csharp/src/AsterCache.Service/Streaming.cs index 88239c9..f096305 100644 --- a/csharp/src/AsterCache.Service/Streaming.cs +++ b/csharp/src/AsterCache.Service/Streaming.cs @@ -98,6 +98,7 @@ private sealed record TraceSourceCacheEntry(long Length, long LastWriteTicks, IR /// 시험용: 원시 트레이스를 실제로 파싱한 횟수 (경로별). internal static readonly ConcurrentDictionary SourceTraceParses = new(StringComparer.OrdinalIgnoreCase); private static readonly Dictionary TraceSourceCache = new(StringComparer.OrdinalIgnoreCase); + private static int _sourceCacheGeneration; // TraceSourceCache 가 바뀔 때마다 늘림 (캐시에서만 찾는 예산 추정의 재사용 키) internal static void ForgetRemovedGames(IEnumerable games) { @@ -110,6 +111,8 @@ internal static void ForgetRemovedGames(IEnumerable games) { _ = TraceSourceCache.Remove(path + "|" + game.OriginRoot); } + + _ = Interlocked.Increment(ref _sourceCacheGeneration); } foreach (string path in paths) @@ -242,6 +245,22 @@ private static long HeadBytesOf(GameSettings game, CancellationToken cancellatio } } + // 예산 추정 (WarmBytes·ObservedBytes) 이 읽는 입력의 (존재, 길이, 수정 시각): 목록의 입력 (PrewarmRun.InputStamp) 에 원시·boot 트레이스를 더함. 메타데이터 조회뿐이며 만들 수 없으면 null. + // ZoneBytes·LoadRows·LoadSourceTraceRows·LoadTiers.For 의 입력이 바뀌면 여기도 고칠 것. + // fromMemoryCaches: 출처를 메모리 캐시에서만 찾는 추정은 그 캐시의 세대도 입력이라 서명에 더함. + internal static string? EstimateStamp(GameSettings game, bool fromMemoryCaches) + { + string? files = PrewarmRun.InputStamp(game); + string? traces = PrewarmRun.Stamp(() => [AutoTracer.RawTracePath(game), AutoTracer.BootTracePath(game)]); + if (files is null || traces is null) + { + return null; + } + + string stamp = $"{files}|{traces}"; + return fromMemoryCaches ? $"{stamp}|{Volatile.Read(ref _sourceCacheGeneration)}:{LoadTiers.Generation}" : stamp; + } + /// 접근 순서 앞쪽 HeadBytesOf 만큼만 읽습니다 (스케줄러가 앞에 둔 부분과 같음). public void StreamHead(GameSettings game, CancellationToken stop) => StreamHead(game, stop, null); @@ -741,6 +760,7 @@ private static List LoadRows(GameSettings game, bool withWarmOnly, o lock (TraceSourceCacheLock) { TraceSourceCache[key] = new TraceSourceCacheEntry(length, written, rows); + _ = Interlocked.Increment(ref _sourceCacheGeneration); } return rows; @@ -1017,11 +1037,14 @@ internal static void NoteCalibrationCapped() } // 목록의 입력 (계획·트레이스·예열 전용 옆 파일·가변 표지) 의 (존재, 길이, 수정 시각). 메타데이터 조회뿐이며 읽지 못하면 null (재사용 안 함). - internal static string? InputStamp(GameSettings game) + internal static string? InputStamp(GameSettings game) => + Stamp(() => [game.Plan, game.Trace, PlanOutput.WarmOnlyPath(game.Trace), AutoTracer.VolatilePath(game)]); + + internal static string? Stamp(Func paths) { try { - return string.Join('|', new[] { game.Plan, game.Trace, PlanOutput.WarmOnlyPath(game.Trace), AutoTracer.VolatilePath(game) }.Select(path => + return string.Join('|', paths().Select(path => { var info = new FileInfo(path); return info.Exists ? $"{info.Length}:{info.LastWriteTimeUtc.Ticks}" : "-"; diff --git a/csharp/src/AsterCache.Service/WarmEstimateCache.cs b/csharp/src/AsterCache.Service/WarmEstimateCache.cs new file mode 100644 index 0000000..51375a9 --- /dev/null +++ b/csharp/src/AsterCache.Service/WarmEstimateCache.cs @@ -0,0 +1,79 @@ +namespace AsterCache.Service; + +/// 예산 계산 (WarmBudget) 이 쓰는 게임별 추정값 (전체 크기, 관찰량) 을 입력 서명이 같고 MaxAge 안이면 다시 계산하지 않고 돌려줍니다. +/// 값 (모름 = null 포함) 과 서명만 보관합니다: 계획·목록 해석 결과는 붙잡지 않음. 예열이 실제로 읽을 범위를 정하는 호출은 이 캐시를 거치지 않습니다. +internal sealed class WarmEstimateCache(Func now) +{ + // 입력 파일이 그대로여도 게임 파일 크기 (게임 갱신) 는 서명에 없어서, 이 시간 안에 반영되게 합니다. + internal static readonly TimeSpan MaxAge = TimeSpan.FromMinutes(10); + + private sealed record Entry(string Stamp, DateTimeOffset At, long? Value); + + private readonly object _lock = new(); + private readonly Dictionary _entries = new(StringComparer.OrdinalIgnoreCase); + + /// stamp 가 null 이면 (서명을 만들 수 없음) 저장하지 않고 compute 를 그대로 부릅니다. compute 는 잠금 밖에서 돕니다. + public long? Get(string kind, string game, string? stamp, Func compute) + { + if (stamp is null) + { + return compute(); + } + + string key = kind + "|" + game; + DateTimeOffset at = now(); + lock (_lock) + { + if (_entries.TryGetValue(key, out Entry? hit) && hit.Stamp == stamp && at >= hit.At && at - hit.At < MaxAge) + { + return hit.Value; + } + } + + long? value = compute(); + lock (_lock) + { + _entries[key] = new Entry(stamp, at, value); + } + + return value; + } + + /// 새로 읽은 값을 넣습니다. stamp 는 읽기 전에 잡은 것 (읽는 동안 입력이 바뀌면 다음에 다시 계산됨), null 이면 넣지 않음. + public void Set(string kind, string game, string? stamp, long? value) + { + if (stamp is null) + { + return; + } + + DateTimeOffset at = now(); + lock (_lock) + { + _entries[kind + "|" + game] = new Entry(stamp, at, value); + } + } + + /// 목록에 없는 게임의 항목을 지웁니다 (games 는 대소문자 무시 집합) (항목 수가 등록 게임 수를 넘지 않게). + public void Retain(IReadOnlySet games) + { + lock (_lock) + { + foreach (string key in _entries.Keys.Where(k => !games.Contains(k[(k.IndexOf('|') + 1)..])).ToList()) + { + _ = _entries.Remove(key); + } + } + } + + internal int Count + { + get + { + lock (_lock) + { + return _entries.Count; + } + } + } +} diff --git a/csharp/tests/AsterCache.Tests/Service/WarmEstimateCacheTests.cs b/csharp/tests/AsterCache.Tests/Service/WarmEstimateCacheTests.cs new file mode 100644 index 0000000..912e417 --- /dev/null +++ b/csharp/tests/AsterCache.Tests/Service/WarmEstimateCacheTests.cs @@ -0,0 +1,393 @@ +using AsterCache.Core.Plan; +using AsterCache.Core.Settings; +using AsterCache.Core.Trace; +using AsterCache.Prefetch; +using AsterCache.Service; +using AsterCache.Windows; +using Xunit; + +namespace AsterCache.Tests.Service; + +/// 예산 계산 (WarmBudget) 이 같은 입력의 게임별 추정 (전체 크기, 관찰량) 을 다시 읽지 않고, 입력·설정·시간이 바뀌면 다시 계산하는가. +public sealed class WarmEstimateCacheTests +{ + private const long Mib = 1L << 20; + private readonly ListLog _log = new(); + private DateTimeOffset _now = new(2026, 11, 14, 12, 0, 0, TimeSpan.Zero); + private string _stamp = "v1"; + private int _memoryGeneration; + private long? _zoneMib = 100; + private int _zoneCalls; + private int _observedCalls; + private int _cachedCalls; + private bool _running; + private long _avail = 6000; + private readonly Streamer _streamer = new(); + + private sealed class Streamer : IGameStreamer + { + public int Calls { get; private set; } + + public long ReadMib { get; set; } = 100; + + public long? TotalMib { get; set; } + + public Action>? OnProgress { get; set; } + + public StreamStats Stream(GameSettings game) => throw new NotSupportedException(); + + public (StreamStats Stats, bool Complete) StreamPrewarm(GameSettings game, CancellationToken stop, Action? progress, long maxBytes) + { + Calls++; + progress?.Invoke(0, (TotalMib ?? ReadMib) * Mib); + if (OnProgress is not null && progress is not null) + { + OnProgress(progress); + } + + return (new StreamStats(ReadMib * Mib, 1, 0, 0.01), !stop.IsCancellationRequested); + } + + public (StreamStats Stats, bool Complete) Stream(GameSettings game, CancellationToken stop, Action? progress) => + StreamPrewarm(game, stop, progress, 0); + } + + private static GameSettings Game(string name) => Games.Make(name, name + ".exe") with { OriginRoot = @"D:\Games\" + name }; + + private Prewarmer Make(params GameSettings[] games) => new(games, _streamer, new PrewarmEnv + { + AvailableRamMib = () => _avail, + GameRunning = _ => _running, + PhysicalRamBytes = () => 16L << 30, + ZoneBytes = _ => + { + _zoneCalls++; + return _zoneMib is { } z ? z * Mib : null; + }, + ObservedBytes = _ => + { + _observedCalls++; + return 40 * Mib; + }, + ObservedBytesIfCached = _ => + { + _cachedCalls++; + return 30 * Mib; + }, + EstimateStamp = (_, memoryCaches) => memoryCaches ? $"{_stamp}:{_memoryGeneration}" : _stamp, + TraceExists = _ => true, + ProbeReadMs = _ => new ProbeResult(8, 8, 5.0), + }, _log, null, () => _now); + + private Prewarmer Ranked(params GameSettings[] games) + { + Prewarmer p = Make(games); + p.WarmRank = list => list; + Assert.True(p.Run(games[0], "service start").Ran); + return p; + } + + [Fact] + public void ASecondBudgetOnTheSameInputsDoesNotRecomputeTheEstimates() + { + Prewarmer p = Ranked(Game("a")); + var first = p.WarmBudget()!.Value; + (int zone, int observed) = (_zoneCalls, _observedCalls); + + var second = p.WarmBudget()!.Value; + + Assert.Equal((zone, observed), (_zoneCalls, _observedCalls)); + Assert.Equal((first.UsedMib, first.AllocMib["a"]), (second.UsedMib, second.AllocMib["a"])); + Assert.Equal(100, second.AllocMib["a"]); // 관찰 40 + 보충 60 + } + + [Fact] + public void AChangedInputSignatureRecomputesTheEstimates() + { + Prewarmer p = Ranked(Game("a")); + _ = p.WarmBudget(); + int zone = _zoneCalls; + + _stamp = "v2"; // 입력 파일의 길이·수정 시각이 달라짐 + _zoneMib = 200; + var budget = p.WarmBudget()!.Value; + + Assert.True(_zoneCalls > zone); + Assert.Equal(200, budget.AllocMib["a"]); + } + + [Fact] + public void EstimatesExpireAfterTheMaxAge() + { + Prewarmer p = Ranked(Game("a")); + _ = p.WarmBudget(); + int zone = _zoneCalls; + + _now += WarmEstimateCache.MaxAge - TimeSpan.FromSeconds(1); + _ = p.WarmBudget(); + Assert.Equal(zone, _zoneCalls); + + _now += TimeSpan.FromSeconds(2); + _ = p.WarmBudget(); + Assert.True(_zoneCalls > zone); + } + + [Fact] + public void AChangedGameSettingRecomputesTheEstimates() + { + GameSettings a = Game("a"); + Prewarmer p = Ranked(a); + _ = p.WarmBudget(); + int zone = _zoneCalls; + + p.SetGames([a with { MaxStreamMib = 64, MaxStreamSet = true }]); + _ = p.WarmBudget(); + + Assert.True(_zoneCalls > zone); + } + + [Fact] + public void AnUnknownEstimateIsReusedUntilTheInputAppears() + { + Prewarmer p = Ranked(Game("a")); + (_stamp, _zoneMib) = ("v2", null); // 계획이 사라짐 + _ = p.WarmBudget(); + int zone = _zoneCalls; + + _ = p.WarmBudget(); + Assert.Equal(zone, _zoneCalls); + + (_stamp, _zoneMib) = ("v3", 100); // 계획 파일이 생김 + _ = p.WarmBudget(); + Assert.True(_zoneCalls > zone); + } + + [Fact] + public void RunReadsTheListFreshEvenWhenTheBudgetEstimateIsCached() + { + GameSettings a = Game("a"); + Prewarmer p = Ranked(a); + _ = p.WarmBudget(); + int zone = _zoneCalls; + + _ = p.Run(a, "service start"); // 읽을 범위를 정하는 호출은 서명이 같아도 새로 읽음 + + Assert.True(_zoneCalls > zone); + } + + // 메모리 한도로 앞부분 (windowMib 중 readMib) 만 읽고 멈춘 부분 예열 (목록 hotMib, 시작 가용 RAM startAvail). + private Prewarmer Partial(GameSettings g, long hotMib, long startAvail, long windowMib, long readMib) + { + (_zoneMib, _avail) = (hotMib, startAvail); + (_streamer.ReadMib, _streamer.TotalMib) = (readMib, windowMib); + Prewarmer p = Make(g); + p.WarmRank = list => list; + _streamer.OnProgress = progress => + { + _avail = 4096; + progress(readMib * Mib, windowMib * Mib); + }; + PrewarmResult r = p.Run(g, "test"); + Assert.True(r.Ran, r.SkipReason + " | " + string.Join("; ", _log.Lines)); + _streamer.OnProgress = null; + (_streamer.ReadMib, _streamer.TotalMib) = (100, null); + _now += TimeSpan.FromMinutes(3); + return p; + } + + [Fact] + public void ASupplementThatOnlyWaitsForMemoryDoesNotParseTheListAgain() + { + GameSettings a = Game("a"); + Prewarmer p = Partial(a, 8000, 6000, 1648, 300); + _avail = 4096 + Prewarmer.FinalShrinkHeadroomMib + 300 + 255; // 들어가는 범위가 읽은 양보다 255 MiB 만 큼: 읽지 않고 기다림 + int reads = _streamer.Calls; + + Assert.False(p.Supplement(a, CancellationToken.None)); + int zone = _zoneCalls; + Assert.False(p.Supplement(a, CancellationToken.None)); + Assert.False(p.Supplement(a, CancellationToken.None)); + + Assert.Equal(zone, _zoneCalls); // 두 번째부터 계획 해석 0 + Assert.Equal(reads, _streamer.Calls); + Assert.Single(_log.Lines, l => l.Contains("a: supplement waits for memory (read 300 MiB, room for 555 MiB)", StringComparison.Ordinal)); + } + + [Fact] + public void ASupplementThatReadsFetchesTheListFreshRightBeforeReading() + { + GameSettings a = Game("a"); + Prewarmer p = Partial(a, 8000, 6000, 1648, 300); + _avail = 20000; + _ = p.WarmBudget(); // 추정이 캐시에 있음 + (int zone, int reads) = (_zoneCalls, _streamer.Calls); + + Assert.True(p.Supplement(a, CancellationToken.None)); + + Assert.True(_zoneCalls > zone); // 읽기 직전에 새로 읽음 + Assert.Equal(reads + 1, _streamer.Calls); + } + + [Fact] + public void WhenTheFreshListDiffersFromTheEstimateThisRoundDoesNotReadAndTheNextOneJudgesWithTheFreshValue() + { + GameSettings a = Game("a"); + Prewarmer p = Partial(a, 1000, 5000, 648, 500); + _avail = 5100; + _ = p.WarmBudget(); // 추정: 목록 1000 MiB + _zoneMib = 3000; // 게임 파일이 커져 목록이 늘었지만 입력 파일의 서명은 같음 + (int reads, var before, int logs) = (_streamer.Calls, p.PartialInfo("a"), _log.Lines.Count); + + // 추정으로는 통째로 들어가 읽을 판이지만 읽기 직전의 새 값 (3000 MiB) 이 달라 이번 회는 읽지 않고, 상태도 로그도 바꾸지 않음 + Assert.False(p.Supplement(a, CancellationToken.None)); + Assert.Equal((reads, before, logs), (_streamer.Calls, p.PartialInfo("a"), _log.Lines.Count)); + + // 다음 보충은 새 값으로 판정: 들어가는 범위 748 MiB 가 읽은 500 MiB 와 256 미만 차이라 기다림 + int zone = _zoneCalls; + Assert.False(p.Supplement(a, CancellationToken.None)); + Assert.Equal(zone, _zoneCalls); + Assert.Equal(reads, _streamer.Calls); + Assert.Equal(1000, p.PartialInfo("a")!.Value.HotMib); // 기다리기만 한 회는 기억한 목록 크기를 올리지 않음 (상태는 읽기 직전에 새로 읽은 값으로만 고침) + Assert.Single(_log.Lines, l => l.Contains("a: supplement waits for memory (read 500 MiB, room for 748 MiB)", StringComparison.Ordinal)); + } + + [Fact] + public void AStaleLargerEstimateNeverRaisesTheRememberedListSizeAndTheNextRoundReads() + { + GameSettings a = Game("a"); + Prewarmer p = Partial(a, 1000, 5000, 648, 500); + _avail = 20000; + _zoneMib = 3000; + _ = p.WarmBudget(); // 낡은 추정: 3000 MiB + _zoneMib = 1000; // 게임 갱신으로 목록의 파일이 줄었지만 입력 파일의 서명은 같음 + int reads = _streamer.Calls; + + Assert.False(p.Supplement(a, CancellationToken.None)); // 새 값이 다르므로 이번 회는 읽지 않음 + Assert.Equal(reads, _streamer.Calls); + Assert.Equal(1000, p.PartialInfo("a")!.Value.HotMib); // 추정값 3000 으로 올라가지 않음 + + Assert.True(p.Supplement(a, CancellationToken.None)); // 다음 회는 새 값으로 판정해 읽음 + Assert.Equal(reads + 1, _streamer.Calls); + } + + [Fact] + public void WhileAGameRunsOnTheDiskOnlyTheCachedSourceEstimateIsReusedAndNoSourceIsRead() + { + Prewarmer p = Ranked(Game("a")); + _running = true; + _ = p.WarmBudget(); + (int observed, int cached) = (_observedCalls, _cachedCalls); + Assert.True(cached > 0); + + _ = p.WarmBudget(); + Assert.Equal((observed, cached), (_observedCalls, _cachedCalls)); + + _memoryGeneration++; // 출처 캐시가 채워짐: 캐시에서만 찾는 추정은 새로 계산하되 원본은 여전히 읽지 않음 + _ = p.WarmBudget(); + Assert.True(_cachedCalls > cached); + Assert.Equal(observed, _observedCalls); + } + + [Fact] + public void CacheKeepsOnlyRetainedGamesAndRefreshesAfterTheMaxAge() + { + var cache = new WarmEstimateCache(() => _now); + int calls = 0; + long? Compute() => ++calls; + + Assert.Equal(1, cache.Get("zone", "a", "s", Compute)); + Assert.Equal(1, cache.Get("zone", "a", "s", Compute)); + Assert.Equal(2, cache.Get("zone", "a", "t", Compute)); // 서명이 달라짐 + Assert.Equal(3, cache.Get("zone", "a", null, Compute)); // 서명을 못 만들면 저장하지 않음 + Assert.Equal(4, cache.Get("zone", "a", null, Compute)); + _ = cache.Get("zone", "b", "s", Compute); + _ = cache.Get("observed", "b", "s", Compute); + Assert.Equal(3, cache.Count); + + cache.Retain(new HashSet(["A"], StringComparer.OrdinalIgnoreCase)); + Assert.Equal(1, cache.Count); + + _now += WarmEstimateCache.MaxAge; + Assert.Equal(7, cache.Get("zone", "a", "t", Compute)); + } + + [Fact] + public void TheInputStampChangesForEachInputFileAndTheMemoryCacheGenerationAndNotOtherwise() + { + string dir = Path.Combine(Path.GetTempPath(), "aster_estimate_stamp_" + Guid.NewGuid().ToString("N")); + string root = Path.Combine(dir, "game"); + _ = Directory.CreateDirectory(root); + try + { + string asset = Path.Combine(root, "asset.bin"); + File.WriteAllBytes(asset, new byte[8 * (int)Mib]); + GameSettings game = Game("stamp") with + { + OriginRoot = root, + AllowedRoots = [root], + Plan = Path.Combine(dir, "stamp.plan.json"), + Trace = Path.Combine(dir, "stamp.prefetch.json"), + }; + string?[] files = + [ + game.Plan, game.Trace, PlanOutput.WarmOnlyPath(game.Trace), AutoTracer.VolatilePath(game), AutoTracer.RawTracePath(game), AutoTracer.BootTracePath(game), + ]; + string? last = PrefetchStreamer.EstimateStamp(game, false); + Assert.Equal(last, PrefetchStreamer.EstimateStamp(game, false)); + foreach (string? file in files) + { + File.WriteAllText(file!, "{}"); // 생성 + string? created = PrefetchStreamer.EstimateStamp(game, false); + Assert.NotEqual(last, created); + + File.WriteAllText(file!, "{ }"); // 길이 변경 + string? resized = PrefetchStreamer.EstimateStamp(game, false); + Assert.NotEqual(created, resized); + + File.SetLastWriteTimeUtc(file!, DateTime.UtcNow.AddHours(-1)); // 수정 시각 변경 + last = PrefetchStreamer.EstimateStamp(game, false); + Assert.NotEqual(resized, last); + } + + Assert.NotEqual(last, PrefetchStreamer.EstimateStamp(game, true)); // 캐시 세대가 더해짐 + + foreach (string? file in files.Where(f => f != game.Trace && f != AutoTracer.RawTracePath(game))) + { + File.Delete(file!); // 아래 파싱에는 목록과 원시 트레이스만 필요 + } + + PlanOutput.SavePrefetch(game.Trace, [new TraceEvent(asset, 0, 8 * Mib)]); + PlanOutput.SavePrefetch(AutoTracer.RawTracePath(game), [new TraceEvent(asset, 6 * Mib, 2 * Mib)]); + string? beforeParse = PrefetchStreamer.EstimateStamp(game, true); + string? beforeParseFiles = PrefetchStreamer.EstimateStamp(game, false); + _ = PrefetchStreamer.ObservedBytes(game); // 원시 트레이스를 파싱해 출처 캐시를 채움 + Assert.Equal(beforeParseFiles, PrefetchStreamer.EstimateStamp(game, false)); + string? afterParse = PrefetchStreamer.EstimateStamp(game, true); + Assert.NotEqual(beforeParse, afterParse); // 캐시가 채워지면 캐시에서만 찾는 추정의 서명이 달라짐 + PrefetchStreamer.ForgetRemovedGames([game]); + Assert.NotEqual(afterParse, PrefetchStreamer.EstimateStamp(game, true)); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void ChangingTheOriginRootOrTheStreamLimitModeRecomputesTheEstimates() + { + GameSettings a = Game("a"); + Prewarmer p = Ranked(a); + _ = p.WarmBudget(); + int zone = _zoneCalls; + + p.SetGames([a with { OriginRoot = @"D:\Other" }]); + _ = p.WarmBudget(); + Assert.True(_zoneCalls > zone); + + zone = _zoneCalls; + p.SetGames([a with { OriginRoot = @"D:\Other", MaxStreamSet = true }]); + _ = p.WarmBudget(); + Assert.True(_zoneCalls > zone); + } +}