diff --git a/csharp/src/AsterCache.Prefetch/ZoneStreamer.cs b/csharp/src/AsterCache.Prefetch/ZoneStreamer.cs index 17bef68..db649e3 100644 --- a/csharp/src/AsterCache.Prefetch/ZoneStreamer.cs +++ b/csharp/src/AsterCache.Prefetch/ZoneStreamer.cs @@ -30,8 +30,8 @@ public sealed record StreamStats(long Bytes, long Requests, long Errors, double public long BufferedBytes { get; init; } - /// 예열 완료 로그 줄에 붙이는 말 (스파스 파일을 버퍼드로 읽은 것이 없으면 빈 문자열). - public string BufferedNote() => BufferedBytes > 0 ? $", {BufferedBytes >> 20} MiB of sparse files by buffered read" : string.Empty; + /// 예열 완료 로그 줄에 붙이는 말 (스파스 파일을 버퍼드로 읽은 것이 MiB 로 0 이면 빈 문자열). + public string BufferedNote() => BufferedBytes >> 20 > 0 ? $", {BufferedBytes >> 20} MiB of sparse files by buffered read" : string.Empty; } /// ops 를 파일 캐시로 가져옵니다. 쓰기 없음. Python prefetch/reader.py read_ops (method read | pvm). diff --git a/csharp/src/AsterCache.Service/Prewarm.Run.cs b/csharp/src/AsterCache.Service/Prewarm.Run.cs index fffb51e..c1f2b26 100644 --- a/csharp/src/AsterCache.Service/Prewarm.Run.cs +++ b/csharp/src/AsterCache.Service/Prewarm.Run.cs @@ -257,7 +257,7 @@ void Settle(StreamStats stats, bool shortFinish) while (true) { streamed = StreamHot(warm, ct, zoneBytes, availableRamMib, memoryPartial is not null, Settle, - allowReorder: memoryPartial is null && !HasPartialState(game.Name), roomMib: roomMib ?? long.MaxValue, allowWhileExiting: trigger, why: why); + allowReorder: memoryPartial is null && !HasPartialState(game.Name), roomMib: roomMib ?? long.MaxValue, allowWhileExiting: trigger, why: why, windowMib: memoryPartial?.WarmedMib ?? 0); if (fellBack || memoryPartial is not null || streamed is not { Complete: false, MemoryStop: true } || MemoryFallbackCapMib(game, finalReadMib, roomMib ?? long.MaxValue, ct) is not { } fallbackCap) { @@ -443,8 +443,9 @@ private void CheckPrewarmBatch(GameSettings game, CancellationTokenSource stop, // settle: 끝까지 읽었을 때 (또는 아래 메모리 한도로 마무리했을 때, 두 번째 인자 true) StreamEnded 알림보다 먼저 부름 (읽은 통계를 받아 부분 예열 상태를 확정하거나 지움). // 창 읽기 (partial) 와 allowFinish 가 켜진 읽기 (순서를 바꾸지 않는 보충) 는 메모리 한도에 걸려도 취소하지 않고 읽은 데까지로 마무리해 (Complete 와 MemoryStop 이 함께 참, CancelReason 은 가용·예비량) 돌려줍니다. // 틈을 메우는 계획 (partial 아님) 이 마무리하면 통계의 GapBytes > 0 이라 호출 쪽이 읽은 양을 보수적으로 세고 이어 읽을 위치는 남기지 않습니다. limitBytes: 건너뛴 앞부분을 포함해 이만큼까지만 읽음. + // windowMib: 읽는 창 (MiB) 의 크기, 틈 없는 창 읽기가 한도로 마무리했을 때 읽은 범위의 사다리를 만드는 데 씀 (0 이면 모름: 만들지 않음). // skipBytes: 읽기 목록 앞쪽 이만큼은 이미 읽었으니 건너뜀 (끊긴 보충을 이어 읽음, 틈 메우기 없는 경로에서만). onTotal: 이번에 읽을 목록의 길이 (건너뛴 앞부분 제외). - private (StreamStats Stats, bool Complete, string? CancelReason, bool MemoryStop)? StreamHot(GameSettings game, CancellationToken ct, long? zoneBytes, long availableRamMib, bool partial = false, Action? settle = null, long skipBytes = 0, Action? onTotal = null, bool allowReorder = true, long roomMib = long.MaxValue, bool allowWhileExiting = false, string? why = null, long limitBytes = long.MaxValue, bool allowFinish = false) + private (StreamStats Stats, bool Complete, string? CancelReason, bool MemoryStop)? StreamHot(GameSettings game, CancellationToken ct, long? zoneBytes, long availableRamMib, bool partial = false, Action? settle = null, long skipBytes = 0, Action? onTotal = null, bool allowReorder = true, long roomMib = long.MaxValue, bool allowWhileExiting = false, string? why = null, long limitBytes = long.MaxValue, bool allowFinish = false, long windowMib = 0) { using DiskReadCoordinator.Lease? lease = _coordinator?.TryBegin(game.OriginRoot, "prewarm stream", ct, preserveForGameRoot: _auto.KeepStreamOnGameStart ? game.OriginRoot : null, preserveGameKey: game.Name, allowWhileExiting: allowWhileExiting); if (_coordinator is not null && (lease is null || !lease.IsValid)) @@ -480,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 ExposureSeconds() => Math.Max(0, Stopwatch.GetElapsedTime(startedAt).TotalSeconds - (requeue?.Seconds ?? 0)); // 다시 만지기는 디스크 읽기가 아니므로 노출 시간에서 뺌 + double protectedSeconds = 0; // 스트림 앞의 보호가 쓴 시간 + double ExposureSeconds() => Math.Max(0, Stopwatch.GetElapsedTime(startedAt).TotalSeconds - (requeue?.Seconds ?? 0) - protectedSeconds); // 다시 만지기는 디스크 읽기가 아니므로 노출 시간에서 뺌 try { using CancellationTokenRegistration leaseReg = lease?.Token.Register(() => @@ -504,6 +506,9 @@ private void CheckPrewarmBatch(GameSettings game, CancellationTokenSource stop, long allocationBytes = allocationMib > (long.MaxValue >> 20) ? long.MaxValue : allocationMib << 20; maxBytes = Math.Min(maxBytes, allocationBytes); } + RequeueTally protectedOthers = ProtectOthers(game, stop.Token, allowWhileExiting, readYield); // 디스크를 읽기 전에 (보정 읽기도 읽기): 트리거 예열은 하지 않음 + protectedSeconds = protectedOthers.ProtectedSeconds; + _requeued += protectedOthers; // 트리거 예열은 보정 읽기를 하지 않음 (ORBIT 결정은 이미 쌓인 장치 구간만 쓰고 이번 스트림의 표본을 요구하지 않음) if (!allowWhileExiting && _env.DeviceCalibration is not null && (_env.DeviceCalibrationEnabled?.Invoke() ?? true)) // 훅이 없거나 off 면 기준선과 같은 호출만 남김 { @@ -552,9 +557,10 @@ private void CheckPrewarmBatch(GameSettings game, CancellationTokenSource stop, finalStats = r.Item1; LogYield(game.Name, r.Item1.Bytes, lease); streamSeconds = ExposureSeconds(); - if (complete && r.Item1.Bytes > 0) + if (r.Item1.Bytes > 0 && (complete || (shortFinish && partial && r.Item1.GapBytes == 0 && windowMib > 0))) { - LadderAfterStream(game, stop.Token); // 같은 디스크 대여 안에서: 게임 시작·대여 취소가 stop 으로 바로 멈춤. 한도로 마무리한 스트림은 건너뜀: 읽은 만큼의 앞부분은 창 크기가 달라 다시 계획한 목록과 같지 않아, 읽지 않은 구간을 만질 수 있음 + // 같은 디스크 대여 안에서: 게임 시작·대여 취소가 stop 으로 바로 멈춤. 한도로 마무리한 스트림은 창 읽기·이어 읽기 (partial: 틈 메우기 예산 0, 스트림이 읽은 목록 = 같은 창으로 계획한 목록) 일 때만 읽은 창의 앞부분을 만짐: 예산이 있는 계획으로 읽은 전체 경로는 목록이 달라질 수 있어 건너뜀 + LadderAfterStream(game, stop.Token, 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 2d546ee..f5061cf 100644 --- a/csharp/src/AsterCache.Service/Prewarm.Solar.cs +++ b/csharp/src/AsterCache.Service/Prewarm.Solar.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using AsterCache.Core; using AsterCache.Core.Json; using AsterCache.Core.Logging; @@ -43,20 +44,59 @@ private void RequeueSkipped(GameSettings game, long skipBytes, StreamRequeue? re } } + // 핫 집합 스트림이 디스크를 읽기 시작하기 직전 (StreamHot, 디스크 대여 안): 표식이 있는 다른 게임들을 순위가 낮은 것부터 한 번 만집니다 (유지 틱의 묶음 새로 고침과 같은 함수, 대상에서 이 게임만 뺌). + // 그러면 이 스트림이 새로 읽는 페이지는 다른 게임이 아니라 캐시 (와 이 게임 자신의 오래된 페이지) 부터 밀어냅니다. 표본 점검과 게이트는 새로 고침과 같습니다. + // 트리거 예열 (allowWhileExiting) 은 게임 시작이 임박한 경로라 늦추지 않으려고 하지 않고, 스트림이 끝난 뒤 사다리가 다른 게임의 A 만 줄입니다 (Solar.Order). + // 만지는 데 쓴 시간은 스트림의 노출 시간·양보 시간에서 빼도록 돌려줍니다. 실패·취소돼도 스트림 결과는 같습니다. + private RequeueTally ProtectOthers(GameSettings game, CancellationToken stop, bool trigger, ReadYield readYield) + { + bool Halted() => _streamGameStarted || _streamPreempted || PreemptedByTriggerWait(game.Name); + if (trigger || !_solar.Enabled || _auto.KeepStreamOnGameStart || stop.IsCancellationRequested || Halted()) + { + 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; + } + + long startedAt = Stopwatch.GetTimestamp(); + (int Games, long Bytes) touched = (0, 0); + try + { + touched = _rewarm.RefreshGroup(others, _auto, stop, Halted); + } + catch (OperationCanceledException) + { + // 스트림이 멈춤: 아무것도 하지 않음 + } + catch (Exception e) when (e is not (OutOfMemoryException or StackOverflowException)) + { + _log.Warn($"{game.Name}: protecting the other games skipped: {e.GetType().Name}"); // 예열 결과에는 영향을 주지 않음 + } + + double seconds = Stopwatch.GetElapsedTime(startedAt).TotalSeconds; + readYield.Exclude(seconds); + return new RequeueTally(0, 0, false, 0, touched.Games, touched.Bytes, seconds); + } + // 핫 집합 스트림이 끝까지 읽은 직후 (StreamHot, 디스크 대여 안): 그 게임이 지금까지 캐시에 올린 전체의 역순으로 캐시를 만져 중요한 것이 마지막에 밀려나게 합니다. // game 은 스트림이 읽은 범위를 나타내는 사본 (메모리·상한으로 줄인 것 포함, 보충이면 이어 읽은 창) 이라 앞에서 읽은 부분과 보충분이 모두 대상입니다. 방금 읽었으므로 표본 점검은 생략합니다. + // reads: 메모리 한도로 마무리한 스트림은 읽은 창의 앞부분 (WindowLadderReads), 끝까지 읽었으면 null (읽은 범위 전체). // 게임이 이미 도는 디스크이거나 keep_stream_on_game_start (게임이 떠도 스트림을 끊지 않는 실험 설정) 가 켜져 있으면 만지지 않습니다: 만지기도 캐시에 없으면 디스크 읽기라 게임을 느리게 할 수 있습니다. - private void LadderAfterStream(GameSettings game, CancellationToken stop) + private void LadderAfterStream(GameSettings game, CancellationToken stop, Func?>? reads = null) { if (!_solar.Enabled || _auto.KeepStreamOnGameStart || _streamGameStarted || stop.IsCancellationRequested || _streamPreempted || PreemptedByTriggerWait(game.Name) || DriveHasRunningGame(game) - || LadderReads(game) is not { Count: > 0 } reads) + || (reads is null ? LadderReads(game) : reads()) is not { Count: > 0 } ladder) { return; } try { - _ = _solar.Order(game, reads, stop); + _ = _solar.Order(game, ladder, stop); } catch (Exception e) when (e is not (OperationCanceledException or OutOfMemoryException or StackOverflowException)) { @@ -84,6 +124,26 @@ private void LadderAfterStream(GameSettings game, CancellationToken stop) } } + // 틈 없는 창 읽기가 읽은 범위의 사다리 대상 (공통 블록 + 창 목록의 앞 bytes 바이트): 창 (windowMib) 은 스트림이 읽은 것과 같은 크기로 계획하므로 읽지 않은 구간을 만지지 않습니다. 계획할 수 없으면 null. + private IReadOnlyList? WindowLadderReads(GameSettings game, long windowMib, long bytes) + { + try + { + GameSettings window = game with { MaxStreamMib = (int)Math.Min(windowMib, int.MaxValue), MaxStreamSet = true }; + if (_streamer.PlannedPrewarmReads(window) is not { Count: > 0 } planned || PrefetchStreamer.Head(planned, bytes) is not { Count: > 0 } read) + { + return null; + } + + IReadOnlyList common = _env.CommonFirstOps(window, 0, 0).Ops; + return common.Count == 0 ? read : [.. common, .. read]; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or PlanRejectedException or PyValueException) + { + return null; + } + } + /// 지금까지 읽은 범위를 나타내는 게임: 부분 예열 (메모리·시작 상한) 이면 읽은 앞부분까지로 줄인 사본, 아니면 그대로. internal GameSettings CoveredPart(GameSettings g) { @@ -93,6 +153,13 @@ internal GameSettings CoveredPart(GameSettings g) } } + // 이 게임 디스크의 읽기 대여를 지금 얻을 수 있는가 (얻으면 바로 놓음): 새로 고침이 대여 거절을 "표본 없음" 으로 보고 표식을 지우지 않게 먼저 물어봅니다. + internal bool DiskReadAllowed(GameSettings game, CancellationToken ct) + { + using DiskReadCoordinator.Lease? lease = _coordinator?.TryBegin(game.OriginRoot, "cache refresh gate", ct); + return _coordinator is null || lease is { IsValid: true }; + } + // 디스크 대여 안에서 work 를 돌립니다 (거절·무효면 null): 게임이 시작돼 대여가 취소되면 work 의 토큰이 취소됩니다. internal TouchEnd? UnderLease(GameSettings game, string purpose, CancellationToken ct, Func work) { diff --git a/csharp/src/AsterCache.Service/Prewarm.Supplement.cs b/csharp/src/AsterCache.Service/Prewarm.Supplement.cs index 87bcd89..f88e8ff 100644 --- a/csharp/src/AsterCache.Service/Prewarm.Supplement.cs +++ b/csharp/src/AsterCache.Service/Prewarm.Supplement.cs @@ -707,7 +707,7 @@ internal bool Supplement(GameSettings game, CancellationToken ct, long? allocate { NoteProgress(game, inputStamp, ActualMib(stats, p.HotMib, skipBytes), p.HotMib, skipBytes + stats.Bytes); } - }, allowReorder: false, roomMib: allocatedMib ?? long.MaxValue, limitBytes: limitMib is { } limitBytesMib ? MibBytes(limitBytesMib) : long.MaxValue, allowFinish: true); + }, allowReorder: false, roomMib: allocatedMib ?? long.MaxValue, limitBytes: limitMib is { } limitBytesMib ? MibBytes(limitBytesMib) : long.MaxValue, allowFinish: true, windowMib: windowMib ?? p.HotMib); if (fellBack || windowMib is not null || hot is not { Complete: false, MemoryStop: true } || !HeavyWorkAllowed(game) || MemoryFallbackCapMib(game, finalReadMib, budgetCap, ct) is not { } fallbackCap || fallbackCap <= Math.Max(p.WarmedMib, ActualMib(hot.Value.Stats, p.HotMib, skipBytes))) @@ -881,21 +881,7 @@ internal bool Supplement(GameSettings game, CancellationToken ct, long? allocate } } - try - { - GameSettings window = game with { MaxStreamMib = (int)Math.Min(resume.WindowMib, int.MaxValue), MaxStreamSet = true }; - if (_streamer.PlannedPrewarmReads(window) is not { Count: > 0 } planned || PrefetchStreamer.Head(planned, resume.Bytes) is not { Count: > 0 } read) - { - return null; - } - - IReadOnlyList common = _env.CommonFirstOps(window, 0, 0).Ops; - return common.Count == 0 ? read : [.. common, .. read]; - } - catch (Exception e) when (e is IOException or UnauthorizedAccessException or PlanRejectedException or PyValueException) - { - return null; - } + return WindowLadderReads(game, resume.WindowMib, resume.Bytes); } // 들어가는 범위가 읽은 양보다 의미 있게 크지 않아 보충을 시작하지 않는 상태: 읽은 양이 바뀔 때만 한 줄 (들어가는 범위는 가용 메모리에 따라 계속 흔들리므로 키에 넣지 않음). diff --git a/csharp/src/AsterCache.Service/Rewarm.Ladder.cs b/csharp/src/AsterCache.Service/Rewarm.Ladder.cs index a992a27..f23ce56 100644 --- a/csharp/src/AsterCache.Service/Rewarm.Ladder.cs +++ b/csharp/src/AsterCache.Service/Rewarm.Ladder.cs @@ -5,13 +5,15 @@ namespace AsterCache.Service; -/// Rewarmer 의 사다리 (Solar) 유지: 30초 틱마다 시점이 된 게임의 캐시를 디스크 읽기 없이 새로 고치고, 서비스를 다시 시작한 뒤 표식이 없는 게임은 재예열 점검이 warm 으로 판정할 때 첫 사다리를 만듭니다. +/// Rewarmer 의 사다리 (Solar) 유지: 30초 틱마다 표식이 있는 게임 중 하나라도 시점이 되면 묶음 전체의 캐시를 디스크 읽기 없이 새로 고치고, 서비스를 다시 시작한 뒤 표식이 없는 게임은 재예열 점검이 warm 으로 판정할 때 첫 사다리를 만듭니다. /// 게이트는 재예열 점검과 같은 함수 (ReadBlock) 를 씁니다: 재배치 중이 아니고, 같은 디스크의 게임이 돌지 않고, 게임 종료 직후 유예가 지났고, 예산 안의 게임이고, 디스크가 바쁘지 않을 때만 만집니다. /// 여기서는 재예열을 직접 부르지 않습니다: 디스크에서 다시 읽을지는 재예열 점검 (rewarm_minutes 주기, 표본으로 cold 확인 또는 사다리가 남긴 다시 채움 표시, 자체 게이트) 만 정하므로 HDD 를 다시 읽는 빈도는 늘지 않습니다. /// 사다리를 새로 고치는 일 자체 (메모리 안에서만 만지기) 는 자동 모드·rewarm_minutes 와 무관하게 memory.standby_ladder 로만 켜고 끕니다. public sealed partial class Rewarmer { - /// 유지 틱: 대기 목록을 한 번 읽어 (표식이 있을 때만) 새로 고칠 때가 된 게임을 순위가 낮은 것부터 (가장 최근에 예열한 게임이 마지막) 다시 만집니다. + private readonly object _groupLock = new(); // 묶음 새로 고침 (유지 틱) 과 스트림 앞의 보호 (스트림 스레드) 가 같은 게임을 동시에 만지지 않게, 기다리지 않고 못 잡으면 그 회는 건너뜀 + + /// 유지 틱: 대기 목록을 한 번 읽어 (표식이 있을 때만) 표식이 있는 게임 중 하나라도 새로 고칠 때가 됐으면 묶음 전체를 순위가 낮은 것부터 (가장 최근에 예열한 게임이 마지막) 다시 만집니다. public void LadderTick(AutoSettings auto, CancellationToken ct = default) { Solar solar = _prewarm.Solar; @@ -21,32 +23,59 @@ public void LadderTick(AutoSettings auto, CancellationToken ct = default) } DateTimeOffset now = _now(); - IReadOnlyList due = solar.Due(Enumerable.Reverse(_prewarm.Warmed()), now); // Warmed 는 최근 순 - if (due.Count == 0) + Lazy budget = new(_prewarm.WarmBudget); // 표식이 있고 때가 된 게임이 있을 때만 계산 + IReadOnlyList group = solar.DueGroup(Enumerable.Reverse(_prewarm.Warmed()), now, g => Touchable(g, now, auto, budget.Value, ct)); // Warmed 는 최근 순 + if (group.Count > 0) { - return; + _ = RefreshGroup(group, auto, ct, _prewarm.TriggerWaitPending); } + } + + // 지금 이 게임을 만져도 되는가 (재예열 점검과 같은 게이트): 재배치 중이 아니고, 방금 예열하지 않았고, ReadBlock 에 막히지 않고, 읽기 대여를 얻을 수 있음. + // 대여 거절은 표본을 읽지 못한 것이 아니라 지금은 만질 수 없다는 뜻이라, 표식을 지우지 않고 건너뜁니다 (다음 틱에 다시). + 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); - WarmBudgetInfo? budget = _prewarm.WarmBudget(); - foreach (GameSettings g in due) + /// order (순위가 낮은 것부터) 의 표식이 있는 게임을 차례로 새로 고치고 (표본 점검 → warm 이면 만지기), 만진 게임들의 표식을 같은 기준으로 다시 씁니다 (Solar.Regroup). + /// 유지 틱의 묶음 새로 고침과 스트림 앞의 보호가 같은 함수를 씁니다. 게이트에 막힌 게임은 건너뛰고 나머지는 계속하며, halted 가 참이거나 취소·재배치가 시작되면 거기서 멈춥니다. + /// 돌려주는 값: 끝까지 만진 게임 수와 만진 바이트. + internal (int Games, long Bytes) RefreshGroup(IReadOnlyList order, AutoSettings auto, CancellationToken ct, Func halted) + { + if (!Monitor.TryEnter(_groupLock)) { - if (ct.IsCancellationRequested || _prewarm.Games.Any(x => _prewarm.Relocating(x.Name))) - { - return; - } + return (0, 0); + } - if (_prewarm.RewarmAlreadyCovered(g) || ReadBlock(g, now, auto, budget) is not null) + List<(string Game, long Bytes, LadderMark? Previous)> touched = []; + try + { + DateTimeOffset now = _now(); + WarmBudgetInfo? budget = _prewarm.WarmBudget(); + foreach (GameSettings g in order) { - continue; - } + if (ct.IsCancellationRequested || halted() || _prewarm.Games.Any(x => _prewarm.Relocating(x.Name))) + { + break; + } - Refresh(g, ct); + if (Touchable(g, now, auto, budget, ct)) + { + Refresh(g, ct, (previous, bytes) => touched.Add((g.Name, bytes, previous))); + } + } } + finally + { + _prewarm.Solar.Regroup(touched); // 중간에 멈췄어도 만진 것은 이미 뒤로 갔으므로 그만큼을 반영 + Monitor.Exit(_groupLock); + } + + return (touched.Count, touched.Sum(t => t.Bytes)); } // 새로 고치기 전에 기존 재예열 점검의 표본 (warm/cold 판정) 을 한 번 거칩니다: warm 일 때만 만집니다. // 시간 감시만으로는 CPU 선점을 디스크 읽기로 오판할 수 있어서이고, 이미 식었다면 표식을 지우고 재예열 점검에 맡깁니다. - private void Refresh(GameSettings g, CancellationToken ct) + private void Refresh(GameSettings g, CancellationToken ct, Action touched) { Solar solar = _prewarm.Solar; ProbeResult? probe = Probe(g, ct, out bool failed); @@ -64,7 +93,7 @@ private void Refresh(GameSettings g, CancellationToken ct) return; } - Run(g, ct, token => solar.Refresh(g, token), "cache refresh"); + Run(g, ct, token => solar.Refresh(g, token, touched), "cache refresh"); } // 재예열 점검이 warm 으로 판정한 게임에 표식이 없으면 (서비스를 다시 시작한 뒤) 읽은 범위 전체의 역순으로 첫 사다리를 만듭니다 (표본은 방금 점검에서 읽음). diff --git a/csharp/src/AsterCache.Service/Solar.cs b/csharp/src/AsterCache.Service/Solar.cs index cec2d7c..10074da 100644 --- a/csharp/src/AsterCache.Service/Solar.cs +++ b/csharp/src/AsterCache.Service/Solar.cs @@ -15,6 +15,7 @@ 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 DateTimeOffset? _groupAt; // 마지막으로 묶음을 새로 고친 (표식을 같은 기준으로 다시 쓴) 시각, 메모리에만 둠, _lock 으로 보호 public bool Enabled => env.StandbyLadderEnabled(); @@ -96,7 +97,9 @@ public void Prune(IReadOnlySet live) break; } + LadderMark? previous = MarkOf(game.Name); // SetMark 가 덮어쓰기 전의 표식 SetMark(game.Name, ladder, r); + 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; case TouchEnd.Canceled: @@ -119,7 +122,8 @@ public void Prune(IReadOnlySet live) /// 표식이 있는 게임의 사다리를 다시 만듭니다 (디스크 읽기 없음). 표식이 없거나 꺼져 있으면 null. /// 디스크 읽기가 섞이면 표식을 지우고 다시 채움 표시를 남겨 기존 재예열 점검에 맡깁니다: 재예열은 여기서 부르지 않으며, 재예열이 끝나 사다리가 다시 만들어질 때까지 새로 고치지 않습니다. - public TouchEnd? Refresh(GameSettings game, CancellationToken ct) + /// touched: 끝까지 만졌을 때 이전 표식과 만진 바이트를 알림 (묶음 새로 고침이 Regroup 에 넘길 값). + public TouchEnd? Refresh(GameSettings game, CancellationToken ct, Action? touched = null) { LadderMark? mark; lock (_lock) @@ -144,6 +148,7 @@ public void Prune(IReadOnlySet live) else { SetMark(game.Name, mark.Ladder, r); + touched?.Invoke(mark, r.Bytes); } log.Info($"{game.Name}: cache refreshed in memory: {r.Bytes >> 20} MiB in {r.Seconds:F1} s ({repurposed >> 20} MiB repurposed of {mark.AheadBytes >> 20} MiB ahead){Errors(r)}{Sparse(r)}"); @@ -165,8 +170,57 @@ public void Prune(IReadOnlySet live) return r.End; } - /// 표식이 있고 새로 고칠 때가 된 게임 (입력 순서 그대로). 대기 목록은 표식이 하나라도 있을 때만 한 번 읽습니다. - public IReadOnlyList Due(IEnumerable games, DateTimeOffset at) + /// 표식이 있는 게임 (입력 순서 그대로). + public IReadOnlyList Marked(IEnumerable games) => [.. MarkedPairs(games).Select(m => m.Game)]; + + /// 묶음 새로 고침 대상: 표식이 있는 게임 전체 (입력 순서 그대로). 그중 지금 만질 수 있는 (usable, 없으면 모두) 게임 하나라도 새로 고칠 때가 됐고 직전 묶음 새로 고침 뒤 GroupEvery 가 지났을 때만, 아니면 빈 목록. + /// 게이트에 막힌 게임은 때가 됐어도 시작 판정에 쓰지 않습니다: 계속 막힌 게임 하나 때문에 다른 게임이 되풀이해 만져지지 않게. 대기 목록은 표식이 하나라도 있을 때만 한 번 읽습니다. + public IReadOnlyList DueGroup(IEnumerable games, DateTimeOffset at, Func? usable = null) + { + List<(GameSettings Game, LadderMark Mark)> marked = MarkedPairs(games); + DateTimeOffset? last; + lock (_lock) + { + last = _groupAt; + } + + if (marked.Count == 0 || (last is { } groupAt && at - groupAt < SolarPolicy.GroupEvery)) + { + return []; + } + + MemoryLists? lists = env.Lists(); + return marked.Any(m => SolarPolicy.RefreshDue(m.Mark, lists, at) && (usable?.Invoke(m.Game) ?? true)) ? [.. marked.Select(m => m.Game)] : []; + } + + /// 묶음을 만진 순서대로 (touched: 게임, 만진 바이트, 만지기 전의 표식, 가장 오래된 것이 먼저) 표식을 같은 기준 시점 (재사용 누계·대기 바이트) 으로 다시 쓰고, + /// 이번에 만지지 않은 표식은 앞에 있는 양을 SolarPolicy.AheadAfterOthersTouched 로 줄입니다. 대기 목록을 못 읽으면 표식은 그대로 두고 (시간 기준) 줄이기만 합니다. + public void Regroup(IReadOnlyList<(string Game, long Bytes, LadderMark? Previous)> touched) + { + if (touched.Count == 0) + { + return; + } + + MemoryLists? lists = env.Lists(); + long[] ahead = lists is { } l ? SolarPolicy.GroupAhead(l.CacheStandbyBytes, [.. touched.Select(t => t.Bytes)]) : []; + DateTimeOffset at = now(); + lock (_lock) + { + for (int i = 0; lists is { } current && i < touched.Count; i++) + { + if (_marks.TryGetValue(touched[i].Game, out LadderMark? mark)) + { + _marks[touched[i].Game] = new LadderMark(at.AddTicks(i), current.CacheRepurposedBytes, ahead[i], touched[i].Bytes, mark.Ladder); // 만진 순서대로 시각을 늘려 (가장 오래된 게임이 가장 이름) 같은 묶음 안에서도 AheadAfterOthersTouched 가 앞뒤를 가림 + } + } + + ReduceOthersLocked([.. touched.Select(t => t.Game)], [.. touched.Select(t => (t.Previous, t.Bytes))]); + _groupAt = at; + } + } + + private List<(GameSettings Game, LadderMark Mark)> MarkedPairs(IEnumerable games) { List<(GameSettings Game, LadderMark Mark)> marked = []; lock (_lock) @@ -180,13 +234,33 @@ public IReadOnlyList Due(IEnumerable games, DateTime } } - if (marked.Count == 0) + return marked; + } + + private LadderMark? MarkOf(string game) + { + lock (_lock) { - return []; + return _marks.GetValueOrDefault(game); } + } - MemoryLists? lists = env.Lists(); - return [.. marked.Where(m => SolarPolicy.RefreshDue(m.Mark, lists, at)).Select(m => m.Game)]; + // except 가 아닌 표식의 A 를 줄입니다 (touched: 그 게임들이 만지기 전의 표식과 만진 바이트). + private void ReduceOthers(IEnumerable except, IReadOnlyList<(LadderMark? Previous, long Bytes)> touched) + { + lock (_lock) + { + ReduceOthersLocked(except, touched); + } + } + + private void ReduceOthersLocked(IEnumerable except, IReadOnlyList<(LadderMark? Previous, long Bytes)> touched) + { + 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))) }; + } } private void SetMark(string game, IReadOnlyList ladder, TouchResult r) @@ -256,8 +330,8 @@ public void Forget(string game) // 다시 채움 로그의 꼬리: 사다리 (역순) 를 몇 MiB 만지고 멈췄는지 (만진 양이 전체에 가까우면 빠진 것은 가장 중요한 앞쪽), 느린 조각 수와 디스크에서 읽힌 양, 오류 수. N 은 건너뛴 스파스 범위를 포함한 사다리 전체 (건너뛴 양은 줄 끝 꼬리로 따로 보임). private static string Missing(TouchResult r, IReadOnlyList ladder) => $"after {r.Bytes >> 20} of {ladder.Sum(o => o.Length) >> 20} MiB ({r.SlowPieces} slow pieces{(r.DiskBytes > 0 ? $", {r.DiskBytes >> 20} MiB read from disk" : string.Empty)}{(r.Errors > 0 ? $", {r.Errors} errors" : string.Empty)})"; - // 줄 끝에 붙이는 스파스 파일이라 만지지 않은 양 (없으면 빈 문자열) - private static string Sparse(TouchResult r) => r.SkippedBytes > 0 ? $", {r.SkippedBytes >> 20} MiB in sparse files not touched" : string.Empty; + // 줄 끝에 붙이는 스파스 파일이라 만지지 않은 양 (MiB 로 0 이면 빈 문자열) + private static string Sparse(TouchResult r) => r.SkippedBytes >> 20 > 0 ? $", {r.SkippedBytes >> 20} MiB in sparse files not touched" : string.Empty; // 줄 끝에 붙이는 오류 수와 디스크에서 읽힌 것으로 본 양 private static string Errors(TouchResult r) => (r.Errors > 0 ? $", {r.Errors} errors" : string.Empty) + (r.DiskBytes > 0 ? $", {r.DiskBytes >> 20} MiB read from disk" : string.Empty); diff --git a/csharp/src/AsterCache.Service/SolarPolicy.cs b/csharp/src/AsterCache.Service/SolarPolicy.cs index bdeb869..051eecb 100644 --- a/csharp/src/AsterCache.Service/SolarPolicy.cs +++ b/csharp/src/AsterCache.Service/SolarPolicy.cs @@ -3,8 +3,12 @@ namespace AsterCache.Service; -/// 게임의 사다리를 만든 직후의 표식: 시각, 그때의 캐시 재사용 누계 (대기 목록을 못 읽었으면 null), 우리보다 앞에 있는 캐시 양 A, 만진 바이트, 다시 만질 범위 (이미 역순으로 만든 것). -internal sealed record LadderMark(DateTimeOffset At, long? RepurposedBytes, long AheadBytes, long TouchedBytes, IReadOnlyList Ladder); +/// 게임의 사다리를 만든 직후의 표식: 시각, 그때의 캐시 재사용 누계 (대기 목록을 못 읽었으면 null), 우리보다 앞에 있는 캐시 양 A (다른 게임이 만지면 줄어듦), 만진 바이트, 다시 만질 범위 (이미 역순으로 만든 것). +/// MarkedAheadBytes: 표식을 만들 때의 A (줄어들지 않음), 남은 여유 기준을 적용할 게임인지 가리는 데 씀. +internal sealed record LadderMark(DateTimeOffset At, long? RepurposedBytes, long AheadBytes, long TouchedBytes, IReadOnlyList Ladder) +{ + public long MarkedAheadBytes { get; init; } = AheadBytes; +} /// 사다리를 언제 다시 만들지의 판정 (순수 함수). 대기 목록은 먼저 들어간 페이지부터 재사용되므로, 우리 페이지 앞에 있던 캐시 A 의 일부가 재사용되면 우리 페이지가 목록 앞에 가까워진 것입니다. internal static class SolarPolicy @@ -15,19 +19,48 @@ internal static class SolarPolicy /// A 가 작아 판정이 흔들릴 때를 위한 하한: 증가분이 이보다 작으면 새로 고치지 않습니다. public const long MinRepurposedBytes = 512L << 20; + /// 남은 여유 (A - 재사용 증가분) 가 이보다 작으면 비율에 못 미쳐도 새로 고칩니다. 근거: 30초에 약 1.9 GiB 의 재사용이 몰리는 것이 두 번 관측됨 (한 틱 폭주만큼의 여유). + public const long MinHeadroomBytes = 2L << 30; + + /// 묶음 새로 고침은 직전 묶음 새로 고침 뒤 이 시간 안에는 다시 하지 않습니다 (몰림이 30초 단위라 틱마다 되풀이하지 않게). + public static readonly TimeSpan GroupEvery = TimeSpan.FromSeconds(60); + /// 대기 목록을 읽을 수 없을 때 (권한 없음 등) 시간으로 대신하는 주기. public static readonly TimeSpan FallbackEvery = TimeSpan.FromMinutes(10); /// 우리보다 앞에 있는 양: 사다리를 만든 직후 캐시 대기 바이트에서 그 게임이 만진 바이트를 뺀 값 (0 미만이면 0). public static long Ahead(long cacheStandbyBytes, long touchedBytes) => Math.Max(0, cacheStandbyBytes - touchedBytes); + /// 다른 게임들이 방금 만진 뒤 우리 앞에 있는 양: 그 게임 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)); + + /// 묶음을 만진 순서 (가장 오래된 것이 먼저) 대로 각 게임의 앞에 있는 양: g_i 의 A = max(0, C - (g_i 와 그 뒤에 만진 게임들이 만진 합)), C 는 만진 직후 캐시 대기 바이트. + public static long[] GroupAhead(long cacheStandbyBytes, IReadOnlyList touchedBytes) + { + long[] ahead = new long[touchedBytes.Count]; + long fromHere = 0; + for (int i = touchedBytes.Count - 1; i >= 0; i--) + { + fromHere += touchedBytes[i]; + ahead[i] = Ahead(cacheStandbyBytes, fromHere); + } + + return ahead; + } + /// 사다리를 만든 뒤의 캐시 재사용 누계 증가분 (표식이나 지금 값이 없으면 null). public static long? Repurposed(LadderMark mark, MemoryLists? lists) => lists is { } l && mark.RepurposedBytes is { } baseline ? Math.Max(0, l.CacheRepurposedBytes - baseline) : null; - /// 지금 새로 고칠 때인가: 증가분이 max(하한, 0.4 x A) 이상이면 예. 대기 목록을 못 읽으면 (표식을 만들 때나 지금) 마지막 사다리 뒤 FallbackEvery. + /// 지금 새로 고칠 때인가: 증가분이 max(하한, 0.4 x A) 이상이거나 남은 여유 (A - 증가분) 가 MinHeadroomBytes 미만이면 예. + /// 남은 여유 기준은 표식을 만들 때 A 가 2 x MinHeadroomBytes 이상이었던 게임에만: 캐시가 원래 작은 게임은 만질 때마다 곧바로 다시 때가 되기 때문. + /// 대기 목록을 못 읽으면 (표식을 만들 때나 지금) 마지막 사다리 뒤 FallbackEvery. public static bool RefreshDue(LadderMark mark, MemoryLists? lists, DateTimeOffset now) => Repurposed(mark, lists) is { } grown ? grown >= Math.Max(MinRepurposedBytes, (long)(AheadFraction * mark.AheadBytes)) + || (mark.MarkedAheadBytes >= 2 * MinHeadroomBytes && mark.AheadBytes - grown < MinHeadroomBytes) : now - mark.At >= FallbackEvery; } diff --git a/csharp/src/AsterCache.Service/StreamRequeue.cs b/csharp/src/AsterCache.Service/StreamRequeue.cs index 9bbefa4..1f31deb 100644 --- a/csharp/src/AsterCache.Service/StreamRequeue.cs +++ b/csharp/src/AsterCache.Service/StreamRequeue.cs @@ -5,12 +5,16 @@ namespace AsterCache.Service; /// 한 예열 스트림이 만지는 데 쓴 양: 만진 바이트, 걸린 시간 (초), 디스크 읽기가 감지돼 만지기가 멈췄는가, 스파스 파일이라 만지지 않은 바이트. -internal readonly record struct RequeueTally(long Bytes, double Seconds, bool DiskStopped, long SkippedBytes = 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) { - 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); + 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); - /// 예열 완료 로그 줄 끝에 붙이는 말 (만진 것도 멈춘 것도 건너뛴 것도 없으면 빈 문자열). - public string Note() => Bytes > 0 || DiskStopped || SkippedBytes > 0 ? $", requeued {Bytes >> 20} MiB in {Seconds:F1} s{(SkippedBytes > 0 ? $" ({SkippedBytes >> 20} MiB in sparse files not touched)" : string.Empty)}{(DiskStopped ? ", requeue stopped by disk reads" : string.Empty)}" : string.Empty; + /// 예열 완료 로그 줄 끝에 붙이는 말 (만진 것도 멈춘 것도 건너뛴 것도 지킨 것도 없으면 빈 문자열, MiB 로 0 인 스파스·보호 꼬리는 붙이지 않음). + public string Note() => RequeuedNote() + (ProtectedGames > 0 && ProtectedBytes >> 20 > 0 ? $", protected {ProtectedGames} games ({ProtectedBytes >> 20} MiB in {ProtectedSeconds:F1} s)" : 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; } /// 예열 스트림이 묶음을 읽을 때마다 그 범위를 곧바로 다시 만집니다 (SOLAR 의 만지기, 디스크 읽기 없음이 전제). diff --git a/csharp/tests/AsterCache.Tests/Service/SolarTests.cs b/csharp/tests/AsterCache.Tests/Service/SolarTests.cs index b767360..9739599 100644 --- a/csharp/tests/AsterCache.Tests/Service/SolarTests.cs +++ b/csharp/tests/AsterCache.Tests/Service/SolarTests.cs @@ -53,6 +53,9 @@ public sealed class SolarTests private static LadderMark Mark(long? repurposedMib, long aheadMib, DateTimeOffset at) => new(at, repurposedMib is { } r ? r * Mib : null, aheadMib * Mib, 0, []); + // 다른 게임이 만지기 전에 가졌던 표식 (그때 만진 양과 만든 시각만 의미 있음) + private static LadderMark Previous(long touchedMib, DateTimeOffset at) => new(at, null, 0, touchedMib * Mib, []); + [Theory] [InlineData(10000, 3999, false)] // A 10000 MiB 의 0.4 배 = 4000 MiB 미만 [InlineData(10000, 4000, true)] @@ -85,6 +88,42 @@ public void AheadIsStandbyMinusWhatThisGameTouchedAndNeverNegative() Assert.Equal(0, SolarPolicy.Ahead(500 * Mib, 1000 * Mib)); } + [Theory] + [InlineData(6000, 2500, 400, false)] // 표식 A 6000 MiB 가 다른 게임이 만져 2500 MiB 로 줄었음: 남은 여유 2100 MiB, 비율 기준 (하한 512 MiB) 에도 못 미침 + [InlineData(6000, 2500, 453, true)] // 남은 여유 2047 MiB < 2 GiB + [InlineData(1500, 1500, 100, false)] // 표식을 만들 때 A 가 4 GiB 미만이면 남은 여유 기준은 적용하지 않음 + [InlineData(1500, 1500, 599, false)] // 비율 기준 0.4 x 1500 = 600 MiB + [InlineData(1500, 1500, 600, true)] + public void RefreshIsAlsoDueWhenWhatIsLeftAheadFallsUnderTwoGiBUnlessTheMarkWasSmallToBeginWith(long markedMib, long aheadMib, long grownMib, bool due) + { + LadderMark mark = Mark(5000, markedMib, _now) with { AheadBytes = aheadMib * Mib }; + + Assert.Equal(due, SolarPolicy.RefreshDue(mark, new MemoryLists(0, (5000 + grownMib) * Mib), _now)); + } + + [Theory] + [InlineData(-1, 300, 7000, 6700)] // X 의 이전 표식이 내 표식보다 먼저 (그때 X 의 페이지가 내 앞에 있었음): 줄임 + [InlineData(1, 300, 7000, 7000)] // X 의 이전 표식이 내 표식보다 나중: X 의 페이지는 원래 내 뒤였으므로 줄이지 않음 + [InlineData(0, 300, 7000, 7000)] // 같은 시각도 먼저가 아님 + [InlineData(99, 300, 7000, 7000)] // X 에 이전 표식이 없었음 (새로 예열된 게임): 줄이지 않음 + [InlineData(-1, 300, 200, 6800)] // min(이전 표식이 만진 300, 이번에 만진 200) = 200: 이번에 적게 만졌으면 그만큼만 + [InlineData(-1, 100, 200, 6900)] // min(100, 200) = 100: 이전 표식이 적게 만졌으면 (나머지는 디스크에서 새로 읽은 것) 그만큼만 + [InlineData(-1, 9000, 9000, 0)] // 0 미만이 되지 않음 + public void WhatIsAheadFallsOnlyByWhatTheOthersMovedFromAheadOfUsToBehindUs(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)])); + } + + [Fact] + public void TheGroupAheadIsStandbyMinusWhatThisGameAndTheOnesTouchedAfterItTouched() + { + Assert.Equal([4000 * Mib, 5000 * Mib, 7000 * Mib], SolarPolicy.GroupAhead(10000 * Mib, [1000 * Mib, 2000 * Mib, 3000 * Mib])); + Assert.Equal([0L, 0L, 500 * Mib], SolarPolicy.GroupAhead(3500 * Mib, [1000 * Mib, 2000 * Mib, 3000 * Mib])); + } + // ---- 본체 ---- [Fact] @@ -334,7 +373,7 @@ public void RefreshThatNeedsDiskReadsClearsTheMarkSoItIsNotRetriedUntilANewLadde Assert.False(solar.HasMark("a")); Assert.Contains("a: cache refresh found data missing from memory after 10 of 30 MiB (5 slow pieces, 32 MiB read from disk, 1 errors), left to the rewarm check", _log.Lines.Last()); - Assert.Empty(solar.Due([Game("a")], _now + TimeSpan.FromHours(1))); + Assert.Empty(solar.DueGroup([Game("a")], _now + TimeSpan.FromHours(1))); } [Fact] @@ -350,25 +389,181 @@ public void ACanceledRefreshKeepsTheMark() } [Fact] - public void DueKeepsTheInputOrderOnlyListsMarkedGamesAndReadsTheListsOnceOnlyWhenSomethingIsMarked() + public void DueGroupListsEveryMarkedGameInTheInputOrderWhenOneOfThemIsDueAndReadsTheListsOnceOnlyWhenSomethingIsMarked() { Solar solar = Make(); - Assert.Empty(solar.Due([Game("a")], _now)); + Assert.Empty(solar.DueGroup([Game("a")], _now)); Assert.Equal(0, _listQueries); - _lists = new MemoryLists(0, 0, 8000 * Mib); - _ = solar.Order(Game("a"), Reads, CancellationToken.None); + _lists = new MemoryLists(0, 0, 1000 * Mib); + _ = solar.Order(Game("a"), Reads, CancellationToken.None); // A 는 작아 증가분 하한 512 MiB 가 기준 + _lists = new MemoryLists(0, 0, 20000 * Mib); _ = solar.Order(Game("b"), Reads, CancellationToken.None); - _ = solar.Order(Game("c"), Reads, CancellationToken.None); - _lists = new MemoryLists(0, 9000 * Mib, 8000 * Mib); // 모든 표식이 시점에 이름 + _ = solar.Order(Game("c"), Reads, CancellationToken.None); // A = 19900 MiB: 증가분 600 MiB 로는 때가 아님 + _lists = new MemoryLists(0, 100 * Mib, 20000 * Mib); + Assert.Empty(solar.DueGroup([Game("c"), Game("a"), Game("b")], _now)); // 아무도 때가 아님 + _lists = new MemoryLists(0, 600 * Mib, 20000 * Mib); // a 만 때가 됨 int before = _listQueries; - IReadOnlyList due = solar.Due([Game("c"), Game("x"), Game("a"), Game("b")], _now); + IReadOnlyList group = solar.DueGroup([Game("c"), Game("x"), Game("a"), Game("b")], _now); - Assert.Equal(["c", "a", "b"], due.Select(g => g.Name)); + Assert.Equal(["c", "a", "b"], group.Select(g => g.Name)); // 때가 된 a 만이 아니라 표식이 있는 모두, 입력 순서 그대로 Assert.Equal(before + 1, _listQueries); } + [Fact] + public void ADueGameThatCannotBeTouchedNowDoesNotStartTheGroup() + { + Solar solar = Make(); + _lists = new MemoryLists(0, 0, 1000 * Mib); + _ = solar.Order(Game("a"), Reads, CancellationToken.None); // A 가 작아 증가분 512 MiB 에 때가 됨 + _lists = new MemoryLists(0, 0, 20000 * Mib); + _ = solar.Order(Game("b"), Reads, CancellationToken.None); + _lists = new MemoryLists(0, 600 * Mib, 20000 * Mib); + + Assert.Empty(solar.DueGroup([Game("a"), Game("b")], _now, g => g.Name != "a")); // 때가 된 a 가 막혀 있음 + Assert.Equal(["a", "b"], solar.DueGroup([Game("a"), Game("b")], _now, g => g.Name != "b").Select(g => g.Name)); // 막힌 것은 때가 아닌 b: 시작하고 묶음은 전체 + } + + [Fact] + public void AGroupIsNotDueAgainWithinSixtySecondsOfTheLastGroupRefresh() + { + Solar solar = Make(); + _ = solar.Order(Game("a"), Reads, CancellationToken.None); + solar.Regroup([("a", 100 * Mib, null)]); + _lists = new MemoryLists(0, 9000 * Mib, 8000 * Mib); // 때가 됨 + + Assert.Empty(solar.DueGroup([Game("a")], _now + TimeSpan.FromSeconds(59))); + Assert.Single(solar.DueGroup([Game("a")], _now + TimeSpan.FromSeconds(60))); + } + + [Fact] + public void RegroupWritesTheTouchedMarksFromOneInstantAndLowersTheOtherOnesByWhatWasTouched() + { + Solar solar = Make(); + foreach (string name in new[] { "a", "b", "c", "d" }) + { + _ = solar.Order(Game(name), Reads, CancellationToken.None); // 표식 (A 는 아래에서 다시 쓰거나 줄어듦: d 는 이 시점에 7900 MiB) + } + + _lists = new MemoryLists(0, 777 * Mib, 8000 * Mib); + DateTimeOffset before = _now - TimeSpan.FromMinutes(1); // 이 게임들의 이전 표식은 d 보다 먼저 만들어졌음: 그 페이지는 d 앞에 있었음 + solar.Regroup([("a", 1000 * Mib, Previous(1000, before)), ("b", 2000 * Mib, Previous(2000, before)), ("c", 3000 * Mib, Previous(3000, before))]); // a 가 가장 먼저 (가장 오래됨) 만짐 + _lists = new MemoryLists(0, 877 * Mib, 8000 * Mib); + foreach (string name in new[] { "a", "b", "c", "d" }) + { + _ = solar.Refresh(Game(name), CancellationToken.None); + } + + string[] lines = [.. _log.Lines.Where(l => l.Contains("cache refreshed in memory"))]; + Assert.Contains("a: cache refreshed in memory: 100 MiB in 0.4 s (100 MiB repurposed of 2000 MiB ahead)", lines[0]); // 8000 - (1000 + 2000 + 3000) + Assert.Contains("b: cache refreshed in memory: 100 MiB in 0.4 s (100 MiB repurposed of 3000 MiB ahead)", lines[1]); // 8000 - (2000 + 3000) + Assert.Contains("c: cache refreshed in memory: 100 MiB in 0.4 s (100 MiB repurposed of 5000 MiB ahead)", lines[2]); // 8000 - 3000 + Assert.Contains("d: cache refreshed in memory: 100 MiB in 0.4 s (877 MiB repurposed of 1900 MiB ahead)", lines[3]); // 만지지 않은 표식: 기준값은 그대로, A 에서 만진 합 6000 MiB 를 뺌 + } + + [Fact] + public void WhatTheOthersTouchedNeverTakesWhatIsAheadBelowZero() + { + Solar solar = Make(); + _ = solar.Order(Game("a"), Reads, CancellationToken.None); + _ = solar.Order(Game("b"), Reads, CancellationToken.None); + + solar.Regroup([("b", 20000 * Mib, Previous(20000, _now - TimeSpan.FromMinutes(1)))]); + _ = solar.Refresh(Game("a"), CancellationToken.None); + + Assert.Contains("a: cache refreshed in memory: 100 MiB in 0.4 s (0 MiB repurposed of 0 MiB ahead)", _log.Lines.Last()); + } + + [Theory] + [InlineData(1000, 300, 300)] // min(이전 표식이 만진 1000, 이번에 만진 300) + [InlineData(300, 1000, 300)] // min(300, 1000): 이번에 더 만진 것은 디스크에서 새로 읽은 것 + public void ALadderOfAGameThatHadAnEarlierMarkLowersWhatIsAheadOfTheOtherMarksByTheSmallerOfTheTwoAmounts(long earlierMib, long nowMib, long reducedMib) + { + Solar solar = Make(); + _result = _result with { Bytes = earlierMib * Mib }; + _ = solar.Order(Game("b"), Reads, CancellationToken.None); // b 의 이전 표식 + _now += TimeSpan.FromMinutes(1); + _result = _result with { Bytes = 100 * Mib }; + _ = solar.Order(Game("a"), Reads, CancellationToken.None); // A = 7900 MiB, b 의 이전 표식보다 나중 + _now += TimeSpan.FromMinutes(1); + _result = _result with { Bytes = nowMib * Mib }; + + _ = solar.Order(Game("b"), Reads, CancellationToken.None); + _result = _result with { Bytes = 100 * Mib }; + _ = solar.Refresh(Game("a"), CancellationToken.None); + + Assert.Contains($"(0 MiB repurposed of {7900 - reducedMib} MiB ahead)", _log.Lines.Last()); + } + + [Fact] + public void AFirstLadderOrOneWhoseEarlierMarkIsLaterThanTheOthersLeavesWhatIsAheadAlone() + { + Solar solar = Make(); + _ = solar.Order(Game("a"), Reads, CancellationToken.None); // A = 7900 MiB + _now += TimeSpan.FromMinutes(1); + _ = solar.Order(Game("b"), Reads, CancellationToken.None); // b 는 이전 표식이 없었음: 이미 캐시에 있던 페이지의 위치를 모르므로 줄이지 않음 + _now += TimeSpan.FromMinutes(1); + _ = solar.Order(Game("b"), Reads, CancellationToken.None); // b 의 이전 표식은 a 의 표식보다 나중: 그 페이지는 원래 a 뒤였음 + + _ = solar.Refresh(Game("a"), CancellationToken.None); + + Assert.Contains("(0 MiB repurposed of 7900 MiB ahead)", _log.Lines.Last()); + } + + // 묶음으로 새로 고친 a (먼저 만짐) 와 b: A 는 8000 - (1000 + 2000) = 5000 MiB 와 8000 - 2000 = 6000 MiB + private Solar RegroupedPair() + { + Solar solar = Make(); + _ = solar.Order(Game("a"), Reads, CancellationToken.None); + _ = solar.Order(Game("b"), Reads, CancellationToken.None); + solar.Regroup([("a", 1000 * Mib, null), ("b", 2000 * Mib, null)]); + return solar; + } + + [Fact] + public void AfterAGroupRefreshReTouchingOnlyTheLaterGameLeavesTheEarlierOnesAheadAlone() + { + Solar solar = RegroupedPair(); + _result = _result with { Bytes = 300 * Mib }; + + _ = solar.Order(Game("b"), Reads, CancellationToken.None); // b 의 이전 표식은 a 보다 나중: b 의 페이지는 원래 a 뒤였음 + _result = _result with { Bytes = 100 * Mib }; + _ = solar.Refresh(Game("a"), CancellationToken.None); + + Assert.Contains("(0 MiB repurposed of 5000 MiB ahead)", _log.Lines.Last()); + } + + [Fact] + public void AfterAGroupRefreshReTouchingOnlyTheEarlierGameLowersTheLaterOnesAhead() + { + Solar solar = RegroupedPair(); + _result = _result with { Bytes = 300 * Mib }; + + _ = solar.Order(Game("a"), Reads, CancellationToken.None); // a 의 이전 표식은 b 보다 먼저 (같은 묶음 안의 순서): a 의 페이지는 b 앞에 있었음 + _result = _result with { Bytes = 100 * Mib }; + _ = solar.Refresh(Game("b"), CancellationToken.None); + + Assert.Contains("(0 MiB repurposed of 5700 MiB ahead)", _log.Lines.Last()); // 6000 - min(이전 표식이 만진 1000, 이번 300) + } + + [Fact] + public void RefreshReportsTheBytesItTouchedOnlyWhenItFinishedTheWholeLadder() + { + Solar solar = Make(); + _ = solar.Order(Game("a"), Reads, CancellationToken.None); + _ = solar.Order(Game("b"), Reads, CancellationToken.None); + long? got = null; + + _ = solar.Refresh(Game("a"), CancellationToken.None, (_, bytes) => got = bytes); + Assert.Equal(100 * Mib, got); + + got = null; + _result = new TouchResult(10 * Mib, 0.6, 5, 0, TouchEnd.NeedsDiskReads, 32 * Mib); + _ = solar.Refresh(Game("b"), CancellationToken.None, (_, bytes) => got = bytes); + Assert.Null(got); + } + [Fact] public void PruneDropsTheMarksOfGamesThatLeftTheSettings() { @@ -501,6 +696,7 @@ private sealed class PlannedStreamer : IGameStreamer private bool _diskBusy; private long _avail = 20000; private ProbeResult? _probe = new ProbeResult(8, 0, 0.2); // warm (null: 표본을 읽을 수 없음) + private readonly HashSet _coldGames = []; // 이 게임들의 표본은 식음 (나머지는 _probe) private IReadOnlyList _common = []; private bool _resumeProbe; // 참이면 끊긴 보충이 이어 읽을 위치를 확인하는 표본 훅이 있음 (warm) private bool _commonProgress; // 참이면 공통 블록 읽기가 op 하나마다 진행을 알림 (실제 서비스의 묶음 진행처럼), 아니면 진행 없는 읽기 @@ -515,7 +711,7 @@ 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 = _ => _probe, + ProbeReadMs = g => _coldGames.Contains(g.Name) ? new ProbeResult(8, 8, 5.0) : _probe, DiskBytesPerSecond = _ => _diskBusy ? 1e9 : 0, CommonFirstOps = (_, _, _) => (_common, _common.Sum(o => o.Length)), ColdRead = (ops, _) => (new StreamStats(ops.Sum(o => o.Length), 1, 0, 0.01), true), @@ -1285,10 +1481,11 @@ public void WithSeveralGamesTheMostRecentlyPrewarmedOneIsTouchedLast() _touched.Clear(); GrowReuse(20000); _now += TimeSpan.FromMinutes(5); + int logged = _log.Lines.Count; // 예열 스트림 앞의 보호가 남긴 줄은 뺌 p.Tick(Auto()); - string[] refreshed = _log.Lines.Where(l => l.Contains("cache refreshed in memory")).Select(l => l.Split(':')[1].Trim()).ToArray(); + string[] refreshed = _log.Lines.Skip(logged).Where(l => l.Contains("cache refreshed in memory")).Select(l => l.Split(':')[1].Trim()).ToArray(); Assert.Equal(["b", "a", "c"], refreshed); // 가장 오래 전에 예열한 b 부터, 가장 최근 c 가 마지막 } @@ -1348,7 +1545,7 @@ public void AfterAPartialPrewarmIsSuppliedTheLadderCoversTheWholeHotSetWithTheCo } [Fact] - public void AStreamFinishedShortAtTheMemoryLimitIsNotTouchedBecauseItsReadRangeIsNotTheReplannedList() + public void AStreamFinishedShortAtTheMemoryLimitLaddersTheRangeItReadAndNotTheRestOfTheWindow() { ReadOp[] all = HotSet(50); (_streamer.All, _streamer.ZoneMib) = (all, 5000); @@ -1364,12 +1561,12 @@ public void AStreamFinishedShortAtTheMemoryLimitIsNotTouchedBecauseItsReadRangeI Assert.True(p.Run(Game("a"), "service start").Ran); Assert.Equal((1000L, 5000L), p.PartialInfo("a")); - Assert.Empty(_touched); // 읽은 만큼의 앞부분은 창 크기가 다르면 다시 계획한 목록과 같지 않아 (물리 순서 재정렬), 읽지 않은 구간을 만질 수 있으므로 건너뜀 - Assert.False(p.Solar.HasMark("a")); + Assert.Equal(Reversed(all.Take(10)), _touched.Single()); // 스트림이 읽은 창 (3000 MiB) 의 목록 중 읽은 앞 1000 MiB (파일 10 개) 만: 나머지 구간은 만지지 않음 + Assert.True(p.Solar.HasMark("a")); } [Fact] - public void ASupplementFinishedShortAtTheMemoryLimitIsNotTouchedEither() + public void ASupplementFinishedShortAtTheMemoryLimitLaddersTheRangeItReadToo() { ReadOp[] all = HotSet(50); (_streamer.All, _streamer.ZoneMib) = (all, 5000); @@ -1390,7 +1587,7 @@ public void ASupplementFinishedShortAtTheMemoryLimitIsNotTouchedEither() Assert.True(p.SupplementWhenIdle(Game("a"), CancellationToken.None)); Assert.Equal((3300L, 5000L), p.PartialInfo("a")); - Assert.Empty(_touched); + Assert.Equal(Reversed(all.Take(33)), _touched.Single()); // 보충 창 (4300 MiB) 의 목록 중 읽은 앞 3300 MiB 만 } [Fact] @@ -1427,7 +1624,9 @@ private Prewarmer ShortFinishedAndWaiting(ReadOp[] all, bool waitForMemory) GrowReuse(1000); Prewarmer p = MakePrewarmer(Game("a")); Assert.True(p.Run(Game("a"), "service start").Ran); - Assert.Empty(_touched); // 마무리한 스트림은 만지지 않음 + Assert.Equal(Reversed(all.Take(10)), _touched.Single()); // 마무리한 스트림도 끝난 직후 읽은 범위의 사다리를 만듦 (아래 시나리오는 사다리가 없는 상태에서 시작하므로 표식을 지움) + p.Solar.Forget("a"); + _touched.Clear(); _streamer.OnProgress = null; _avail = waitForMemory ? 4096 + 256 + 1100 : 20000; // 대기: 들어가는 범위 1100 은 읽은 1000 보다 256 이상 크지 않음 / 아니면 곧 더 읽을 수 있음 _now += TimeSpan.FromSeconds(30); @@ -1496,4 +1695,361 @@ public void AWaitingSupplementLadderThatFindsDataMissingLeavesTheRefillMarkAndIs Assert.Single(_touched); // 표시가 있는 게임은 다시 만들지 않음 Assert.Equal(callsBefore, _streamer.Calls.Count); // 보충이 대기 중이라 표시로 인한 재예열도 읽지 못하고 건너뛰기를 되풀이하지 않음 (읽기 호출 없음) } + + // ---- 묶음 새로 고침과 스트림 앞의 보호 ---- + + // a (더 오래전, 순위가 낮고 A 가 작음: 3900 MiB) 와 b (최근, A 7900 MiB) 를 예열해 둘 다 표식이 있고 4 분 넘게 지난 상태. b 는 트리거 경로로 예열해 스트림 앞의 보호가 a 를 만지지 않게 함. + // 재사용 누계는 1000 MiB 에서 시작: a 는 1560 MiB (0.4 x 3900) 늘면 때가 되고 b 는 3160 MiB 가 필요. c 는 예열하지 않은 세 번째 게임. + private Prewarmer TwoMarkedGames() + { + _lists = new MemoryLists(0, 1000 * Mib, 4000 * Mib); + Prewarmer p = MakePrewarmer(Game("a"), Game("b"), Game("c")); + Assert.True(p.Run(Game("a"), "service start").Ran); + GrowReuse(1000); + _now += TimeSpan.FromMinutes(2); + Assert.True(p.Run(Game("b"), "launcher", trigger: true).Ran); + _now += TimeSpan.FromMinutes(2); + _touched.Clear(); + return p; + } + + [Fact] + public void WhenOnlyOneMarkedGameIsDueTheTickRefreshesTheWholeGroupLowestRankFirstAndWritesTheMarksFromTheTouchOrder() + { + 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 에는 못 미침 + int logged = _log.Lines.Count; + + p.Tick(Auto()); + + 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("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); + _ = p.Solar.Refresh(Game("a"), CancellationToken.None); + _ = p.Solar.Refresh(Game("b"), CancellationToken.None); + string[] after = [.. _log.Lines.TakeLast(2)]; + Assert.Contains("(100 MiB repurposed of 8800 MiB ahead)", after[0]); // 9000 - (100 + 100) + Assert.Contains("(100 MiB repurposed of 8900 MiB ahead)", after[1]); // 9000 - 100 + } + + [Fact] + public void AColdGameLeavesTheGroupAndAGameTheGateSkipsKeepsItsMarkWithWhatWasAheadLoweredByWhatTheOthersTouched() + { + _lists = new MemoryLists(0, 1000 * Mib, 4000 * Mib); + Prewarmer p = MakePrewarmer(Game("a"), Game("b"), Game("c")); + Assert.True(p.Run(Game("a"), "service start").Ran); // A = 3900 MiB + GrowReuse(1000); + _now += TimeSpan.FromMinutes(2); + Assert.True(p.Run(Game("b"), "launcher", trigger: true).Ran); + _now += TimeSpan.FromMinutes(2); + Assert.True(p.Run(Game("c"), "launcher", trigger: true).Ran); // A: b 7900, c 7900 MiB + _now += TimeSpan.FromSeconds(30); // c 는 방금 (1 분 안) 예열: 게이트가 건너뜀 + _touched.Clear(); + _coldGames.Add("a"); + GrowReuse(1000 + 1600); // a: 0.4 x 3900 = 1560 MiB 에 이름 + int logged = _log.Lines.Count; + + p.Tick(Auto()); + + Assert.Single(_touched); // b 만 만짐 + Assert.False(p.Solar.HasMark("a")); // 식은 게임은 표식을 지우고 재예열 점검에 맡김 + Assert.Contains(_log.Lines.Skip(logged), l => l.Contains("a: cache refresh skipped, the samples are cold")); + Assert.True(p.Solar.HasMark("c")); + _coldGames.Clear(); + _ = p.Solar.Refresh(Game("c"), CancellationToken.None); + Assert.Contains("(1600 MiB repurposed of 7800 MiB ahead)", _log.Lines.Last()); // 7900 - min(b 의 이전 표식이 만진 100, 이번에 만진 100): b 의 이전 표식이 c 보다 먼저, 기준값은 그대로 + _ = p.Solar.Refresh(Game("b"), CancellationToken.None); + Assert.Contains("(0 MiB repurposed of 7900 MiB ahead)", _log.Lines.Last()); // b 의 새 표식: 대기 8000 - 자기가 만진 100, 기준값은 이번 시점 + } + + [Fact] + public void TheGroupIsNotRefreshedAgainWithinSixtySecondsEvenWhenDue() + { + Prewarmer p = TwoMarkedGames(); + GrowReuse(1000 + 1600); + p.Tick(Auto()); + Assert.Equal(2, _touched.Count); + GrowReuse(30000); + _now += TimeSpan.FromSeconds(30); + + p.Tick(Auto()); + + Assert.Equal(2, _touched.Count); + _now += TimeSpan.FromSeconds(31); + p.Tick(Auto()); + Assert.Equal(4, _touched.Count); + } + + [Fact] + public void AStreamThatIsNotATriggerTouchesTheOtherMarkedGamesLowestRankFirstBeforeItReadsAndTheDoneLineShowsIt() + { + Prewarmer p = TwoMarkedGames(); + int touchesAtStream = -1; + _streamer.DuringStream = () => touchesAtStream = _touched.Count; + int logged = _log.Lines.Count; + + PrewarmResult result = p.Run(Game("c"), "service start"); + + Assert.True(result.Ran); + Assert.Equal(2, touchesAtStream); // 스트림이 읽는 동안에는 a, b 를 이미 만졌음 + Assert.Equal(3, _touched.Count); // 그리고 이 게임의 사다리 + 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.DoesNotContain(_log.Lines, l => l.Contains("a: prewarm done") && l.Contains("protected")); // 표식 있는 다른 게임이 없던 스트림에는 꼬리가 없음 + } + + [Fact] + public void ATriggerStreamDoesNotProtectTheOtherGamesButItsLadderLowersWhatIsAheadOfThem() + { + GrowReuse(1000); + Prewarmer p = MakePrewarmer(Game("a"), Game("b")); + Assert.True(p.Run(Game("b"), "launcher", trigger: true).Ran); // b 의 이전 표식 (100 MiB) + _now += TimeSpan.FromMinutes(2); + Assert.True(p.Run(Game("a"), "launcher", trigger: true).Ran); // A = 7900 MiB, b 의 표식보다 나중 + _now += TimeSpan.FromMinutes(2); + _touched.Clear(); + int touchesAtStream = -1; + _streamer.DuringStream = () => touchesAtStream = _touched.Count; + int logged = _log.Lines.Count; + + Assert.True(p.Run(Game("b"), "launcher", ignoreCooldown: true, trigger: true).Ran); + + Assert.Equal(0, touchesAtStream); // 트리거 예열은 늦추지 않음 + Assert.Single(_touched); // b 의 사다리만 + Assert.DoesNotContain(_log.Lines.Skip(logged), l => l.Contains("protected")); + _ = p.Solar.Refresh(Game("a"), CancellationToken.None); + Assert.Contains("(0 MiB repurposed of 7800 MiB ahead)", _log.Lines.Last()); // 7900 - min(b 의 이전 표식이 만진 100, 이번에 만진 100) + } + + [Fact] + public void AStreamLeavesGamesWithoutAMarkAloneAndWithTheSettingOffItIsTheSameAsBefore() + { + GrowReuse(1000); + _enabled = false; + Prewarmer unmarked = MakePrewarmer(Game("a"), Game("b")); + Assert.True(unmarked.Run(Game("a"), "service start").Ran); // 표식 없음 + _enabled = true; + _now += TimeSpan.FromMinutes(2); + int touchesAtStream = -1; + _streamer.DuringStream = () => touchesAtStream = _touched.Count; + + Assert.True(unmarked.Run(Game("b"), "service start").Ran); + + Assert.Equal(0, touchesAtStream); + _touched.Clear(); + Prewarmer marked = TwoMarkedGames(); + _enabled = false; + _touched.Clear(); + + Assert.True(marked.Run(Game("c"), "service start").Ran); + + Assert.Empty(_touched); + Assert.DoesNotContain(_log.Lines, l => l.Contains("c: prewarm done") && l.Contains("protected")); + } + + [Fact] + public void AColdOtherGameIsNotTouchedByTheProtectionBecauseTheSampleCheckStaysInPlace() + { + Prewarmer p = TwoMarkedGames(); + _coldGames.Add("a"); + int touchesAtStream = -1; + _streamer.DuringStream = () => touchesAtStream = _touched.Count; + int logged = _log.Lines.Count; + + Assert.True(p.Run(Game("c"), "service start").Ran); + + Assert.Equal(1, touchesAtStream); // b 만 + Assert.False(p.Solar.HasMark("a")); + Assert.Contains(_log.Lines.Skip(logged), l => l.Contains("a: cache refresh skipped, the samples are cold")); + } + + [Fact] + public void AGameStartDuringTheProtectionStopsItAtOnceAndTheRestOfTheMarkedGamesAreNotTouched() + { + Prewarmer p = TwoMarkedGames(); + _onTouch = () => p.NoteGameStart("c"); + int logged = _log.Lines.Count; + + PrewarmResult result = p.Run(Game("c"), "service start"); + + Assert.False(result.Ran); + Assert.Single(_touched); + Assert.Contains(_log.Lines.Skip(logged), l => l.Contains("a: cache refresh stopped after")); + Assert.DoesNotContain(_log.Lines.Skip(logged), l => l.Contains("b: cache refresh")); + } + + [Fact] + public void AProtectionThatFindsDataMissingOrThrowsLeavesTheStreamResultTheSame() + { + Prewarmer missing = TwoMarkedGames(); + _result = _result with { End = TouchEnd.NeedsDiskReads, SlowPieces = 3, DiskBytes = 32 * Mib }; + + PrewarmResult result = missing.Run(Game("c"), "service start"); + + Assert.True(result.Ran); + Assert.Equal(30 * Mib, result.Stats!.Bytes); + Assert.True(missing.Solar.HasMissing("a")); // 빠진 부분은 재예열 점검에 맡김 + Assert.DoesNotContain(_log.Lines, l => l.Contains("c: prewarm done") && l.Contains("protected")); // 끝까지 만진 게임이 없으면 꼬리 없음 + + _result = new TouchResult(100 * Mib, 0.4, 0, 0, TouchEnd.Completed); + Prewarmer throwing = TwoMarkedGames(); + _onTouch = () => throw new IOException("boom"); + + PrewarmResult thrown = throwing.Run(Game("c"), "service start"); + + Assert.True(thrown.Ran); + Assert.Equal(30 * Mib, thrown.Stats!.Bytes); + Assert.Contains(_log.Lines, l => l.Contains("a: cache refresh failed: IOException")); + } + + [Fact] + public void AGameThatAGateKeepsBlockedWhileItIsDueDoesNotMakeTheOtherGamesBeTouchedAgainAndAgain() + { + GameSettings e = Game("e") with { OriginRoot = @"E:\Games\e" }; // 다른 디스크 + _lists = new MemoryLists(0, 1000 * Mib, 4000 * Mib); + Prewarmer p = MakePrewarmer(e, Game("b"), Game("c")); + Assert.True(p.Run(e, "service start").Ran); // A = 3900 MiB + GrowReuse(1000); + _now += TimeSpan.FromMinutes(2); + Assert.True(p.Run(Game("b"), "launcher", trigger: true).Ran); + _now += TimeSpan.FromMinutes(2); + Assert.True(p.Run(Game("c"), "launcher", trigger: true).Ran); + _now += TimeSpan.FromMinutes(2); + _touched.Clear(); + _ = _running.Add("e"); // e 는 실행 중: 게이트가 막음 + GrowReuse(1000 + 1600); // e 만 때가 됨 (b, c 는 3160 MiB 가 필요) + + for (int i = 0; i < 4; i++) + { + p.Tick(Auto()); + _now += TimeSpan.FromSeconds(61); + } + + Assert.Empty(_touched); // 막힌 e 때문에 b, c 가 되풀이해 만져지지 않음 + Assert.True(p.Solar.HasMark("e")); + GrowReuse(1000 + 3200); // 이제 b, c 자신이 때가 됨 + + p.Tick(Auto()); + + Assert.Equal(2, _touched.Count); // b 와 c 만 (막힌 e 는 건너뜀) + } + + [Fact] + public void AGameWhoseDiskReadLeaseIsRefusedKeepsItsMarkAndIsSkippedWithoutALog() + { + Prewarmer p = TwoMarkedGames(); + var coordinator = new DiskReadCoordinator(new VolumeDisks(_ => new HashSet { 1 }, () => 0), _log); + p.SetDiskReadCoordinator(coordinator); + coordinator.Yield(@"D:\Games\x", "game start", "x"); // 같은 디스크에서 게임이 시작해 양보 중: 대여 거절 + GrowReuse(1000 + 1600); + int logged = _log.Lines.Count; + + p.Tick(Auto()); + + Assert.Empty(_touched); + Assert.True(p.Solar.HasMark("a")); + Assert.True(p.Solar.HasMark("b")); + Assert.DoesNotContain(_log.Lines.Skip(logged), l => l.Contains("cache refresh skipped")); + } + + [Fact] + public void ATriggerThatStartsWaitingDuringTheProtectionStopsItBeforeTheNextGame() + { + Prewarmer p = TwoMarkedGames(); + IDisposable? wait = null; + _onTouch = () => wait ??= p.BeginTriggerWait([Game("b")]); // a 를 만지는 도중 같은 디스크의 트리거 예열이 기다리기 시작함 + int logged = _log.Lines.Count; + + _ = p.Run(Game("c"), "service start"); + wait?.Dispose(); + + Assert.Single(_touched); // a 만: b 와 이 게임의 사다리는 만지지 않음 + Assert.DoesNotContain(_log.Lines.Skip(logged), l => l.Contains("b: cache refresh")); + } + + [Fact] + public void ARewarmStreamAndASupplementStreamProtectTheOtherGamesBeforeTheyReadToo() + { + Prewarmer p = TwoMarkedGames(); + int touchesAtStream = -1; + _streamer.DuringStream = () => touchesAtStream = _touched.Count; + + Assert.True(p.Run(Game("c"), "rewarm", ignoreCooldown: true).Ran); + + Assert.Equal(2, touchesAtStream); // 재예열: a, b 를 이미 만졌음 + + ReadOp[] all = HotSet(50); + (_streamer.All, _streamer.ZoneMib) = (all, 5000); + _avail = 7000; // 핫 집합 5000 MiB + 예비 4096 MiB 가 다 들어가지 않아 앞부분만 읽음 + Prewarmer partial = TwoMarkedGames(); + Assert.True(partial.Run(Game("c"), "service start").Ran); + Assert.True(partial.SupplementPending("c")); + _touched.Clear(); + _avail = 20000; + touchesAtStream = -1; + Assert.False(partial.SupplementWhenIdle(Game("c"), CancellationToken.None)); + _now += TimeSpan.FromSeconds(61); + + Assert.True(partial.SupplementWhenIdle(Game("c"), CancellationToken.None)); + + Assert.Equal(2, touchesAtStream); // 보충: 읽기 전에 a, b 를 만졌음 + Assert.Contains(_log.Lines, l => l.Contains("c: supplement prewarm done") && l.Contains(", protected 2 games (")); + } + + [Fact] + public void ASupplementThatReadAFullPlanAndFinishedShortAtTheMemoryLimitDoesNotLadderItsReadRange() + { + ReadOp[] all = HotSet(50); + (_streamer.All, _streamer.ZoneMib) = (all, 5000); + _avail = 4096 + 256 + 3000; + Prewarmer p = MakePrewarmer(Game("a")); + Assert.True(p.Run(Game("a"), "service start").Ran); // 창 3000 을 끝까지 읽음 + _touched.Clear(); + _avail = 4096 + 256 + 6000; // 이번에는 전체가 들어감: 틈 메우기 예산이 있는 전체 경로 (창 없음) + _streamer.ReadMib = 3300; + _streamer.OnProgress = progress => + { + _avail = 4096 + 3300; + progress(3300 * Mib, 5000 * Mib); + }; + + Assert.False(p.SupplementWhenIdle(Game("a"), CancellationToken.None)); + _now += TimeSpan.FromSeconds(61); + Assert.True(p.SupplementWhenIdle(Game("a"), CancellationToken.None)); + + Assert.Equal((3300L, 5000L), p.PartialInfo("a")); + Assert.Empty(_touched); // 예산이 있는 계획으로 읽은 범위는 창으로 다시 계획한 목록과 같다고 알 수 없어 만지지 않음 (재예열 점검의 첫 사다리가 받침) + } + + [Fact] + public void TailsThatWouldShowZeroMibAreLeftOut() + { + Assert.Equal(string.Empty, new RequeueTally(0, 0, false, 700 * 1024).Note()); + Assert.Equal(string.Empty, new RequeueTally(700 * 1024, 0.1, false).Note()); + Assert.Equal(", requeued 5 MiB in 1.0 s", new RequeueTally(5 * Mib, 1.0, false, 700 * 1024).Note()); + Assert.Equal(", requeued 5 MiB in 1.0 s (2 MiB in sparse files not touched)", new RequeueTally(5 * Mib, 1.0, false, 2 * Mib).Note()); + Assert.Equal(string.Empty, new RequeueTally(0, 0, false, 0, 2, 500 * 1024, 0.1).Note()); + Assert.Equal(", protected 2 games (3 MiB in 0.1 s)", new RequeueTally(0, 0, false, 0, 2, 3 * Mib, 0.1).Note()); + Assert.Equal(string.Empty, new StreamStats(5 * Mib, 1, 0, 1) { BufferedBytes = 700 * 1024 }.BufferedNote()); + + _result = _result with { SkippedBytes = 700 * 1024 }; + Solar solar = Make(); + _ = solar.Order(Game("a"), Reads, CancellationToken.None); + _ = solar.Refresh(Game("a"), CancellationToken.None); + Assert.All(_log.Lines, l => Assert.DoesNotContain("sparse files not touched", l)); + _result = new TouchResult(10 * Mib, 0.6, 5, 0, TouchEnd.NeedsDiskReads, 32 * Mib, 700 * 1024); + _ = solar.Order(Game("b"), Reads, CancellationToken.None); + Assert.EndsWith("left to the rewarm check", _log.Lines.Last()); + } } diff --git a/docs/SOLAR.ko.md b/docs/SOLAR.ko.md index 0aae2e1..57e295f 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 가 지금까지 다시 쓴 캐시 페이지의 누계, 그리고 앞에 있는 양(대기 목록의 캐시 양에서 이 게임이 만진 양을 뺀 값)을 적어 둡니다. 표식은 메모리에만 있습니다. -3. **통계 하나를 봅니다.** 표식이 있는 게임이 있으면 약 30초마다 Windows 의 대기 목록 통계를 한 번 읽습니다. 시스템 전체 값이며 프로세스를 열지 않습니다. 이 게임의 페이지 앞에 있는 페이지가 먼저 다시 쓰이므로, 표식 이후 다시 쓴 캐시 페이지가 `max(512 MiB, 0.4 x 앞에 있는 양)` 만큼 늘었으면 이 게임의 페이지가 목록 앞에 가까워진 것입니다. -4. **디스크 없이 새로 고칩니다.** 새로 고치기 전에 평소의 표본 점검(8곳에서 1 MiB 씩)을 합니다. 표본이 따뜻하면 같은 사다리를 다시 만져 페이지를 목록 맨 뒤로 보냅니다. 식었거나 읽을 수 없으면 표식을 지우고 [재예열 점검](AUTO_MODE.ko.md#5-유지)에 맡깁니다. 여러 게임이 시점이 되면 순위가 낮은 게임부터, 가장 최근에 예열한 게임을 마지막으로 만집니다. +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초 안에는 다시 하지 않으며, 스트림 앞의 보호(세부 참고)도 한 번으로 셉니다. 5. **디스크가 읽히면 멈춥니다.** 캐시에 없는 페이지를 만지면 디스크 읽기가 됩니다. 뷰 하나를 만질 때마다 그동안 시스템 전체가 디스크에서 읽은 쪽 수를 비교해, 32 MiB 를 넘게 세어지면 멈춥니다. 그러면 표식을 지우고 그 게임에 "다시 채워야 함" 표시를 남겨, 다음 재예열 점검이 표본이 따뜻해 보여도 그 게임을 다시 예열합니다. 캐시에 있는 부분은 디스크 시간이 거의 들지 않으므로 실제로는 빠진 부분만 디스크에서 읽힙니다. 이 재예열은 게임마다 마지막 예열 시도로부터 `auto.rewarm_minutes` 에 한 번을 넘지 않습니다. Windows 의 통계를 읽을 수 없으면(예: 권한이 없을 때) 3번은 마지막 사다리 뒤 10분마다 새로 고치는 것으로 바뀌고 나머지는 그대로입니다. @@ -30,11 +30,12 @@ Windows 의 통계를 읽을 수 없으면(예: 권한이 없을 때) 3번은 - **만지는 방식.** 범위마다 최대 64 MiB 의 읽기 전용 뷰를 따로 매핑하고, 서비스 자기 프로세스 안에서 1 MiB 씩 읽은 뒤 바로 풉니다. 잠금도, 열어 두는 매핑도 없습니다. 한 번에 최대 64개 파일을 엽니다. 프로그램이 그 페이지를 평소처럼 읽은 것과 같은 결과입니다. - **범위마다 뷰를 쓰는 이유.** 시험 PC 에서 파일 안의 범위들이 만진 순서대로 밀려난 것은 범위마다 따로 뷰를 만들어 바로 푼 경우뿐이었습니다. 한 뷰로 모든 범위를 만들면 주소 순서로 밀려났습니다(실험 E10, E11). -- **0.4 와 512 MiB 의 근거.** 밀려남 실험(E9)에서 만지지 않은 첫 파일은 다시 쓴 양의 누계가 앞에 있는 양의 0.61~0.81배 늘었을 때 밀려났습니다. 앞에 있는 페이지 일부는 다시 참조되어 빠지므로 실제 시점은 단순 계산보다 일찍 오고, 그래서 여유를 두었습니다. 하한 512 MiB 는 앞에 있는 양이 작을 때 너무 자주 새로 고치지 않게 합니다. 두 값 모두 그 실험 하나에서 나온 것이며 더 다듬지 않았습니다. +- **0.4 와 512 MiB 의 근거.** 밀려남 실험(E9)에서 만지지 않은 첫 파일은 다시 쓴 양의 누계가 앞에 있는 양의 0.61~0.81배 늘었을 때 밀려났습니다. 앞에 있는 페이지 일부는 다시 참조되어 빠지므로 실제 시점은 단순 계산보다 일찍 오고, 그래서 여유를 두었습니다. 하한 512 MiB 는 앞에 있는 양이 작을 때 너무 자주 새로 고치지 않게 합니다. 두 값 모두 그 실험 하나에서 나온 것이며 더 다듬지 않았습니다. 남은 여유 2 GiB 는 설치된 PC 에서 잰 실험에서 30초에 약 1.9 GiB 의 다시 쓰기가 몰린 것이 두 번 관측된 데서 나왔습니다([측정 기록](#측정-기록) 참고). 그 PC 와 그 시간대의 값이며 일반값이 아닙니다. - **디스크 읽기를 가리는 방법.** 뷰 하나를 만지기 전후로 시스템의 페이지 읽기 누계를 읽습니다. 늘어난 양에서 다른 프로그램의 몫(2쪽과 초당 약 8 MiB)을 뺀 나머지를 디스크에서 읽힌 것으로 세며, 뷰 크기를 넘지 않게 합니다. 32 MiB 를 넘게 세어지면 만지기를 끝내므로, 최악에는 멈추기 전에 디스크에서 약 100 MiB 가 읽힙니다(센 32 MiB 와 만지던 뷰 하나). 누계를 읽을 수 없으면 64 MiB 당 약 256 ms 를 넘게 걸린 뷰를 디스크에서 읽힌 것으로 봅니다. 더 느슨한 안전망으로, 1 MiB 당 20 ms 넘게 걸린 조각의 시간이 합쳐 2초를 넘어도 멈춥니다. - **무엇을 만지나.** 예열이 먼저 읽는 공통 블록 다음에 읽은 목록 전체를 읽은 순서대로 늘어놓은 것의 역순입니다. 앞부분만 읽은 예열이면 읽은 부분입니다. 같은 파일에서 앞 범위에 이어지는 범위는 하나로 합칩니다. - **읽는 도중 만지기.** 예열 스트림이 묶음 (읽기 약 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번의 규칙으로 줄입니다(이전 표식이 없던 게임의 첫 사다리는 줄이지 않음). 메모리 한도에서 마무리한 스트림도 끝난 직후 같은 대여 안에서 읽은 범위의 사다리를 만듭니다. 대상은 스트림이 읽은 것과 같은 창의 목록을 읽은 양에서 자른 앞부분이라 읽지 않은 구간은 만지지 않습니다. 틈 메우기 예산이 없는 창 읽기나 이어 읽기일 때만 그렇습니다(스트림이 읽은 목록이 같은 창으로 계획한 목록이기 때문). 그 예산이 있는 계획으로 전체 목록을 읽다 한도에서 마무리한 보충은 만들지 않고 재예열 점검에 맡기며, 점검의 첫 사다리가 메모리를 기다리는 게임을 받칩니다. 서비스를 다시 시작하면 표식이 없어지므로, 게임마다 표본이 따뜻한 첫 재예열 점검에서 새 사다리를 만듭니다. @@ -47,8 +48,8 @@ Windows 의 통계를 읽을 수 없으면(예: 권한이 없을 때) 3번은 | 설정 | `memory.standby_ladder` 가 `true`(기본). | | 서비스 권한 | 서비스가 관리자 권한이고 신뢰할 수 있는 데이터 폴더를 쓸 때. [배경 메모리 양보](AUTO_MODE.ko.md#배경-메모리-양보)와 같은 조건입니다. | | 그 디스크에서 게임이 실행 중이 아님 | 캐시에 없는 것을 만지면 디스크 읽기라서, 게임이 도는 디스크에는 새 배경 읽기를 내지 않는 규칙을 따릅니다. 게임이 시작하면 멈춥니다. | -| 런처 예열이 기다리는 중이 아님 | 런처 예열이 먼저입니다. 런처 때문에 멈춘 스트림 뒤에도 만들지 않습니다. | -| 새로 고침의 관문 | 재예열 점검과 같습니다: 재배치 중이 아니고, 게임 드라이브가 4 MiB/s 미만이고, 게임 종료 직후 유예가 지났고, 게임이 예열 예산 안에 있고, 디스크 읽기 대여가 허용될 때. | +| 런처 예열이 기다리는 중이 아님 | 런처 예열이 먼저입니다. 런처 때문에 멈춘 스트림 뒤에도 만들지 않고, 묶음 새로 고침은 런처 예열이 기다리기 시작하면 멈춥니다. | +| 새로 고침의 관문 | 재예열 점검과 같습니다: 재배치 중이 아니고, 게임 드라이브가 4 MiB/s 미만이고, 게임 종료 직후 유예가 지났고, 게임이 예열 예산 안에 있고, 디스크 읽기 대여가 허용될 때. 대여가 거절되면 그 게임만 건너뛰고 표식은 남습니다. | | `auto.keep_stream_on_game_start` 가 꺼져 있음 | 이 실험 설정에서는 게임이 떠도 스트림을 끊지 않아, SOLAR 가 따라가지 않습니다. | ## 하지 않는 것 @@ -70,10 +71,11 @@ Windows 의 통계를 읽을 수 없으면(예: 권한이 없을 때) 3번은 ## 켜졌는지 확인하는 법 -아래 줄은 `service.log` 에 남습니다. `<게임>` 은 게임 이름이고, `, N MiB read from disk`, `, N errors`, `, K MiB in sparse files not touched` 같은 꼬리는 0 이 아닐 때만 붙습니다. +아래 줄은 `service.log` 에 남습니다. `<게임>` 은 게임 이름이고, `, N MiB read from disk`, `, N errors`, `, K MiB in sparse files not touched` 같은 꼬리는 0 이 아닐 때만 붙습니다(MiB 단위 값이 0 MiB 로 보이면 붙이지 않음). - `<게임>: 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` 는 앞에 있는 양입니다. +- `<게임>: 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 (...) ..., 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` 에 가까운지를 보십시오. - `<게임>: 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` 는 주기가 지나기를 기다린다는 뜻입니다. @@ -85,6 +87,8 @@ Windows 의 통계를 읽을 수 없으면(예: 권한이 없을 때) 3번은 ## 한계와 주의 - **실제 플레이에서의 효과는 아직 재지 않았습니다.** 실험은 순서를 정할 수 있다는 것과, 아직 메모리에 있는 캐시를 새로 고치는 데는 디스크 읽기가 들지 않는다는 것을 보였습니다. 실행 중인 게임에서 로딩 시간이나 끊김이 얼마나 달라지는지는 보여 주지 않습니다. 식은 것으로 판정되는 재예열 점검의 비율이 줄었는지도 아직 확인하지 않았습니다. +- **다른 프로그램이 이 게임 앞쪽 페이지를 다시 참조해 뒤로 보내는 것은 셀 수 없습니다.** 그래서 기준이 늦을 수 있고, 그 경우는 재예열 점검이 받칩니다. +- **묶음 새로 고침, 스트림 앞의 보호, 한도 마무리 뒤 사다리의 효과는 설치본에서 아직 확인하지 않았습니다.** - **밀려나는 것을 막지는 않습니다.** 메모리가 정말 모자라면 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` 로 사다리가 어디까지 만졌는지 볼 수 있습니다. @@ -116,6 +120,10 @@ Windows 의 통계를 읽을 수 없으면(예: 권한이 없을 때) 3번은 | 디스크 읽기와 캐시 적중을 가릴 수 있나 | E12: 256 MiB 시험 파일을 64 MiB 뷰로 만짐 | 캐시에 없음: 2.74초, 시스템 페이지 읽기 누계 +66,115쪽(258 MiB, 읽은 양과 같음). 캐시에 있음: 0.13~0.15초, +65~128쪽. 2초 동안 아무것도 안 함: +449쪽. | | 페이지 우선순위를 6·7 로 올릴 수 있나 | E4 | 아니오. `STATUS_INVALID_PARAMETER` 로 거부됨. | +### 서비스가 도는 동안의 시험 파일 (2026-11-14) + +설치된 서비스가 돌고 PC 를 평소처럼 쓰는 중에 HDD 의 512 MiB 시험 파일 4개를 캐시에 올리고 61분 동안 지켜봤습니다. 다시 만지지 않은 세 파일은 다시 쓰기가 몰린 한 번에 함께 사라졌고, 15분 뒤 한 번 더 만진 파일은 39분을 더 남았습니다. 서비스가 다른 게임을 새로 고치자 시험 파일 앞에 있던 양이 12.4 GiB 에서 약 5.9 GiB 로 줄었습니다. 이 값은 서비스 로그와 잰 다시 쓰기 양으로 계산한 것이며 직접 읽은 값이 아닙니다. 다시 쓰기는 몰려서 왔고 30초에 약 1.9 GiB 가 두 번 있었습니다. 남은 여유 2 GiB 와 묶음 새로 고침은 이 사실들에 근거합니다. + ### 설치본 (서비스 로그, 개발 빌드 1.0.4-dev.4, 2026-11-14) - 재예열 직후 1,163 MiB 의 사다리를 0.5초에 만들었고 디스크 읽기는 없었습니다. diff --git a/docs/SOLAR.md b/docs/SOLAR.md index 442d267..e92916e 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 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)`, the game's pages are getting close to the front. -4. **Refresh without the disk.** Before a 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. When several games are due, the lowest-ranked one is touched first and the most recently prewarmed one last. +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. 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. @@ -30,11 +30,12 @@ If Windows' statistics cannot be read (for example because the privilege is miss - **How a touch is made.** Each range is mapped as its own read-only view of at most 64 MiB, read 1 MiB at a time from inside the service's own process, and unmapped at once. Nothing is locked and no mapping is kept. Up to 64 files are open at a time. The result is the same as if a program had read the pages normally. - **Why a view per range.** On the test PC, a file's ranges left the cache in the order they were touched only when each range had its own view that was released right after. With one view for all ranges they left in address order (experiments E10 and E11). -- **Where 0.4 and 512 MiB come from.** In the eviction experiment (E9) the first untouched file left the cache when the reuse count had grown by 0.61 to 0.81 times the amount ahead. Some pages ahead are used again and drop out of the way, so the real moment comes earlier than a plain count suggests, and a margin was added. The lower bound of 512 MiB keeps a small amount ahead from triggering refreshes too often. Both values come from that one experiment and have not been tuned further. +- **Where 0.4 and 512 MiB come from.** In the eviction experiment (E9) the first untouched file left the cache when the reuse count had grown by 0.61 to 0.81 times the amount ahead. Some pages ahead are used again and drop out of the way, so the real moment comes earlier than a plain count suggests, and a margin was added. The lower bound of 512 MiB keeps a small amount ahead from triggering refreshes too often. Both values come from that one experiment and have not been tuned further. The 2 GiB of room comes from a reuse burst of about 1.9 GiB within 30 seconds that was seen twice in one measurement on the installed PC (see the measurement record); it is a value of that PC and time of day, not a general one. - **How disk reads are told.** The system's page-read count is read before and after each view. The increase, minus an allowance for other programs (2 pages and about 8 MiB per second), counts as read from the disk, at most the size of the view. More than 32 MiB counted ends the touch, so in the worst case about 100 MiB is read from the disk before it stops (the 32 MiB counted, plus the view being touched). If the count cannot be read, a view that takes more than about 256 ms per 64 MiB counts as read from the disk. A looser safety net also stops the touch when pieces that took more than 20 ms per MiB add up to more than 2 seconds. - **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. -- **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). A stream that ended at the memory limit does not make one at once; the first ladder is made at a re-prewarm check whose samples are warm. After a service restart the notes are gone, so each game gets a new ladder at the first such check. +- **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. @@ -47,8 +48,8 @@ All of these must hold. Otherwise nothing is touched. | Setting | `memory.standby_ladder` is `true` (the default). | | 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. | -| 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. | +| 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. | | 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 @@ -70,10 +71,11 @@ There is no switch in the Settings tab. The service re-reads the file and then s ## Checking that it runs -Each line goes to `service.log`. `` is the game's name, and a tail such as `, N MiB read from disk`, `, N errors` or `, K MiB in sparse files not touched` is added only when it is not zero. +Each line goes to `service.log`. `` is the game's name, and a tail such as `, N MiB read from disk`, `, N errors` or `, K MiB in sparse files not touched` is added only when it is not zero (a figure in MiB is left out when it would show 0 MiB). - `: 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. +- `: 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 (...) ..., 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`. - `: 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. @@ -85,6 +87,8 @@ Each line goes to `service.log`. `` is the game's name, and a tail such as ## Limits and cautions - **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.** - **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. @@ -116,6 +120,10 @@ These use test files and an experiment tool, not the service and not a running g | Can a disk read be told from a cache hit? | E12: 256 MiB test file touched through a 64 MiB view | Not in cache: 2.74 s and the system page-read count rose by 66,115 pages (258 MiB, the amount read). In cache: 0.13 to 0.15 s and a rise of 65 to 128 pages. Idle for 2 s: 449 pages. | | Can page priority 6 or 7 be set? | E4 | No, refused with `STATUS_INVALID_PARAMETER`. | +### Test files while the service was running (2026-11-14) + +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.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.