From da83b4210a8cc87c168decacf4aaad555fc9e02b Mon Sep 17 00:00:00 2001 From: DO0OG Date: Sat, 14 Nov 2026 17:00:00 +0900 Subject: [PATCH] =?UTF-8?q?Fix:=20=EC=BA=90=EC=8B=9C=20=EC=83=88=EB=A1=9C?= =?UTF-8?q?=20=EA=B3=A0=EC=B9=A8=EC=9D=B4=20=ED=95=84=EC=9A=94=ED=95=9C=20?= =?UTF-8?q?=EB=95=8C=EC=97=90=20=EB=8F=8C=EC=A7=80=20=EB=AA=BB=ED=95=B4=20?= =?UTF-8?q?=EC=A7=80=ED=82=AC=20=EC=88=98=20=EC=9E=88=EB=8D=98=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=EA=B0=80=20=EB=B0=80=EB=A0=A4=EB=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 새로 고침은 디스크를 읽지 않는 일인데 다른 프로그램이 게임 드라이브를 쓰고 있으면 건너뛰었고, 한 게임의 예열 스트림이 오래 도는 동안에는 다른 게임이 보호되지 않았습니다. 서비스를 다시 시작하면 캐시가 따뜻해도 첫 재예열 점검까지 표식이 없었고, 식었을 때 로그로는 원인을 가릴 수 없었습니다. - 새로 고침은 드라이브 사용량 게이트를 따르지 않음. 게임 실행, 같은 드라이브의 게임 실행, 종료 직후, 예산 게이트와 재예열 점검의 게이트는 그대로 둠 - 드라이브가 바쁠 때 표본이 식게 나오면 한 번은 표식을 두고 건너뛰고, 두 번째에는 재예열 점검에 맡김 - 런처 트리거가 아닌 예열 스트림은 묶음 사이에서 때가 되면 표식이 있는 다른 게임을 다시 만짐 - 스트림 도중에 다시 표식할 때는 그 스트림이 그때까지 만진 양을 빼고 앞에 있는 양을 계산함 - 서비스 시작 때 이미 따뜻해서 예열을 건너뛴 게임은 곧바로 사다리를 만듦 - 식음·건너뜀·빠짐 로그 줄 끝에 표식 이후 경과 시간과 재사용량, 앞에 있던 양을 덧붙임 - `docs/SOLAR.md`, `docs/SOLAR.ko.md` 에 반영함 Issues #199 --- csharp/src/AsterCache.Service/Prewarm.Run.cs | 12 +- .../src/AsterCache.Service/Prewarm.Solar.cs | 49 +- .../src/AsterCache.Service/Prewarm.Startup.cs | 4 + .../src/AsterCache.Service/Rewarm.Ladder.cs | 100 ++- csharp/src/AsterCache.Service/Rewarm.cs | 11 +- csharp/src/AsterCache.Service/Solar.cs | 96 ++- csharp/src/AsterCache.Service/SolarPolicy.cs | 11 + .../src/AsterCache.Service/StreamRequeue.cs | 10 +- .../AsterCache.Tests/Service/SolarTests.cs | 591 +++++++++++++++++- docs/SOLAR.ko.md | 16 +- docs/SOLAR.md | 16 +- 11 files changed, 849 insertions(+), 67 deletions(-) diff --git a/csharp/src/AsterCache.Service/Prewarm.Run.cs b/csharp/src/AsterCache.Service/Prewarm.Run.cs index c1f2b26..9cc6cc3 100644 --- a/csharp/src/AsterCache.Service/Prewarm.Run.cs +++ b/csharp/src/AsterCache.Service/Prewarm.Run.cs @@ -481,7 +481,8 @@ private void CheckPrewarmBatch(GameSettings game, CancellationTokenSource stop, // 읽기는 이 스레드 (묶음 점검 콜백도 같은 스레드) 에서 내므로 I/O 우선순위도 여기서 바꾸고 아래 finally 에서 되돌립니다. var readYield = new ReadYield(_env.SetReadIoLow, PrewarmYield.StartsYielding(why, _env.UptimeMs())); StreamRequeue? requeue = BeginRequeue(game, readYield.Exclude); // 읽는 묶음마다 곧바로 다시 만듦 (SOLAR 가 꺼져 있으면 null: 스트림은 지금과 같음) - double protectedSeconds = 0; // 스트림 앞의 보호가 쓴 시간 + double protectedSeconds = 0; // 스트림 앞과 도중의 보호가 쓴 시간 + DateTimeOffset protectCheckedAt = default; // 스트림 도중의 보호가 마지막으로 새로 고칠 때를 본 시각 double ExposureSeconds() => Math.Max(0, Stopwatch.GetElapsedTime(startedAt).TotalSeconds - (requeue?.Seconds ?? 0) - protectedSeconds); // 다시 만지기는 디스크 읽기가 아니므로 노출 시간에서 뺌 try { @@ -509,6 +510,7 @@ private void CheckPrewarmBatch(GameSettings game, CancellationTokenSource stop, RequeueTally protectedOthers = ProtectOthers(game, stop.Token, allowWhileExiting, readYield); // 디스크를 읽기 전에 (보정 읽기도 읽기): 트리거 예열은 하지 않음 protectedSeconds = protectedOthers.ProtectedSeconds; _requeued += protectedOthers; + protectCheckedAt = _now(); // 방금 보호했으므로 도중의 판정은 그 뒤 ProtectCheckEvery 부터 // 트리거 예열은 보정 읽기를 하지 않음 (ORBIT 결정은 이미 쌓인 장치 구간만 쓰고 이번 스트림의 표본을 요구하지 않음) if (!allowWhileExiting && _env.DeviceCalibration is not null && (_env.DeviceCalibrationEnabled?.Invoke() ?? true)) // 훅이 없거나 off 면 기준선과 같은 호출만 남김 { @@ -543,7 +545,13 @@ private void CheckPrewarmBatch(GameSettings game, CancellationTokenSource stop, Progress?.Invoke(game, done >> 20, (total + (1 << 20) - 1) >> 20); } }, maxBytes, skipBytes, () => !stop.IsCancellationRequested && !(fit?.Finished ?? false) && (lease?.IsValid ?? true), allowReorder, - (stage, bytes, isData) => lease?.NoteReadIssue(stage, bytes, isData), requeue is null ? null : batch => requeue.Touch(batch, stop.Token)); + (stage, bytes, isData) => lease?.NoteReadIssue(stage, bytes, isData), requeue is null ? null : batch => + { + requeue.Touch(batch, stop.Token); + RequeueTally midStream = ProtectBetweenBatches(game, stop.Token, allowWhileExiting, readYield, requeue.Bytes, ref protectCheckedAt); // 묶음 읽기 → 묶음 점검 → 묶음 다시 만지기 → 다른 게임 보호 + protectedSeconds += midStream.ProtectedSeconds; + _requeued += midStream; + }); // 읽은 것을 버리지 않아도 될 때만 (오류·순서 바꿈 없음) 마무리로 남기고, 아니면 기존 메모리 취소와 같게 둠. 틈을 메운 계획은 읽은 곳이 앞쪽과 같다고 보장할 수 없어도 마무리하되 호출 쪽이 보수적으로 셈 (GapBytes). bool clean = r.Item1.Bytes > 0 && r.Item1.Errors == 0 && !_streamer.WasPrewarmReordered(game); bool limited = fit is { Finished: true } && !stop.IsCancellationRequested && (lease?.IsValid ?? true); diff --git a/csharp/src/AsterCache.Service/Prewarm.Solar.cs b/csharp/src/AsterCache.Service/Prewarm.Solar.cs index f5061cf..9861b8e 100644 --- a/csharp/src/AsterCache.Service/Prewarm.Solar.cs +++ b/csharp/src/AsterCache.Service/Prewarm.Solar.cs @@ -17,6 +17,8 @@ public sealed partial class Prewarmer private RequeueTally _requeued; // 핫 집합 스트림이 읽는 묶음을 다시 만든 양 (Run·보충이 예열 한 회 동안 모아 done 줄에 씀), _run 아래에서만 씀 + private readonly HashSet _protectedNames = new(StringComparer.OrdinalIgnoreCase); // 이번 예열 한 회에서 보호한 다른 게임 (done 줄의 서로 다른 게임 수), 첫 보호 때 비움, _run 아래에서만 씀 + // 핫 집합 스트림 한 번이 읽는 묶음마다 그 범위를 곧바로 다시 만지는 도우미 (StreamHot 이 만듦). 꺼져 있거나 keep_stream_on_game_start (게임이 떠도 스트림이 계속 읽음, 만지기도 디스크 읽기가 될 수 있음) 면 null. // 만지기는 스트림과 같은 스레드·같은 대여·같은 중지 토큰 아래에서만 돌고, 게임 시작·트리거 선점 표시가 서면 건너뜁니다 (묶음 점검이 그 사유로 스트림을 멈추기 전의 틈도 막음). private StreamRequeue? BeginRequeue(GameSettings game, Action? onTouched = null) => @@ -48,25 +50,45 @@ private void RequeueSkipped(GameSettings game, long skipBytes, StreamRequeue? re // 그러면 이 스트림이 새로 읽는 페이지는 다른 게임이 아니라 캐시 (와 이 게임 자신의 오래된 페이지) 부터 밀어냅니다. 표본 점검과 게이트는 새로 고침과 같습니다. // 트리거 예열 (allowWhileExiting) 은 게임 시작이 임박한 경로라 늦추지 않으려고 하지 않고, 스트림이 끝난 뒤 사다리가 다른 게임의 A 만 줄입니다 (Solar.Order). // 만지는 데 쓴 시간은 스트림의 노출 시간·양보 시간에서 빼도록 돌려줍니다. 실패·취소돼도 스트림 결과는 같습니다. - private RequeueTally ProtectOthers(GameSettings game, CancellationToken stop, bool trigger, ReadYield readYield) + private RequeueTally ProtectOthers(GameSettings game, CancellationToken stop, bool trigger, ReadYield readYield) => + trigger || !CanProtectOthers(game, stop) ? default : TouchOthers(game, stop, readYield, () => _solar.Marked(OtherGames(game))); + + // 핫 집합 스트림이 도는 동안 묶음 사이 (StreamHot 의 묶음 다시 만지기 직후): 표식이 있는 다른 게임이 새로 고칠 때가 됐으면 (유지 틱과 같은 판정 Rewarmer.DueGroup) 스트림 앞의 보호와 같은 함수로 다시 만집니다. + // 판정에는 대기 목록 통계 조회가 한 번 들므로 직전 판정 뒤 SolarPolicy.ProtectCheckEvery 가 지났을 때만 봅니다 (checkedAt). 조건과 계약 (같은 스레드·대여·중지 토큰, 실패가 결과를 바꾸지 않음) 은 스트림 앞의 보호와 같습니다. + // streamBytes: 이 스트림이 그때까지 만진 양 (공통 블록·건너뛴 앞부분·묶음): 새 표식의 앞에 있는 양에서 끝의 사다리가 줄여 주지 않을 만큼을 뺍니다. + private RequeueTally ProtectBetweenBatches(GameSettings game, CancellationToken stop, bool trigger, ReadYield readYield, long streamBytes, ref DateTimeOffset checkedAt) { - bool Halted() => _streamGameStarted || _streamPreempted || PreemptedByTriggerWait(game.Name); - if (trigger || !_solar.Enabled || _auto.KeepStreamOnGameStart || stop.IsCancellationRequested || Halted()) + DateTimeOffset now = _now(); + if (trigger || !CanProtectOthers(game, stop) || (now >= checkedAt && now - checkedAt < SolarPolicy.ProtectCheckEvery)) { return default; } - IReadOnlyList others = _solar.Marked(Enumerable.Reverse(Warmed()).Where(g => !string.Equals(g.Name, game.Name, StringComparison.OrdinalIgnoreCase))); // Warmed 는 최근 순 - if (others.Count == 0) - { - return default; - } + checkedAt = now; + return TouchOthers(game, stop, readYield, () => _rewarm.DueGroup(OtherGames(game), _auto, stop), SolarPolicy.StreamAheadLess(streamBytes, _solar.PriorTouchedBytes(game.Name))); + } + + // 보호할 수 있는 상태인가: SOLAR 가 켜져 있고, 게임이 떠도 스트림이 계속 읽는 설정이 아니고, 스트림이 멈추지 않았고, 게임 시작·트리거 선점 표시가 서 있지 않음. + private bool CanProtectOthers(GameSettings game, CancellationToken stop) => _solar.Enabled && !_auto.KeepStreamOnGameStart && !stop.IsCancellationRequested && !ProtectionHalted(game); + + private bool ProtectionHalted(GameSettings game) => _streamGameStarted || _streamPreempted || PreemptedByTriggerWait(game.Name); + // 스트림의 게임을 뺀 예열한 게임, 순위가 낮은 것부터 (Warmed 는 최근 순). + private IEnumerable OtherGames(GameSettings game) => Enumerable.Reverse(Warmed()).Where(g => !string.Equals(g.Name, game.Name, StringComparison.OrdinalIgnoreCase)); + + // pick 이 고른 다른 게임들을 만지고 (Rewarmer.RefreshGroup) 쓴 시간을 스트림의 양보 시간에서 빼도록 알립니다. 고른 게임이 없으면 아무것도 하지 않습니다. + private RequeueTally TouchOthers(GameSettings game, CancellationToken stop, ReadYield readYield, Func> pick, long aheadLess = 0) + { long startedAt = Stopwatch.GetTimestamp(); - (int Games, long Bytes) touched = (0, 0); + (int Games, long Bytes, IReadOnlyList Names) touched = (0, 0, []); try { - touched = _rewarm.RefreshGroup(others, _auto, stop, Halted); + if (pick() is not { Count: > 0 } others) + { + return default; + } + + touched = _rewarm.RefreshGroup(others, _auto, stop, () => ProtectionHalted(game), aheadLess); } catch (OperationCanceledException) { @@ -79,7 +101,12 @@ private RequeueTally ProtectOthers(GameSettings game, CancellationToken stop, bo double seconds = Stopwatch.GetElapsedTime(startedAt).TotalSeconds; readYield.Exclude(seconds); - return new RequeueTally(0, 0, false, 0, touched.Games, touched.Bytes, seconds); + if (_requeued.ProtectedTimes == 0) + { + _protectedNames.Clear(); // 이번 예열 한 회의 첫 보호 + } + + return new RequeueTally(0, 0, false, 0, touched.Names.Count(_protectedNames.Add), touched.Bytes, seconds, touched.Games > 0 ? 1 : 0); } // 핫 집합 스트림이 끝까지 읽은 직후 (StreamHot, 디스크 대여 안): 그 게임이 지금까지 캐시에 올린 전체의 역순으로 캐시를 만져 중요한 것이 마지막에 밀려나게 합니다. diff --git a/csharp/src/AsterCache.Service/Prewarm.Startup.cs b/csharp/src/AsterCache.Service/Prewarm.Startup.cs index f536fae..75fcaa2 100644 --- a/csharp/src/AsterCache.Service/Prewarm.Startup.cs +++ b/csharp/src/AsterCache.Service/Prewarm.Startup.cs @@ -50,6 +50,7 @@ public IReadOnlyList RunServiceStart(IEnumerable? r } var skipped = new List(); + var warmSkipped = new List(); // 이미 warm 이라 건너뛴 게임 (표본 판정을 그대로 써서 곧바로 첫 사다리) var cold = new List(); var trimmed = new Dictionary(StringComparer.OrdinalIgnoreCase); var candidates = new List(); @@ -60,6 +61,7 @@ public IReadOnlyList RunServiceStart(IEnumerable? r { LogSkipped(g.Name, "already warm", $"{g.Name}: prewarm skipped (already warm) [service start]"); skipped.Add(new PrewarmResult(g.Name, false, "already warm", null, _now())); + warmSkipped.Add(g); } else if (_env.ZoneBytes(g) is not null && !IsCold(g) && WillSkipReason(g, availNow) is { } why) { @@ -71,6 +73,8 @@ public IReadOnlyList RunServiceStart(IEnumerable? r } } + _rewarm.LadderAfterStartSkip(warmSkipped, _auto, ct); // 서비스를 다시 시작하면 표식이 없어 첫 재예열 점검까지 새로 고침이 없던 공백을 메움 + // 시작 상한도 출처별 두 단계로 나눕니다: 모든 게임의 관찰분을 먼저 (입력 순서), 남은 상한을 보충분에 씁니다. 기록 없는 게임은 전부 첫 단계에서 순서대로 셉니다. var totalMib = new Dictionary(StringComparer.OrdinalIgnoreCase); var observedMib = new Dictionary(StringComparer.OrdinalIgnoreCase); diff --git a/csharp/src/AsterCache.Service/Rewarm.Ladder.cs b/csharp/src/AsterCache.Service/Rewarm.Ladder.cs index f23ce56..e76ee8e 100644 --- a/csharp/src/AsterCache.Service/Rewarm.Ladder.cs +++ b/csharp/src/AsterCache.Service/Rewarm.Ladder.cs @@ -5,8 +5,8 @@ namespace AsterCache.Service; -/// Rewarmer 의 사다리 (Solar) 유지: 30초 틱마다 표식이 있는 게임 중 하나라도 시점이 되면 묶음 전체의 캐시를 디스크 읽기 없이 새로 고치고, 서비스를 다시 시작한 뒤 표식이 없는 게임은 재예열 점검이 warm 으로 판정할 때 첫 사다리를 만듭니다. -/// 게이트는 재예열 점검과 같은 함수 (ReadBlock) 를 씁니다: 재배치 중이 아니고, 같은 디스크의 게임이 돌지 않고, 게임 종료 직후 유예가 지났고, 예산 안의 게임이고, 디스크가 바쁘지 않을 때만 만집니다. +/// Rewarmer 의 사다리 (Solar) 유지: 30초 틱마다 표식이 있는 게임 중 하나라도 시점이 되면 묶음 전체의 캐시를 디스크 읽기 없이 새로 고치고, 서비스를 다시 시작한 뒤 표식이 없는 게임은 서비스 시작 예열이 이미 warm 이라 건너뛴 직후나 재예열 점검이 warm 으로 판정할 때 첫 사다리를 만듭니다. +/// 게이트는 재예열 점검과 같은 함수 (ReadBlock) 를 쓰되 다른 프로세스의 드라이브 사용량은 보지 않습니다 (만지기는 디스크를 읽지 않으므로): 재배치 중이 아니고, 같은 디스크의 게임이 돌지 않고, 게임 종료 직후 유예가 지났고, 예산 안의 게임일 때만 만집니다. /// 여기서는 재예열을 직접 부르지 않습니다: 디스크에서 다시 읽을지는 재예열 점검 (rewarm_minutes 주기, 표본으로 cold 확인 또는 사다리가 남긴 다시 채움 표시, 자체 게이트) 만 정하므로 HDD 를 다시 읽는 빈도는 늘지 않습니다. /// 사다리를 새로 고치는 일 자체 (메모리 안에서만 만지기) 는 자동 모드·rewarm_minutes 와 무관하게 memory.standby_ladder 로만 켜고 끕니다. public sealed partial class Rewarmer @@ -22,28 +22,35 @@ public void LadderTick(AutoSettings auto, CancellationToken ct = default) return; } - DateTimeOffset now = _now(); - Lazy budget = new(_prewarm.WarmBudget); // 표식이 있고 때가 된 게임이 있을 때만 계산 - IReadOnlyList group = solar.DueGroup(Enumerable.Reverse(_prewarm.Warmed()), now, g => Touchable(g, now, auto, budget.Value, ct)); // Warmed 는 최근 순 + IReadOnlyList group = DueGroup(Enumerable.Reverse(_prewarm.Warmed()), auto, ct); // Warmed 는 최근 순 if (group.Count > 0) { _ = RefreshGroup(group, auto, ct, _prewarm.TriggerWaitPending); } } - // 지금 이 게임을 만져도 되는가 (재예열 점검과 같은 게이트): 재배치 중이 아니고, 방금 예열하지 않았고, ReadBlock 에 막히지 않고, 읽기 대여를 얻을 수 있음. - // 대여 거절은 표본을 읽지 못한 것이 아니라 지금은 만질 수 없다는 뜻이라, 표식을 지우지 않고 건너뜁니다 (다음 틱에 다시). + /// order (순위가 낮은 것부터) 중 지금 새로 고칠 묶음 (Solar.DueGroup, 만질 수 있는 게임 하나라도 때가 됐고 직전 묶음 새로 고침 뒤 60초가 지났을 때): 유지 틱과 스트림 도중의 보호가 같은 판정을 씁니다. + internal IReadOnlyList DueGroup(IEnumerable order, AutoSettings auto, CancellationToken ct) + { + DateTimeOffset now = _now(); + Lazy budget = new(_prewarm.WarmBudget); // 표식이 있고 때가 된 게임이 있을 때만 계산 + return _prewarm.Solar.DueGroup(order, now, g => Touchable(g, now, auto, budget.Value, ct)); + } + + // 지금 이 게임을 만져도 되는가 (재예열 점검과 같은 게이트, 단 드라이브 사용량은 제외): 재배치 중이 아니고, 방금 예열하지 않았고, ReadBlock 에 막히지 않고, 읽기 대여를 얻을 수 있음. + // 대여는 게임 실행으로 양보 중일 때만 거절되고 (DiskReadCoordinator) 다른 프로세스의 드라이브 사용은 보지 않습니다. 대여 거절은 표본을 읽지 못한 것이 아니라 지금은 만질 수 없다는 뜻이라, 표식을 지우지 않고 건너뜁니다 (다음 틱에 다시). private bool Touchable(GameSettings g, DateTimeOffset now, AutoSettings auto, WarmBudgetInfo? budget, CancellationToken ct) => - !_prewarm.Games.Any(x => _prewarm.Relocating(x.Name)) && !_prewarm.RewarmAlreadyCovered(g) && ReadBlock(g, now, auto, budget) is null && _prewarm.DiskReadAllowed(g, ct); + !_prewarm.Games.Any(x => _prewarm.Relocating(x.Name)) && !_prewarm.RewarmAlreadyCovered(g) && ReadBlock(g, now, auto, budget, ignoreDriveLoad: true) is null && _prewarm.DiskReadAllowed(g, ct); /// order (순위가 낮은 것부터) 의 표식이 있는 게임을 차례로 새로 고치고 (표본 점검 → warm 이면 만지기), 만진 게임들의 표식을 같은 기준으로 다시 씁니다 (Solar.Regroup). /// 유지 틱의 묶음 새로 고침과 스트림 앞의 보호가 같은 함수를 씁니다. 게이트에 막힌 게임은 건너뛰고 나머지는 계속하며, halted 가 참이거나 취소·재배치가 시작되면 거기서 멈춥니다. - /// 돌려주는 값: 끝까지 만진 게임 수와 만진 바이트. - internal (int Games, long Bytes) RefreshGroup(IReadOnlyList order, AutoSettings auto, CancellationToken ct, Func halted) + /// aheadLess: 스트림 도중의 보호가 넘기는, 진행 중인 스트림의 몫 (Solar.Regroup 참고), 그 밖에는 0. + /// 돌려주는 값: 끝까지 만진 게임 수와 만진 바이트와 그 게임 이름. + internal (int Games, long Bytes, IReadOnlyList Names) RefreshGroup(IReadOnlyList order, AutoSettings auto, CancellationToken ct, Func halted, long aheadLess = 0) { if (!Monitor.TryEnter(_groupLock)) { - return (0, 0); + return (0, 0, []); } List<(string Game, long Bytes, LadderMark? Previous)> touched = []; @@ -66,11 +73,11 @@ private bool Touchable(GameSettings g, DateTimeOffset now, AutoSettings auto, Wa } finally { - _prewarm.Solar.Regroup(touched); // 중간에 멈췄어도 만진 것은 이미 뒤로 갔으므로 그만큼을 반영 + _prewarm.Solar.Regroup(touched, aheadLess); // 중간에 멈췄어도 만진 것은 이미 뒤로 갔으므로 그만큼을 반영 Monitor.Exit(_groupLock); } - return (touched.Count, touched.Sum(t => t.Bytes)); + return (touched.Count, touched.Sum(t => t.Bytes), [.. touched.Select(t => t.Game)]); } // 새로 고치기 전에 기존 재예열 점검의 표본 (warm/cold 판정) 을 한 번 거칩니다: warm 일 때만 만집니다. @@ -84,12 +91,22 @@ private void Refresh(GameSettings g, CancellationToken ct, Action? made = null) { Solar solar = _prewarm.Solar; if (solar.Enabled && !solar.HasMark(g.Name) && !solar.HasMissing(g.Name) && !solar.HasNothingToTouch(g.Name) && !_prewarm.TriggerWaitPending() && _prewarm.LadderReads(_prewarm.CoveredPart(g)) is { Count: > 0 } reads) { - Run(g, ct, token => solar.Order(g, reads, token), "cache ladder"); + Run(g, ct, token => solar.Order(g, reads, token, made), "cache ladder"); } } // 보충이 메모리를 기다리는 중인 게임 (매번 한도로 끝나 읽기를 끝내는 회가 없음) 의 첫 사다리: 읽은 범위 (이어 읽을 위치까지) 의 역순으로 만듭니다. 대상 판정은 Prewarmer 가 함 (WaitingSupplementLadderReads). - private void LadderWaitingSupplement(GameSettings g, CancellationToken ct) + private void LadderWaitingSupplement(GameSettings g, CancellationToken ct, Action? made = null) { Solar solar = _prewarm.Solar; if (solar.Enabled && !solar.HasMark(g.Name) && !solar.HasMissing(g.Name) && !solar.HasNothingToTouch(g.Name) && !_prewarm.TriggerWaitPending() && _prewarm.WaitingSupplementLadderReads(g) is { Count: > 0 } reads) { - Run(g, ct, token => solar.Order(g, reads, token), "cache ladder"); + Run(g, ct, token => solar.Order(g, reads, token, made), "cache ladder"); + } + } + + /// 서비스 시작 예열이 이미 warm 이라 건너뛴 게임들 (순위가 높은 것부터) 의 첫 사다리: 표본은 그 판정을 그대로 쓰고 (다시 읽지 않음), 재예열 점검의 첫 사다리와 같은 조건·함수로 순위가 낮은 게임부터 만든 뒤 묶음 규칙으로 표식을 정리합니다. + /// 시작 예열이 서비스를 다시 시작한 뒤 첫 재예열 점검 (rewarm_minutes 뒤) 까지 표식이 없던 공백을 메웁니다. 유지 틱의 묶음 새로 고침과 겹치면 (_groupLock) 건너뛰고 재예열 점검이 받침. + internal void LadderAfterStartSkip(IReadOnlyList skipped, AutoSettings auto, CancellationToken ct) + { + Solar solar = _prewarm.Solar; + if (skipped.Count == 0 || !solar.Enabled || !Monitor.TryEnter(_groupLock)) + { + return; + } + + List<(string Game, long Bytes, LadderMark? Previous)> made = []; + try + { + DateTimeOffset now = _now(); + WarmBudgetInfo? budget = _prewarm.WarmBudget(); + foreach (GameSettings g in Enumerable.Reverse(skipped)) + { + if (ct.IsCancellationRequested || _prewarm.Games.Any(x => _prewarm.Relocating(x.Name))) + { + break; + } + + if (ReadBlock(g, now, auto, budget, ignoreDriveLoad: true) is not null) + { + continue; + } + + void Made(long bytes) => made.Add((g.Name, bytes, null)); + if (_prewarm.SupplementPending(g.Name)) + { + LadderWaitingSupplement(g, ct, Made); + } + else + { + LadderUnmarked(g, ct, Made); + } + } + } + finally + { + solar.Regroup(made); + Monitor.Exit(_groupLock); } } diff --git a/csharp/src/AsterCache.Service/Rewarm.cs b/csharp/src/AsterCache.Service/Rewarm.cs index 8c8a090..f719409 100644 --- a/csharp/src/AsterCache.Service/Rewarm.cs +++ b/csharp/src/AsterCache.Service/Rewarm.cs @@ -151,7 +151,8 @@ public void Tick(AutoSettings auto, CancellationToken ct = default) } // 점검과 사다리가 이 게임 디스크를 지금 읽어도 되는가: 막히면 (상태 키, 로그 문구, 게이트가 풀릴 시각 (모르면 null)) 를, 괜찮으면 null 을 돌려줍니다. - private (string State, string Message, DateTimeOffset? Due)? ReadBlock(GameSettings g, DateTimeOffset now, AutoSettings auto, WarmBudgetInfo? budget) + // ignoreDriveLoad: 디스크를 읽지 않는 만지기 (사다리 새로 고침) 는 다른 프로세스의 드라이브 사용량에 막히지 않음. 게임 실행·종료 직후·예산 게이트는 그대로. + private (string State, string Message, DateTimeOffset? Due)? ReadBlock(GameSettings g, DateTimeOffset now, AutoSettings auto, WarmBudgetInfo? budget, bool ignoreDriveLoad = false) { if (_exitTracker?.HeavyWorkNotBefore(g, _prewarm.Games, _env.SameDisk, auto.RewarmAfterExitMin) is { } heavyWorkDue && now < heavyWorkDue) { @@ -174,7 +175,7 @@ public void Tick(AutoSettings auto, CancellationToken ct = default) return ("drive-game-running|" + string.Join(",", busy.Select(x => x.Name)), $"{g.Name}: rewarm check skipped: a game is running on the same drive and is not idle" + Blockers(busy.ToArray()), null); // 한가한 다른 게임은 막지 않음 } - if (_env.DiskBytesPerSecond(g) is not { } bps || bps >= DiskActivity.BusyBytesPerSecond) + if (!ignoreDriveLoad && DriveBusy(g)) { return ("busy", $"{g.Name}: rewarm check skipped (the game drive is busy or its load is unknown)", null); } @@ -182,6 +183,9 @@ public void Tick(AutoSettings auto, CancellationToken ct = default) return null; } + // 게임 드라이브가 바쁘거나 처리량을 모르는가. + private bool DriveBusy(GameSettings g) => _env.DiskBytesPerSecond(g) is not { } bps || bps >= DiskActivity.BusyBytesPerSecond; + /// 결과(상태)가 바뀔 때만 남김: 틱마다 같은 결과를 반복해 쓰지 않음 (예열 동작은 그대로). private void NoteCheck(GameSettings g, string state, string message) { @@ -302,11 +306,12 @@ private void Check(GameSettings g, CancellationToken ct, long? roomMib, AutoSett else if (!probe.Cold) { NoteCheck(g, "warm", $"{g.Name}: rewarm check: warm ({probe.Slow}/{probe.Samples} samples over {CacheProbe.ColdThresholdMs:F1} ms, average {probe.AverageMs:F2} ms)"); + solar.ClearOverlooked(g.Name); LadderUnmarked(g, ct); // 서비스를 다시 시작한 뒤 표식이 없는 게임은 warm 일 때 첫 사다리를 만듦 } else { - NoteCheck(g, "cold", $"{g.Name}: rewarm check: cold ({probe.Slow}/{probe.Samples} samples over {CacheProbe.ColdThresholdMs:F1} ms, average {probe.AverageMs:F2} ms, available {_env.AvailableRamMib()} MiB), prewarming again (cooldown ignored)"); + NoteCheck(g, "cold", $"{g.Name}: rewarm check: cold ({probe.Slow}/{probe.Samples} samples over {CacheProbe.ColdThresholdMs:F1} ms, average {probe.AverageMs:F2} ms, available {_env.AvailableRamMib()} MiB), prewarming again (cooldown ignored){solar.SinceLadder(g.Name)}"); _prewarm.RewarmGame(g, ct, roomMib); } } diff --git a/csharp/src/AsterCache.Service/Solar.cs b/csharp/src/AsterCache.Service/Solar.cs index 10074da..f2eae84 100644 --- a/csharp/src/AsterCache.Service/Solar.cs +++ b/csharp/src/AsterCache.Service/Solar.cs @@ -15,8 +15,12 @@ internal sealed class Solar(PrewarmEnv env, ILog log, Func now) private readonly Dictionary _marks = new(StringComparer.OrdinalIgnoreCase); // 마지막으로 만든 사다리의 표식, 메모리에만 둠 (서비스를 다시 시작하면 없음), _lock 으로 보호 private readonly HashSet _missing = new(StringComparer.OrdinalIgnoreCase); // 사다리가 목록의 일부가 캐시에 없음을 발견한 게임 (재예열 점검이 다시 채울 때까지), 메모리에만 둠, _lock 으로 보호 private readonly HashSet _untouchable = new(StringComparer.OrdinalIgnoreCase); // 목록이 전부 스파스 파일이라 만질 것이 없었던 게임 (다음 예열 스트림 뒤 사다리가 다시 판정할 때까지 재예열 점검이 사다리를 다시 시도하지 않음), 메모리에만 둠, _lock 으로 보호 + private readonly Dictionary _dropped = new(StringComparer.OrdinalIgnoreCase); // 식거나 빠졌다고 보아 지운 표식의 시각·그때의 재사용 양·앞에 있는 양 (재예열 점검의 식음 로그가 한 번 쓰면 버리고, 새 표식이 서도 버림), 메모리에만 둠, _lock 으로 보호 + private readonly HashSet _overlooked = new(StringComparer.OrdinalIgnoreCase); // 드라이브가 바쁠 때 표본이 식어 나온 것을 이 표식에서 이미 한 번 봐 준 게임 (표식이 사라지거나 새로 서거나 warm 표본이 나오면 비움), 메모리에만 둠, _lock 으로 보호 private DateTimeOffset? _groupAt; // 마지막으로 묶음을 새로 고친 (표식을 같은 기준으로 다시 쓴) 시각, 메모리에만 둠, _lock 으로 보호 + private readonly record struct DroppedMark(DateTimeOffset At, long? RepurposedBytes, long AheadBytes); + public bool Enabled => env.StandbyLadderEnabled(); public bool HasMark(string game) @@ -66,11 +70,18 @@ public void Prune(IReadOnlySet live) _ = _missing.RemoveWhere(k => !live.Contains(k)); _ = _untouchable.RemoveWhere(k => !live.Contains(k)); + foreach (string gone in _dropped.Keys.Where(k => !live.Contains(k)).ToList()) + { + _ = _dropped.Remove(gone); + } + + _ = _overlooked.RemoveWhere(k => !live.Contains(k)); } } /// 읽은 목록을 예열 순서의 역순으로 만지고 표식을 남깁니다 (예열 직후, 표식 없는 게임의 첫 사다리). 꺼져 있거나 만질 것이 없으면 null. - public TouchEnd? Order(GameSettings game, IReadOnlyList reads, CancellationToken ct) + /// made: 표식을 남겼을 때 만진 바이트를 알림 (서비스 시작 직후 여러 게임의 사다리가 Regroup 에 넘길 값). + public TouchEnd? Order(GameSettings game, IReadOnlyList reads, CancellationToken ct, Action? made = null) { if (!Enabled) { @@ -83,6 +94,8 @@ public void Prune(IReadOnlySet live) return null; } + LadderMark? earlier = MarkOf(game.Name); // 이 줄이 빠짐을 알릴 때 붙일 값을 위해 지우기 전에 잡아 둠 + long? grown = earlier is null ? null : SolarPolicy.Repurposed(earlier, env.Lists()); TouchResult r = env.TouchRanges(ladder, ct); switch (r.End) { @@ -99,6 +112,7 @@ public void Prune(IReadOnlySet live) LadderMark? previous = MarkOf(game.Name); // SetMark 가 덮어쓰기 전의 표식 SetMark(game.Name, ladder, r); + made?.Invoke(r.Bytes); ReduceOthers([game.Name], [(previous, r.Bytes)]); // 이 게임이 만진 페이지가 다른 게임의 뒤로 감 log.Info($"{game.Name}: ladder ordered {r.Bytes >> 20} MiB in {r.Seconds:F1} s{Errors(r)}{Sparse(r)}"); break; @@ -107,13 +121,14 @@ public void Prune(IReadOnlySet live) log.Info($"{game.Name}: ladder stopped after {r.Bytes >> 20} MiB{Errors(r)}{Sparse(r)}"); break; default: + string since = Since(game.Name, earlier, grown); if (!NoteIncomplete(game.Name, r)) { log.Info($"{game.Name}: ladder was too slow without disk reads seen ({r.SlowPieces} slow pieces{Errors(r)}), skipped"); break; } - log.Info($"{game.Name}: ladder found data missing from memory {Missing(r, ladder)}, left to the rewarm check{Sparse(r)}"); + log.Info($"{game.Name}: ladder found data missing from memory {Missing(r, ladder)}, left to the rewarm check{Sparse(r)}{since}"); break; } @@ -136,7 +151,8 @@ public void Prune(IReadOnlySet live) return null; } - long repurposed = SolarPolicy.Repurposed(mark, env.Lists()) ?? 0; + long? grown = SolarPolicy.Repurposed(mark, env.Lists()); + long repurposed = grown ?? 0; TouchResult r = env.TouchRanges(mark.Ladder, ct); switch (r.End) { @@ -157,13 +173,14 @@ public void Prune(IReadOnlySet live) log.Info($"{game.Name}: cache refresh stopped after {r.Bytes >> 20} MiB{Errors(r)}{Sparse(r)}"); // 표식은 그대로 둠: 다음 틱에 다시 시도 break; default: + string since = Since(game.Name, mark, grown); if (!NoteIncomplete(game.Name, r)) { log.Info($"{game.Name}: cache refresh was too slow without disk reads seen ({r.SlowPieces} slow pieces{Errors(r)}), left to the rewarm check"); break; } - log.Info($"{game.Name}: cache refresh found data missing from memory {Missing(r, mark.Ladder)}, left to the rewarm check{Sparse(r)}"); + log.Info($"{game.Name}: cache refresh found data missing from memory {Missing(r, mark.Ladder)}, left to the rewarm check{Sparse(r)}{since}"); break; } @@ -195,7 +212,8 @@ public IReadOnlyList DueGroup(IEnumerable games, Dat /// 묶음을 만진 순서대로 (touched: 게임, 만진 바이트, 만지기 전의 표식, 가장 오래된 것이 먼저) 표식을 같은 기준 시점 (재사용 누계·대기 바이트) 으로 다시 쓰고, /// 이번에 만지지 않은 표식은 앞에 있는 양을 SolarPolicy.AheadAfterOthersTouched 로 줄입니다. 대기 목록을 못 읽으면 표식은 그대로 두고 (시간 기준) 줄이기만 합니다. - public void Regroup(IReadOnlyList<(string Game, long Bytes, LadderMark? Previous)> touched) + /// aheadLess: 진행 중인 스트림이 이미 읽은 양 중 끝의 사다리가 줄여 주지 않을 몫 (스트림 도중의 보호), 캐시 대기 바이트에서 빼고 계산합니다. + public void Regroup(IReadOnlyList<(string Game, long Bytes, LadderMark? Previous)> touched, long aheadLess = 0) { if (touched.Count == 0) { @@ -203,7 +221,7 @@ public void Regroup(IReadOnlyList<(string Game, long Bytes, LadderMark? Previous } MemoryLists? lists = env.Lists(); - long[] ahead = lists is { } l ? SolarPolicy.GroupAhead(l.CacheStandbyBytes, [.. touched.Select(t => t.Bytes)]) : []; + long[] ahead = lists is { } l ? SolarPolicy.GroupAhead(SolarPolicy.Ahead(l.CacheStandbyBytes, aheadLess), [.. touched.Select(t => t.Bytes)]) : []; DateTimeOffset at = now(); lock (_lock) { @@ -270,6 +288,8 @@ private void SetMark(string game, IReadOnlyList ladder, TouchResult r) lock (_lock) { _marks[game] = mark; + _ = _dropped.Remove(game); + _ = _overlooked.Remove(game); _ = _missing.Remove(game); // 목록 전체가 메모리에 있음 _ = _untouchable.Remove(game); } @@ -281,6 +301,7 @@ private bool NoteNothingToTouch(string game) lock (_lock) { _ = _marks.Remove(game); + _ = _overlooked.Remove(game); _ = _missing.Remove(game); return _untouchable.Add(game); } @@ -295,6 +316,7 @@ private bool NoteIncomplete(string game, TouchResult r) lock (_lock) { _ = _marks.Remove(game); + _ = _overlooked.Remove(game); _ = _untouchable.Remove(game); // 디스크 읽기를 만났으면 만질 것이 있음 if (diskSeen) { @@ -317,12 +339,74 @@ public void RestoreMissing(string game) } } + /// 드라이브가 바쁠 때 표본이 식어 나온 것을 이 표식에서 처음 봐 주는가 (표식이 있고 아직 안 봐 줬으면 기억하고 true). 둘째부터는 false: 바쁘지 않을 때와 같게 표식을 지워 재예열 점검에 맡김 (바쁜 상태가 이어져도 표본을 되풀이해 읽지 않음). + public bool OverlookBusyCold(string game) + { + lock (_lock) + { + return _marks.ContainsKey(game) && _overlooked.Add(game); + } + } + + /// warm 표본이 나왔으므로 봐 준 기억을 지웁니다. + public void ClearOverlooked(string game) + { + lock (_lock) + { + _ = _overlooked.Remove(game); + } + } + + /// 표식이 있는 게임이 직전에 만든 사다리가 만진 바이트 (표식이 없으면 0): 스트림 도중의 보호가 스트림의 끝 사다리와 겹쳐 줄이지 않게 하는 데 씀. + public long PriorTouchedBytes(string game) => MarkOf(game)?.TouchedBytes ?? 0; + + /// 표본이 식어서 표식을 지웁니다: 지우기 전의 값으로 만든 로그 꼬리 (표식이 없었으면 빈 문자열) 를 돌려주고, 뒤의 재예열 점검 로그가 쓰도록 기억합니다. + public string ForgetCold(string game) + { + LadderMark? mark = MarkOf(game); + string since = Since(game, mark, mark is null ? null : SolarPolicy.Repurposed(mark, env.Lists())); + Forget(game); + return since; + } + + /// 표식이 있던 게임이 식었다고 볼 때 로그 줄 끝에 붙일 말 (표식도 지우기 전에 기억한 값도 없으면 빈 문자열). + public string SinceLadder(string game) + { + LadderMark? mark = MarkOf(game); + if (mark is not null) + { + return SolarPolicy.SinceLadder(mark.At, now(), SolarPolicy.Repurposed(mark, env.Lists()), mark.AheadBytes); + } + + lock (_lock) + { + return _dropped.Remove(game, out DroppedMark dropped) ? SolarPolicy.SinceLadder(dropped.At, now(), dropped.RepurposedBytes, dropped.AheadBytes) : string.Empty; + } + } + + // 지우려는 표식의 로그 꼬리를 만들고 그 값을 기억합니다 (표식이 없었으면 빈 문자열). + private string Since(string game, LadderMark? mark, long? repurposed) + { + if (mark is null) + { + return string.Empty; + } + + lock (_lock) + { + _dropped[game] = new DroppedMark(mark.At, repurposed, mark.AheadBytes); + } + + return SolarPolicy.SinceLadder(mark.At, now(), repurposed, mark.AheadBytes); + } + /// 표식을 지웁니다 (새로 고침을 더 시도하지 않음). public void Forget(string game) { lock (_lock) { _ = _marks.Remove(game); + _ = _overlooked.Remove(game); _ = _untouchable.Remove(game); } } diff --git a/csharp/src/AsterCache.Service/SolarPolicy.cs b/csharp/src/AsterCache.Service/SolarPolicy.cs index 051eecb..c67047c 100644 --- a/csharp/src/AsterCache.Service/SolarPolicy.cs +++ b/csharp/src/AsterCache.Service/SolarPolicy.cs @@ -25,6 +25,9 @@ internal static class SolarPolicy /// 묶음 새로 고침은 직전 묶음 새로 고침 뒤 이 시간 안에는 다시 하지 않습니다 (몰림이 30초 단위라 틱마다 되풀이하지 않게). public static readonly TimeSpan GroupEvery = TimeSpan.FromSeconds(60); + /// 스트림이 도는 동안 다른 게임의 새로 고칠 때를 보는 간격: 판정마다 대기 목록 통계를 한 번 읽으므로 묶음마다 (약 1초) 보지 않습니다. + public static readonly TimeSpan ProtectCheckEvery = TimeSpan.FromSeconds(10); + /// 대기 목록을 읽을 수 없을 때 (권한 없음 등) 시간으로 대신하는 주기. public static readonly TimeSpan FallbackEvery = TimeSpan.FromMinutes(10); @@ -37,6 +40,10 @@ internal static class SolarPolicy public static long AheadAfterOthersTouched(LadderMark mine, IEnumerable<(LadderMark? Previous, long TouchedBytes)> others) => Ahead(mine.AheadBytes, others.Sum(o => o.Previous is { } previous && previous.At < mine.At ? Math.Min(previous.TouchedBytes, o.TouchedBytes) : 0)); + /// 스트림 도중의 보호가 새 표식의 A 를 계산할 때 캐시 대기 바이트에서 뺄 양: 스트림이 그때까지 읽은 양은 끝의 사다리에서 다시 만져져 보호한 게임들의 뒤로 가지만, + /// 끝의 사다리는 스트림 게임의 이전 표식이 있을 때만 min(이전 표식이 만진 양, 이번 양) 을 줄이므로 (AheadAfterOthersTouched) 그 몫을 빼 이중으로 줄지 않게 합니다. + public static long StreamAheadLess(long streamReadBytes, long priorTouchedBytes) => Math.Max(0, streamReadBytes - priorTouchedBytes); + /// 묶음을 만진 순서 (가장 오래된 것이 먼저) 대로 각 게임의 앞에 있는 양: g_i 의 A = max(0, C - (g_i 와 그 뒤에 만진 게임들이 만진 합)), C 는 만진 직후 캐시 대기 바이트. public static long[] GroupAhead(long cacheStandbyBytes, IReadOnlyList touchedBytes) { @@ -55,6 +62,10 @@ public static long[] GroupAhead(long cacheStandbyBytes, IReadOnlyList touc public static long? Repurposed(LadderMark mark, MemoryLists? lists) => lists is { } l && mark.RepurposedBytes is { } baseline ? Math.Max(0, l.CacheRepurposedBytes - baseline) : null; + /// 표식이 있던 게임이 식었다고 볼 때 로그 줄 끝에 붙이는 말: 사다리를 만든 뒤 지난 분과 (재사용 누계를 알면) 그동안의 재사용 양과 지금 앞에 있는 양. + public static string SinceLadder(DateTimeOffset at, DateTimeOffset now, long? repurposedBytes, long aheadBytes) => + $", since the ladder {Math.Max(0, (long)(now - at).TotalMinutes)} min ago{(repurposedBytes is { } grown ? $": {grown >> 20} MiB repurposed of {aheadBytes >> 20} MiB ahead" : string.Empty)}"; + /// 지금 새로 고칠 때인가: 증가분이 max(하한, 0.4 x A) 이상이거나 남은 여유 (A - 증가분) 가 MinHeadroomBytes 미만이면 예. /// 남은 여유 기준은 표식을 만들 때 A 가 2 x MinHeadroomBytes 이상이었던 게임에만: 캐시가 원래 작은 게임은 만질 때마다 곧바로 다시 때가 되기 때문. /// 대기 목록을 못 읽으면 (표식을 만들 때나 지금) 마지막 사다리 뒤 FallbackEvery. diff --git a/csharp/src/AsterCache.Service/StreamRequeue.cs b/csharp/src/AsterCache.Service/StreamRequeue.cs index 1f31deb..3470512 100644 --- a/csharp/src/AsterCache.Service/StreamRequeue.cs +++ b/csharp/src/AsterCache.Service/StreamRequeue.cs @@ -5,14 +5,14 @@ namespace AsterCache.Service; /// 한 예열 스트림이 만지는 데 쓴 양: 만진 바이트, 걸린 시간 (초), 디스크 읽기가 감지돼 만지기가 멈췄는가, 스파스 파일이라 만지지 않은 바이트. -/// Protected*: 스트림이 디스크를 읽기 전에 표식이 있는 다른 게임을 만진 양 (게임 수, 바이트, 걸린 시간): 묶음을 다시 만지는 것과 따로 셈. -internal readonly record struct RequeueTally(long Bytes, double Seconds, bool DiskStopped, long SkippedBytes = 0, int ProtectedGames = 0, long ProtectedBytes = 0, double ProtectedSeconds = 0) +/// Protected*: 스트림이 디스크를 읽기 전과 읽는 도중에 표식이 있는 다른 게임을 만진 양 (서로 다른 게임 수, 바이트, 걸린 시간, 보호한 횟수): 묶음을 다시 만지는 것과 따로 셈. +internal readonly record struct RequeueTally(long Bytes, double Seconds, bool DiskStopped, long SkippedBytes = 0, int ProtectedGames = 0, long ProtectedBytes = 0, double ProtectedSeconds = 0, int ProtectedTimes = 0) { public static RequeueTally operator +(RequeueTally a, RequeueTally b) => new(a.Bytes + b.Bytes, a.Seconds + b.Seconds, a.DiskStopped || b.DiskStopped, a.SkippedBytes + b.SkippedBytes, - a.ProtectedGames + b.ProtectedGames, a.ProtectedBytes + b.ProtectedBytes, a.ProtectedSeconds + b.ProtectedSeconds); + a.ProtectedGames + b.ProtectedGames, a.ProtectedBytes + b.ProtectedBytes, a.ProtectedSeconds + b.ProtectedSeconds, a.ProtectedTimes + b.ProtectedTimes); - /// 예열 완료 로그 줄 끝에 붙이는 말 (만진 것도 멈춘 것도 건너뛴 것도 지킨 것도 없으면 빈 문자열, MiB 로 0 인 스파스·보호 꼬리는 붙이지 않음). - public string Note() => RequeuedNote() + (ProtectedGames > 0 && ProtectedBytes >> 20 > 0 ? $", protected {ProtectedGames} games ({ProtectedBytes >> 20} MiB in {ProtectedSeconds:F1} s)" : string.Empty); + /// 예열 완료 로그 줄 끝에 붙이는 말 (만진 것도 멈춘 것도 건너뛴 것도 지킨 것도 없으면 빈 문자열, MiB 로 0 인 스파스·보호 꼬리는 붙이지 않음, 두 번 이상 보호했으면 횟수를 덧붙임). + public string Note() => RequeuedNote() + (ProtectedGames > 0 && ProtectedBytes >> 20 > 0 ? $", protected {ProtectedGames} games ({ProtectedBytes >> 20} MiB in {ProtectedSeconds:F1} s){(ProtectedTimes > 1 ? $", {ProtectedTimes} times" : string.Empty)}" : string.Empty); private string RequeuedNote() => Bytes >> 20 > 0 || DiskStopped || SkippedBytes >> 20 > 0 ? $", requeued {Bytes >> 20} MiB in {Seconds:F1} s{(SkippedBytes >> 20 > 0 ? $" ({SkippedBytes >> 20} MiB in sparse files not touched)" : string.Empty)}{(DiskStopped ? ", requeue stopped by disk reads" : string.Empty)}" : string.Empty; } diff --git a/csharp/tests/AsterCache.Tests/Service/SolarTests.cs b/csharp/tests/AsterCache.Tests/Service/SolarTests.cs index 9739599..62ca6e7 100644 --- a/csharp/tests/AsterCache.Tests/Service/SolarTests.cs +++ b/csharp/tests/AsterCache.Tests/Service/SolarTests.cs @@ -288,7 +288,7 @@ public void SparseBytesNotTouchedAreAppendedToEveryLadderAndRefreshLineAndTheMis _result = new TouchResult(6 * Mib, 0.6, 3, 0, TouchEnd.NeedsDiskReads, 37 * Mib, 12 * Mib); _ = solar.Refresh(Game("a"), CancellationToken.None); _ = solar.Order(Game("a"), Reads, CancellationToken.None); - Assert.Equal("Info: a: cache refresh found data missing from memory after 6 of 30 MiB (3 slow pieces, 37 MiB read from disk), left to the rewarm check, 12 MiB in sparse files not touched", _log.Lines.ElementAt(5)); + Assert.Equal("Info: a: cache refresh found data missing from memory after 6 of 30 MiB (3 slow pieces, 37 MiB read from disk), left to the rewarm check, 12 MiB in sparse files not touched, since the ladder 0 min ago: 0 MiB repurposed of 7900 MiB ahead", _log.Lines.ElementAt(5)); // 표식이 있던 게임의 빠짐 줄에는 그 뒤의 값이 붙음 (아래 사다리 줄은 표식이 이미 지워져 없음) Assert.Equal("Info: a: ladder found data missing from memory after 6 of 30 MiB (3 slow pieces, 37 MiB read from disk), left to the rewarm check, 12 MiB in sparse files not touched", _log.Lines.ElementAt(6)); } @@ -694,10 +694,12 @@ private sealed class PlannedStreamer : IGameStreamer private readonly PlannedStreamer _streamer = new(); private readonly HashSet _running = []; private bool _diskBusy; + private bool _diskUnknown; // 참이면 드라이브 처리량을 읽을 수 없음 (null) private long _avail = 20000; private ProbeResult? _probe = new ProbeResult(8, 0, 0.2); // warm (null: 표본을 읽을 수 없음) private readonly HashSet _coldGames = []; // 이 게임들의 표본은 식음 (나머지는 _probe) private IReadOnlyList _common = []; + private int _probeReads; // 표본 읽기 호출 수 private bool _resumeProbe; // 참이면 끊긴 보충이 이어 읽을 위치를 확인하는 표본 훅이 있음 (warm) private bool _commonProgress; // 참이면 공통 블록 읽기가 op 하나마다 진행을 알림 (실제 서비스의 묶음 진행처럼), 아니면 진행 없는 읽기 @@ -711,8 +713,12 @@ private Prewarmer MakePrewarmer(params GameSettings[] games) ZoneBytes = g => Math.Min(_streamer.ZoneMib, g.StreamCapBytes(_streamer.ZoneMib * Mib) / Mib) * Mib, TraceExists = env.TraceExists, PhysicalRamBytes = () => 64L << 30, - ProbeReadMs = g => _coldGames.Contains(g.Name) ? new ProbeResult(8, 8, 5.0) : _probe, - DiskBytesPerSecond = _ => _diskBusy ? 1e9 : 0, + ProbeReadMs = g => + { + _probeReads++; + return _coldGames.Contains(g.Name) ? new ProbeResult(8, 8, 5.0) : _probe; + }, + DiskBytesPerSecond = _ => _diskUnknown ? null : _diskBusy ? 1e9 : 0, CommonFirstOps = (_, _, _) => (_common, _common.Sum(o => o.Length)), ColdRead = (ops, _) => (new StreamStats(ops.Sum(o => o.Length), 1, 0, 0.01), true), ProbeDone = _resumeProbe ? (_, _) => _probe : null, @@ -1078,7 +1084,7 @@ public void TheTickRefreshesOnlyWhenTheReuseReachesTheThreshold() } [Fact] - public void TheTickDoesNotTouchWhileTheGameRunsOrTheDriveIsBusyOrTheTokenIsCanceled() + public void TheTickDoesNotTouchWhileTheGameRunsOrTheTokenIsCanceled() { GrowReuse(1000); Prewarmer p = MakePrewarmer(Game("a")); @@ -1090,9 +1096,6 @@ public void TheTickDoesNotTouchWhileTheGameRunsOrTheDriveIsBusyOrTheTokenIsCance _ = _running.Add("a"); p.Tick(Auto()); _ = _running.Remove("a"); - _diskBusy = true; - p.Tick(Auto()); - _diskBusy = false; using var cts = new CancellationTokenSource(); cts.Cancel(); p.Tick(Auto(), cts.Token); @@ -1185,7 +1188,7 @@ public void RefreshWaitsForTheRewarmCheckSamplesAndLeavesColdOnesToIt() Assert.Empty(_touched); // 식은 캐시는 만지지 않음 Assert.False(p.Solar.HasMark("a")); Assert.Empty(_streamer.Calls); // 재예열은 부르지 않음 - Assert.Contains(_log.Lines, l => l.Contains("a: cache refresh skipped, the samples are cold") && l.EndsWith("left to the rewarm check")); + Assert.Contains(_log.Lines, l => l.Contains("a: cache refresh skipped, the samples are cold") && l.EndsWith("left to the rewarm check, since the ladder 5 min ago: 19000 MiB repurposed of 7900 MiB ahead", StringComparison.Ordinal)); // 표식이 있었으므로 그 뒤의 값이 붙음 } [Fact] @@ -1225,7 +1228,7 @@ public void WhenTheRefreshNeedsDiskReadsNoRewarmIsCalledAndTheLadderReturnsOnlyA p.Tick(auto); // 새로 고침이 디스크 읽기를 요구 -> 표식만 지우고 재예열은 부르지 않음 Assert.Single(_touched); Assert.False(p.Solar.HasMark("a")); - Assert.Contains(_log.Lines, l => l.Contains("a: cache refresh found data missing from memory after 10 of 30 MiB (4 slow pieces, 40 MiB read from disk), left to the rewarm check")); + Assert.Contains(_log.Lines, l => l.EndsWith("a: cache refresh found data missing from memory after 10 of 30 MiB (4 slow pieces, 40 MiB read from disk), left to the rewarm check, since the ladder 5 min ago: 19000 MiB repurposed of 7900 MiB ahead", StringComparison.Ordinal)); _result = _result with { SlowPieces = 0, DiskBytes = 0, End = TouchEnd.Completed }; for (int i = 0; i < 3; i++) @@ -2032,6 +2035,576 @@ public void ASupplementThatReadAFullPlanAndFinishedShortAtTheMemoryLimitDoesNotL Assert.Empty(_touched); // 예산이 있는 계획으로 읽은 범위는 창으로 다시 계획한 목록과 같다고 알 수 없어 만지지 않음 (재예열 점검의 첫 사다리가 받침) } + // ---- 새로 고침이 필요한 때에 돌게 ---- + + [Fact] + public void TheTickRefreshesWhileTheDriveIsBusyButTheRewarmCheckStillSkipsAndDoesNotReadAgain() + { + GrowReuse(1000); + Prewarmer p = MakePrewarmer(Game("a")); + _ = p.Run(Game("a"), "service start"); + _touched.Clear(); + _streamer.Calls.Clear(); + AutoSettings auto = Auto(); + p.Tick(auto); // 첫 틱은 다음 점검 시각만 정함 + _diskBusy = true; // 다른 프로세스가 같은 드라이브를 씀 + GrowReuse(20000); + _now += TimeSpan.FromMinutes(5); + + p.Tick(auto); + + Assert.Single(_touched); // 디스크를 읽지 않는 새로 고침은 돌고 + Assert.Contains("a: cache refreshed in memory", _log.Lines.Last()); + _now += TimeSpan.FromMinutes(6); + GrowReuse(40000); + + p.Tick(auto); // 재예열 점검 차례 + + Assert.Contains(_log.Lines, l => l == "Info: a: rewarm check skipped (the game drive is busy or its load is unknown)"); // 디스크에서 다시 읽을 수 있는 경로는 그대로 막힘 + Assert.Empty(_streamer.Calls); + } + + [Fact] + public void ABusyDriveDoesNotLiftTheGatesThatARunningGameAndTheExitGraceSetOnTheRefresh() + { + GrowReuse(1000); + Prewarmer p = MakePrewarmer(Game("a")); + _ = p.Run(Game("a"), "service start"); + _touched.Clear(); + GrowReuse(20000); + _now += TimeSpan.FromMinutes(5); + _diskBusy = true; + + _ = _running.Add("a"); // 그 게임 실행 중 + p.Tick(Auto()); + _ = _running.Remove("a"); + var exits = new GameExitTracker(() => _now); // 종료 직후 + HashSet none = new(StringComparer.OrdinalIgnoreCase); + exits.ObserveStarted(Game("a")); + _ = exits.Poll([Game("a")], none); + p.SetGameExitTracker(exits); + _now += GameExitTracker.Grace; + Assert.Single(exits.Poll([Game("a")], none)); + AutoSettings auto = Auto() with { RewarmAfterExitMin = 3 }; + p.Tick(auto); + + Assert.Empty(_touched); + _now += TimeSpan.FromMinutes(4); + p.Tick(auto); // 유예가 지나면 바쁜 드라이브에서도 돎 + + Assert.Single(_touched); + } + + [Fact] + public void AGameOutsideTheWarmBudgetIsNotRefreshedEvenWhileTheDriveIsBusy() + { + _streamer.ZoneMib = 30000; + _avail = 100000; + _lists = new MemoryLists(0, 1000 * Mib, 40000 * Mib); + Prewarmer p = MakePrewarmer(Game("a"), Game("b"), Game("c")); + p.WarmRank = games => games; // 순위가 있어야 예산 (물리 메모리 64 GiB 의 절반) 을 계산함 + foreach (string name in new[] { "a", "b", "c" }) + { + _now += TimeSpan.FromMinutes(2); + Assert.True(p.Run(Game(name), "service start").Ran); + } + + Assert.DoesNotContain("a", p.WarmBudget()!.Value.In); // 가장 먼저 예열한 a 는 예산 밖 + _touched.Clear(); + _diskBusy = true; + GrowReuse(1000 + 20000); + _now += TimeSpan.FromMinutes(5); + + p.Tick(Auto()); + + Assert.Equal(2, _touched.Count); // b, c 만 + Assert.True(p.Solar.HasMark("a")); + } + + [Fact] + public void ASampleThatIsColdWhileTheDriveIsBusyKeepsTheMarkAndTouchesAndLogsNothingButOnAnIdleDriveItForgetsTheMark() + { + GrowReuse(1000); + Prewarmer p = MakePrewarmer(Game("a")); + _ = p.Run(Game("a"), "service start"); + _touched.Clear(); + GrowReuse(20000); + _now += TimeSpan.FromMinutes(5); + _coldGames.Add("a"); + _diskBusy = true; + int logged = _log.Lines.Count; + + p.Tick(Auto()); + + Assert.Empty(_touched); + Assert.True(p.Solar.HasMark("a")); // 바쁜 드라이브의 느린 표본은 식은 증거가 아님: 표식을 지우지 않음 + Assert.DoesNotContain(_log.Lines.Skip(logged), l => l.Contains("cache refresh")); + _diskBusy = false; + _now += TimeSpan.FromSeconds(30); + + p.Tick(Auto()); // 바쁘지 않을 때의 cold 는 지금처럼 + + Assert.Empty(_touched); + Assert.False(p.Solar.HasMark("a")); + Assert.Contains(_log.Lines.Skip(logged), l => l.Contains("a: cache refresh skipped, the samples are cold")); + } + + // 스트림 도중의 보호 시험용: 묶음 3 개 (각 1 op) 를 읽는 스트림. + private void ThreeSingleOpBatches() => _streamer.Batches.AddRange([[Reads[0]], [Reads[1]], [Reads[2]]]); + + private int[] TouchSizes() => [.. _touched.Select(t => t.Count)]; + + [Fact] + public void AStreamProtectsTheOtherMarkedGamesBetweenBatchesWhenTheyAreDueAndTheDoneLineSumsItUp() + { + Prewarmer p = TwoMarkedGames(); + ThreeSingleOpBatches(); + _streamer.BetweenProgressAndTouch = i => + { + if (i == 1) + { + _now += TimeSpan.FromSeconds(61); // 앞의 보호 뒤 묶음 최소 간격이 지나고 + GrowReuse(1000 + 5000); // a, b 모두 때가 됨 + } + }; + int logged = _log.Lines.Count; + + PrewarmResult result = p.Run(Game("c"), "service start"); + + Assert.True(result.Ran); + Assert.Equal([3, 3, 1, 1, 3, 3, 1, 3], TouchSizes()); // 앞의 보호 (a, b) → 묶음 0, 묶음 1 (읽기 → 점검 → 다시 만지기) → 보호 (a, b) → 묶음 2 → c 의 사다리 + string done = _log.Lines.Skip(logged).Single(l => l.Contains("c: prewarm done (service start)")); + Assert.Contains(", protected 2 games (400 MiB in ", done); // 서로 다른 게임은 2, 양은 두 번 합산 + Assert.EndsWith(" s), 2 times", done); + } + + [Fact] + public void TheStreamChecksWhetherTheOthersAreDueAtMostOnceEveryTenSeconds() + { + Prewarmer p = TwoMarkedGames(); + _streamer.Batches.AddRange([[Reads[0]], [Reads[1]], [Reads[2]], [Reads[0]], [Reads[1]]]); + List queries = []; + _streamer.BetweenProgressAndTouch = i => + { + queries.Add(_listQueries); + _now += TimeSpan.FromSeconds(i == 0 ? 61 : 4); // 첫 묶음에서 앞의 보호 뒤 1 분을 넘기고, 그 뒤는 묶음마다 4 초 + }; + + Assert.True(p.Run(Game("c"), "service start").Ran); + + // 판정은 첫 묶음 (61 초) 과 그 뒤 12 초째 묶음에서만: 사이의 묶음 (4, 8 초) 은 통계를 읽지 않음 + Assert.Equal([1, 0, 0, 1], queries.Zip(queries.Skip(1), (before, after) => after - before)); + } + + [Fact] + public void TheStreamDoesNotProtectBetweenBatchesWithinSixtySecondsOfTheLastGroupRefreshEvenWhenDue() + { + Prewarmer p = TwoMarkedGames(); + ThreeSingleOpBatches(); + _streamer.BetweenProgressAndTouch = i => + { + if (i == 1) + { + _now += TimeSpan.FromSeconds(30); + GrowReuse(1000 + 5000); // 때는 됐지만 앞의 보호 (묶음 새로 고침) 뒤 60 초가 안 지남 + } + }; + + Assert.True(p.Run(Game("c"), "service start").Ran); + + Assert.Equal([3, 3, 1, 1, 1, 3], TouchSizes()); // 앞의 보호만 + } + + [Fact] + public void ATriggerStreamDoesNotProtectBetweenBatches() + { + Prewarmer p = TwoMarkedGames(); + ThreeSingleOpBatches(); + _streamer.BetweenProgressAndTouch = i => + { + if (i == 1) + { + _now += TimeSpan.FromSeconds(61); + GrowReuse(1000 + 5000); + } + }; + + Assert.True(p.Run(Game("c"), "launcher", trigger: true).Ran); + + Assert.Equal([1, 1, 1, 3], TouchSizes()); // 앞의 보호도 도중의 보호도 없음: 묶음 다시 만지기와 c 의 사다리만 + } + + [Fact] + public void AGameStartOrATriggerWaitStopsTheProtectionBetweenBatches() + { + Prewarmer started = TwoMarkedGames(); + ThreeSingleOpBatches(); + _streamer.BetweenProgressAndTouch = i => + { + if (i == 1) + { + _now += TimeSpan.FromSeconds(61); + GrowReuse(1000 + 5000); + started.NoteGameStart("c"); + } + }; + + Assert.False(started.Run(Game("c"), "service start").Ran); + + Assert.Equal([3, 3, 1], TouchSizes()); // 게임 시작: 점검이 스트림을 멈추고 도중의 보호도 없음 + + _touched.Clear(); + _streamer.Batches.Clear(); + Prewarmer waiting = TwoMarkedGames(); + ThreeSingleOpBatches(); + IDisposable? wait = null; + _streamer.BetweenProgressAndTouch = i => + { + if (i == 1) + { + _now += TimeSpan.FromSeconds(61); + GrowReuse(1000 + 5000); + wait ??= waiting.BeginTriggerWait([Game("b")]); + } + }; + + _ = waiting.Run(Game("c"), "service start"); + wait?.Dispose(); + + Assert.DoesNotContain(TouchSizes().Skip(4), size => size == 3); // 선점 표시가 서면 도중의 보호는 없음 (사다리도 없음) + } + + [Fact] + public void AProtectionBetweenBatchesThatThrowsLeavesTheStreamResultTheSame() + { + Prewarmer p = TwoMarkedGames(); + ThreeSingleOpBatches(); + bool armed = false; + _onTouch = () => + { + if (armed && _touched[^1].Count == 3) + { + throw new IOException("boom"); + } + }; + _streamer.BetweenProgressAndTouch = i => + { + if (i == 1) + { + _now += TimeSpan.FromSeconds(61); + GrowReuse(1000 + 5000); + armed = true; + } + }; + + PrewarmResult result = p.Run(Game("c"), "service start"); + + Assert.True(result.Ran); + Assert.Equal(30 * Mib, result.Stats!.Bytes); + Assert.Contains(_log.Lines, l => l.Contains("a: cache refresh failed: IOException")); + } + + [Fact] + public void TheDoneLineShowsHowManyTimesTheOthersWereProtectedOnlyWhenItWasMoreThanOnce() + { + Assert.Equal(", protected 2 games (3 MiB in 0.1 s)", new RequeueTally(0, 0, false, 0, 2, 3 * Mib, 0.1, 1).Note()); + Assert.Equal(", protected 2 games (3 MiB in 0.1 s), 3 times", new RequeueTally(0, 0, false, 0, 2, 3 * Mib, 0.1, 3).Note()); + Assert.Equal(", protected 2 games (4 MiB in 0.3 s), 2 times", (new RequeueTally(0, 0, false, 0, 2, 3 * Mib, 0.1, 1) + new RequeueTally(0, 0, false, 0, 0, 1 * Mib, 0.2, 1)).Note()); + } + + [Fact] + public void AGameTheServiceStartSkippedAsAlreadyWarmGetsItsLadderAtOnceWithoutReadingTheSamplesAgain() + { + GrowReuse(1000); + Prewarmer p = MakePrewarmer(Game("a")); + + PrewarmResult result = Assert.Single(p.RunServiceStart([Game("a")])); + + Assert.False(result.Ran); + Assert.Equal("already warm", result.SkipReason); + Assert.True(p.Solar.HasMark("a")); + Assert.Equal(Reversed(Reads), _touched.Single()); + Assert.Equal(1, _probeReads); // 건너뛸 때의 표본 판정 한 번뿐 + Assert.Empty(_streamer.Calls); + } + + [Fact] + public void SeveralGamesSkippedAtServiceStartAreLadderedLowestRankFirstAndTheMarksAreWrittenAsAGroup() + { + Prewarmer p = MakePrewarmer(Game("a"), Game("b")); + _lists = new MemoryLists(0, 1000 * Mib, 8000 * Mib); + + Assert.Equal(2, p.RunServiceStart([Game("a"), Game("b")]).Count); + + Assert.Equal(2, _touched.Count); + string[] ordered = [.. _log.Lines.Where(l => l.Contains("ladder ordered")).Select(l => l.Split(':')[1].Trim())]; + Assert.Equal(["b", "a"], ordered); // 순위가 낮은 b 먼저 + _ = p.Solar.Refresh(Game("b"), CancellationToken.None); + Assert.Contains("(0 MiB repurposed of 7800 MiB ahead)", _log.Lines.Last()); // 8000 - (b 100 + a 100) + _ = p.Solar.Refresh(Game("a"), CancellationToken.None); + Assert.Contains("(0 MiB repurposed of 7900 MiB ahead)", _log.Lines.Last()); // 8000 - a 100 + } + + [Fact] + public void AGameWhoseSupplementWaitsForMemoryIsLadderedOverTheRangeItReadAtServiceStartToo() + { + ReadOp[] all = HotSet(50); + Prewarmer p = ShortFinishedAndWaiting(all, waitForMemory: true); + Assert.False(p.SupplementWhenIdle(Game("a"), CancellationToken.None)); + Assert.Empty(_touched); + + _ = p.RunServiceStart([Game("a")]); + + Assert.Equal([.. Reversed(all.Take(10))], _touched.Single()); // 읽은 앞 1000 MiB 만 (재예열 점검의 LadderWaitingSupplement 와 같은 대상) + Assert.True(p.Solar.HasMark("a")); + } + + [Fact] + public void AServiceStartLadderFollowsTheGatesTheGameRunningAndTheSettingOff() + { + Prewarmer running = MakePrewarmer(Game("a")); + _ = _running.Add("a"); + _ = running.RunServiceStart([Game("a")]); + _ = _running.Remove("a"); + _enabled = false; + Prewarmer off = MakePrewarmer(Game("b")); + _ = off.RunServiceStart([Game("b")]); + + Assert.Empty(_touched); + Assert.False(running.Solar.HasMark("a")); + } + + [Fact] + public void TheDiagnosticTailShowsWhatHappenedSinceTheLadderOnTheRewarmCheckLineWhetherTheMarkWasClearedByTheRefreshOrStillStands() + { + GrowReuse(1000); + Prewarmer cleared = MakePrewarmer(Game("a")); + _ = cleared.Run(Game("a"), "service start"); + AutoSettings auto = Auto(); + cleared.Tick(auto); + GrowReuse(20000); + _now += TimeSpan.FromMinutes(5); + _coldGames.Add("a"); + cleared.Tick(auto); // 새로 고침의 표본이 식음: 표식을 지우되 그 값을 기억함 + Assert.False(cleared.Solar.HasMark("a")); + _now += TimeSpan.FromMinutes(5.5); + + cleared.Tick(auto); // 재예열 점검도 식음 + + Assert.Contains(_log.Lines, l => l.StartsWith("Info: a: rewarm check: cold (", StringComparison.Ordinal) && l.EndsWith("prewarming again (cooldown ignored), since the ladder 10 min ago: 19000 MiB repurposed of 7900 MiB ahead", StringComparison.Ordinal)); + + _coldGames.Clear(); + GrowReuse(1000); + Prewarmer standing = MakePrewarmer(Game("b")); + _ = standing.Run(Game("b"), "service start"); + standing.Tick(auto); + _now += TimeSpan.FromMinutes(10.5); + _coldGames.Add("b"); + + standing.Tick(auto); // 표식이 서 있는 채 점검이 식음을 봄 + + Assert.Contains(_log.Lines, l => l.StartsWith("Info: b: rewarm check: cold (", StringComparison.Ordinal) && l.EndsWith("prewarming again (cooldown ignored), since the ladder 10 min ago: 0 MiB repurposed of 7900 MiB ahead", StringComparison.Ordinal)); + } + + [Fact] + public void TheDiagnosticTailIsLeftOutWhenThereWasNoMarkAndShowsOnlyTheTimeWhenTheListsCannotBeRead() + { + _enabled = false; // 표식 없이 예열 + Prewarmer p = MakePrewarmer(Game("a")); + _ = p.Run(Game("a"), "service start"); + _enabled = true; + AutoSettings auto = Auto(); + p.Tick(auto); + _now += TimeSpan.FromMinutes(10.5); + _coldGames.Add("a"); + + p.Tick(auto); + + Assert.Contains(_log.Lines, l => l.StartsWith("Info: a: rewarm check: cold (", StringComparison.Ordinal) && l.EndsWith("prewarming again (cooldown ignored)", StringComparison.Ordinal)); + + _lists = null; + Solar solar = Make(); + _ = solar.Order(Game("b"), Reads, CancellationToken.None); + _now += TimeSpan.FromMinutes(3); + _result = new TouchResult(10 * Mib, 0.6, 5, 0, TouchEnd.NeedsDiskReads, 32 * Mib); + _ = solar.Order(Game("b"), Reads, CancellationToken.None); + Assert.EndsWith("left to the rewarm check, since the ladder 3 min ago", _log.Lines.Last()); // 대기 목록을 읽을 수 없으면 시간만 + } + + [Fact] + public void ALadderThatFindsDataMissingForAGameThatHadAMarkShowsWhatHappenedSinceIt() + { + Solar solar = Make(); + _ = solar.Order(Game("a"), Reads, CancellationToken.None); + _now += TimeSpan.FromMinutes(3); + _result = new TouchResult(10 * Mib, 0.6, 5, 0, TouchEnd.NeedsDiskReads, 32 * Mib); + + _ = solar.Order(Game("a"), Reads, CancellationToken.None); + + Assert.EndsWith("left to the rewarm check, since the ladder 3 min ago: 0 MiB repurposed of 7900 MiB ahead", _log.Lines.Last()); + } + + [Theory] + [InlineData(false)] // 처리량을 읽을 수 있고 바쁨 + [InlineData(true)] // 처리량을 모름 (늘 바쁨으로 봄) + public void ABusyColdSampleIsOverlookedOnlyOnceThenTheMarkIsDroppedAndNothingIsReadOrTouchedAgain(bool throughputUnknown) + { + GrowReuse(1000); + Prewarmer p = MakePrewarmer(Game("a"), Game("b")); + _ = p.Run(Game("a"), "service start"); + _ = p.Run(Game("b"), "service start"); + _touched.Clear(); + GrowReuse(20000); + _now += TimeSpan.FromMinutes(5); + _coldGames.Add("a"); // b 는 warm 인 채 표식이 있음 + (_diskBusy, _diskUnknown) = (!throughputUnknown, throughputUnknown); + int logged = _log.Lines.Count; + + p.Tick(Auto()); + + Assert.True(p.Solar.HasMark("a")); // 첫 회: 봐 줌 (표식 유지, 로그 없음) + Assert.DoesNotContain(_log.Lines.Skip(logged), l => l.Contains("a: cache refresh")); + _now += TimeSpan.FromSeconds(61); + + p.Tick(Auto()); + + Assert.False(p.Solar.HasMark("a")); // 둘째 회: 바쁘지 않을 때와 같게 표식을 지우고 재예열 점검에 맡김 + Assert.Contains(_log.Lines.Skip(logged), l => l.Contains("a: cache refresh skipped, the samples are cold") && l.Contains(", since the ladder 6 min ago")); + int probes = _probeReads; + int touches = _touched.Count; + for (int i = 0; i < 3; i++) + { + _now += TimeSpan.FromSeconds(61); + GrowReuse(30000 + i * 10000L); + p.Tick(Auto()); + } + + Assert.Equal(probes + 3, _probeReads); // 그 뒤에는 a 의 표본을 읽지 않음 (b 의 표본만: 틱마다 한 번) + Assert.All(_touched.Skip(touches), t => Assert.Equal(Reversed(Reads), t)); // 만지기는 b 만 + } + + [Fact] + public void AWarmSampleAfterAnOverlookedBusyColdSampleGivesTheMarkItsOneOverlookAgain() + { + GrowReuse(1000); + Prewarmer p = MakePrewarmer(Game("a")); + _ = p.Run(Game("a"), "service start"); + _touched.Clear(); + GrowReuse(20000); + _now += TimeSpan.FromMinutes(5); + _coldGames.Add("a"); + _diskBusy = true; + p.Tick(Auto()); // 봐 줌 + _coldGames.Clear(); + _now += TimeSpan.FromSeconds(61); + p.Tick(Auto()); // 바쁜 드라이브에서 warm: 새로 고침이 돌고 새 표식이 섬 + Assert.Single(_touched); + GrowReuse(40000); + _coldGames.Add("a"); + _now += TimeSpan.FromSeconds(61); + int logged = _log.Lines.Count; + + p.Tick(Auto()); // 다시 바쁜 cold: 횟수가 0 으로 돌아갔으므로 한 번 더 봐 줌 + + Assert.True(p.Solar.HasMark("a")); + Assert.DoesNotContain(_log.Lines.Skip(logged), l => l.Contains("a: cache refresh")); + } + + [Fact] + public void WhenTheDriveLoadIsUnknownTheRefreshStillRunsAndTheRewarmCheckStaysBlocked() + { + GrowReuse(1000); + Prewarmer p = MakePrewarmer(Game("a")); + _ = p.Run(Game("a"), "service start"); + _touched.Clear(); + _streamer.Calls.Clear(); + AutoSettings auto = Auto(); + p.Tick(auto); + _diskUnknown = true; + GrowReuse(20000); + _now += TimeSpan.FromMinutes(5); + + p.Tick(auto); + + Assert.Single(_touched); + _now += TimeSpan.FromMinutes(6); + p.Tick(auto); + Assert.Contains(_log.Lines, l => l == "Info: a: rewarm check skipped (the game drive is busy or its load is unknown)"); + Assert.Empty(_streamer.Calls); + } + + [Fact] + public void ATriggerRunOfAGameWithoutAMarkMakesNoLadderBeforeItsStream() + { + _enabled = false; + Prewarmer p = MakePrewarmer(Game("a")); + _ = p.Run(Game("a"), "service start"); + _enabled = true; + _touched.Clear(); + int touchesAtStream = -1; + _streamer.DuringStream = () => touchesAtStream = _touched.Count; + _now += TimeSpan.FromMinutes(5); + + Assert.True(p.Run(Game("a"), "launcher", ignoreCooldown: true, trigger: true).Ran); + + Assert.Equal(0, touchesAtStream); // 서비스 시작 직후 사다리는 서비스 시작 경로 전용: 트리거 경로는 스트림 전에 만지지 않음 + } + + private void StreamWithProtectionFromAMarkedStreamGame(bool streamGameHasAMark) + { + Prewarmer p = TwoMarkedGames(); + if (streamGameHasAMark) + { + Assert.True(p.Run(Game("c"), "service start").Ran); // c 의 이전 표식 (100 MiB) + _now += TimeSpan.FromMinutes(2); + } + + _touched.Clear(); + ThreeSingleOpBatches(); + _streamer.BetweenProgressAndTouch = i => + { + if (i == 1) + { + _now += TimeSpan.FromSeconds(61); + _lists = new MemoryLists(0, 20000 * Mib, 9000 * Mib); // 대기 바이트 C = 9000 MiB, a, b 모두 때가 됨 + } + }; + _ = p.Run(Game("c"), "rewarm", ignoreCooldown: true); + _onTouch = null; + _ = p.Solar.Refresh(Game("a"), CancellationToken.None); + _ = p.Solar.Refresh(Game("b"), CancellationToken.None); + } + + [Fact] + public void AProtectionDuringAStreamTakesWhatTheStreamReadSoFarOutOfTheAheadOfTheNewMarks() + { + StreamWithProtectionFromAMarkedStreamGame(streamGameHasAMark: false); + + string[] after = [.. _log.Lines.TakeLast(2)]; + // 보호 시점의 C = 9000, 스트림이 만진 양 = 묶음 2 개 x 100 MiB: 9000 - 200 = 8800 에서 b (마지막) 는 -100, a 는 -200. 스트림 게임에 이전 표식이 없어 끝의 사다리는 줄이지 않음 + Assert.Contains("of 8600 MiB ahead", after[0]); + Assert.Contains("of 8700 MiB ahead", after[1]); + } + + [Fact] + public void WhenTheStreamGameHadAMarkTheEndLadderAndTheProtectionDoNotLowerWhatIsAheadTwice() + { + StreamWithProtectionFromAMarkedStreamGame(streamGameHasAMark: true); + + string[] after = [.. _log.Lines.TakeLast(2)]; + // 이전 표식이 만진 100 MiB 는 끝의 사다리가 줄이므로 보호는 200 - 100 만 뺌: 보호 직후 8900 에서 b -100 = 8800, a -200 = 8700, 끝의 사다리가 min(100, 100) 씩 더 줄여 8700 / 8600 (이중 적용이면 8500 / 8400) + Assert.Contains("of 8600 MiB ahead", after[0]); + Assert.Contains("of 8700 MiB ahead", after[1]); + } + + [Fact] + public void TheStreamShareToTakeOutIsWhatTheStreamReadMinusWhatTheEndLadderLowersAnyway() + { + Assert.Equal(200 * Mib, SolarPolicy.StreamAheadLess(200 * Mib, 0)); + Assert.Equal(100 * Mib, SolarPolicy.StreamAheadLess(200 * Mib, 100 * Mib)); + Assert.Equal(0, SolarPolicy.StreamAheadLess(100 * Mib, 300 * Mib)); + } + [Fact] public void TailsThatWouldShowZeroMibAreLeftOut() { diff --git a/docs/SOLAR.ko.md b/docs/SOLAR.ko.md index 4d6d30d..b86d81f 100644 --- a/docs/SOLAR.ko.md +++ b/docs/SOLAR.ko.md @@ -20,7 +20,7 @@ SOLAR 는 두 문제를 모두 일부러 디스크를 읽지 않고 다룹니다 1. **사다리를 만듭니다.** 게임의 예열이 목록을 끝까지 읽으면, SOLAR 가 그 게임이 캐시에 가진 것을 읽은 순서의 역순으로 만집니다. 목록의 뒤쪽이 먼저, 로딩 앞부분과 공통 블록이 마지막입니다. 만진 페이지는 대기 목록의 맨 뒤로 가므로 밀려나는 순서가 중요도의 역순이 됩니다. 중요도 순서는 예열이 이미 쓰는 순서이고, 게임 자신의 기록된 읽기에서 나옵니다. 예열이 아직 읽는 도중에도 방금 읽은 묶음을 읽은 순서대로 곧바로 다시 만집니다. 예열 읽기는 이미 캐시에 있는 페이지를 건너뛰고 목록에서 옮기지 않으므로, 이렇게 하지 않으면 그 게임의 나머지 페이지가 이전 예열 때의 오래된 순서로 남습니다. 묶음을 만지면 건너뛴 페이지도 뒤로 갑니다. 끊긴 읽기를 이어 읽는 보충은 이전 회가 읽은 앞부분을 건너뛰므로, 나머지를 읽기 전에 그 건너뛴 앞부분을 목록 순서대로 한 번 만집니다. 캐시에 있는 데이터 1 GiB 당 약 0.5초가 들고, 예열 완료 줄에 `, requeued N MiB in S s` 로 보입니다. 2. **표식을 남깁니다.** 사다리를 만든 뒤 시각, Windows 가 지금까지 다시 쓴 캐시 페이지의 누계, 그리고 앞에 있는 양(대기 목록의 캐시 양에서 이 게임이 만진 양을 뺀 값)을 적어 둡니다. 앞에 있는 양은 늘 같은 값이 아닙니다. 다른 게임이 자기 캐시를 다시 만지면 이 게임보다 앞에 있던 그 게임의 페이지가 뒤로 갑니다. 그래서 그 게임에 이전 표식이 있고 그 표식이 이 게임의 표식보다 먼저 만들어졌으면, 앞에 있는 양을 그 이전 표식이 만진 양과 이번에 만진 양 중 작은 쪽만큼 줄입니다(0 미만은 0). 이번에 만진 것 중 나머지는 디스크에서 새로 읽은 것이라 다시 쓴 양의 누계에 이미 잡히고, 이전 표식이 없던 게임은 위치를 알 수 없으므로 둘 다 줄이지 않습니다. 표식은 메모리에만 있습니다. 3. **통계 하나를 봅니다.** 표식이 있는 게임이 있으면 약 30초마다 Windows 의 대기 목록 통계를 한 번 읽습니다. 시스템 전체 값이며 프로세스를 열지 않습니다. 이 게임의 페이지 앞에 있는 페이지가 먼저 다시 쓰이므로, 표식 이후 다시 쓴 캐시 페이지가 `max(512 MiB, 0.4 x 앞에 있는 양)` 만큼 늘었거나, 남은 여유(앞에 있는 양에서 그 늘어난 양을 뺀 값)가 2 GiB 미만이면 이 게임의 페이지가 목록 앞에 가까워진 것입니다. 둘째 기준은 표식을 만들 때 앞에 있는 양이 이미 4 GiB 미만이던 게임에는 적용하지 않습니다. -4. **묶음 전체를 디스크 없이 새로 고칩니다.** 표식이 있는 게임 중 지금 만질 수 있는(관문이 열린) 게임 하나라도 시점이 되면 표식이 있는 게임 전체를 순위가 낮은 게임부터, 가장 최근에 예열한 게임을 마지막으로 새로 고칩니다. 아직 시점이 아닌 게임도 함께 고치는 것은 다른 게임을 위해 만진 페이지가 그 게임의 뒤로 갔기 때문입니다. 게임마다 새로 고치기 전에 평소의 표본 점검(8곳에서 1 MiB 씩)을 합니다. 표본이 따뜻하면 같은 사다리를 다시 만져 페이지를 목록 맨 뒤로 보냅니다. 식었거나 읽을 수 없으면 표식을 지우고 [재예열 점검](AUTO_MODE.ko.md#5-유지)에 맡깁니다. 관문에 막힌 게임은 건너뛰고 나머지는 계속합니다. 때가 됐어도 막힌 게임은 묶음을 시작하게 하지 않으므로, 그 게임 때문에 다른 게임이 되풀이해 만져지지 않습니다. 끝나면 만진 게임들의 표식을 같은 시점 기준으로 다시 씁니다. 한 게임의 앞에 있는 양은 그때의 대기 목록 캐시 양에서 그 게임과 그 뒤에 만진 게임들이 만진 양을 뺀 값이고, 다시 쓴 양의 누계도 그 시점의 값입니다. 건너뛴 게임은 표식을 그대로 두되 앞에 있는 양을 2번의 규칙으로 줄입니다. 묶음 새로 고침은 직전 묶음 새로 고침 뒤 60초 안에는 다시 하지 않으며, 스트림 앞의 보호(세부 참고)도 한 번으로 셉니다. +4. **묶음 전체를 디스크 없이 새로 고칩니다.** 표식이 있는 게임 중 지금 만질 수 있는(관문이 열린) 게임 하나라도 시점이 되면 표식이 있는 게임 전체를 순위가 낮은 게임부터, 가장 최근에 예열한 게임을 마지막으로 새로 고칩니다. 아직 시점이 아닌 게임도 함께 고치는 것은 다른 게임을 위해 만진 페이지가 그 게임의 뒤로 갔기 때문입니다. 게임마다 새로 고치기 전에 평소의 표본 점검(8곳에서 1 MiB 씩)을 합니다. 표본이 따뜻하면 같은 사다리를 다시 만져 페이지를 목록 맨 뒤로 보냅니다. 식었거나 읽을 수 없으면 표식을 지우고 [재예열 점검](AUTO_MODE.ko.md#5-유지)에 맡깁니다. 다만 게임 드라이브가 바쁜 상태(4 MiB/s 이상이거나 처리량을 모름)에서 표본이 식어 나오면, 다른 프로그램의 읽기 때문에 느렸을 수 있으므로 한 표식에서 처음 한 번만 표식을 지우지 않고 그 회를 건너뜁니다(로그 없음). 같은 표식에서 둘째로 식어 나오면 바쁘지 않을 때와 같게 표식을 지우고 재예열 점검에 맡깁니다. 봐 준 기억은 표식이 지워지거나 새로 서거나 warm 표본이 나오면 사라집니다. 관문에 막힌 게임은 건너뛰고 나머지는 계속합니다. 때가 됐어도 막힌 게임은 묶음을 시작하게 하지 않으므로, 그 게임 때문에 다른 게임이 되풀이해 만져지지 않습니다. 끝나면 만진 게임들의 표식을 같은 시점 기준으로 다시 씁니다. 한 게임의 앞에 있는 양은 그때의 대기 목록 캐시 양에서 그 게임과 그 뒤에 만진 게임들이 만진 양을 뺀 값이고, 다시 쓴 양의 누계도 그 시점의 값입니다. 건너뛴 게임은 표식을 그대로 두되 앞에 있는 양을 2번의 규칙으로 줄입니다. 묶음 새로 고침은 직전 묶음 새로 고침 뒤 60초 안에는 다시 하지 않으며, 스트림 앞의 보호(세부 참고)도 한 번으로 셉니다. 5. **디스크가 읽히면 멈춥니다.** 캐시에 없는 페이지를 만지면 디스크 읽기가 됩니다. 뷰 하나를 만질 때마다 그동안 시스템 전체가 디스크에서 읽은 쪽 수를 비교해, 32 MiB 를 넘게 세어지면 멈춥니다. 그러면 표식을 지우고 그 게임에 "다시 채워야 함" 표시를 남겨, 다음 재예열 점검이 표본이 따뜻해 보여도 그 게임을 다시 예열합니다. 캐시에 있는 부분은 디스크 시간이 거의 들지 않으므로 실제로는 빠진 부분만 디스크에서 읽힙니다. 이 재예열은 게임마다 마지막 예열 시도로부터 `auto.rewarm_minutes` 에 한 번을 넘지 않습니다. Windows 의 통계를 읽을 수 없으면(예: 권한이 없을 때) 3번은 마지막 사다리 뒤 10분마다 새로 고치는 것으로 바뀌고 나머지는 그대로입니다. @@ -35,7 +35,9 @@ Windows 의 통계를 읽을 수 없으면(예: 권한이 없을 때) 3번은 - **무엇을 만지나.** 예열이 먼저 읽는 공통 블록 다음에 읽은 목록 전체를 읽은 순서대로 늘어놓은 것의 역순입니다. 앞부분만 읽은 예열이면 읽은 부분입니다. 같은 파일에서 앞 범위에 이어지는 범위는 하나로 합칩니다. - **읽는 도중 만지기.** 예열 스트림이 묶음 (읽기 약 64 MiB) 을 읽을 때마다, 그리고 공통 블록을 읽은 뒤에, 방금 읽은 범위를 같은 스레드에서 스트림과 같은 디스크 읽기 대여·중지 신호 아래 다시 만집니다. 스트림이 묶음마다 하는 기존 점검 (메모리, 외부 부하, 게임 실행) 뒤에 하므로 그 점검의 순서와 의미는 그대로입니다. 게임이 시작하거나 대여가 끝나면 그 뒤 묶음은 만지지 않습니다. 만지기가 빠진 부분을 만나거나 예외가 나거나 취소돼도 스트림은 계속하고 결과 (읽은 양, 성공 여부) 는 같으며, 예외는 `requeue of the read batches skipped` 로 한 번만 남깁니다. 한 묶음의 만지기가 디스크 읽기를 만나 멈추면 그 스트림의 남은 묶음은 만지지 않고 (묶음마다 디스크 읽기를 되풀이하지 않게), 완료 줄에 `requeue stopped by disk reads` 를 남깁니다. 건너뛴 앞부분 만지기가 같은 이유로 멈추면 그 만지기만 끝납니다. 만지는 데 쓴 시간은 디스크 읽기가 아니므로 `yielded` 시간과 스트림의 노출 시간에서 뺍니다. 조건은 사다리와 같고 (아래), `memory.standby_ladder` 가 꺼져 있으면 스트림은 전과 완전히 같습니다. - **스트림 앞의 보호.** 예열 스트림(서비스 시작, 재예열, 보충)이 디스크를 읽기 시작하기 직전에, 표식이 있는 다른 게임들을 순위가 낮은 것부터 한 번 만집니다. 묶음 새로 고침과 같은 함수이고 스트림의 게임만 뺍니다. 그러면 스트림이 새로 읽는 페이지가 다른 게임보다 먼저 보통 캐시와 그 게임 자신의 오래된 페이지를 밀어냅니다. 스트림과 같은 디스크 읽기 대여·중지 신호 아래에서, 게임 시작이나 런처 선점 표시가 서 있지 않을 때만 돕니다. 표본 점검과 관문은 새로 고침과 같습니다. 런처 예열은 이 보호를 하지 않습니다. 게임 시작이 임박해 예열을 늦추면 안 되기 때문이며, 대신 그 스트림이 끝난 뒤 사다리가 다른 게임의 앞에 있는 양만 줄입니다. 걸린 시간은 `yielded` 시간과 스트림의 노출 시간에서 빼고, 완료 줄에 `, protected K games (N MiB in S s)` 를 덧붙입니다(만진 것이 없으면 붙이지 않음). 실패하거나 취소돼도 스트림은 계속하고 결과는 같습니다. -- **첫 사다리가 만들어지는 곳.** 게임의 예열 스트림(서비스 시작, 런처, 재예열, 보충)이 끝까지 읽은 직후입니다. 게임의 사다리가 만들어지면 다른 게임 표식의 앞에 있는 양을 2번의 규칙으로 줄입니다(이전 표식이 없던 게임의 첫 사다리는 줄이지 않음). 메모리 한도에서 마무리한 스트림도 끝난 직후 같은 대여 안에서 읽은 범위의 사다리를 만듭니다. 대상은 스트림이 읽은 것과 같은 창의 목록을 읽은 양에서 자른 앞부분이라 읽지 않은 구간은 만지지 않습니다. 틈 메우기 예산이 없는 창 읽기나 이어 읽기일 때만 그렇습니다(스트림이 읽은 목록이 같은 창으로 계획한 목록이기 때문). 그 예산이 있는 계획으로 전체 목록을 읽다 한도에서 마무리한 보충은 만들지 않고 재예열 점검에 맡기며, 점검의 첫 사다리가 메모리를 기다리는 게임을 받칩니다. 서비스를 다시 시작하면 표식이 없어지므로, 게임마다 표본이 따뜻한 첫 재예열 점검에서 새 사다리를 만듭니다. +- **스트림 도중의 보호.** 트리거가 아닌 예열 스트림이 도는 동안, 묶음 하나를 읽고 점검하고 다시 만진 직후마다 표식이 있는 다른 게임이 새로 고칠 때가 됐는지 봅니다. 유지 틱과 같은 판정(위 3번의 기준, 만질 수 있는 게임 하나라도 때가 됐고 직전 묶음 새로 고침 뒤 60초가 지났을 때)과 같은 함수입니다. 판정에는 대기 목록 통계를 한 번 읽으므로 직전 판정 뒤 10초가 지났을 때만 봅니다. 때가 됐으면 스트림 앞의 보호와 같은 함수로 다른 게임들을 다시 만집니다. 스트림과 같은 스레드·같은 디스크 읽기 대여·같은 중지 신호 아래에서, 게임 시작이나 런처 선점 표시가 서 있지 않을 때만 돌고, 스트림 자신의 게임은 대상이 아닙니다(끝의 사다리가 만듭니다). 런처 예열은 하지 않습니다. 걸린 시간은 `yielded` 시간과 스트림의 노출 시간에서 빼고, 완료 줄의 `protected` 꼬리에 합산합니다(서로 다른 게임 수, 양과 시간의 누계, 두 번 이상이면 횟수). 새 표식의 앞에 있는 양을 계산할 때는 스트림이 그때까지 만진 양에서 끝의 사다리가 어차피 줄일 몫(스트림 게임의 이전 표식이 만진 양)을 뺀 값을 대기 목록 캐시 양에서 뺍니다. 스트림이 읽은 페이지는 끝의 사다리에서 다시 만져져 그 게임들의 뒤로 가기 때문이며, 이중으로 줄지 않게 하려는 것입니다. 실패하거나 취소돼도 스트림은 계속하고 결과는 같습니다. +- **서비스 시작 직후의 사다리.** 서비스 시작 예열이 `prewarm skipped (already warm) [service start]` 로 건너뛴 게임은 그 자리에서, 건너뛸 때의 표본 판정을 그대로 쓰고(표본을 다시 읽지 않음) 재예열 점검의 첫 사다리와 같은 조건·같은 함수로 첫 사다리를 만듭니다. 여러 게임이면 순위가 낮은 게임부터 만든 뒤 묶음 규칙으로 표식을 정리합니다. 부분 예열 상태(보충 대기)인 게임은 보충이 메모리를 기다리는 게임의 첫 사다리와 같은 조건을 따릅니다. 런처 경로는 하지 않습니다. 서비스를 다시 시작하면 표식이 없어 첫 재예열 점검(`auto.rewarm_minutes` 뒤)까지 새로 고침이 없던 공백을 메우기 위한 것입니다. +- **첫 사다리가 만들어지는 곳.** 게임의 예열 스트림(서비스 시작, 런처, 재예열, 보충)이 끝까지 읽은 직후입니다. 게임의 사다리가 만들어지면 다른 게임 표식의 앞에 있는 양을 2번의 규칙으로 줄입니다(이전 표식이 없던 게임의 첫 사다리는 줄이지 않음). 메모리 한도에서 마무리한 스트림도 끝난 직후 같은 대여 안에서 읽은 범위의 사다리를 만듭니다. 대상은 스트림이 읽은 것과 같은 창의 목록을 읽은 양에서 자른 앞부분이라 읽지 않은 구간은 만지지 않습니다. 틈 메우기 예산이 없는 창 읽기나 이어 읽기일 때만 그렇습니다(스트림이 읽은 목록이 같은 창으로 계획한 목록이기 때문). 그 예산이 있는 계획으로 전체 목록을 읽다 한도에서 마무리한 보충은 만들지 않고 재예열 점검에 맡기며, 점검의 첫 사다리가 메모리를 기다리는 게임을 받칩니다. 서비스를 다시 시작하면 표식이 없어지므로, 시작 예열이 이미 따뜻하다고 건너뛴 게임은 그 직후에, 그 밖의 게임은 표본이 따뜻한 첫 재예열 점검에서 새 사다리를 만듭니다. @@ -49,7 +51,7 @@ Windows 의 통계를 읽을 수 없으면(예: 권한이 없을 때) 3번은 | 서비스 권한 | 서비스가 관리자 권한이고 신뢰할 수 있는 데이터 폴더를 쓸 때. [배경 메모리 양보](AUTO_MODE.ko.md#배경-메모리-양보)와 같은 조건입니다. | | 그 디스크에서 게임이 실행 중이 아님 | 캐시에 없는 것을 만지면 디스크 읽기라서, 게임이 도는 디스크에는 새 배경 읽기를 내지 않는 규칙을 따릅니다. 게임이 시작하면 멈춥니다. | | 런처 예열이 기다리는 중이 아님 | 런처 예열이 먼저입니다. 런처 때문에 멈춘 스트림 뒤에도 만들지 않고, 묶음 새로 고침은 런처 예열이 기다리기 시작하면 멈춥니다. | -| 새로 고침의 관문 | 재예열 점검과 같습니다: 재배치 중이 아니고, 게임 드라이브가 4 MiB/s 미만이고, 게임 종료 직후 유예가 지났고, 게임이 예열 예산 안에 있고, 디스크 읽기 대여가 허용될 때. 대여가 거절되면 그 게임만 건너뛰고 표식은 남습니다. | +| 새로 고침의 관문 | 재예열 점검과 같되 드라이브 사용량은 보지 않습니다: 재배치 중이 아니고, 게임 종료 직후 유예가 지났고, 게임이 예열 예산 안에 있고, 디스크 읽기 대여가 허용될 때. 새로 고침은 캐시에 있는 페이지를 메모리 안에서 만질 뿐이라 다른 프로그램이 같은 드라이브를 쓰고 있어도 돕니다. 디스크에서 다시 읽을 수 있는 재예열 점검의 관문(게임 드라이브가 4 MiB/s 미만일 것 포함)은 그대로입니다. 대여는 그 디스크에서 게임이 실행돼 양보 중일 때만 거절되고, 거절되면 그 게임만 건너뛰고 표식은 남습니다. | | `auto.keep_stream_on_game_start` 가 꺼져 있음 | 이 실험 설정에서는 게임이 떠도 스트림을 끊지 않아, SOLAR 가 따라가지 않습니다. | ## 하지 않는 것 @@ -75,11 +77,11 @@ Windows 의 통계를 읽을 수 없으면(예: 권한이 없을 때) 3번은 - `<게임>: ladder ordered N MiB in S s`: 예열이나 재예열 점검 뒤에 사다리를 만들었습니다. `<게임>: ladder has nothing to touch (K MiB in sparse files not touched)` 는 목록 전체가 스파스 파일이라 사다리를 만들지 않았다는 뜻이며, 점검마다가 아니라 그 판정이 새로 나올 때만(서비스를 다시 시작하거나 새 예열 뒤에는 다시) 남습니다. - `<게임>: cache refreshed in memory: N MiB in S s (R MiB repurposed of A MiB ahead)`: 새로 고쳤습니다. `R` 은 다시 쓴 양의 늘어난 값, `A` 는 앞에 있는 양입니다. 스스로는 시점이 아니었지만 묶음과 함께 새로 고친 게임도 그 게임의 값으로 같은 줄을 남깁니다. -- `<게임>: prewarm done (...) ..., protected K games (N MiB in S s)` (완료 줄 끝, `requeued` 가 있으면 그 뒤): 스트림이 읽기 직전에 표식이 있는 다른 게임 K 개를 모두 N MiB 만졌습니다. 만진 것이 없으면 붙지 않고, 그 게임들의 `cache refreshed in memory` 줄은 그대로 남습니다. 런처 예열의 줄에는 붙지 않습니다. +- `<게임>: prewarm done (...) ..., protected K games (N MiB in S s)` (완료 줄 끝, `requeued` 가 있으면 그 뒤): 스트림이 읽기 직전과 읽는 도중에 표식이 있는 다른 게임 중 서로 다른 K 개를 모두 N MiB, S 초 만졌습니다. 두 번 이상 보호했으면 꼬리 끝에 `, R times` 가 더 붙습니다(한 번이면 없음). 만진 것이 없으면 붙지 않고, 그 게임들의 `cache refreshed in memory` 줄은 그대로 남습니다. 런처 예열의 줄에는 붙지 않습니다. - `<게임>: prewarm done (...) ..., requeued N MiB in S s` (`supplement prewarm done` 줄 끝에도 같음): 스트림이 읽는 도중 만진 묶음입니다. 만진 것도 건너뛴 것도 없으면 붙지 않습니다. `requeued N MiB in S s (K MiB in sparse files not touched)` 는 묶음 가운데 스파스 파일에 있어 건너뛴 양을 보여 주며("한계와 주의" 참고), 0 이면 괄호가 없습니다. 만지기가 디스크 읽기를 보고 멈췄으면 `, requeue stopped by disk reads` 가 더 붙습니다. 보충이 창만 읽고 끝나면 `done` 대신 `supplement prewarm read the first …` 줄로 끝나며, 그 줄에도 같은 `requeued` 문구가 붙습니다. `, K MiB of sparse files by buffered read` (같은 줄들의 `requeued` 앞) 는 스트림에서 매핑 대신 그렇게 읽은 양을 보여 주며, 0 이면 붙지 않습니다. -- `<게임>: ladder found data missing from memory after T of N MiB (K slow pieces, M MiB read from disk), left to the rewarm check`, 그리고 `cache refresh found data missing from memory after T of N MiB (...)`: 목록 일부가 캐시에 없었습니다. 사다리는 역순으로 만지므로 `T` 가 `N` 에 가까우면 빠진 것은 목록 앞쪽, 곧 가장 중요한 부분입니다. `N` 은 건너뛴 스파스 범위를 포함한 사다리 전체 길이이므로, 줄 끝에 `K MiB in sparse files not touched` 가 있으면 `T` 에 `K` 를 더한 값이 `N` 에 가까운지를 보십시오. +- `<게임>: ladder found data missing from memory after T of N MiB (K slow pieces, M MiB read from disk), left to the rewarm check`, 그리고 `cache refresh found data missing from memory after T of N MiB (...)`: 목록 일부가 캐시에 없었습니다. 표식이 있던 게임이면 줄 끝에 `, since the ladder M min ago: R MiB repurposed of A MiB ahead` 가 붙습니다(M 은 그 표식을 만든 뒤 분, R 은 그 뒤 다시 쓴 양, A 는 지금(표식을 지웠으면 지울 때) 앞에 있는 양이며, 대기 목록을 읽을 수 없으면 `: R MiB … ahead` 없이 시간만). 표식이 없었으면 붙지 않습니다. 식은 것이 새로 고침이 늦어서인지, 막혀서인지, 순서 밖에서 밀려난 것인지를 가리는 데 씁니다. 사다리는 역순으로 만지므로 `T` 가 `N` 에 가까우면 빠진 것은 목록 앞쪽, 곧 가장 중요한 부분입니다. `N` 은 건너뛴 스파스 범위를 포함한 사다리 전체 길이이므로, 줄 끝에 `K MiB in sparse files not touched` 가 있으면 `T` 에 `K` 를 더한 값이 `N` 에 가까운지를 보십시오. - `<게임>: rewarm check: part of the list is not in memory (found by the ladder), prewarming again (cooldown ignored)`: 다음 재예열 점검이 그 게임을 다시 예열합니다. `..., waiting until N min after the last prewarm attempt` 는 주기가 지나기를 기다린다는 뜻입니다. -- `<게임>: cache refresh skipped, the samples are cold (X/Y over Z ms), left to the rewarm check`, 또는 `the samples could not be read`, `there are no readable samples`: 새로 고치기 전에 표본 점검을 했습니다. +- `<게임>: cache refresh skipped, the samples are cold (X/Y over Z ms), left to the rewarm check`, 또는 `the samples could not be read`, `there are no readable samples`: 새로 고치기 전에 표본 점검을 했습니다. 이 세 가지 줄(식음, `could not be read`, `no readable samples`)과 `<게임>: rewarm check: cold (...), prewarming again (cooldown ignored)` 줄 끝에도 표식이 있던 게임이면 같은 `, since the ladder M min ago: ...` 꼬리가 붙습니다(표식을 새로 고침이 먼저 지웠으면 지우기 전의 값을 기억해 두었다가 재예열 점검의 식음 줄에 한 번 붙이고 버립니다. 새 표식이 서도 버립니다). 게임 드라이브가 바쁠 때 식어 나온 표본은 한 표식에서 처음 한 번은 로그 없이 건너뛰므로 그때는 이 줄이 남지 않습니다. - `<게임>: ladder stopped after N MiB`, `cache refresh stopped after N MiB`: 게임이 시작했거나 대여가 끝나 멈췄습니다. 표식은 남아 다시 시도합니다. - `<게임>: ladder was too slow without disk reads seen (K slow pieces), skipped`, `cache refresh was too slow without disk reads seen (...)`: 만지는 데 오래 걸렸지만 디스크 읽기는 보이지 않았습니다. 예를 들어 CPU 부하가 클 때입니다. - `<게임>: ladder skipped: <예외 형식>`, `<게임>: cache refresh failed: <예외 형식>`(또는 `cache ladder failed`): 예열에는 영향을 주지 않은 실패입니다. @@ -89,6 +91,8 @@ Windows 의 통계를 읽을 수 없으면(예: 권한이 없을 때) 3번은 - **실제 플레이에서의 효과는 아직 재지 않았습니다.** 실험은 순서를 정할 수 있다는 것과, 아직 메모리에 있는 캐시를 새로 고치는 데는 디스크 읽기가 들지 않는다는 것을 보였습니다. 실행 중인 게임에서 로딩 시간이나 끊김이 얼마나 달라지는지는 보여 주지 않습니다. 식은 것으로 판정되는 재예열 점검의 비율이 줄었는지도 아직 확인하지 않았습니다. - **다른 프로그램이 이 게임 앞쪽 페이지를 다시 참조해 뒤로 보내는 것은 셀 수 없습니다.** 그래서 기준이 늦을 수 있고, 그 경우는 재예열 점검이 받칩니다. - **묶음 새로 고침, 스트림 앞의 보호, 한도 마무리 뒤 사다리의 효과는 설치본에서 아직 확인하지 않았습니다.** +- **드라이브가 바쁠 때도 새로 고침은 돕니다(디스크를 읽지 않으므로).** 표본이나 만지기가 캐시에 없는 부분을 만나면 그때는 읽게 되며, 그 양은 표본 8 MiB 와 멈추기 전까지의 최대 약 100 MiB 입니다. 바쁜 드라이브에서 식어 나온 표본은 한 표식에서 처음 한 번만 봐 주고(표식 유지, 그 회의 표본 8 MiB 읽기), 둘째부터는 표식을 지우고 재예열 점검에 맡깁니다. 드라이브 처리량을 읽을 수 없는 시스템도 같습니다. 디스크에서 다시 읽을지는 여전히 재예열 점검만 정합니다. +- **스트림 도중의 보호, 서비스 시작 직후의 사다리, 로그 꼬리와 이 변경 전체의 효과는 설치본에서 아직 확인하지 않았습니다.** 서비스 시작 직후의 사다리가 시작 예열을 얼마나 늦추는지도 재지 않았습니다(사다리 줄 `ladder ordered N MiB in S s` 의 S 만큼 먼저 돕니다). - **밀려나는 것을 막지는 않습니다.** 메모리가 정말 모자라면 Windows 는 이 페이지도 다시 씁니다. SOLAR 는 어느 것이 먼저 나가는지와 얼마나 빨리 되돌리는지만 바꿉니다. - **스파스 속성 파일은 다시 만지지 않습니다.** 같은 PC 한 대의 HDD 에서 시험 파일로 잰 결과입니다. 예열 읽기 직후 새 매핑(섹션)으로 만지면 일반 파일은 디스크를 읽지 않았고(47회), 스파스 속성 파일은 매번 디스크에서 다시 읽었습니다(64 MiB 에 약 1.1초). 그래서 SOLAR 는 섹션을 열기 전에 파일 속성을 조회해(메타데이터만, 한 번의 실행에서 파일마다 한 번) 스파스이면 건너뜁니다. 건너뛴 양은 로그에 `K MiB in sparse files not touched` 로 남고, 만진 양에도 오류에도 빠진 데이터에도 넣지 않습니다. 그런 파일의 데이터는 SOLAR 의 순서·유지 대상이 아니며 예열과 재예열 점검(버퍼드 읽기 표본)에만 의존합니다. 이것은 그 PC 에서 잰 것이며 Windows 가 일반적으로 그렇게 동작한다는 말은 아닙니다. SOLAR 는 속성을 읽기만 하고 파일을 바꾸지 않습니다. 예열도 그런 파일은 일반 파일과 다르게 읽습니다. 매핑하지 않고 같은 범위를 같은 순서로 보통의 버퍼드 읽기로 읽습니다. 그 PC 에서 스파스 시험 파일은 매핑을 만들면 앞서 읽은 그 파일 전체의 캐시 페이지가 버려졌고, 버퍼드 읽기만 쓰면 이어졌기 때문입니다. 이 읽기는 캐시 관리자의 앞읽기를 끕니다. 켜 두면 그 PC 에서 64~256 KiB 조각이 흩어진 목록(요청 256 MiB)이 디스크에서 요청보다 13~23% 더 읽혔고, 끄면 잰 회들에서 요청의 약 1% 이내였습니다. 실제 게임에서의 효과는 아직 재지 않았습니다. 한 게임의 목록 전체가 스파스 파일이면 로그에 `ladder has nothing to touch (K MiB in sparse files not touched)` 가 한 번 남고, 다음 예열 스트림이 다시 판정할 때까지 재예열 점검이 사다리를 다시 시도하지 않습니다. - **아주 긴 예열은 자기 앞부분을 잃을 수 있습니다.** 한 번은 Call of Duty 의 11,388 MiB 예열이 322초 걸렸고, 끝난 직후 만든 사다리가 이미 빠진 부분을 찾았습니다. 목록이 대기 목록 용량에 가까워서, 먼저 읽은 부분이 나머지를 읽는 동안 밀려났을 수 있습니다. 다른 프로그램의 읽기도 더해졌을 수 있습니다. 따로, 설치본 1.0.4-dev.7 에서 Call of Duty 의 묶음 다시 만지기가 서로 다른 스트림에서 같은 위치(3,379 MiB)에서 `requeue stopped by disk reads` 로 멈췄고 사다리도 빠진 데이터를 만나 재예열이 되풀이됐습니다. 파일 속성을 집계하니(메타데이터만, 2026-11-14) 그 게임 폴더의 `.xsub` 210개 중 78개(50.6 GiB)가 스파스 속성이었고, 사다리가 디스크 읽기 없이 끝나던 다른 게임의 폴더에는 스파스 파일이 없었습니다. 이 세 가지로 되풀이의 원인을 스파스 파일로 판단했습니다. 멈춘 위치의 파일이 스파스였는지는 확인하지 않았고, 처음 사례에서 밀려남이 얼마나였는지도 가려내지 못했습니다. 또 빠진 부분만 읽는 재예열은 그 게임의 나머지 페이지를 이전 예열 때의 오래된 순서로 남기고 Windows 는 그것부터 다시 쓰는데, 읽은 묶음을 곧바로 다시 만지는 것은 이를 막으려는 것입니다. **이 변경 뒤 되풀이가 멈추는지는 설치본에서 아직 확인하지 않았습니다.** 목록이 대기 목록이 담을 수 있는 양보다 크면 먼저 읽은 부분이 나머지를 읽는 동안 밀려날 수는 여전히 있고, 로그의 `after T of N MiB` 로 사다리가 어디까지 만졌는지 볼 수 있습니다. diff --git a/docs/SOLAR.md b/docs/SOLAR.md index e1c7274..5c79051 100644 --- a/docs/SOLAR.md +++ b/docs/SOLAR.md @@ -20,7 +20,7 @@ SOLAR answers both without reading the disk on purpose. It touches the cache aga 1. **Make the ladder.** When a prewarm of a game has read its list to the end, SOLAR touches what that game has in the cache in the reverse of the read order: the later part of the list first, and the loading head and the common blocks last. A touched page goes to the back of the standby list, so the order of eviction becomes the reverse of the order of importance. The importance order is the one the prewarm already uses, which comes from the game's own recorded reads. While the prewarm is still reading, each batch it has just read is touched again right away, in read order. A prewarm read skips pages that are already cached and does not move them in the list, so without this the rest of the game's pages would keep the old order of an earlier prewarm; touching the batch sends those skipped pages to the back as well. A top-up that continues an interrupted read skips the front part an earlier pass already read, so it touches that skipped front part once, in list order, before it reads the rest. It costs about 0.5 s per GiB of cached data, and the prewarm done line shows it as `, requeued N MiB in S s`. 2. **Make a note.** After a ladder it notes the time, Windows' running count of cache pages that have been reused, and the amount ahead: the cache in the standby list minus what this game touched. The amount ahead is not always the same value: when another game touches its cache again, the pages that were ahead of this game's move behind it. So when that game had an earlier note and the note is older than this game's, the amount ahead is lowered by the smaller of what that earlier note touched and what the game touched now (never below zero). The rest of a touch was read from the disk and is already in the reuse count, and a game with no earlier note has no known position, so neither lowers it. The note lives in memory only. 3. **Watch one statistic.** About every 30 seconds, when a game has a note, SOLAR reads Windows' standby-list statistics once. It is a system-wide figure and no process is opened. Pages ahead of the game's pages are reused first, so when the count of reused cache pages has grown since the note by `max(512 MiB, 0.4 x the amount ahead)`, or when what is left ahead (the amount ahead minus that growth) is under 2 GiB, the game's pages are getting close to the front. The second rule does not apply to a game whose amount ahead was already under 4 GiB when its note was made. -4. **Refresh the whole group without the disk.** When a game with a note that can be touched right now (its gates are open) is due, all the games with a note are refreshed, the lowest-ranked one first and the most recently prewarmed one last (a game that is not due yet is refreshed too, because the pages touched for the others have moved behind it). Before each game's refresh, the usual sample check runs (1 MiB at each of 8 places). If the samples are warm, SOLAR touches the same ladder again, which moves the pages to the back of the list. If they are cold or cannot be read, the note is dropped and the [re-prewarm check](AUTO_MODE.md#5-upkeep) takes over. A game that a gate blocks is skipped and the rest go on; a blocked game that is due does not start a group, so it cannot make the others be touched again and again. Afterwards the notes of the games that were touched are written again from one point in time: the amount ahead of a game is the cache in the standby list minus what that game and the games touched after it touched, and the count of reused pages is the one of that moment. A skipped game keeps its note, but its amount ahead is lowered by the rule of step 2. A group refresh is not repeated within 60 seconds of the last one, and a protection before a stream (see the details) counts as one. +4. **Refresh the whole group without the disk.** When a game with a note that can be touched right now (its gates are open) is due, all the games with a note are refreshed, the lowest-ranked one first and the most recently prewarmed one last (a game that is not due yet is refreshed too, because the pages touched for the others have moved behind it). Before each game's refresh, the usual sample check runs (1 MiB at each of 8 places). If the samples are warm, SOLAR touches the same ladder again, which moves the pages to the back of the list. If they are cold or cannot be read, the note is dropped and the [re-prewarm check](AUTO_MODE.md#5-upkeep) takes over. One exception: when the game drive is busy (4 MiB/s or more, or its load is unknown) and the samples come out cold, they may have been slow because of another program's reads, so, once per note, the note is kept and only that round is skipped (no log line). If the samples come out cold a second time for the same note, it is handled as when the drive is not busy: the note is dropped and the game is left to the re-prewarm check. The memory of having let one go is cleared when the note is dropped or made anew, or when a warm sample is seen. A game that a gate blocks is skipped and the rest go on; a blocked game that is due does not start a group, so it cannot make the others be touched again and again. Afterwards the notes of the games that were touched are written again from one point in time: the amount ahead of a game is the cache in the standby list minus what that game and the games touched after it touched, and the count of reused pages is the one of that moment. A skipped game keeps its note, but its amount ahead is lowered by the rule of step 2. A group refresh is not repeated within 60 seconds of the last one, and a protection before a stream (see the details) counts as one. 5. **Stop when the disk is read.** A page that is no longer in the cache would be read from the disk by the touch. After each view, SOLAR compares how many pages the whole system has read from the disk meanwhile and stops once more than 32 MiB is counted. Then the note is dropped and the game is flagged for the next re-prewarm check, which re-prewarms it even if its samples look warm. Parts still in the cache cost almost no disk time, so in effect only the missing parts are read. At most one such re-prewarm per game is made each `auto.rewarm_minutes`, counted from the last prewarm attempt. If Windows' statistics cannot be read (for example because the privilege is missing), step 3 falls back to a refresh every 10 minutes after the last ladder, with no other change. @@ -35,7 +35,9 @@ If Windows' statistics cannot be read (for example because the privilege is miss - **What is touched.** The list is the common blocks that the prewarm reads first followed by the whole list that was read, in read order; the ladder is its reverse. For a prewarm that read only the front part, it is the part that was read. A range that continues the previous one in the same file is joined to it. - **Touching while reading.** After each batch of a prewarm stream (about 64 MiB of reads) and after the common blocks, the range just read is touched again in the same thread, under the same disk read lease and the same stop signal as the stream, and after the stream's own per-batch checks (memory, outside load, game running), whose order and meaning are unchanged. If a game starts or the lease ends, no later batch is touched. If the touch finds data missing, throws, or is cancelled, the stream goes on and its result (the amount read, success) is the same; an exception is logged once as `requeue of the read batches skipped`. When the touch of a batch stops on disk reads, the later batches of that stream are not touched (so the disk reads are not repeated for each batch), and the done line says `requeue stopped by disk reads`. The same stop of the skipped front part ends only that touch. The time spent touching is left out of the `yielded` time and of the stream's exposure time, because a touch is not a disk read. It follows the same conditions as the ladder (see below), and with `memory.standby_ladder` off the stream is exactly as before. - **Protecting the games with a note before a stream.** Right before a prewarm stream (service start, re-prewarm, top-up) reads from the disk, the other games that have a note are touched once, the lowest-ranked first, by the same function as the group refresh (the stream's own game is left out). The pages the stream then reads push out the plain cache, and the stream's own old pages, before they push out another game. It runs under the stream's disk read lease and stop signal and only while no game start or launcher preemption flag stands. The sample check and the gates are the same as for a refresh. A launcher prewarm does not do this, because a game start is close and its prewarm must not be delayed; after that stream the ladder only lowers the amount ahead of the other games. The time it takes is left out of the `yielded` time and of the stream's exposure time, and the done line gets `, protected K games (N MiB in S s)` (left out when none was touched). If it fails or is cancelled, the stream goes on and its result is the same. -- **Where the first ladder comes from.** After a prewarm stream of the game has finished to the end (service start, launcher, re-prewarm, top-up). When the ladder of a game is made, the amount ahead of the other games' notes is lowered by the rule of step 2 (a first ladder, of a game with no earlier note, lowers nothing). A stream that ended at the memory limit makes a ladder over the range it read, right after it ends, in the same lease. The target is the front of the same window the stream read, cut at the amount read, so the part that was not read is not touched. This holds only for a window read or a continued read, which have no gap-filling budget, so the list the stream read is the one planned for the same window. A top-up that read a full list with such a budget and stopped at the limit makes none; it is left to the re-prewarm check, whose first ladder covers a game that waits for memory. After a service restart the notes are gone, so each game gets a new ladder at the first re-prewarm check with warm samples. +- **Protecting during a stream.** While a prewarm stream that is not a launcher's runs, after each batch has been read, checked and touched again, SOLAR looks at whether another game with a note is due. It is the same decision as the maintenance tick (the criterion of step 3 above: a game that can be touched is due and 60 seconds have passed since the last group refresh) by the same function. The decision reads the standby-list statistics once, so it is made only when 10 seconds have passed since the previous decision. When it is due, the other games are touched again by the same function as the protection before a stream. It runs in the stream's thread, under the stream's disk read lease and stop signal, and only while no game start or launcher preemption flag stands; the stream's own game is not a target (its ladder at the end makes it). A launcher prewarm does not do it. The time it takes is left out of the `yielded` time and of the stream's exposure time and is added into the `protected` tail of the done line (the number of different games, the totals of amount and time, and the number of times when it was more than once). When it writes the new notes, the cache in the standby list is reduced by what the stream has touched so far minus the share the ladder at the end of the stream lowers anyway (what the stream game's earlier note touched), because the pages the stream read are touched again by that ladder and then lie behind these games; this keeps the amount ahead from being lowered twice. If it fails or is cancelled, the stream goes on and its result is the same. +- **A ladder right after a service start.** A game that the service-start prewarm skipped as `prewarm skipped (already warm) [service start]` gets its first ladder on the spot, using the sample verdict of that skip (the samples are not read again), under the same conditions and by the same function as the first ladder of the re-prewarm check. With several games the lowest-ranked is made first and the notes are then tidied by the group rule. A game in a partial state (a top-up waiting) follows the conditions of the first ladder for a top-up that waits for memory. The launcher path does not do it. It fills the gap in which, after a restart, there is no note and no refresh until the first re-prewarm check (after `auto.rewarm_minutes`). +- **Where the first ladder comes from.** After a prewarm stream of the game has finished to the end (service start, launcher, re-prewarm, top-up). When the ladder of a game is made, the amount ahead of the other games' notes is lowered by the rule of step 2 (a first ladder, of a game with no earlier note, lowers nothing). A stream that ended at the memory limit makes a ladder over the range it read, right after it ends, in the same lease. The target is the front of the same window the stream read, cut at the amount read, so the part that was not read is not touched. This holds only for a window read or a continued read, which have no gap-filling budget, so the list the stream read is the one planned for the same window. A top-up that read a full list with such a budget and stopped at the limit makes none; it is left to the re-prewarm check, whose first ladder covers a game that waits for memory. After a service restart the notes are gone, so a game that the start prewarm skipped as already warm gets a new ladder right after that, and any other game at the first re-prewarm check with warm samples. @@ -49,7 +51,7 @@ All of these must hold. Otherwise nothing is touched. | Service rights | The service has administrator rights and a trusted data folder, the same condition as [background memory yield](AUTO_MODE.md#background-memory-yield). | | No game running on that disk | A touch that misses the cache is a disk read, so it follows the rule that no new background reads go to a disk where a game runs. It stops when a game starts. | | Not while a launcher prewarm waits | A launcher's prewarm has priority. SOLAR also skips a stream that was stopped for it, and the group refresh stops when a launcher prewarm starts to wait. | -| Refresh gates | The same gates as the re-prewarm check: no relocation in progress, the game drive under 4 MiB/s, the exit grace period over, the game inside the prewarm budget, and the disk read lease granted. A refused lease only skips the game and its note stays. | +| Refresh gates | The same gates as the re-prewarm check except the drive load: no relocation in progress, the exit grace period over, the game inside the prewarm budget, and the disk read lease granted. A refresh only touches pages that are in the cache, in memory, so it runs while another program uses the same drive. The gates of the re-prewarm check, which can read the disk again (including the game drive under 4 MiB/s), are unchanged. The lease is refused only while a game on that disk is running and yielding; a refused lease only skips the game and its note stays. | | Not with `auto.keep_stream_on_game_start` | With this experimental setting a stream is not stopped when the game starts, so SOLAR does not follow it. | ## What it does not do @@ -75,11 +77,11 @@ Each line goes to `service.log`. `` is the game's name, and a tail such as - `: ladder ordered N MiB in S s`: a ladder was made after a prewarm or a re-prewarm check. `: ladder has nothing to touch (K MiB in sparse files not touched)` means every range of the list is in sparse files, so no ladder was made; it is logged when that finding is new (again after a service restart or a new prewarm), not at every check. - `: cache refreshed in memory: N MiB in S s (R MiB repurposed of A MiB ahead)`: a refresh. `R` is the growth of the reuse count and `A` the amount ahead. A game that was not due itself but was refreshed with the group gets the same line, with its own values. -- `: prewarm done (...) ..., protected K games (N MiB in S s)` (at the end of the done lines, after `requeued` when both are there): right before the stream read, K other games with a note were touched, N MiB in all. It is left out when none was touched, and a `cache refreshed in memory` line stays for each of those games. It is never on the line of a launcher prewarm. +- `: prewarm done (...) ..., protected K games (N MiB in S s)` (at the end of the done lines, after `requeued` when both are there): right before and while the stream read, K different other games with a note were touched, N MiB and S seconds in all. When it protected more than once, `, R times` is added at the very end (not for one time). It is left out when none was touched, and a `cache refreshed in memory` line stays for each of those games. It is never on the line of a launcher prewarm. - `: prewarm done (...) ..., requeued N MiB in S s` (and the same at the end of `supplement prewarm done`): the batches the stream touched while reading; left out when nothing was touched or skipped. `requeued N MiB in S s (K MiB in sparse files not touched)` shows the part of the batches that sat in sparse files (see Limits and cautions) and was skipped; the parentheses are left out when it is zero. `, requeue stopped by disk reads` is added when a touch saw disk reads and stopped. A top-up that read only a window ends with `supplement prewarm read the first …` instead of `done`, and that line carries the same `requeued` text. `, K MiB of sparse files by buffered read` (before `requeued`, on the same lines) shows how much of the stream was read that way instead of through a mapping; it is left out when zero. -- `: ladder found data missing from memory after T of N MiB (K slow pieces, M MiB read from disk), left to the rewarm check`, and the same for `cache refresh found data missing from memory after T of N MiB (...)`: part of the list was not in the cache. The ladder touches in reverse order, so when `T` is close to `N` the missing part is at the front of the list, the most important part. `N` is the whole ladder including sparse ranges that were skipped, so when the line ends with `K MiB in sparse files not touched`, check whether `T` plus `K` is close to `N`. +- `: ladder found data missing from memory after T of N MiB (K slow pieces, M MiB read from disk), left to the rewarm check`, and the same for `cache refresh found data missing from memory after T of N MiB (...)`: part of the list was not in the cache. For a game that had a note, `, since the ladder M min ago: R MiB repurposed of A MiB ahead` is added at the end of the line (M is minutes since that note was made, R the reuse since then, A the amount ahead now (at the moment the note was dropped, if it was); when the standby-list statistics cannot be read, only the time and no `: R MiB … ahead`). It is left out when there was no note. It helps tell whether the cooling came from a refresh that was late, one that was blocked, or an eviction out of order. The ladder touches in reverse order, so when `T` is close to `N` the missing part is at the front of the list, the most important part. `N` is the whole ladder including sparse ranges that were skipped, so when the line ends with `K MiB in sparse files not touched`, check whether `T` plus `K` is close to `N`. - `: rewarm check: part of the list is not in memory (found by the ladder), prewarming again (cooldown ignored)`: the next re-prewarm check re-prewarms the game. `..., waiting until N min after the last prewarm attempt` means it waits for the period to pass. -- `: cache refresh skipped, the samples are cold (X/Y over Z ms), left to the rewarm check`, or `the samples could not be read` or `there are no readable samples`: the sample check came before the refresh. +- `: cache refresh skipped, the samples are cold (X/Y over Z ms), left to the rewarm check`, or `the samples could not be read` or `there are no readable samples`: the sample check came before the refresh. These three lines (cold, `could not be read`, `there are no readable samples`) and the `: rewarm check: cold (...), prewarming again (cooldown ignored)` line also get the same `, since the ladder M min ago: ...` tail for a game that had a note (when a refresh dropped the note first, the values from before are kept and added once to the cold line of the re-prewarm check, then discarded; a new note discards them too). A cold sample read while the game drive is busy is skipped without a log line once per note, so that line does not appear then. - `: ladder stopped after N MiB`, `cache refresh stopped after N MiB`: stopped because a game started or the lease ended; the note stays and a refresh is tried again. - `: ladder was too slow without disk reads seen (K slow pieces), skipped`, `cache refresh was too slow without disk reads seen (...)`: the touch took long but no disk read was seen, for example under heavy CPU load. - `: ladder skipped: ` and `: cache refresh failed: ` (or `cache ladder failed`): a failure that did not affect the prewarm. @@ -89,6 +91,8 @@ Each line goes to `service.log`. `` is the game's name, and a tail such as - **The effect in real play has not been measured.** The experiments show that the order can be set and that refreshing cache that is still in memory needs no disk read. They do not show how much load time or stutter changes in a running game. Whether the share of cold re-prewarm checks has fallen has not been checked either. - **A program that uses the pages ahead of the game's again moves them behind it, and that cannot be counted.** So the criterion can be late; the re-prewarm check is the backstop for that case. - **The effect of the group refresh, the protection before a stream and the ladder after a memory-limit finish has not been checked on an installed build yet.** +- **The refresh runs while the drive is busy too, because it does not read the disk.** When a sample or a touch meets a part that is not in the cache, it does read then: the sample 8 MiB, and a touch up to about 100 MiB before it stops. A cold sample read on a busy drive is let go once per note (the note is kept, that round's 8 MiB sample read is spent); the second one drops the note and leaves the game to the re-prewarm check, also on a system where the drive load cannot be read. Whether to read from the disk again is still decided only by the re-prewarm check. +- **The protection during a stream, the ladder right after a service start, the log tails and this whole change have not been checked on an installed build yet.** How much the ladder right after a service start delays the start prewarm has not been measured either (it runs first, for the `S` seconds of its `ladder ordered N MiB in S s` line). - **It does not stop eviction.** When memory is truly short, Windows still reuses these pages. SOLAR changes only which ones go first and how soon they come back. - **Files with the sparse attribute are not touched.** Measured on one PC (an HDD, test files): right after a prewarm read, an ordinary file touched through a new mapping (a section) caused no disk read (47 runs), while a sparse file was read again from the disk every time (64 MiB in about 1.1 s). So before it opens a section SOLAR reads the file's attributes (metadata only, once per file in a run) and skips a sparse file. The log shows the skipped amount (`K MiB in sparse files not touched`); it is not counted as touched, as errors or as data missing. The data of such files is not part of SOLAR's order or upkeep and depends only on the prewarm and the re-prewarm check (a buffered-read sample). This is what was measured on that PC, not a statement about how Windows behaves in general. SOLAR only reads the attribute and never changes a file. The prewarm reads such files differently from ordinary ones as well: it does not map them but reads them with an ordinary buffered read of the same ranges in the same order, because on that PC creating a mapping for a sparse test file discarded the cache pages read earlier from that whole file, while buffered reads alone kept them. These reads turn the cache manager's read-ahead off: with it on, a list of scattered 64 to 256 KiB pieces (256 MiB requested) read 13 to 23% more than requested from the disk on that PC, and with it off the disk read stayed within about 1% of the request in the measured runs. How much this helps in a real game has not been measured yet. If every range of a game's list is in sparse files, the log says `ladder has nothing to touch (K MiB in sparse files not touched)` once, and the re-prewarm check does not try the ladder again until the next prewarm stream has judged it again. - **A very long prewarm can lose its own start.** Once, a prewarm of 11,388 MiB for Call of Duty took 322 seconds, and the ladder made right after it already found data missing. The list was close to the capacity of the standby list, so the part read first may have been pushed out while the rest was being read; other programs' reads may have added to it. Separately, in the installed build 1.0.4-dev.7 the batch touch for Call of Duty stopped with `requeue stopped by disk reads` at the same position (3,379 MiB) in different streams, and the ladder also found data missing, so the re-prewarm repeated. A count of file attributes (metadata only, 2026-11-14) found 78 of the 210 `.xsub` files in that game's folder (50.6 GiB) sparse, and no sparse files in the folder of another game whose ladder ended without disk reads. From these three the repetition was judged to come from sparse files; that the file at the stopping position is sparse was not checked, and how much of the first case was pushing out has not been separated. A re-prewarm that reads only the missing part also leaves the rest of the game's pages in the old order of the earlier prewarm, which Windows reuses first; touching each batch right after it is read is meant to prevent that. **Whether the repetition stops after this change has not been checked on an installed build yet.** If the list is larger than what the standby list can hold, the part read first can still be pushed out while the rest is read; the `after T of N MiB` in the log shows how far the ladder got.