diff --git a/csharp/src/AsterCache.Service/Prewarm.Run.cs b/csharp/src/AsterCache.Service/Prewarm.Run.cs index 9cc6cc3..edb3deb 100644 --- a/csharp/src/AsterCache.Service/Prewarm.Run.cs +++ b/csharp/src/AsterCache.Service/Prewarm.Run.cs @@ -568,7 +568,7 @@ private void CheckPrewarmBatch(GameSettings game, CancellationTokenSource stop, if (r.Item1.Bytes > 0 && (complete || (shortFinish && partial && r.Item1.GapBytes == 0 && windowMib > 0))) { // 같은 디스크 대여 안에서: 게임 시작·대여 취소가 stop 으로 바로 멈춤. 한도로 마무리한 스트림은 창 읽기·이어 읽기 (partial: 틈 메우기 예산 0, 스트림이 읽은 목록 = 같은 창으로 계획한 목록) 일 때만 읽은 창의 앞부분을 만짐: 예산이 있는 계획으로 읽은 전체 경로는 목록이 달라질 수 있어 건너뜀 - LadderAfterStream(game, stop.Token, complete ? null : () => WindowLadderReads(game, windowMib, skipBytes + r.Item1.Bytes)); + LadderAfterStream(game, stop.Token, allowWhileExiting, readYield, complete ? null : () => WindowLadderReads(game, windowMib, skipBytes + r.Item1.Bytes)); } return shortFinish ? (r.Item1, true, fit!.Reason, true) : (r.Item1, complete, cancelDetail.First, cancelDetail.Memory); diff --git a/csharp/src/AsterCache.Service/Prewarm.Solar.cs b/csharp/src/AsterCache.Service/Prewarm.Solar.cs index 9861b8e..672599a 100644 --- a/csharp/src/AsterCache.Service/Prewarm.Solar.cs +++ b/csharp/src/AsterCache.Service/Prewarm.Solar.cs @@ -77,10 +77,11 @@ private RequeueTally ProtectBetweenBatches(GameSettings game, CancellationToken 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) + // touchedGames: 주면 끝까지 만진 게임 (만진 순서, 이름과 바이트) 을 채워 줍니다 (스트림 끝의 사다리가 묶음으로 다시 표식할 때 씀). + private RequeueTally TouchOthers(GameSettings game, CancellationToken stop, ReadYield readYield, Func> pick, long aheadLess = 0, List<(string Game, long Bytes)>? touchedGames = null) { long startedAt = Stopwatch.GetTimestamp(); - (int Games, long Bytes, IReadOnlyList Names) touched = (0, 0, []); + (int Games, long Bytes, IReadOnlyList<(string Game, long Bytes)> Touched) touched = (0, 0, []); try { if (pick() is not { Count: > 0 } others) @@ -106,14 +107,17 @@ private RequeueTally TouchOthers(GameSettings game, CancellationToken stop, Read _protectedNames.Clear(); // 이번 예열 한 회의 첫 보호 } - return new RequeueTally(0, 0, false, 0, touched.Names.Count(_protectedNames.Add), touched.Bytes, seconds, touched.Games > 0 ? 1 : 0); + touchedGames?.AddRange(touched.Touched); + return new RequeueTally(0, 0, false, 0, touched.Touched.Count(t => _protectedNames.Add(t.Game)), touched.Bytes, seconds, touched.Games > 0 ? 1 : 0); } // 핫 집합 스트림이 끝까지 읽은 직후 (StreamHot, 디스크 대여 안): 그 게임이 지금까지 캐시에 올린 전체의 역순으로 캐시를 만져 중요한 것이 마지막에 밀려나게 합니다. // game 은 스트림이 읽은 범위를 나타내는 사본 (메모리·상한으로 줄인 것 포함, 보충이면 이어 읽은 창) 이라 앞에서 읽은 부분과 보충분이 모두 대상입니다. 방금 읽었으므로 표본 점검은 생략합니다. // reads: 메모리 한도로 마무리한 스트림은 읽은 창의 앞부분 (WindowLadderReads), 끝까지 읽었으면 null (읽은 범위 전체). // 게임이 이미 도는 디스크이거나 keep_stream_on_game_start (게임이 떠도 스트림을 끊지 않는 실험 설정) 가 켜져 있으면 만지지 않습니다: 만지기도 캐시에 없으면 디스크 읽기라 게임을 느리게 할 수 있습니다. - private void LadderAfterStream(GameSettings game, CancellationToken stop, Func?>? reads = null) + // 사다리를 만들기 직전에 (트리거 예열은 제외) 표식이 있는 다른 게임을 한 번 만져 보호합니다 (스트림 앞의 보호와 같은 함수·게이트, "때가 됐는지"와 묶음 최소 간격은 보지 않음: 스트림 끝의 이 순서를 확정해야 A 가 맞음). + // 그러면 이 게임의 사다리는 다른 게임들 뒤에 오고, 둘을 만진 순서대로 한 묶음으로 다시 표식합니다 (Solar.Order). 쓴 시간은 완료 줄의 보호 꼬리에 합산하고 양보 시간에서 뺍니다. + private void LadderAfterStream(GameSettings game, CancellationToken stop, bool trigger, ReadYield readYield, Func?>? reads = null) { if (!_solar.Enabled || _auto.KeepStreamOnGameStart || _streamGameStarted || stop.IsCancellationRequested || _streamPreempted || PreemptedByTriggerWait(game.Name) || DriveHasRunningGame(game) || (reads is null ? LadderReads(game) : reads()) is not { Count: > 0 } ladder) @@ -123,7 +127,17 @@ private void LadderAfterStream(GameSettings game, CancellationToken stop, Func protectedFirst = []; + if (!trigger && CanProtectOthers(game, stop)) + { + _requeued += TouchOthers(game, stop, readYield, () => _solar.Marked(OtherGames(game)), touchedGames: protectedFirst); + if (ProtectionHalted(game) || stop.IsCancellationRequested) + { + return; // 보호하는 사이 게임이 시작했거나 선점됨: 진입할 때 막힌 것처럼 사다리를 시작하지 않음 + } + } + + _ = _solar.Order(game, ladder, stop, protectedFirst: protectedFirst); } catch (Exception e) when (e is not (OperationCanceledException or OutOfMemoryException or StackOverflowException)) { diff --git a/csharp/src/AsterCache.Service/Rewarm.Ladder.cs b/csharp/src/AsterCache.Service/Rewarm.Ladder.cs index e76ee8e..32ef601 100644 --- a/csharp/src/AsterCache.Service/Rewarm.Ladder.cs +++ b/csharp/src/AsterCache.Service/Rewarm.Ladder.cs @@ -11,7 +11,7 @@ namespace AsterCache.Service; /// 사다리를 새로 고치는 일 자체 (메모리 안에서만 만지기) 는 자동 모드·rewarm_minutes 와 무관하게 memory.standby_ladder 로만 켜고 끕니다. public sealed partial class Rewarmer { - private readonly object _groupLock = new(); // 묶음 새로 고침 (유지 틱) 과 스트림 앞의 보호 (스트림 스레드) 가 같은 게임을 동시에 만지지 않게, 기다리지 않고 못 잡으면 그 회는 건너뜀 + private readonly object _groupLock = new(); // 묶음 새로 고침 (유지 틱) 과 스트림의 보호 (스트림 스레드: 앞·도중·사다리 직전) 가 같은 게임을 동시에 만지지 않게, 기다리지 않고 못 잡으면 그 회는 건너뜀 /// 유지 틱: 대기 목록을 한 번 읽어 (표식이 있을 때만) 표식이 있는 게임 중 하나라도 새로 고칠 때가 됐으면 묶음 전체를 순위가 낮은 것부터 (가장 최근에 예열한 게임이 마지막) 다시 만집니다. public void LadderTick(AutoSettings auto, CancellationToken ct = default) @@ -43,10 +43,10 @@ private bool Touchable(GameSettings g, DateTimeOffset now, AutoSettings auto, Wa !_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 가 참이거나 취소·재배치가 시작되면 거기서 멈춥니다. + /// 유지 틱의 묶음 새로 고침과 스트림의 보호 (앞·도중·사다리 직전) 네 호출부가 같은 함수를 씁니다. 게이트에 막힌 게임은 건너뛰고 나머지는 계속하며, halted 가 참이거나 취소·재배치가 시작되면 거기서 멈춥니다. /// aheadLess: 스트림 도중의 보호가 넘기는, 진행 중인 스트림의 몫 (Solar.Regroup 참고), 그 밖에는 0. - /// 돌려주는 값: 끝까지 만진 게임 수와 만진 바이트와 그 게임 이름. - internal (int Games, long Bytes, IReadOnlyList Names) RefreshGroup(IReadOnlyList order, AutoSettings auto, CancellationToken ct, Func halted, long aheadLess = 0) + /// 돌려주는 값: 끝까지 만진 게임 수와 만진 바이트와 그 게임들 (만진 순서, 이름과 바이트). + internal (int Games, long Bytes, IReadOnlyList<(string Game, long Bytes)> Touched) RefreshGroup(IReadOnlyList order, AutoSettings auto, CancellationToken ct, Func halted, long aheadLess = 0) { if (!Monitor.TryEnter(_groupLock)) { @@ -77,7 +77,7 @@ private bool Touchable(GameSettings g, DateTimeOffset now, AutoSettings auto, Wa Monitor.Exit(_groupLock); } - return (touched.Count, touched.Sum(t => t.Bytes), [.. touched.Select(t => t.Game)]); + return (touched.Count, touched.Sum(t => t.Bytes), [.. touched.Select(t => (t.Game, t.Bytes))]); } // 새로 고치기 전에 기존 재예열 점검의 표본 (warm/cold 판정) 을 한 번 거칩니다: warm 일 때만 만집니다. diff --git a/csharp/src/AsterCache.Service/Solar.cs b/csharp/src/AsterCache.Service/Solar.cs index f2eae84..9fa65e3 100644 --- a/csharp/src/AsterCache.Service/Solar.cs +++ b/csharp/src/AsterCache.Service/Solar.cs @@ -81,7 +81,9 @@ public void Prune(IReadOnlySet live) /// 읽은 목록을 예열 순서의 역순으로 만지고 표식을 남깁니다 (예열 직후, 표식 없는 게임의 첫 사다리). 꺼져 있거나 만질 것이 없으면 null. /// made: 표식을 남겼을 때 만진 바이트를 알림 (서비스 시작 직후 여러 게임의 사다리가 Regroup 에 넘길 값). - public TouchEnd? Order(GameSettings game, IReadOnlyList reads, CancellationToken ct, Action? made = null) + /// protectedFirst: null 이면 스트림 끝의 사다리가 아님 (기존 규칙). 비어 있으면 스트림 끝의 사다리인데 직전에 보호한 게임이 없음: 이전 표식이 없는 게임이 만진 전체를 다른 게임의 앞에 있는 양에서 줄임. + /// 비어 있지 않으면 스트림 끝이고 직전에 보호로 만진 다른 게임 (만진 순서, 바이트): 이 게임과 함께 한 묶음으로 다시 표식합니다. + public TouchEnd? Order(GameSettings game, IReadOnlyList reads, CancellationToken ct, Action? made = null, IReadOnlyList<(string Game, long Bytes)>? protectedFirst = null) { if (!Enabled) { @@ -113,7 +115,16 @@ 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)]); // 이 게임이 만진 페이지가 다른 게임의 뒤로 감 + if (protectedFirst is { Count: > 0 }) + { + // 보호한 게임들은 이미 만진 만큼 다른 표식에서 줄였으므로 (alreadyReduced) 이 게임의 몫만 더 줄임. 만진 순서 그대로 한 묶음으로 A 를 실제 순서에 맞게 다시 계산 + Regroup([.. protectedFirst.Select(p => (p.Game, p.Bytes, (LadderMark?)null)), (game.Name, r.Bytes, previous)], alreadyReduced: protectedFirst.Count, reduceWithoutPrevious: true); + } + else + { + ReduceOthers([game.Name], [(previous, r.Bytes)], protectedFirst is not null); // 이 게임이 만진 페이지가 다른 게임의 뒤로 감 + } + log.Info($"{game.Name}: ladder ordered {r.Bytes >> 20} MiB in {r.Seconds:F1} s{Errors(r)}{Sparse(r)}"); break; case TouchEnd.Canceled: @@ -213,7 +224,8 @@ public IReadOnlyList DueGroup(IEnumerable games, Dat /// 묶음을 만진 순서대로 (touched: 게임, 만진 바이트, 만지기 전의 표식, 가장 오래된 것이 먼저) 표식을 같은 기준 시점 (재사용 누계·대기 바이트) 으로 다시 쓰고, /// 이번에 만지지 않은 표식은 앞에 있는 양을 SolarPolicy.AheadAfterOthersTouched 로 줄입니다. 대기 목록을 못 읽으면 표식은 그대로 두고 (시간 기준) 줄이기만 합니다. /// aheadLess: 진행 중인 스트림이 이미 읽은 양 중 끝의 사다리가 줄여 주지 않을 몫 (스트림 도중의 보호), 캐시 대기 바이트에서 빼고 계산합니다. - public void Regroup(IReadOnlyList<(string Game, long Bytes, LadderMark? Previous)> touched, long aheadLess = 0) + /// alreadyReduced: touched 의 앞 몇 개는 이미 다른 표식에서 줄였음 (스트림 끝의 묶음: 보호가 먼저 줄임). reduceWithoutPrevious: SolarPolicy.AheadAfterOthersTouched 의 withoutPreviousAll. + public void Regroup(IReadOnlyList<(string Game, long Bytes, LadderMark? Previous)> touched, long aheadLess = 0, int alreadyReduced = 0, bool reduceWithoutPrevious = false) { if (touched.Count == 0) { @@ -233,7 +245,7 @@ public void Regroup(IReadOnlyList<(string Game, long Bytes, LadderMark? Previous } } - ReduceOthersLocked([.. touched.Select(t => t.Game)], [.. touched.Select(t => (t.Previous, t.Bytes))]); + ReduceOthersLocked([.. touched.Select(t => t.Game)], [.. touched.Skip(alreadyReduced).Select(t => (t.Previous, t.Bytes))], reduceWithoutPrevious); _groupAt = at; } } @@ -264,20 +276,20 @@ public void Regroup(IReadOnlyList<(string Game, long Bytes, LadderMark? Previous } // except 가 아닌 표식의 A 를 줄입니다 (touched: 그 게임들이 만지기 전의 표식과 만진 바이트). - private void ReduceOthers(IEnumerable except, IReadOnlyList<(LadderMark? Previous, long Bytes)> touched) + private void ReduceOthers(IEnumerable except, IReadOnlyList<(LadderMark? Previous, long Bytes)> touched, bool withoutPreviousAll = false) { lock (_lock) { - ReduceOthersLocked(except, touched); + ReduceOthersLocked(except, touched, withoutPreviousAll); } } - private void ReduceOthersLocked(IEnumerable except, IReadOnlyList<(LadderMark? Previous, long Bytes)> touched) + private void ReduceOthersLocked(IEnumerable except, IReadOnlyList<(LadderMark? Previous, long Bytes)> touched, bool withoutPreviousAll = false) { HashSet skip = new(except, StringComparer.OrdinalIgnoreCase); foreach ((string game, LadderMark mark) in _marks.Where(m => !skip.Contains(m.Key)).Select(m => (m.Key, m.Value)).ToList()) { - _marks[game] = mark with { AheadBytes = SolarPolicy.AheadAfterOthersTouched(mark, touched.Select(t => (t.Previous, t.Bytes))) }; + _marks[game] = mark with { AheadBytes = SolarPolicy.AheadAfterOthersTouched(mark, touched.Select(t => (t.Previous, t.Bytes)), withoutPreviousAll) }; } } diff --git a/csharp/src/AsterCache.Service/SolarPolicy.cs b/csharp/src/AsterCache.Service/SolarPolicy.cs index c67047c..2c908f6 100644 --- a/csharp/src/AsterCache.Service/SolarPolicy.cs +++ b/csharp/src/AsterCache.Service/SolarPolicy.cs @@ -37,12 +37,15 @@ internal static class SolarPolicy /// 다른 게임들이 방금 만진 뒤 우리 앞에 있는 양: 그 게임 X 의 이전 표식이 우리 표식보다 먼저 만들어졌을 때만 (그때만 X 의 페이지가 우리 앞에 있었음) min(X 의 이전 표식이 만진 바이트, X 가 이번에 만진 바이트) 만큼 줄어듦 (0 미만이면 0). /// X 에 이전 표식이 없었거나 (새로 예열된 게임) 우리 표식보다 나중이면 줄이지 않습니다. 스트림이 디스크에서 새로 읽은 양은 재사용 누계에 이미 잡히므로 이 규칙에서 자연히 빠지고, 스파스로 건너뛴 양은 만진 바이트에 없습니다. /// 묶음 새로 고침·스트림 뒤 사다리·스트림 앞의 보호가 같은 규칙을 씁니다 (묶음에서 새 표식을 받는 게임은 GroupAhead 로 다시 계산하므로 대상이 아님). - 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)); + /// withoutPreviousAll: 이전 표식이 없는 X 가 만진 바이트 전체를 줄입니다 (스트림 끝의 사다리용): 재예열·보충 스트림의 게임은 표식이 이미 지워져 있어 위 규칙대로면 0 이 되는데, 실제로는 그 게임이 캐시에 가진 페이지가 우리 앞에서 뒤로 가기 때문. + /// 얼마가 그런 페이지인지 알 수 없어 만진 전체를 줄입니다 (과하면 새로 고침이 한 번 일찍 올 뿐이고 덜 줄이면 캐시를 잃음). + public static long AheadAfterOthersTouched(LadderMark mine, IEnumerable<(LadderMark? Previous, long TouchedBytes)> others, bool withoutPreviousAll = false) => + Ahead(mine.AheadBytes, others.Sum(o => o.Previous is { } previous ? (previous.At < mine.At ? Math.Min(previous.TouchedBytes, o.TouchedBytes) : 0) : withoutPreviousAll ? o.TouchedBytes : 0)); /// 스트림 도중의 보호가 새 표식의 A 를 계산할 때 캐시 대기 바이트에서 뺄 양: 스트림이 그때까지 읽은 양은 끝의 사다리에서 다시 만져져 보호한 게임들의 뒤로 가지만, - /// 끝의 사다리는 스트림 게임의 이전 표식이 있을 때만 min(이전 표식이 만진 양, 이번 양) 을 줄이므로 (AheadAfterOthersTouched) 그 몫을 빼 이중으로 줄지 않게 합니다. - public static long StreamAheadLess(long streamReadBytes, long priorTouchedBytes) => Math.Max(0, streamReadBytes - priorTouchedBytes); + /// 끝의 사다리는 스트림 게임의 이전 표식이 있으면 min(이전 표식이 만진 양, 이번 양) 만 줄이므로 (AheadAfterOthersTouched) 그 몫을 뺀 나머지를 미리 뺍니다. + /// 이전 표식이 없으면 (priorTouchedBytes 0) 끝의 사다리가 만진 전체를 줄이므로 (withoutPreviousAll) 여기서는 빼지 않습니다: 빼면 끝에서 건너뛴 게임이 두 번 줄고, 사다리 없이 끝나도 스트림 페이지는 그 게임 앞에 그대로 있어 안 빼는 쪽이 실제와 맞습니다. + public static long StreamAheadLess(long streamReadBytes, long priorTouchedBytes) => priorTouchedBytes > 0 ? Math.Max(0, streamReadBytes - priorTouchedBytes) : 0; /// 묶음을 만진 순서 (가장 오래된 것이 먼저) 대로 각 게임의 앞에 있는 양: g_i 의 A = max(0, C - (g_i 와 그 뒤에 만진 게임들이 만진 합)), C 는 만진 직후 캐시 대기 바이트. public static long[] GroupAhead(long cacheStandbyBytes, IReadOnlyList touchedBytes) diff --git a/csharp/tests/AsterCache.Tests/Service/SolarTests.cs b/csharp/tests/AsterCache.Tests/Service/SolarTests.cs index 62ca6e7..538ebd9 100644 --- a/csharp/tests/AsterCache.Tests/Service/SolarTests.cs +++ b/csharp/tests/AsterCache.Tests/Service/SolarTests.cs @@ -1701,8 +1701,8 @@ public void AWaitingSupplementLadderThatFindsDataMissingLeavesTheRefillMarkAndIs // ---- 묶음 새로 고침과 스트림 앞의 보호 ---- - // a (더 오래전, 순위가 낮고 A 가 작음: 3900 MiB) 와 b (최근, A 7900 MiB) 를 예열해 둘 다 표식이 있고 4 분 넘게 지난 상태. b 는 트리거 경로로 예열해 스트림 앞의 보호가 a 를 만지지 않게 함. - // 재사용 누계는 1000 MiB 에서 시작: a 는 1560 MiB (0.4 x 3900) 늘면 때가 되고 b 는 3160 MiB 가 필요. c 는 예열하지 않은 세 번째 게임. + // a (더 오래전, 순위가 낮고 A 가 작음: b 의 트리거 사다리가 이전 표식 없이 100 MiB 를 만져 3900 에서 3800 MiB) 와 b (최근, A 7900 MiB) 를 예열해 둘 다 표식이 있고 4 분 넘게 지난 상태. b 는 트리거 경로로 예열해 스트림 앞의 보호가 a 를 만지지 않게 함. + // 재사용 누계는 1000 MiB 에서 시작: a 는 1520 MiB (0.4 x 3800) 늘면 때가 되고 b 는 3160 MiB 가 필요. c 는 예열하지 않은 세 번째 게임. private Prewarmer TwoMarkedGames() { _lists = new MemoryLists(0, 1000 * Mib, 4000 * Mib); @@ -1721,7 +1721,7 @@ public void WhenOnlyOneMarkedGameIsDueTheTickRefreshesTheWholeGroupLowestRankFir { Prewarmer p = TwoMarkedGames(); _onTouch = () => _lists = new MemoryLists(0, 2600 * Mib, 9000 * Mib); // 만진 직후의 캐시 대기 바이트 C - GrowReuse(1000 + 1600); // a: 0.4 x 3900 = 1560 MiB 에 이름, b: 0.4 x 7900 = 3160 MiB 에는 못 미침 + GrowReuse(1000 + 1600); // a: 0.4 x 3800 = 1520 MiB 에 이름, b: 0.4 x 7900 = 3160 MiB 에는 못 미침 int logged = _log.Lines.Count; p.Tick(Auto()); @@ -1729,7 +1729,7 @@ public void WhenOnlyOneMarkedGameIsDueTheTickRefreshesTheWholeGroupLowestRankFir Assert.Equal(2, _touched.Count); Assert.All(_touched, t => Assert.Equal(Reversed(Reads), t)); string[] lines = [.. _log.Lines.Skip(logged).Where(l => l.Contains("cache refreshed in memory"))]; - Assert.Contains("a: cache refreshed in memory: 100 MiB in 0.4 s (1600 MiB repurposed of 3900 MiB ahead)", lines[0]); // 때가 되지 않은 b 도 함께, 순위가 낮은 a 가 먼저 + Assert.Contains("a: cache refreshed in memory: 100 MiB in 0.4 s (1600 MiB repurposed of 3800 MiB ahead)", lines[0]); // 때가 되지 않은 b 도 함께, 순위가 낮은 a 가 먼저 Assert.Contains("b: cache refreshed in memory: 100 MiB in 0.4 s (1600 MiB repurposed of 7900 MiB ahead)", lines[1]); _onTouch = null; _lists = new MemoryLists(0, 2700 * Mib, 9000 * Mib); @@ -1800,15 +1800,15 @@ public void AStreamThatIsNotATriggerTouchesTheOtherMarkedGamesLowestRankFirstBef Assert.True(result.Ran); Assert.Equal(2, touchesAtStream); // 스트림이 읽는 동안에는 a, b 를 이미 만졌음 - Assert.Equal(3, _touched.Count); // 그리고 이 게임의 사다리 + Assert.Equal(5, _touched.Count); // 그리고 사다리 직전의 보호 (a, b) 와 이 게임의 사다리 Assert.All(_touched, t => Assert.Equal(Reversed(Reads), t)); string[] lines = [.. _log.Lines.Skip(logged)]; int a = Array.FindIndex(lines, l => l.Contains("a: cache refreshed in memory")); int b = Array.FindIndex(lines, l => l.Contains("b: cache refreshed in memory")); int done = Array.FindIndex(lines, l => l.Contains("c: prewarm done (service start)")); Assert.True(a >= 0 && a < b && b < done); // 순위가 낮은 a 가 먼저 - Assert.Contains(", protected 2 games (200 MiB in ", lines[done]); - Assert.EndsWith(" s)", lines[done]); + Assert.Contains(", protected 2 games (400 MiB in ", lines[done]); // 앞과 사다리 직전, 두 번 합산 (서로 다른 게임은 2) + Assert.EndsWith(" s), 2 times", lines[done]); Assert.DoesNotContain(_log.Lines, l => l.Contains("a: prewarm done") && l.Contains("protected")); // 표식 있는 다른 게임이 없던 스트림에는 꼬리가 없음 } @@ -2172,10 +2172,10 @@ public void AStreamProtectsTheOtherMarkedGamesBetweenBatchesWhenTheyAreDueAndThe 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 의 사다리 + Assert.Equal([3, 3, 1, 1, 3, 3, 1, 3, 3, 3], TouchSizes()); // 앞의 보호 (a, b) → 묶음 0, 묶음 1 (읽기 → 점검 → 다시 만지기) → 보호 (a, b) → 묶음 2 → 사다리 직전의 보호 (a, b) → 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); + Assert.Contains(", protected 2 games (600 MiB in ", done); // 서로 다른 게임은 2, 양은 세 번 합산 + Assert.EndsWith(" s), 3 times", done); } [Fact] @@ -2212,7 +2212,7 @@ public void TheStreamDoesNotProtectBetweenBatchesWithinSixtySecondsOfTheLastGrou Assert.True(p.Run(Game("c"), "service start").Ran); - Assert.Equal([3, 3, 1, 1, 1, 3], TouchSizes()); // 앞의 보호만 + Assert.Equal([3, 3, 1, 1, 1, 3, 3, 3], TouchSizes()); // 앞의 보호와 사다리 직전의 보호만 (도중에는 없음) } [Fact] @@ -2581,9 +2581,62 @@ public void AProtectionDuringAStreamTakesWhatTheStreamReadSoFarOutOfTheAheadOfTh 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]); + // 스트림 끝의 사다리 직전 보호가 a, b, c 를 만진 순서대로 한 묶음으로 다시 표식해 (실제 순서) 도중의 값을 덮음: 최종값은 9000 - 300 (a), 9000 - 200 (b) + Assert.Contains("of 8700 MiB ahead", after[0]); + Assert.Contains("of 8800 MiB ahead", after[1]); + } + + // 도중의 보호를 받은 게임 e (다른 디스크) 가 스트림 끝에서는 게임 실행으로 막혀 건너뛰어지는 경우: 도중에 만든 표식의 A 와 끝의 줄이기를 함께 고정함. + // 도중의 보호 시점: C = 9000, 스트림이 만진 양 = 묶음 2 개 x 100 MiB, e 가 만진 양 100 MiB. + private void ProtectedMidStreamThenSkippedAtTheEnd(bool streamGameHasAMark) + { + GameSettings e = Game("e") with { OriginRoot = @"E:\Games\e" }; + GrowReuse(1000); + Prewarmer p = MakePrewarmer(e, Game("c")); + Assert.True(p.Run(e, "service start").Ran); + if (streamGameHasAMark) + { + _now += TimeSpan.FromMinutes(2); + 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); // e 가 때가 됨 + } + + if (i == 2) + { + _ = _running.Add("e"); // 도중의 보호는 끝났고, 끝에서는 e 가 실행 중이라 막힘 + } + }; + + Assert.True(p.Run(Game("c"), "rewarm", ignoreCooldown: true).Ran); + _ = p.Solar.Refresh(e, CancellationToken.None); + } + + [Fact] + public void WhenTheStreamGameHadNoMarkAProtectedGameThatTheEndSkipsIsLoweredOnlyByTheEndLadderNotTwice() + { + ProtectedMidStreamThenSkippedAtTheEnd(streamGameHasAMark: false); + + // 도중: 이전 표식이 없으면 스트림 몫을 빼지 않아 9000 - 100 (e) = 8900, 끝: 건너뛴 e 가 사다리 100 MiB 전체만큼 줄어 8800 (둘 다 빼면 8600) + Assert.Contains("of 8800 MiB ahead", _log.Lines.Last()); + } + + [Fact] + public void WhenTheStreamGameHadAMarkAProtectedGameThatTheEndSkipsIsLoweredByTheShareTheEndLadderDoesNotTakeAndThenByTheEndLadder() + { + ProtectedMidStreamThenSkippedAtTheEnd(streamGameHasAMark: true); + + // 도중: 스트림 몫 200 에서 이전 표식이 만진 100 을 뺀 100 만 빼 9000 - 100 - 100 = 8800, 끝: 이전 표식이 e 의 표식보다 먼저라 min(100, 100) = 100 이 더 줄어 8700 + Assert.Contains("of 8700 MiB ahead", _log.Lines.Last()); } [Fact] @@ -2592,19 +2645,268 @@ public void WhenTheStreamGameHadAMarkTheEndLadderAndTheProtectionDoNotLowerWhatI 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]); + // 이전 표식이 있어도 같은 결과: 스트림 끝의 사다리 직전 보호가 a, b, c 를 만진 순서대로 한 묶음으로 다시 표식해 9000 - 300 (a), 9000 - 200 (b) + Assert.Contains("of 8700 MiB ahead", after[0]); + Assert.Contains("of 8800 MiB ahead", after[1]); } [Fact] - public void TheStreamShareToTakeOutIsWhatTheStreamReadMinusWhatTheEndLadderLowersAnyway() + public void TheStreamShareToTakeOutIsWhatTheStreamReadMinusWhatTheEndLadderLowersAnywayAndNothingWithoutAPreviousMark() { - Assert.Equal(200 * Mib, SolarPolicy.StreamAheadLess(200 * Mib, 0)); + Assert.Equal(0, 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 AGameStartOrAPreemptionWhileTheEndProtectionRunsLeavesNoLadderAndNoStoppedLadderLine() + { + Prewarmer started = TwoMarkedGames(); + bool armed = false; + ThreeSingleOpBatches(); + _streamer.BetweenProgressAndTouch = i => armed = i == 2; // 마지막 묶음 뒤 사다리 직전의 보호부터 (보호 만지기는 3 op, 묶음은 1 op) + _onTouch = () => + { + if (armed && _touched[^1].Count == 3) + { + started.NoteGameStart("c"); + } + }; + + _ = started.Run(Game("c"), "service start"); + + Assert.DoesNotContain(_log.Lines, l => l.Contains("c: ladder ordered") || l.Contains("c: ladder stopped")); + Assert.False(started.Solar.HasMark("c")); + + _onTouch = null; + _touched.Clear(); + _streamer.Batches.Clear(); + Prewarmer waiting = TwoMarkedGames(); + int logged = _log.Lines.Count; + ThreeSingleOpBatches(); + IDisposable? wait = null; + armed = false; + _streamer.BetweenProgressAndTouch = i => armed = i == 2; + _onTouch = () => + { + if (armed && _touched[^1].Count == 3) + { + wait ??= waiting.BeginTriggerWait([Game("b")]); + } + }; + + _ = waiting.Run(Game("c"), "service start"); + wait?.Dispose(); + + Assert.DoesNotContain(_log.Lines.Skip(logged), l => l.Contains("c: ladder ordered") || l.Contains("c: ladder stopped")); + Assert.False(waiting.Solar.HasMark("c")); + } + + [Fact] + public void ASupplementThatReadItsWindowToTheEndProtectsTheOtherMarkedGamesRightBeforeItsLadder() + { + ReadOp[] all = HotSet(50); + (_streamer.All, _streamer.ZoneMib) = (all, 5000); + _avail = 7000; // 핫 집합 5000 MiB + 예비 4096 MiB 가 다 들어가지 않아 앞부분만 읽음 + Prewarmer p = TwoMarkedGames(); + Assert.True(p.Run(Game("c"), "service start").Ran); + Assert.True(p.SupplementPending("c")); + _touched.Clear(); + _avail = 20000; + Assert.False(p.SupplementWhenIdle(Game("c"), CancellationToken.None)); + _now += TimeSpan.FromSeconds(61); + int logged = _log.Lines.Count; + + Assert.True(p.SupplementWhenIdle(Game("c"), CancellationToken.None)); + + string[] lines = [.. _log.Lines.Skip(logged)]; + int a = Array.FindLastIndex(lines, l => l.Contains("a: cache refreshed in memory")); + int b = Array.FindLastIndex(lines, l => l.Contains("b: cache refreshed in memory")); + int ladder = Array.FindIndex(lines, l => l.Contains("c: ladder ordered")); + Assert.True(a >= 0 && a < b && b < ladder); // 사다리 직전의 보호 (a, b) 가 이 게임의 사다리보다 먼저 + Assert.Contains(_log.Lines.Skip(logged), l => l.Contains("c: supplement prewarm done") && l.Contains(", protected 2 games (") && l.EndsWith(" s), 2 times")); + } + + [Theory] + [InlineData(99, 300, 200, 6800)] // 이전 표식이 없으면 이번에 만진 200 MiB 전체 + [InlineData(99, 300, 9000, 0)] // 0 미만이 되지 않음 + [InlineData(-1, 300, 200, 6800)] // 이전 표식이 먼저면 기존 규칙 min(300, 200) + [InlineData(-1, 100, 200, 6900)] + [InlineData(1, 300, 200, 7000)] // 이전 표식이 나중이면 줄이지 않음 + public void WithoutAPreviousMarkTheStreamEndRuleLowersWhatIsAheadByEverythingTheLadderTouched(int previousIsEarlier, long previousTouchedMib, long touchedNowMib, long expectedMib) + { + LadderMark mine = Mark(0, 7000, _now); + LadderMark? previous = previousIsEarlier == 99 ? null : Previous(previousTouchedMib, _now + TimeSpan.FromMinutes(previousIsEarlier)); + + Assert.Equal(expectedMib * Mib, SolarPolicy.AheadAfterOthersTouched(mine, [(previous, touchedNowMib * Mib)], withoutPreviousAll: true)); + } + + [Fact] + public void ALadderThatFollowsAProtectionRemarksTheProtectedGamesAndItselfAsOneGroupAndLowersTheRestOnlyByItsOwnTouch() + { + Solar solar = Make(); + _ = solar.Order(Game("x"), Reads, CancellationToken.None); // 보호에서 빠질 게임, A = 7900 + _now += TimeSpan.FromMinutes(1); + _ = solar.Order(Game("a"), Reads, CancellationToken.None); + _now += TimeSpan.FromMinutes(1); + solar.Regroup([("a", 100 * Mib, null)]); // 보호가 a 를 만짐 (x 는 건너뜀) + + _ = solar.Order(Game("c"), Reads, CancellationToken.None, protectedFirst: [("a", 100 * Mib)]); + + _ = solar.Refresh(Game("a"), CancellationToken.None); + Assert.Contains("of 7800 MiB ahead", _log.Lines.Last()); // 8000 - (a 100 + c 100) + _ = solar.Refresh(Game("c"), CancellationToken.None); + Assert.Contains("of 7900 MiB ahead", _log.Lines.Last()); // 8000 - c 100 + _ = solar.Refresh(Game("x"), CancellationToken.None); + Assert.Contains("of 7800 MiB ahead", _log.Lines.Last()); // 건너뛴 x: c 가 만진 100 만 (a 의 몫은 보호가 이미 반영), c 에 이전 표식이 없어도 전체 + } + + [Fact] + public void ALadderAfterAStreamLowersTheOthersByItsWholeTouchEvenWhenNoGameWasProtectedButAnyOtherLadderStaysTheSame() + { + Solar solar = Make(); + _ = solar.Order(Game("x"), Reads, CancellationToken.None); + _ = solar.Order(Game("c"), Reads, CancellationToken.None, protectedFirst: []); // 보호를 못 한 스트림 끝의 사다리 + _ = solar.Refresh(Game("x"), CancellationToken.None); + Assert.Contains("of 7800 MiB ahead", _log.Lines.Last()); + + Solar plain = Make(); + _ = plain.Order(Game("x"), Reads, CancellationToken.None); + _ = plain.Order(Game("c"), Reads, CancellationToken.None); // 스트림 끝이 아닌 첫 사다리는 지금과 같음 + _ = plain.Refresh(Game("x"), CancellationToken.None); + Assert.Contains("of 7900 MiB ahead", _log.Lines.Last()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void TheEndOfAStreamProtectsTheOtherMarkedGamesRightBeforeItsLadderAndMarksThemAllAsOneGroupWhetherOrNotTheStreamGameHadAMark(bool streamGameHasAMark) + { + Prewarmer p = TwoMarkedGames(); + if (streamGameHasAMark) + { + Assert.True(p.Run(Game("c"), "service start").Ran); + _now += TimeSpan.FromMinutes(2); + } + + _touched.Clear(); + _onTouch = () => _lists = new MemoryLists(0, 2600 * Mib, 9000 * Mib); // 만진 직후의 캐시 대기 바이트 C + int logged = _log.Lines.Count; + + Assert.True(p.Run(Game("c"), "rewarm", ignoreCooldown: true).Ran); + + Assert.Equal(5, _touched.Count); // 앞의 보호 (a, b), 사다리 직전의 보호 (a, b), c 의 사다리 + string[] lines = [.. _log.Lines.Skip(logged)]; + int a = Array.FindLastIndex(lines, l => l.Contains("a: cache refreshed in memory")); + int b = Array.FindLastIndex(lines, l => l.Contains("b: cache refreshed in memory")); + int ladder = Array.FindIndex(lines, l => l.Contains("c: ladder ordered")); + Assert.True(a >= 0 && a < b && b < ladder); // 사다리보다 먼저, 순위가 낮은 a 부터 + _onTouch = null; + _ = p.Solar.Refresh(Game("a"), CancellationToken.None); + _ = p.Solar.Refresh(Game("b"), CancellationToken.None); + _ = p.Solar.Refresh(Game("c"), CancellationToken.None); + string[] after = [.. _log.Lines.TakeLast(3)]; + Assert.Contains("of 8700 MiB ahead", after[0]); // C 9000 - (a, b, c 가 만진 300): 이전 표식 유무와 무관, 이중으로 줄지 않음 + Assert.Contains("of 8800 MiB ahead", after[1]); + Assert.Contains("of 8900 MiB ahead", after[2]); + } + + [Fact] + public void ATriggerStreamWithoutAPreviousMarkLowersWhatIsAheadOfTheOtherGamesByEverythingItsLadderTouched() + { + GrowReuse(1000); + Prewarmer p = MakePrewarmer(Game("a"), Game("b")); + Assert.True(p.Run(Game("a"), "service start").Ran); // A = 7900 MiB + _now += TimeSpan.FromMinutes(2); + _touched.Clear(); + int touchesAtStream = -1; + _streamer.DuringStream = () => touchesAtStream = _touched.Count; + + Assert.True(p.Run(Game("b"), "launcher", trigger: true).Ran); + + Assert.Equal(0, touchesAtStream); + Assert.Single(_touched); // 사다리 직전의 보호도 없음 + _ = p.Solar.Refresh(Game("a"), CancellationToken.None); + Assert.Contains("(0 MiB repurposed of 7800 MiB ahead)", _log.Lines.Last()); // 7900 - b 가 만진 100 전체 + } + + [Fact] + public async Task WhenTheProtectionCannotRunAtTheEndOfAStreamTheLadderStillLowersTheOthersByItsWholeTouch() + { + GrowReuse(1000); + Prewarmer p = MakePrewarmer(Game("a"), Game("c")); + Assert.True(p.Run(Game("a"), "service start").Ran); // A = 7900 MiB + _now += TimeSpan.FromMinutes(2); + _touched.Clear(); + object rewarm = typeof(Prewarmer).GetField("_rewarm", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(p)!; + object groupLock = typeof(Rewarmer).GetField("_groupLock", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(rewarm)!; + using var held = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + Task holder = Task.Run(() => + { + lock (groupLock) // 유지 틱의 묶음 새로 고침이 도는 중: 보호는 기다리지 않고 건너뜀 + { + held.Set(); + release.Wait(); + } + }); + held.Wait(); + try + { + Assert.True(p.Run(Game("c"), "service start").Ran); + } + finally + { + release.Set(); + await holder; + } + + Assert.Single(_touched); // c 의 사다리만 + _ = p.Solar.Refresh(Game("a"), CancellationToken.None); + Assert.Contains("of 7800 MiB ahead", _log.Lines.Last()); + } + + [Fact] + public void AGameThatAGateKeepsOutOfTheProtectionStillHasItsAheadLoweredByTheStreamEndLadder() + { + GameSettings e = Game("e") with { OriginRoot = @"E:\Games\e" }; // 다른 디스크 + GrowReuse(1000); + Prewarmer p = MakePrewarmer(e, Game("c")); + Assert.True(p.Run(e, "service start").Ran); // A = 7900 MiB + _now += TimeSpan.FromMinutes(2); + _touched.Clear(); + _ = _running.Add("e"); // e 는 실행 중: 게이트가 막음 + + Assert.True(p.Run(Game("c"), "service start").Ran); + + Assert.Single(_touched); + _ = p.Solar.Refresh(e, CancellationToken.None); + Assert.Contains("of 7800 MiB ahead", _log.Lines.Last()); + } + + [Fact] + public void AStreamFinishedShortAtTheMemoryLimitProtectsTheOtherMarkedGamesBeforeItsLadderToo() + { + ReadOp[] all = HotSet(50); + (_streamer.All, _streamer.ZoneMib) = (all, 5000); + Prewarmer p = MakePrewarmer(Game("a"), Game("b")); + Assert.True(p.Run(Game("a"), "service start").Ran); // 끝까지 읽음 + _now += TimeSpan.FromMinutes(2); + _touched.Clear(); + _avail = 4096 + 256 + 3000; + _streamer.ReadMib = 1000; + _streamer.OnProgress = progress => + { + _avail = 4096 + 800; + progress(1000 * Mib, 3000 * Mib); + }; + + Assert.True(p.Run(Game("b"), "service start").Ran); + + Assert.Equal((1000L, 5000L), p.PartialInfo("b")); + Assert.Equal([50, 50, 10], [.. _touched.Select(t => t.Count)]); // 앞의 보호 (a), 사다리 직전의 보호 (a), 읽은 앞 1000 MiB 의 사다리 + } + [Fact] public void TailsThatWouldShowZeroMibAreLeftOut() { diff --git a/docs/SOLAR.ko.md b/docs/SOLAR.ko.md index 3644e86..b70fb59 100644 --- a/docs/SOLAR.ko.md +++ b/docs/SOLAR.ko.md @@ -18,9 +18,9 @@ SOLAR 는 두 문제를 모두 일부러 디스크를 읽지 않고 다룹니다 ## 어떻게 판단하나 1. **사다리를 만듭니다.** 게임의 예열이 목록을 끝까지 읽으면, SOLAR 가 그 게임이 캐시에 가진 것을 읽은 순서의 역순으로 만집니다. 목록의 뒤쪽이 먼저, 로딩 앞부분과 공통 블록이 마지막입니다. 만진 페이지는 대기 목록의 맨 뒤로 가므로 밀려나는 순서가 중요도의 역순이 됩니다. 중요도 순서는 예열이 이미 쓰는 순서이고, 게임 자신의 기록된 읽기에서 나옵니다. 예열이 아직 읽는 도중에도 방금 읽은 묶음을 읽은 순서대로 곧바로 다시 만집니다. 예열 읽기는 이미 캐시에 있는 페이지를 건너뛰고 목록에서 옮기지 않으므로, 이렇게 하지 않으면 그 게임의 나머지 페이지가 이전 예열 때의 오래된 순서로 남습니다. 묶음을 만지면 건너뛴 페이지도 뒤로 갑니다. 끊긴 읽기를 이어 읽는 보충은 이전 회가 읽은 앞부분을 건너뛰므로, 나머지를 읽기 전에 그 건너뛴 앞부분을 목록 순서대로 한 번 만집니다. 캐시에 있는 데이터 1 GiB 당 약 0.5초가 들고, 예열 완료 줄에 `, requeued N MiB in S s` 로 보입니다. -2. **표식을 남깁니다.** 사다리를 만든 뒤 시각, Windows 가 지금까지 다시 쓴 캐시 페이지의 누계, 그리고 앞에 있는 양(대기 목록의 캐시 양에서 이 게임이 만진 양을 뺀 값)을 적어 둡니다. 앞에 있는 양은 늘 같은 값이 아닙니다. 다른 게임이 자기 캐시를 다시 만지면 이 게임보다 앞에 있던 그 게임의 페이지가 뒤로 갑니다. 그래서 그 게임에 이전 표식이 있고 그 표식이 이 게임의 표식보다 먼저 만들어졌으면, 앞에 있는 양을 그 이전 표식이 만진 양과 이번에 만진 양 중 작은 쪽만큼 줄입니다(0 미만은 0). 이번에 만진 것 중 나머지는 디스크에서 새로 읽은 것이라 다시 쓴 양의 누계에 이미 잡히고, 이전 표식이 없던 게임은 위치를 알 수 없으므로 둘 다 줄이지 않습니다. 표식은 메모리에만 있습니다. +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-유지)에 맡깁니다. 다만 게임 드라이브가 바쁜 상태(4 MiB/s 이상이거나 처리량을 모름)에서 표본이 식어 나오면, 다른 프로그램의 읽기 때문에 느렸을 수 있으므로 한 표식에서 처음 한 번만 표식을 지우지 않고 그 회를 건너뜁니다(로그 없음). 같은 표식에서 둘째로 식어 나오면 바쁘지 않을 때와 같게 표식을 지우고 재예열 점검에 맡깁니다. 봐 준 기억은 표식이 지워지거나 새로 서거나 warm 표본이 나오면 사라집니다. 관문에 막힌 게임은 건너뛰고 나머지는 계속합니다. 때가 됐어도 막힌 게임은 묶음을 시작하게 하지 않으므로, 그 게임 때문에 다른 게임이 되풀이해 만져지지 않습니다. 끝나면 만진 게임들의 표식을 같은 시점 기준으로 다시 씁니다. 한 게임의 앞에 있는 양은 그때의 대기 목록 캐시 양에서 그 게임과 그 뒤에 만진 게임들이 만진 양을 뺀 값이고, 다시 쓴 양의 누계도 그 시점의 값입니다. 건너뛴 게임은 표식을 그대로 두되 앞에 있는 양을 2번의 규칙으로 줄입니다. 묶음 새로 고침은 직전 묶음 새로 고침 뒤 60초 안에는 다시 하지 않으며, 스트림 앞의 보호(세부 참고)도 한 번으로 셉니다. +4. **묶음 전체를 디스크 없이 새로 고칩니다.** 표식이 있는 게임 중 지금 만질 수 있는(관문이 열린) 게임 하나라도 시점이 되면 표식이 있는 게임 전체를 순위가 낮은 게임부터, 가장 최근에 예열한 게임을 마지막으로 새로 고칩니다. 아직 시점이 아닌 게임도 함께 고치는 것은 다른 게임을 위해 만진 페이지가 그 게임의 뒤로 갔기 때문입니다. 게임마다 새로 고치기 전에 평소의 표본 점검(8곳에서 1 MiB 씩)을 합니다. 표본이 따뜻하면 같은 사다리를 다시 만져 페이지를 목록 맨 뒤로 보냅니다. 식었거나 읽을 수 없으면 표식을 지우고 [재예열 점검](AUTO_MODE.ko.md#5-유지)에 맡깁니다. 다만 게임 드라이브가 바쁜 상태(4 MiB/s 이상이거나 처리량을 모름)에서 표본이 식어 나오면, 다른 프로그램의 읽기 때문에 느렸을 수 있으므로 한 표식에서 처음 한 번만 표식을 지우지 않고 그 회를 건너뜁니다(로그 없음). 같은 표식에서 둘째로 식어 나오면 바쁘지 않을 때와 같게 표식을 지우고 재예열 점검에 맡깁니다. 봐 준 기억은 표식이 지워지거나 새로 서거나 warm 표본이 나오면 사라집니다. 관문에 막힌 게임은 건너뛰고 나머지는 계속합니다. 때가 됐어도 막힌 게임은 묶음을 시작하게 하지 않으므로, 그 게임 때문에 다른 게임이 되풀이해 만져지지 않습니다. 끝나면 만진 게임들의 표식을 같은 시점 기준으로 다시 씁니다. 한 게임의 앞에 있는 양은 그때의 대기 목록 캐시 양에서 그 게임과 그 뒤에 만진 게임들이 만진 양을 뺀 값이고, 다시 쓴 양의 누계도 그 시점의 값입니다. 건너뛴 게임은 표식을 그대로 두되 앞에 있는 양을 2번의 규칙으로 줄입니다. 묶음 새로 고침은 직전 묶음 새로 고침 뒤 60초 안에는 다시 하지 않으며, 스트림 앞·도중·사다리 직전의 보호(세부 참고)도 한 번으로 셉니다. 게임을 만진 보호는 모두 마지막 묶음 새로 고침 시각을 갱신합니다(사다리 직전의 보호 자체는 60초를 기다리지 않습니다). 5. **디스크가 읽히면 멈춥니다.** 캐시에 없는 페이지를 만지면 디스크 읽기가 됩니다. 뷰 하나를 만질 때마다 그동안 시스템 전체가 디스크에서 읽은 쪽 수를 비교해, 32 MiB 를 넘게 세어지면 멈춥니다. 그러면 표식을 지우고 그 게임에 "다시 채워야 함" 표시를 남겨, 다음 재예열 점검이 표본이 따뜻해 보여도 그 게임을 다시 예열합니다. 캐시에 있는 부분은 디스크 시간이 거의 들지 않으므로 실제로는 빠진 부분만 디스크에서 읽힙니다. 이 재예열은 게임마다 마지막 예열 시도로부터 `auto.rewarm_minutes` 에 한 번을 넘지 않습니다. Windows 의 통계를 읽을 수 없으면(예: 권한이 없을 때) 3번은 마지막 사다리 뒤 10분마다 새로 고치는 것으로 바뀌고 나머지는 그대로입니다. @@ -35,9 +35,10 @@ 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)` 를 덧붙입니다(만진 것이 없으면 붙이지 않음). 실패하거나 취소돼도 스트림은 계속하고 결과는 같습니다. -- **스트림 도중의 보호.** 트리거가 아닌 예열 스트림이 도는 동안, 묶음 하나를 읽고 점검하고 다시 만진 직후마다 표식이 있는 다른 게임이 새로 고칠 때가 됐는지 봅니다. 유지 틱과 같은 판정(위 3번의 기준, 만질 수 있는 게임 하나라도 때가 됐고 직전 묶음 새로 고침 뒤 60초가 지났을 때)과 같은 함수입니다. 판정에는 대기 목록 통계를 한 번 읽으므로 직전 판정 뒤 10초가 지났을 때만 봅니다. 때가 됐으면 스트림 앞의 보호와 같은 함수로 다른 게임들을 다시 만집니다. 스트림과 같은 스레드·같은 디스크 읽기 대여·같은 중지 신호 아래에서, 게임 시작이나 런처 선점 표시가 서 있지 않을 때만 돌고, 스트림 자신의 게임은 대상이 아닙니다(끝의 사다리가 만듭니다). 런처 예열은 하지 않습니다. 걸린 시간은 `yielded` 시간과 스트림의 노출 시간에서 빼고, 완료 줄의 `protected` 꼬리에 합산합니다(서로 다른 게임 수, 양과 시간의 누계, 두 번 이상이면 횟수). 새 표식의 앞에 있는 양을 계산할 때는 스트림이 그때까지 만진 양에서 끝의 사다리가 어차피 줄일 몫(스트림 게임의 이전 표식이 만진 양)을 뺀 값을 대기 목록 캐시 양에서 뺍니다. 스트림이 읽은 페이지는 끝의 사다리에서 다시 만져져 그 게임들의 뒤로 가기 때문이며, 이중으로 줄지 않게 하려는 것입니다. 실패하거나 취소돼도 스트림은 계속하고 결과는 같습니다. +- **스트림 도중의 보호.** 트리거가 아닌 예열 스트림이 도는 동안, 묶음 하나를 읽고 점검하고 다시 만진 직후마다 표식이 있는 다른 게임이 새로 고칠 때가 됐는지 봅니다. 유지 틱과 같은 판정(위 3번의 기준, 만질 수 있는 게임 하나라도 때가 됐고 직전 묶음 새로 고침 뒤 60초가 지났을 때)과 같은 함수입니다. 판정에는 대기 목록 통계를 한 번 읽으므로 직전 판정 뒤 10초가 지났을 때만 봅니다. 때가 됐으면 스트림 앞의 보호와 같은 함수로 다른 게임들을 다시 만집니다. 스트림과 같은 스레드·같은 디스크 읽기 대여·같은 중지 신호 아래에서, 게임 시작이나 런처 선점 표시가 서 있지 않을 때만 돌고, 스트림 자신의 게임은 대상이 아닙니다(끝의 사다리가 만듭니다). 런처 예열은 하지 않습니다. 걸린 시간은 `yielded` 시간과 스트림의 노출 시간에서 빼고, 완료 줄의 `protected` 꼬리에 합산합니다(서로 다른 게임 수, 양과 시간의 누계, 두 번 이상이면 횟수). 새 표식의 앞에 있는 양을 계산할 때는 스트림이 그때까지 만진 양에서 끝의 사다리가 어차피 줄일 몫(스트림 게임의 이전 표식이 만진 양)을 뺀 값을 대기 목록 캐시 양에서 뺍니다. 이전 표식이 없으면 끝의 사다리가 만진 전체를 줄이므로 아무것도 빼지 않습니다. 스트림이 읽은 페이지는 끝의 사다리에서 다시 만져져 그 게임들의 뒤로 가기 때문이며, 스트림 끝에서 건너뛰어진 게임의 앞에 있는 양이 이중으로 줄지 않게 하려는 것입니다(스트림 끝에서 아래의 사다리 직전 보호가 되면 그 게임들의 표식은 이 값을 버리고 실제 순서로 다시 쓰입니다). 실패하거나 취소돼도 스트림은 계속하고 결과는 같습니다. +- **사다리 직전의 보호와 묶음 다시 표식.** 트리거가 아닌 예열 스트림이 끝나 그 게임의 사다리를 만들기 직전에(한도로 마무리한 뒤의 사다리 포함), 표식이 있는 다른 게임들을 순위가 낮은 것부터 한 번 만집니다. 스트림 앞의 보호와 같은 함수·관문·표본 점검·디스크 읽기 대여·중지 신호이고 스트림의 게임은 뺍니다. 스트림 끝은 한 번뿐이고 이때 순서를 정해야 앞에 있는 양이 맞으므로 "때가 됐는지"와 묶음 최소 간격 60초는 보지 않습니다. 그 뒤 사다리를 만들고, 만진 다른 게임들과 이 게임을 만진 순서대로 한 묶음으로 표식을 다시 씁니다(4번의 계산). 그러면 이 게임에 이전 표식이 있었는지와 무관하게 앞에 있는 양이 사다리 직후의 실제 순서와 맞고, 묶음으로 다시 쓴 표식은 2번의 줄이기를 이중으로 받지 않습니다. 이때는 스트림 도중의 보호가 쓰는 "스트림이 그때까지 만진 양 빼기"를 쓰지 않습니다. 보호를 못 한 경우(런처 예열, 표식이 있는 다른 게임 없음, 묶음 새로 고침이 도는 중이라 건너뜀)와 보호에서 건너뛴 게임(관문에 막힘 등)은 이 게임에 이전 표식이 없으면 그 사다리가 만진 양 전체를 앞에 있는 양에서 줄입니다. 스트림 게임의 표식은 이미 지워져 있을 수 있고(식음이나 빠짐을 만난 뒤 등), 그러면 2번의 규칙으로는 0 이 되기 때문입니다. 얼마가 앞에서 뒤로 갔는지는 알 수 없어, 많이 줄여 새로 고침이 한 번 일찍 오는 쪽을 택했습니다. 이전 표식이 있으면 2번의 규칙 그대로입니다. 쓴 시간은 `yielded` 시간과 스트림의 노출 시간에서 빼고 완료 줄의 `protected` 꼬리에 합산합니다. - **서비스 시작 직후의 사다리.** 서비스 시작 예열이 `prewarm skipped (already warm) [service start]` 로 건너뛴 게임은 그 자리에서, 건너뛸 때의 표본 판정을 그대로 쓰고(표본을 다시 읽지 않음) 재예열 점검의 첫 사다리와 같은 조건·같은 함수로 첫 사다리를 만듭니다. 여러 게임이면 순위가 낮은 게임부터 만든 뒤 묶음 규칙으로 표식을 정리합니다. 부분 예열 상태(보충 대기)인 게임은 보충이 메모리를 기다리는 게임의 첫 사다리와 같은 조건을 따릅니다. 런처 경로는 하지 않습니다. 서비스를 다시 시작하면 표식이 없어 첫 재예열 점검(`auto.rewarm_minutes` 뒤)까지 새로 고침이 없던 공백을 메우기 위한 것입니다. -- **첫 사다리가 만들어지는 곳.** 게임의 예열 스트림(서비스 시작, 런처, 재예열, 보충)이 끝까지 읽은 직후입니다. 게임의 사다리가 만들어지면 다른 게임 표식의 앞에 있는 양을 2번의 규칙으로 줄입니다(이전 표식이 없던 게임의 첫 사다리는 줄이지 않음). 메모리 한도에서 마무리한 스트림도 끝난 직후 같은 대여 안에서 읽은 범위의 사다리를 만듭니다. 대상은 스트림이 읽은 것과 같은 창의 목록을 읽은 양에서 자른 앞부분이라 읽지 않은 구간은 만지지 않습니다. 틈 메우기 예산이 없는 창 읽기나 이어 읽기일 때만 그렇습니다(스트림이 읽은 목록이 같은 창으로 계획한 목록이기 때문). 그 예산이 있는 계획으로 전체 목록을 읽다 한도에서 마무리한 보충은 만들지 않고 재예열 점검에 맡기며, 점검의 첫 사다리가 메모리를 기다리는 게임을 받칩니다. 서비스를 다시 시작하면 표식이 없어지므로, 시작 예열이 이미 따뜻하다고 건너뛴 게임은 그 직후에, 그 밖의 게임은 표본이 따뜻한 첫 재예열 점검에서 새 사다리를 만듭니다. +- **첫 사다리가 만들어지는 곳.** 게임의 예열 스트림(서비스 시작, 런처, 재예열, 보충)이 끝까지 읽은 직후입니다. 게임의 사다리가 만들어지면 다른 게임 표식의 앞에 있는 양을 2번의 규칙으로 줄입니다(이전 표식이 없던 게임의 첫 사다리는 줄이지 않음, 예열 스트림 끝의 사다리는 위 "사다리 직전의 보호와 묶음 다시 표식"을 따름). 메모리 한도에서 마무리한 스트림도 끝난 직후 같은 대여 안에서 읽은 범위의 사다리를 만듭니다. 대상은 스트림이 읽은 것과 같은 창의 목록을 읽은 양에서 자른 앞부분이라 읽지 않은 구간은 만지지 않습니다. 틈 메우기 예산이 없는 창 읽기나 이어 읽기일 때만 그렇습니다(스트림이 읽은 목록이 같은 창으로 계획한 목록이기 때문). 그 예산이 있는 계획으로 전체 목록을 읽다 한도에서 마무리한 보충은 만들지 않고 재예열 점검에 맡기며, 점검의 첫 사다리가 메모리를 기다리는 게임을 받칩니다. 서비스를 다시 시작하면 표식이 없어지므로, 시작 예열이 이미 따뜻하다고 건너뛴 게임은 그 직후에, 그 밖의 게임은 표본이 따뜻한 첫 재예열 점검에서 새 사다리를 만듭니다. @@ -77,7 +78,7 @@ 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, S 초 만졌습니다. 두 번 이상 보호했으면 꼬리 끝에 `, R times` 가 더 붙습니다(한 번이면 없음). 만진 것이 없으면 붙지 않고, 그 게임들의 `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 (...)`: 목록 일부가 캐시에 없었습니다. 표식이 있던 게임이면 줄 끝에 `, 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` 는 주기가 지나기를 기다린다는 뜻입니다. @@ -92,6 +93,7 @@ Windows 의 통계를 읽을 수 없으면(예: 권한이 없을 때) 3번은 - **다른 프로그램이 이 게임 앞쪽 페이지를 다시 참조해 뒤로 보내는 것은 셀 수 없습니다.** 그래서 기준이 늦을 수 있고, 그 경우는 재예열 점검이 받칩니다. - **묶음 새로 고침, 스트림 앞의 보호, 한도 마무리 뒤 사다리의 효과는 설치본에서 아직 확인하지 않았습니다.** - **드라이브가 바쁠 때도 새로 고침은 돕니다(디스크를 읽지 않으므로).** 표본이나 만지기가 캐시에 없는 부분을 만나면 그때는 읽게 되며, 그 양은 표본 8 MiB 와 멈추기 전까지의 최대 약 100 MiB 입니다. 바쁜 드라이브에서 식어 나온 표본은 한 표식에서 처음 한 번만 봐 주고(표식 유지, 그 회의 표본 8 MiB 읽기), 둘째부터는 표식을 지우고 재예열 점검에 맡깁니다. 드라이브 처리량을 읽을 수 없는 시스템도 같습니다. 디스크에서 다시 읽을지는 여전히 재예열 점검만 정합니다. +- **사다리 직전의 보호와 묶음 다시 표식, 이전 표식이 없을 때 사다리가 만진 양 전체를 줄이는 규칙의 효과는 설치본에서 아직 확인하지 않았습니다.** 전체를 줄이는 것은 실제 양을 알 수 없어서 고른 보수적인 값이라, 실제보다 많이 줄어 새로 고침이 일찍 올 수 있습니다(디스크에서 다시 예열할지는 여전히 재예열 점검만 정합니다. 다만 일찍 온 새로 고침도 표본 8 MiB 와, 캐시에 없는 부분을 만나면 멈추기 전까지 최대 약 100 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)` 가 한 번 남고, 다음 예열 스트림이 다시 판정할 때까지 재예열 점검이 사다리를 다시 시도하지 않습니다. @@ -130,6 +132,14 @@ Windows 의 통계를 읽을 수 없으면(예: 권한이 없을 때) 3번은 설치된 서비스가 돌고 PC 를 평소처럼 쓰는 중에 HDD 의 512 MiB 시험 파일 4개를 캐시에 올리고 61분 동안 지켜봤습니다. 다시 만지지 않은 세 파일은 다시 쓰기가 몰린 한 번에 함께 사라졌고, 15분 뒤 한 번 더 만진 파일은 39분을 더 남았습니다. 서비스가 다른 게임을 새로 고치자 시험 파일 앞에 있던 양이 12.4 GiB 에서 약 5.9 GiB 로 줄었습니다. 이 값은 서비스 로그와 잰 다시 쓰기 양으로 계산한 것이며 직접 읽은 값이 아닙니다. 다시 쓰기는 몰려서 왔고 30초에 약 1.9 GiB 가 두 번 있었습니다. 남은 여유 2 GiB 와 묶음 새로 고침은 이 사실들에 근거합니다. +### 설치본 (서비스 로그, 개발 빌드 1.0.4-dev.12, 2026-11-14) + +두 번 모두 Call of Duty 의 사다리 직후 League of Legends 가 식었고, 이 로그는 이 변경 이전의 동작입니다. + +- 17:45:17 Call of Duty `ladder ordered 8428 MiB`. 17:51:09 League of Legends `cache refresh skipped, the samples are cold (2/8 …), since the ladder 8 min ago: 2551 MiB repurposed of 11056 MiB ahead`. 이어 재예열이 15.68초 걸렸고 실제로 디스크에서 읽었습니다. +- 17:57:42 League of Legends 새로 고침 `0 MiB repurposed of 4903 MiB ahead`. 17:58:02 Call of Duty `ladder ordered 9428 MiB`. 17:59:18 League of Legends `cache refresh found data missing from memory after 41 of 1163 MiB (39 slow pieces, 35 MiB read from disk) …, since the ladder 1 min ago: 2603 MiB repurposed of 4972 MiB ahead`. +- 기록된 앞에 있는 양 대비 다시 쓴 양은 23%(2,551 / 11,056 MiB)와 52%(2,603 / 4,972 MiB)였고, 실험 E9 에서 밀려난 0.61~0.81배에 못 미치는데도 식었습니다. 앞에 있는 양이 실제보다 크게 남아 있었던 것으로 판단하며, 코드를 읽고 내린 이유는 이렇습니다. 스트림 게임의 사다리가 그 게임이 캐시에 가진 페이지를 대기 목록 뒤로 보내 League of Legends 의 앞에 있던 페이지 일부가 뒤로 갔고, 스트림 게임에 이전 표식이 없어 앞에 있는 양이 줄지 않은 채 남았습니다. 뒤로 간 양은 직접 재지 않았습니다. + ### 설치본 (서비스 로그, 개발 빌드 1.0.4-dev.4, 2026-11-14) - 재예열 직후 1,163 MiB 의 사다리를 0.5초에 만들었고 디스크 읽기는 없었습니다. diff --git a/docs/SOLAR.md b/docs/SOLAR.md index 1e144d6..79490b6 100644 --- a/docs/SOLAR.md +++ b/docs/SOLAR.md @@ -18,9 +18,9 @@ SOLAR answers both without reading the disk on purpose. It touches the cache aga ## How it decides 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. +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 ladder at the end of a prewarm stream is the exception; see the details). 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. 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. +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, during one and right before its ladder (see the details) counts as one: each of them that touches a game sets the time of the last group refresh (the one right before a ladder does not wait for the 60 seconds itself). 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,9 +35,10 @@ 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. -- **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. +- **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; with no earlier note nothing is subtracted, because the ladder then lowers the amount ahead by everything it touched), because the pages the stream read are touched again by that ladder and then lie behind these games; this keeps the amount ahead of a game that the end of the stream then skips from being lowered twice (when the protection right before the ladder at the end of the stream, below, runs, the notes of those games drop this value and are written again in their real order). If it fails or is cancelled, the stream goes on and its result is the same. +- **Protecting right before the ladder, and one group of notes.** Right before a prewarm stream that is not a launcher's has finished and makes its game's ladder (a ladder after a memory-limit finish included), the other games that have a note are touched once, the lowest-ranked first. It is the same function, gates, sample check, disk read lease and stop signal as the protection before a stream, and the stream's own game is left out. The end of a stream happens once and the order has to be set then for the amount ahead to be right, so it does not look at "is it due" or at the 60-second minimum between group refreshes. Then the ladder is made, and the notes of the other games touched and of this game are written again as one group, in the order they were touched (the calculation of step 4). So whether or not this game had an earlier note, the amount ahead matches the real order right after the ladder, and a note written again as a group is not lowered a second time by the rule of step 2. This does not use the "subtract what the stream has touched so far" of the protection during a stream. When the protection could not run (a launcher prewarm, no other game with a note, a group refresh in progress so it was skipped) and for a game the protection skipped (blocked by a gate, for example), the ladder lowers the amount ahead by everything the ladder touched if this game had no earlier note; the note of the stream's game can already be dropped (after a cold sample or missing data was found, for example), which would give 0 under the rule of step 2. How much of it really went from ahead to behind is not known, so it takes the larger amount, which at worst brings a refresh one round early. With an earlier note, the rule of step 2 stands as it was. The time it takes is left out of the `yielded` time and of the exposure time and is added into the `protected` tail of the done line. - **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. +- **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; the ladder at the end of a prewarm stream follows "Protecting right before the ladder, and one group of notes" above). 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. @@ -77,7 +78,7 @@ 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 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 (...) ..., 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, while it read and right before its ladder, 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. 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. @@ -92,6 +93,7 @@ Each line goes to `service.log`. `` is the game's name, and a tail such as - **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 effect of protecting right before the ladder, of writing the notes again as one group, and of lowering the amount ahead by everything the ladder touched when the stream game had no earlier note has not been checked on an installed build yet.** Lowering by everything is a cautious value chosen because the real amount is not known, so it can lower more than the real amount and bring a refresh early (the decision to re-prewarm from the disk is still made only by the re-prewarm check; an early refresh may still read the 8 MiB sample and, when a touch meets a part that is not in the cache, up to about 100 MiB before it stops). - **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. @@ -130,6 +132,14 @@ These use test files and an experiment tool, not the service and not a running g Four 512 MiB test files on the HDD were put in the cache and watched for 61 minutes while the installed service was running and the PC was in ordinary use. The three that were not touched again left the cache together within one burst of reuse; the one that was touched once more after 15 minutes lasted 39 minutes longer. When the service refreshed another game, the amount ahead of the test files fell from 12.4 to about 5.9 GiB; that figure is calculated from the service log and the measured reuse, it was not read directly. Reuse came in bursts: about 1.9 GiB within 30 seconds, twice. These are the facts the 2 GiB of room and the group refresh rest on. +### Installed build (service log, development build 1.0.4-dev.12, 2026-11-14) + +Both times League of Legends cooled right after a Call of Duty ladder; this log is the behavior before this change. + +- 17:45:17 Call of Duty `ladder ordered 8428 MiB`. 17:51:09 League of Legends `cache refresh skipped, the samples are cold (2/8 …), since the ladder 8 min ago: 2551 MiB repurposed of 11056 MiB ahead`. The re-prewarm that followed took 15.68 s and really read from the disk. +- 17:57:42 League of Legends refresh `0 MiB repurposed of 4903 MiB ahead`. 17:58:02 Call of Duty `ladder ordered 9428 MiB`. 17:59:18 League of Legends `cache refresh found data missing from memory after 41 of 1163 MiB (39 slow pieces, 35 MiB read from disk) …, since the ladder 1 min ago: 2603 MiB repurposed of 4972 MiB ahead`. +- Against the logged amount ahead, the reuse was 23% (2,551 / 11,056 MiB) and 52% (2,603 / 4,972 MiB), below the 0.61 to 0.81 times at which files left in experiment E9, and the cache was cold all the same. The judgement is that the amount ahead had been left larger than the real one, for this reason from reading the code: the ladder of the stream game sent the pages that game had in the cache to the back of the standby list, so part of the pages that were ahead of League of Legends went behind it, and the stream game had no earlier note, so the amount ahead was not lowered. How much went behind was not measured. + ### Installed build (service log, development build 1.0.4-dev.4, 2026-11-14) - A ladder of 1,163 MiB was made in 0.5 s right after a re-prewarm, with no disk read.