diff --git a/csharp/src/AsterCache.Service/Prewarm.Run.cs b/csharp/src/AsterCache.Service/Prewarm.Run.cs index 6030ccb..26ed332 100644 --- a/csharp/src/AsterCache.Service/Prewarm.Run.cs +++ b/csharp/src/AsterCache.Service/Prewarm.Run.cs @@ -253,6 +253,7 @@ void Settle(StreamStats stats, bool shortFinish) (StreamStats Stats, bool Complete, string? CancelReason, bool MemoryStop)? streamed; bool fellBack = false; // 전체 경로가 메모리로 멈추면 창으로 줄여 한 번만 다시 읽음 _yieldedSeconds = 0; // StreamHot 이 끝날 때마다 더함 (done 줄에 한 번 보임) + _requeued = default; while (true) { streamed = StreamHot(warm, ct, zoneBytes, availableRamMib, memoryPartial is not null, Settle, @@ -324,7 +325,7 @@ void Settle(StreamStats stats, bool shortFinish) double mbps = s.Seconds > 0 ? s.Bytes / 1048576.0 / s.Seconds : 0; string kind = memoryPartial is { } part ? $"partial {ActualMib(s, part.WarmedMib)}/{part.HotMib} MiB, the rest does not fit in memory: " : string.Empty; - _log.Info($"{game.Name}: prewarm done ({why}): {kind}{s.Bytes >> 20} MiB requested, {s.GapBytes / 1048576.0:F2} MiB gaps filled in {s.Seconds:F2} s = {mbps:F0} hint MB/s, {s.Requests} requests, {s.Errors} errors, room {(roomMib is { } roomLimit ? roomLimit + " MiB" : "unlimited")}, available {_env.AvailableRamMib()} MiB{(_yieldedSeconds > 0 ? $", yielded {_yieldedSeconds:F1} s" : string.Empty)}"); + _log.Info($"{game.Name}: prewarm done ({why}): {kind}{s.Bytes >> 20} MiB requested, {s.GapBytes / 1048576.0:F2} MiB gaps filled in {s.Seconds:F2} s = {mbps:F0} hint MB/s, {s.Requests} requests, {s.Errors} errors, room {(roomMib is { } roomLimit ? roomLimit + " MiB" : "unlimited")}, available {_env.AvailableRamMib()} MiB{(_yieldedSeconds > 0 ? $", yielded {_yieldedSeconds:F1} s" : string.Empty)}{_requeued.Note()}"); _log.Info($"{game.Name}: prewarm phases ({why}): {run.Summary()}"); PrewarmResult done = Record(game, warmedAt, new PrewarmResult(game.Name, true, null, s, warmedAt), mbps); WarmMeasured?.Invoke(game, mbps); @@ -478,6 +479,8 @@ private void CheckPrewarmBatch(GameSettings game, CancellationTokenSource stop, double? streamSeconds = null; // 사다리 시간이 노출 시간에 들어가지 않게 스트림이 끝난 때의 값 // 읽기는 이 스레드 (묶음 점검 콜백도 같은 스레드) 에서 내므로 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)); // 다시 만지기는 디스크 읽기가 아니므로 노출 시간에서 뺌 try { using CancellationTokenRegistration leaseReg = lease?.Token.Register(() => @@ -512,9 +515,10 @@ private void CheckPrewarmBatch(GameSettings game, CancellationTokenSource stop, } calibration.Token.ThrowIfCancellationRequested(); + RequeueSkipped(game, skipBytes, requeue, stop.Token); // 이어 읽기: 꼬리를 읽기 전에 건너뛴 앞부분을 한 번 만짐 using (PrewarmRun.Time(PrewarmPhase.Common)) { - WarmCommonFirst(game, stop, maxBytes, skipBytes, lease, cancelDetail, ct, readYield, fit); + WarmCommonFirst(game, stop, maxBytes, skipBytes, lease, cancelDetail, ct, readYield, fit, requeue); } calibration.Token.ThrowIfCancellationRequested(); @@ -534,7 +538,7 @@ private void CheckPrewarmBatch(GameSettings game, CancellationTokenSource stop, Progress?.Invoke(game, done >> 20, (total + (1 << 20) - 1) >> 20); } }, maxBytes, skipBytes, () => !stop.IsCancellationRequested && !(fit?.Finished ?? false) && (lease?.IsValid ?? true), allowReorder, - (stage, bytes, isData) => lease?.NoteReadIssue(stage, bytes, isData)); + (stage, bytes, isData) => lease?.NoteReadIssue(stage, bytes, isData), requeue is null ? null : batch => requeue.Touch(batch, stop.Token)); // 읽은 것을 버리지 않아도 될 때만 (오류·순서 바꿈 없음) 마무리로 남기고, 아니면 기존 메모리 취소와 같게 둠. 틈을 메운 계획은 읽은 곳이 앞쪽과 같다고 보장할 수 없어도 마무리하되 호출 쪽이 보수적으로 셈 (GapBytes). bool clean = r.Item1.Bytes > 0 && r.Item1.Errors == 0 && !_streamer.WasPrewarmReordered(game); bool limited = fit is { Finished: true } && !stop.IsCancellationRequested && (lease?.IsValid ?? true); @@ -547,7 +551,7 @@ private void CheckPrewarmBatch(GameSettings game, CancellationTokenSource stop, complete = r.Item2 && !stop.IsCancellationRequested && !limited; finalStats = r.Item1; LogYield(game.Name, r.Item1.Bytes, lease); - streamSeconds = Stopwatch.GetElapsedTime(startedAt).TotalSeconds; + streamSeconds = ExposureSeconds(); if (complete && r.Item1.Bytes > 0) { LadderAfterStream(game, stop.Token); // 같은 디스크 대여 안에서: 게임 시작·대여 취소가 stop 으로 바로 멈춤. 한도로 마무리한 스트림은 건너뜀: 읽은 만큼의 앞부분은 창 크기가 달라 다시 계획한 목록과 같지 않아, 읽지 않은 구간을 만질 수 있음 @@ -566,13 +570,14 @@ private void CheckPrewarmBatch(GameSettings game, CancellationTokenSource stop, { FinishYield(readYield, game.Name); // 스레드 풀 스레드에 낮은 우선순위가 남지 않게 (정상·취소·예외 모두) _yieldedSeconds += readYield.Seconds; + _requeued += requeue?.Tally ?? default; bool interrupted = !complete && _streamGameStarted; _calibrationStop = null; (_streaming, _streamStop, _streamCancelDetail) = (null, null, null); // shortcut: 스트림 PDH 는 순차 탐침과 비교할 수 없어 교차 확인을 배선하지 않음, 실측 이탈이 확인되면 끝까지 읽은 스트림의 실제 읽기 바이트/시간으로 다시 연결 try { - _env.OrbitExposure?.Invoke(game, streamSeconds ?? Stopwatch.GetElapsedTime(startedAt).TotalSeconds, interrupted); + _env.OrbitExposure?.Invoke(game, streamSeconds ?? ExposureSeconds(), interrupted); } catch (Exception e) when (e is not (OutOfMemoryException or StackOverflowException)) { @@ -626,7 +631,7 @@ private void CalibrateDevice(GameSettings game, IReadOnlyList ops, strin // 디스크 대여·중단 신호는 StreamHot 의 것을 그대로 씁니다. 실패하면 건너뛰고 스트림이 같은 오류를 판단합니다. // 최종 예약 검사는 common 을 읽기 전에 이번 스트림 전체 양으로 합니다 (거절이면 common 도 읽지 않음). private void WarmCommonFirst(GameSettings game, CancellationTokenSource stop, long maxBytes, long skipBytes, DiskReadCoordinator.Lease? lease, - PrewarmCancelDetail cancelDetail, CancellationToken callerToken, ReadYield? readYield = null, MemoryFit? fit = null) + PrewarmCancelDetail cancelDetail, CancellationToken callerToken, ReadYield? readYield = null, MemoryFit? fit = null, StreamRequeue? requeue = null) { try { @@ -652,6 +657,16 @@ private void WarmCommonFirst(GameSettings game, CancellationTokenSource stop, lo } long total = ops.Sum(o => o.Length); + long requeued = 0; // 읽은 공통 블록 중 이미 다시 만진 바이트: 묶음 경계 (읽은 양 done) 마다 새로 읽은 만큼만 읽은 순서대로 만짐 + void Requeue(long readBytes) + { + if (requeue is not null && readBytes > requeued) + { + requeue.Touch(PrefetchStreamer.Tail(PrefetchStreamer.Head(ops, readBytes), requeued), stop.Token); + requeued = readBytes; + } + } + Action progress = (done, planned) => { CheckPrewarmBatch(game, stop, done, planned, detail: cancelDetail, callerToken: callerToken, leaseToken: lease?.Token ?? default, yield: readYield, preemptible: true); @@ -660,6 +675,8 @@ private void WarmCommonFirst(GameSettings game, CancellationTokenSource stop, lo cancelDetail.Set("disk read lease revoked"); stop.Cancel(); } + + Requeue(done); }; (StreamStats Stats, bool Complete) read; if (_env.ColdReadWithIssue is { } withIssue) @@ -676,6 +693,12 @@ private void WarmCommonFirst(GameSettings game, CancellationTokenSource stop, lo lease?.NoteReadIssue("common-first read", total, true); read = _env.ColdRead(ops, stop.Token); } + + if (read.Complete) + { + Requeue(total); // 묶음 진행을 알리지 않는 읽기 경로가 남긴 나머지 + } + _log.Info($"{game.Name}: common-first prewarm {read.Stats.Bytes >> 20}/{total >> 20} MiB" + (read.Complete ? string.Empty : " (stopped)")); } catch (OperationCanceledException) when (stop.IsCancellationRequested) diff --git a/csharp/src/AsterCache.Service/Prewarm.Solar.cs b/csharp/src/AsterCache.Service/Prewarm.Solar.cs index ef3008a..2d546ee 100644 --- a/csharp/src/AsterCache.Service/Prewarm.Solar.cs +++ b/csharp/src/AsterCache.Service/Prewarm.Solar.cs @@ -14,6 +14,35 @@ public sealed partial class Prewarmer internal Solar Solar => _solar; + private RequeueTally _requeued; // 핫 집합 스트림이 읽는 묶음을 다시 만든 양 (Run·보충이 예열 한 회 동안 모아 done 줄에 씀), _run 아래에서만 씀 + + // 핫 집합 스트림 한 번이 읽는 묶음마다 그 범위를 곧바로 다시 만지는 도우미 (StreamHot 이 만듦). 꺼져 있거나 keep_stream_on_game_start (게임이 떠도 스트림이 계속 읽음, 만지기도 디스크 읽기가 될 수 있음) 면 null. + // 만지기는 스트림과 같은 스레드·같은 대여·같은 중지 토큰 아래에서만 돌고, 게임 시작·트리거 선점 표시가 서면 건너뜁니다 (묶음 점검이 그 사유로 스트림을 멈추기 전의 틈도 막음). + private StreamRequeue? BeginRequeue(GameSettings game, Action? onTouched = null) => + _solar.Enabled && !_auto.KeepStreamOnGameStart ? new StreamRequeue(_env.TouchRanges, _log, game.Name, () => _streamGameStarted || _streamPreempted, onTouched) : null; + + // 이어 읽기 (skipBytes > 0) 는 이전 회가 읽은 앞부분 (공통 블록은 목록의 부분집합이라 그 안에 있음) 을 읽지 않으므로, 그대로 두면 그 부분이 이 게임에서 가장 오래된 페이지로 남습니다. + // 꼬리를 읽기 전에 건너뛴 앞부분을 목록 순서대로 한 번 만집니다. 이어 읽기는 틈 메우기 없는 창 읽기라 계획한 목록 (PlannedPrewarmReads) 이 스트림의 목록과 같습니다. 계획할 수 없으면 건너뜁니다. + private void RequeueSkipped(GameSettings game, long skipBytes, StreamRequeue? requeue, CancellationToken stop) + { + if (requeue is null || skipBytes <= 0 || stop.IsCancellationRequested || DriveHasRunningGame(game)) + { + return; + } + + try + { + if (_streamer.PlannedPrewarmReads(game) is { Count: > 0 } planned) + { + requeue.TouchSkipped(PrefetchStreamer.Head(planned, skipBytes), stop); + } + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or PlanRejectedException or PyValueException) + { + // 계획할 수 없음: 건너뛴 앞부분은 스트림 끝의 사다리가 처리 + } + } + // 핫 집합 스트림이 끝까지 읽은 직후 (StreamHot, 디스크 대여 안): 그 게임이 지금까지 캐시에 올린 전체의 역순으로 캐시를 만져 중요한 것이 마지막에 밀려나게 합니다. // game 은 스트림이 읽은 범위를 나타내는 사본 (메모리·상한으로 줄인 것 포함, 보충이면 이어 읽은 창) 이라 앞에서 읽은 부분과 보충분이 모두 대상입니다. 방금 읽었으므로 표본 점검은 생략합니다. // 게임이 이미 도는 디스크이거나 keep_stream_on_game_start (게임이 떠도 스트림을 끊지 않는 실험 설정) 가 켜져 있으면 만지지 않습니다: 만지기도 캐시에 없으면 디스크 읽기라 게임을 느리게 할 수 있습니다. diff --git a/csharp/src/AsterCache.Service/Prewarm.Supplement.cs b/csharp/src/AsterCache.Service/Prewarm.Supplement.cs index 51629a6..3ca71df 100644 --- a/csharp/src/AsterCache.Service/Prewarm.Supplement.cs +++ b/csharp/src/AsterCache.Service/Prewarm.Supplement.cs @@ -631,6 +631,7 @@ internal bool Supplement(GameSettings game, CancellationToken ct, long? allocate _log.Info($"{game.Name}: supplement prewarm of the rest of the hot set ({p.WarmedMib} of {p.HotMib} MiB read{(windowMib is { } w ? $", reading the first {w} MiB" : string.Empty)}{(skipBytes > 0 ? $", continuing after {skipBytes >> 20} MiB" : string.Empty)})"); bool fellBack = false; // 전체 경로가 메모리로 멈추면 창으로 줄여 한 번만 다시 읽음 _yieldedSeconds = 0; // StreamHot 이 끝날 때마다 더함 (done 줄에 한 번 보임) + _requeued = default; while (true) { hot = StreamHot(warm, ct, zoneBytes, avail, windowMib is not null || skipBytes > 0, skipBytes: skipBytes, onTotal: total => tailTotal = total, settle: (stats, shortFinish) => @@ -797,7 +798,7 @@ internal bool Supplement(GameSettings game, CancellationToken ct, long? allocate if (requestListEnded) { DeferSupplement(game, plannedCapMib, Math.Max(progressBefore, progressAfter), inputStamp, r.Stats, planEnded: true, skipBytes + plannedListBytes); - _log.Info($"{game.Name}: supplement prewarm read the first {MibText(progressAfter)} of {p.HotMib} MiB in {r.Stats.Seconds:F2} s ({MibText(r.Stats.Bytes)} accepted of {MibText(r.Stats.RequestedBytes)} requested, {r.Stats.Errors} errors), the plan lists {MibText(skipBytes + plannedListBytes)} within {readCapMib} MiB"); + _log.Info($"{game.Name}: supplement prewarm read the first {MibText(progressAfter)} of {p.HotMib} MiB in {r.Stats.Seconds:F2} s ({MibText(r.Stats.Bytes)} accepted of {MibText(r.Stats.RequestedBytes)} requested, {r.Stats.Errors} errors){_requeued.Note()}, the plan lists {MibText(skipBytes + plannedListBytes)} within {readCapMib} MiB"); return true; } @@ -808,7 +809,7 @@ internal bool Supplement(GameSettings game, CancellationToken ct, long? allocate DeferSupplement(game, plannedCapMib, progressBefore, inputStamp, r.Stats, planEnded: false, skipBytes + plannedListBytes); } - _log.Info($"{game.Name}: supplement prewarm read the first {MibText(progressAfter)} of {p.HotMib} MiB in {r.Stats.Seconds:F2} s ({MibText(r.Stats.Bytes)} accepted of {MibText(r.Stats.RequestedBytes)} requested, {r.Stats.Errors} errors), the partial window remains pending"); + _log.Info($"{game.Name}: supplement prewarm read the first {MibText(progressAfter)} of {p.HotMib} MiB in {r.Stats.Seconds:F2} s ({MibText(r.Stats.Bytes)} accepted of {MibText(r.Stats.RequestedBytes)} requested, {r.Stats.Errors} errors){_requeued.Note()}, the partial window remains pending"); return true; } @@ -819,13 +820,13 @@ internal bool Supplement(GameSettings game, CancellationToken ct, long? allocate DeferSupplement(game, plannedCapMib, progressBefore, inputStamp, r.Stats, planEnded: false, skipBytes + plannedListBytes); } - _log.Info($"{game.Name}: supplement prewarm read the first {MibText(progressAfter)} of {p.HotMib} MiB in {r.Stats.Seconds:F2} s ({MibText(r.Stats.Bytes)} accepted of {MibText(r.Stats.RequestedBytes)} requested, {r.Stats.Errors} errors), the hot set remains partial"); + _log.Info($"{game.Name}: supplement prewarm read the first {MibText(progressAfter)} of {p.HotMib} MiB in {r.Stats.Seconds:F2} s ({MibText(r.Stats.Bytes)} accepted of {MibText(r.Stats.RequestedBytes)} requested, {r.Stats.Errors} errors){_requeued.Note()}, the hot set remains partial"); return true; } ClearPartial(game.Name, p.HotMib); CompletePendingWarmSupplement(game, r.Stats); - _log.Info($"{game.Name}: supplement prewarm done: {MibText(r.Stats.Bytes)} accepted of {MibText(r.Stats.RequestedBytes)} requested, {r.Stats.GapBytes / 1048576.0:F2} MiB gaps filled in {r.Stats.Seconds:F2} s, {r.Stats.Errors} errors{(_yieldedSeconds > 0 ? $", yielded {_yieldedSeconds:F1} s" : string.Empty)}"); + _log.Info($"{game.Name}: supplement prewarm done: {MibText(r.Stats.Bytes)} accepted of {MibText(r.Stats.RequestedBytes)} requested, {r.Stats.GapBytes / 1048576.0:F2} MiB gaps filled in {r.Stats.Seconds:F2} s, {r.Stats.Errors} errors{(_yieldedSeconds > 0 ? $", yielded {_yieldedSeconds:F1} s" : string.Empty)}{_requeued.Note()}"); return true; } finally diff --git a/csharp/src/AsterCache.Service/PrewarmYield.cs b/csharp/src/AsterCache.Service/PrewarmYield.cs index dcbc65b..36defe3 100644 --- a/csharp/src/AsterCache.Service/PrewarmYield.cs +++ b/csharp/src/AsterCache.Service/PrewarmYield.cs @@ -44,12 +44,22 @@ internal sealed class ReadYield(Func setLow, bool startYielding) private bool _everYielded; private DateTimeOffset _since; private TimeSpan _total; + private TimeSpan _excluded; /// 양보로 시작했고 첫 관측을 아직 하지 않음 (첫 묶음 직전에 한 번 관측하고 내림). public bool FirstObservationPending { get; set; } = startYielding; - /// 낮은 우선순위로 읽은 시간 (초). - public double Seconds => _total.TotalSeconds; + /// 낮은 우선순위로 읽은 시간 (초). 디스크를 읽지 않은 일 (다시 만지기) 에 쓴 시간은 뺍니다. + public double Seconds => Math.Max(0, (_total - _excluded).TotalSeconds); + + /// 낮은 우선순위인 동안 디스크를 읽지 않는 일에 seconds 를 썼음을 알려 양보 시간에서 뺍니다. + public void Exclude(double seconds) + { + if (_low) + { + _excluded += TimeSpan.FromSeconds(seconds); + } + } /// 상태를 바꿉니다 (low: 낮음, 아니면 보통). 바꾸지 못하면 (setLow 실패) 상태는 그대로이며 다음 관측 때 다시 시도합니다. /// 이 스트림에서 처음 낮음으로 바뀐 때만 true (로그 한 줄용). diff --git a/csharp/src/AsterCache.Service/Solar.cs b/csharp/src/AsterCache.Service/Solar.cs index 6da7beb..7006510 100644 --- a/csharp/src/AsterCache.Service/Solar.cs +++ b/csharp/src/AsterCache.Service/Solar.cs @@ -89,7 +89,7 @@ public void Prune(IReadOnlySet live) break; } - log.Info($"{game.Name}: ladder found data missing from memory {Missing(r)}, left to the rewarm check"); + log.Info($"{game.Name}: ladder found data missing from memory {Missing(r, ladder)}, left to the rewarm check"); break; } @@ -129,7 +129,7 @@ public void Prune(IReadOnlySet live) break; } - log.Info($"{game.Name}: cache refresh found data missing from memory {Missing(r)}, left to the rewarm check"); + log.Info($"{game.Name}: cache refresh found data missing from memory {Missing(r, mark.Ladder)}, left to the rewarm check"); break; } @@ -208,8 +208,8 @@ public void Forget(string game) } } - // 다시 채움 로그의 괄호: 느린 조각 수와 디스크에서 읽힌 양, 오류 수 - private static string Missing(TouchResult r) => $"({r.SlowPieces} slow pieces{(r.DiskBytes > 0 ? $", {r.DiskBytes >> 20} MiB read from disk" : string.Empty)}{(r.Errors > 0 ? $", {r.Errors} errors" : string.Empty)})"; + // 다시 채움 로그의 꼬리: 사다리 (역순) 를 몇 MiB 만지고 멈췄는지 (만진 양이 전체에 가까우면 빠진 것은 가장 중요한 앞쪽), 느린 조각 수와 디스크에서 읽힌 양, 오류 수 + 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 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/StreamRequeue.cs b/csharp/src/AsterCache.Service/StreamRequeue.cs new file mode 100644 index 0000000..8d08054 --- /dev/null +++ b/csharp/src/AsterCache.Service/StreamRequeue.cs @@ -0,0 +1,73 @@ +using AsterCache.Core.Logging; +using AsterCache.Core.Prefetch; +using AsterCache.Windows; + +namespace AsterCache.Service; + +/// 한 예열 스트림이 만지는 데 쓴 양: 만진 바이트, 걸린 시간 (초), 디스크 읽기가 감지돼 만지기가 멈췄는가. +internal readonly record struct RequeueTally(long Bytes, double Seconds, bool DiskStopped) +{ + public static RequeueTally operator +(RequeueTally a, RequeueTally b) => new(a.Bytes + b.Bytes, a.Seconds + b.Seconds, a.DiskStopped || b.DiskStopped); + + /// 예열 완료 로그 줄 끝에 붙이는 말 (만진 것도 멈춘 것도 없으면 빈 문자열). + public string Note() => Bytes > 0 || DiskStopped ? $", requeued {Bytes >> 20} MiB in {Seconds:F1} s{(DiskStopped ? ", requeue stopped by disk reads" : string.Empty)}" : string.Empty; +} + +/// 예열 스트림이 묶음을 읽을 때마다 그 범위를 곧바로 다시 만집니다 (SOLAR 의 만지기, 디스크 읽기 없음이 전제). +/// PrefetchVirtualMemory 는 이미 캐시에 있는 페이지의 순서를 바꾸지 않으므로, 빠진 부분만 읽는 재예열은 그 게임의 나머지 페이지를 이전 예열 때의 오래된 순서로 남깁니다. +/// 읽은 범위를 바로 만지면 건너뛴 페이지도 대기 목록의 뒤로 가서, 스트림이 끝났을 때 그 게임의 모든 페이지가 이번 스트림 동안의 것이 됩니다. +/// 만지기의 실패·취소·디스크 읽기 감지는 스트림의 결과를 바꾸지 않습니다 (스트림의 일은 읽는 것): 예외는 삼키고 한 번만 경고합니다. +/// 한 묶음의 만지기가 디스크 읽기를 만나 멈추면 그 스트림의 남은 묶음은 만지지 않습니다 (묶음마다 새 허용치로 디스크 읽기를 되풀이하지 않게). 이어 읽기의 건너뛴 앞부분 만지기는 이 판정과 따로입니다. +/// onTouched: 만지는 데 쓴 시간 (초) 을 알림 (양보 시간 계산에서 빼는 데 씀). +internal sealed class StreamRequeue(Func, CancellationToken, TouchResult> touch, ILog log, string game, Func halted, Action? onTouched = null) +{ + private bool _warned; + private bool _batchesStopped; + + public long Bytes { get; private set; } + + public double Seconds { get; private set; } + + public bool DiskStopped { get; private set; } + + public RequeueTally Tally => new(Bytes, Seconds, DiskStopped); + + /// 방금 읽은 범위를 읽은 순서대로 만집니다. 스트림이 멈췄거나 (stop) 게임이 시작했거나 트리거가 멈추게 했으면 (halted), 또는 앞 묶음의 만지기가 디스크 읽기로 멈췄으면 만지지 않습니다. + public void Touch(IReadOnlyList ranges, CancellationToken stop) => Run(ranges, stop, skipped: false); + + /// 이어 읽기가 건너뛴 앞부분 (이전 회에 읽은, 가장 중요한 부분) 을 꼬리를 읽기 전에 한 번 만집니다. 디스크 읽기로 멈춰도 이 만지기만 그만두고, 꼬리 묶음의 만지기는 따로 판정합니다. + public void TouchSkipped(IReadOnlyList ranges, CancellationToken stop) => Run(ranges, stop, skipped: true); + + private void Run(IReadOnlyList ranges, CancellationToken stop, bool skipped) + { + if (ranges.Count == 0 || stop.IsCancellationRequested || halted() || (!skipped && _batchesStopped)) + { + return; + } + + try + { + TouchResult r = touch(ranges, stop); + Bytes += r.Bytes; + Seconds += r.Seconds; + onTouched?.Invoke(r.Seconds); + if (r.End == TouchEnd.NeedsDiskReads) + { + DiskStopped = true; + _batchesStopped |= !skipped; + } + } + catch (OperationCanceledException) + { + // 스트림이 멈춤: 아무것도 하지 않음 + } + catch (Exception e) when (e is not (OutOfMemoryException or StackOverflowException)) + { + if (!_warned) + { + _warned = true; + log.Warn($"{game}: requeue of the read batches skipped: {e.GetType().Name}"); // 예열 결과에는 영향을 주지 않음 + } + } + } +} diff --git a/csharp/src/AsterCache.Service/Streaming.cs b/csharp/src/AsterCache.Service/Streaming.cs index 0efa4ad..88239c9 100644 --- a/csharp/src/AsterCache.Service/Streaming.cs +++ b/csharp/src/AsterCache.Service/Streaming.cs @@ -48,6 +48,11 @@ public interface IGameStreamer (StreamStats Stats, bool Complete) StreamPrewarmCoordinated(GameSettings game, CancellationToken stop, Action? progress, long maxBytes, long skipBytes, Func canIssue, bool allowReorder, Action? noteIssue) => StreamPrewarmCoordinated(game, stop, progress, maxBytes, skipBytes, canIssue, allowReorder); + /// afterBatch: 묶음 하나를 읽고 진행 알림을 낸 직후, 그 묶음이 요청한 읽기 목록으로 부름 (읽은 범위를 호출부가 곧바로 다시 만지게 함). 묶음을 모르는 스트리머는 부르지 않습니다. + (StreamStats Stats, bool Complete) StreamPrewarmCoordinated(GameSettings game, CancellationToken stop, Action? progress, long maxBytes, long skipBytes, Func canIssue, bool allowReorder, Action? noteIssue, + Action>? afterBatch) => + StreamPrewarmCoordinated(game, stop, progress, maxBytes, skipBytes, canIssue, allowReorder, noteIssue); + bool WasPrewarmReordered(GameSettings game) => false; /// 예열이 읽는 목록 (틈 메우기 제외, 부분 예열 뒤 표본을 고르는 것과 같은 기준: game 의 MaxStreamMib 로 잘린 읽은 범위), 모르면 null. 캐시를 디스크 읽기 없이 다시 만지는 사다리 (Solar) 의 대상입니다. @@ -306,7 +311,12 @@ public void StreamHeadCoordinated(GameSettings game, CancellationToken stop, Act public (StreamStats Stats, bool Complete) StreamPrewarmCoordinated(GameSettings game, CancellationToken stop, Action? progress, long maxBytes, long skipBytes, Func canIssue, bool allowReorder, Action? noteIssue) => StreamPrewarmCore(game, stop, progress, maxBytes, skipBytes, canIssue, allowReorder, noteIssue); - private (StreamStats Stats, bool Complete) StreamPrewarmCore(GameSettings game, CancellationToken stop, Action? progress, long maxBytes, long skipBytes, Func? canIssue, bool allowReorder, Action? noteIssue = null) + public (StreamStats Stats, bool Complete) StreamPrewarmCoordinated(GameSettings game, CancellationToken stop, Action? progress, long maxBytes, long skipBytes, Func canIssue, bool allowReorder, Action? noteIssue, + Action>? afterBatch) + => StreamPrewarmCore(game, stop, progress, maxBytes, skipBytes, canIssue, allowReorder, noteIssue, afterBatch); + + private (StreamStats Stats, bool Complete) StreamPrewarmCore(GameSettings game, CancellationToken stop, Action? progress, long maxBytes, long skipBytes, Func? canIssue, bool allowReorder, Action? noteIssue = null, + Action>? afterBatch = null) { List all; long gapBytes; @@ -358,7 +368,7 @@ public void StreamHeadCoordinated(GameSettings game, CancellationToken stop, Act } using var streamTimer = PrewarmRun.Time(PrewarmPhase.Stream); - (StreamStats Stats, bool Complete) r = Batches(ops, stop, progress, canIssue, noteIssue); + (StreamStats Stats, bool Complete) r = Batches(ops, stop, progress, canIssue, noteIssue, afterBatch); if (r.Complete) { if (stop.IsCancellationRequested || (canIssue is not null && !canIssue())) @@ -389,7 +399,7 @@ internal static StreamStats Refresh(IReadOnlyList ops, CancellationToken return ZoneStreamer.Run(refresh, StreamMethod.Read, ct: stop); } - private static (StreamStats Stats, bool Complete) Batches(List ops, CancellationToken stop, Action? progress, Func? canIssue, Action? noteIssue = null) + private static (StreamStats Stats, bool Complete) Batches(List ops, CancellationToken stop, Action? progress, Func? canIssue, Action? noteIssue = null, Action>? afterBatch = null) { long total = ops.Sum(o => o.Length); long done = 0; @@ -404,6 +414,7 @@ private static (StreamStats Stats, bool Complete) Batches(List ops, Canc StreamStats s = ZoneStreamer.Run(batch, StreamMethod.Pvm, ct: stop, noteIssue: noteIssue); done += s.Bytes; progress?.Invoke(done, total); + afterBatch?.Invoke(batch); // 진행 알림 (묶음 점검) 뒤에 부름: 점검이 스트림을 멈췄으면 호출부가 만지지 않음 return s; }); return stop.IsCancellationRequested || (canIssue is not null && !canIssue()) diff --git a/csharp/tests/AsterCache.Tests/Service/PrefetchStreamerTests.cs b/csharp/tests/AsterCache.Tests/Service/PrefetchStreamerTests.cs index d18c76b..e7e10b4 100644 --- a/csharp/tests/AsterCache.Tests/Service/PrefetchStreamerTests.cs +++ b/csharp/tests/AsterCache.Tests/Service/PrefetchStreamerTests.cs @@ -1,3 +1,4 @@ +using AsterCache.Core.Prefetch; using AsterCache.Core.Settings; using AsterCache.Core.Trace; using AsterCache.Prefetch; @@ -71,6 +72,35 @@ public void CoordinatedStartStreamRechecksBeforeEachBatch() Assert.True(checks >= 2); } + [Fact] + public void PrewarmStreamHandsEachReadBatchToTheCallerRightAfterItsProgressNotice() + { + string[] files = [Path.Combine(_game.OriginRoot, "a.bin"), Path.Combine(_game.OriginRoot, "b.bin")]; + foreach (string file in files) + { + using var stream = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.ReadWrite); + stream.SetLength(65 * Mib); // 묶음 크기 (64 MiB) 보다 큰 파일 둘: 묶음이 여러 개 나옴 + } + + _ = TraceWriter.Write([[new TraceEvent(files[0], 0, 65 * Mib), new TraceEvent(files[1], 0, 65 * Mib)]], _game.Trace); + List events = []; + List batches = []; + + (StreamStats stats, bool complete) = new PrefetchStreamer().StreamPrewarmCoordinated(_game, CancellationToken.None, (done, _) => events.Add("progress " + (done >> 20)), 200 * Mib, 0, + () => true, allowReorder: false, null, batch => + { + events.Add("batch"); + batches.Add([.. batch]); + }); + + Assert.True(complete); + Assert.True(batches.Count >= 2); + Assert.Equal(stats.Bytes, batches.Sum(b => b.Sum(o => o.Length))); // 묶음들이 스트림이 읽은 범위 전부 + Assert.Equal(files.Order(), batches.SelectMany(b => b).Select(o => o.Path).Distinct().Order()); + Assert.Equal("progress 0", events[0]); + Assert.Equal(Enumerable.Repeat(new[] { "progress", "batch" }, batches.Count).SelectMany(x => x), events.Skip(1).Select(e => e.Split(' ')[0])); // 묶음마다 진행 알림 뒤에 곧바로 묶음 + } + [Fact] public void CancelledPrewarmPlanningDoesNotPublishAReadRequest() { diff --git a/csharp/tests/AsterCache.Tests/Service/PrewarmYieldTests.cs b/csharp/tests/AsterCache.Tests/Service/PrewarmYieldTests.cs index c45556b..db3071f 100644 --- a/csharp/tests/AsterCache.Tests/Service/PrewarmYieldTests.cs +++ b/csharp/tests/AsterCache.Tests/Service/PrewarmYieldTests.cs @@ -311,4 +311,17 @@ public void TheLoadHeadReadYieldsLikeTheHotSetStreamAndRestoresNormal() Assert.True(low > head, "앞부분 읽기 도중 낮음으로 바뀜"); Assert.True(_events.IndexOf("normal", low) > low, "앞부분 읽기가 끝나면 보통으로 되돌림"); } + + [Fact] + public void TimeSpentOnNonDiskWorkWhileYieldingIsLeftOutOfTheYieldedSeconds() + { + var ry = new ReadYield(_ => true, startYielding: false); + ry.Exclude(3); // 낮은 우선순위가 아닐 때의 시간은 애초에 양보 시간이 아님 + Assert.True(ry.Apply(low: true, _now)); + + ry.Exclude(2); // 낮은 동안 다시 만지는 데 쓴 시간 + Assert.True(ry.Finish(_now + TimeSpan.FromSeconds(5))); + + Assert.Equal(3, ry.Seconds, precision: 3); + } } diff --git a/csharp/tests/AsterCache.Tests/Service/SolarTests.cs b/csharp/tests/AsterCache.Tests/Service/SolarTests.cs index 95c5cd6..c46357c 100644 --- a/csharp/tests/AsterCache.Tests/Service/SolarTests.cs +++ b/csharp/tests/AsterCache.Tests/Service/SolarTests.cs @@ -142,12 +142,12 @@ public void BytesSeenReadFromDiskAreAppendedToTheLogLine() public void ALadderThatFindsDataMissingFlagsTheGameOnceAndItIsClearedWhenTaken() { Solar solar = Make(); - _result = new TouchResult(37 * Mib, 0.6, 36, 0, TouchEnd.NeedsDiskReads, 37 * Mib); + _result = new TouchResult(12 * Mib, 0.6, 36, 0, TouchEnd.NeedsDiskReads, 37 * Mib); _ = solar.Order(Game("a"), Reads, CancellationToken.None); Assert.True(solar.HasMissing("a")); - Assert.Equal("Info: a: ladder found data missing from memory (36 slow pieces, 37 MiB read from disk), left to the rewarm check", _log.Lines.Single()); + Assert.Equal("Info: a: ladder found data missing from memory after 12 of 30 MiB (36 slow pieces, 37 MiB read from disk), left to the rewarm check", _log.Lines.Single()); Assert.True(solar.TakeMissing("a")); Assert.False(solar.TakeMissing("a")); Assert.False(solar.HasMissing("a")); @@ -264,7 +264,7 @@ public void RefreshThatNeedsDiskReadsClearsTheMarkSoItIsNotRetriedUntilANewLadde Assert.Equal(TouchEnd.NeedsDiskReads, solar.Refresh(Game("a"), CancellationToken.None)); Assert.False(solar.HasMark("a")); - Assert.Contains("a: cache refresh found data missing from memory (5 slow pieces, 32 MiB read from disk, 1 errors), left to the rewarm check", _log.Lines.Last()); + 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))); } @@ -366,8 +366,42 @@ private sealed class PlannedStreamer : IGameStreamer /// 실제로 읽었다고 알릴 양 (MiB), 없으면 받은 범위 전부. public long? ReadMib { get; set; } + /// 묶음을 아는 스트리머처럼 묶음마다 진행 알림과 afterBatch 를 부를 읽기 묶음들 (비어 있으면 묶음 없이 한 번에 읽음). + public List Batches { get; } = []; + + /// 묶음의 진행 알림과 afterBatch 사이에서 (묶음 번호) 부르는 일: 점검이 스트림을 멈춘 것처럼 게임 시작을 알림. + public Action? BetweenProgressAndTouch { get; set; } + public StreamStats Stream(GameSettings game) => throw new NotSupportedException(); + public (StreamStats Stats, bool Complete) StreamPrewarmCoordinated(GameSettings game, CancellationToken stop, Action? progress, long maxBytes, long skipBytes, Func canIssue, bool allowReorder, + Action? noteIssue, Action>? afterBatch) + { + if (Batches.Count == 0) + { + return StreamPrewarm(game, stop, progress, maxBytes); + } + + Calls.Add((game.Name, game.MaxStreamMib)); + long total = Batches.Sum(b => b.Sum(o => o.Length)); + long done = 0; + progress?.Invoke(0, total); + for (int i = 0; i < Batches.Count; i++) + { + if (stop.IsCancellationRequested) + { + return (new StreamStats(done, i, 0, 0.01, Aborted: true), false); + } + + done += Batches[i].Sum(o => o.Length); + progress?.Invoke(done, total); + BetweenProgressAndTouch?.Invoke(i); + afterBatch?.Invoke(Batches[i]); + } + + return (new StreamStats(done, Batches.Count, 0, 0.01), true); + } + public (StreamStats Stats, bool Complete) StreamPrewarm(GameSettings game, CancellationToken stop, Action? progress, long maxBytes) { Calls.Add((game.Name, game.MaxStreamMib)); @@ -399,6 +433,8 @@ private sealed class PlannedStreamer : IGameStreamer private long _avail = 20000; private ProbeResult? _probe = new ProbeResult(8, 0, 0.2); // warm (null: 표본을 읽을 수 없음) private IReadOnlyList _common = []; + private bool _resumeProbe; // 참이면 끊긴 보충이 이어 읽을 위치를 확인하는 표본 훅이 있음 (warm) + private bool _commonProgress; // 참이면 공통 블록 읽기가 op 하나마다 진행을 알림 (실제 서비스의 묶음 진행처럼), 아니면 진행 없는 읽기 private Prewarmer MakePrewarmer(params GameSettings[] games) { @@ -414,12 +450,28 @@ private Prewarmer MakePrewarmer(params GameSettings[] games) 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), + ProbeDone = _resumeProbe ? (_, _) => _probe : null, + ColdReadWithIssue = _commonProgress ? ReadCommonWithProgress : null, StandbyLadderEnabled = env.StandbyLadderEnabled, Lists = env.Lists, TouchRanges = env.TouchRanges, }, _log, null, () => _now); } + // 실제 서비스의 묶음 읽기처럼 op 하나를 읽을 때마다 진행 (읽은 양 누계, 전체) 을 알림. + private static (StreamStats Stats, bool Complete) ReadCommonWithProgress(IReadOnlyList ops, CancellationToken ct, Action progress, Action noteIssue) + { + long done = 0; + long total = ops.Sum(o => o.Length); + foreach (ReadOp op in ops) + { + done += op.Length; + progress(done, total); + } + + return (new StreamStats(done, ops.Count, 0, 0.01), true); + } + private static AutoSettings Auto() => AutoSettings.Default with { RewarmMinutes = 10 }; private void GrowReuse(long mib) => _lists = new MemoryLists(0, mib * Mib, 8000 * Mib); @@ -434,7 +486,9 @@ public void AfterTheStreamFinishesTheWholeReadRangeIsTouchedInReverseWithTheComm Assert.True(p.Run(Game("a"), "service start").Ran); - Assert.Equal([new ReadOp(@"D:\a\1.bin", 50 * Mib, 10 * Mib), new ReadOp(@"D:\a\2.bin", 0, 10 * Mib), new ReadOp(@"D:\a\1.bin", 0, 10 * Mib), _common[0]], _touched.Single()); + Assert.Equal([new ReadOp(@"D:\a\1.bin", 50 * Mib, 10 * Mib), new ReadOp(@"D:\a\2.bin", 0, 10 * Mib), new ReadOp(@"D:\a\1.bin", 0, 10 * Mib), _common[0]], _touched[1]); + Assert.Equal(2, _touched.Count); + Assert.Equal(_common, _touched[0]); // 공통 블록은 읽은 직후 한 번 다시 만짐 Assert.True(p.Solar.HasMark("a")); } @@ -492,6 +546,227 @@ public void KeepStreamOnGameStartSkipsTheLadderBecauseTheStreamLeaseIsNotCancele Assert.Empty(_touched); } + private static ReadOp[][] TwoBatches() => [[Reads[0], Reads[1]], [Reads[2]]]; + + [Fact] + public void EveryBatchTheStreamReadsIsTouchedRightAfterInReadOrderThenTheLadderRunsOnceAndTheDoneLineShowsTheCost() + { + _streamer.Batches.AddRange(TwoBatches()); + Prewarmer p = MakePrewarmer(Game("a")); + + PrewarmResult result = p.Run(Game("a"), "service start"); + + Assert.True(result.Ran); + Assert.Equal(30 * Mib, result.Stats!.Bytes); + Assert.Equal(3, _touched.Count); + Assert.Equal([Reads[0], Reads[1]], _touched[0]); // 묶음 순서대로, 묶음 안은 읽은 순서 + Assert.Equal([Reads[2]], _touched[1]); + Assert.Equal(Reversed(Reads), _touched[2]); // 끝의 사다리는 그대로 한 번 + Assert.True(p.Solar.HasMark("a")); + Assert.Contains(_log.Lines, l => l.Contains("a: prewarm done (service start)") && l.EndsWith(", requeued 200 MiB in 0.8 s", StringComparison.Ordinal)); + } + + [Fact] + public void TheCommonBlockIsTouchedRightAfterItIsReadBeforeTheStreamBatches() + { + _common = [new ReadOp(@"D:\c.bin", 0, 5 * Mib)]; + _streamer.Batches.AddRange(TwoBatches()); + Prewarmer p = MakePrewarmer(Game("a")); + + Assert.True(p.Run(Game("a"), "service start").Ran); + + Assert.Equal(4, _touched.Count); + Assert.Equal(_common, _touched[0]); + Assert.Equal([Reads[0], Reads[1]], _touched[1]); + } + + [Fact] + public void WithTheSettingOffOrKeepStreamOnGameStartNoBatchIsTouchedAndTheStreamResultIsTheSame() + { + _streamer.Batches.AddRange(TwoBatches()); + Prewarmer on = MakePrewarmer(Game("a")); + PrewarmResult expected = on.Run(Game("a"), "service start"); + _touched.Clear(); + _enabled = false; + Prewarmer off = MakePrewarmer(Game("b")); + + PrewarmResult disabled = off.Run(Game("b"), "service start"); + _enabled = true; + Prewarmer keep = MakePrewarmer(Game("c")); + keep.SetAuto(AutoSettings.Default with { KeepStreamOnGameStart = true }); + PrewarmResult kept = keep.Run(Game("c"), "service start"); + + Assert.Empty(_touched); + Assert.All([disabled, kept], r => Assert.Equal((expected.Ran, expected.Stats!.Bytes, expected.Stats.Requests), (r.Ran, r.Stats!.Bytes, r.Stats.Requests))); + Assert.Contains(_log.Lines, l => l.StartsWith("Info: b: prewarm done", StringComparison.Ordinal)); + Assert.Contains(_log.Lines, l => l.StartsWith("Info: c: prewarm done", StringComparison.Ordinal)); + Assert.DoesNotContain(_log.Lines, l => (l.StartsWith("Info: b:", StringComparison.Ordinal) || l.StartsWith("Info: c:", StringComparison.Ordinal)) && l.Contains("requeued", StringComparison.Ordinal)); + } + + [Theory] + [InlineData(TouchEnd.NeedsDiskReads, 2, ", requeued 10 MiB in 0.2 s, requeue stopped by disk reads")] // 첫 묶음이 디스크 읽기로 멈추면 남은 묶음은 만지지 않음 (사다리는 그대로) + [InlineData(TouchEnd.Canceled, 3, ", requeued 20 MiB in 0.4 s")] // 취소로 끝난 만지기는 디스크 읽기가 아니므로 다음 묶음도 만짐 + public void ARequeueThatStopsOnDiskReadsStopsTheRestOfTheBatchesAndNeitherItNorACancelChangesWhatTheStreamReads(TouchEnd end, int touches, string tail) + { + _streamer.Batches.AddRange(TwoBatches()); + _result = new TouchResult(10 * Mib, 0.2, 2, 0, end, end == TouchEnd.NeedsDiskReads ? 40 * Mib : 0); + Prewarmer p = MakePrewarmer(Game("a")); + + PrewarmResult result = p.Run(Game("a"), "service start"); + + Assert.True(result.Ran); + Assert.Equal(30 * Mib, result.Stats!.Bytes); + Assert.Equal(touches, _touched.Count); + Assert.Equal(Reversed(Reads), _touched[^1]); // 사다리는 그대로 한 번 + Assert.Contains(_log.Lines, l => l.Contains("a: prewarm done (service start)") && l.EndsWith(tail, StringComparison.Ordinal)); + } + + [Fact] + public void TheCommonBlockIsTouchedPerReadBatchByTheAmountReadSoFarWhenTheReadReportsProgress() + { + _commonProgress = true; + _common = [new ReadOp(@"D:\a\c1.bin", 0, 5 * Mib), new ReadOp(@"D:\a\c2.bin", 0, 5 * Mib), new ReadOp(@"D:\a\c3.bin", 0, 5 * Mib)]; + Prewarmer p = MakePrewarmer(Game("a")); + + Assert.True(p.Run(Game("a"), "service start").Ran); + + Assert.Equal(4, _touched.Count); // 공통 블록 묶음 3개 + 사다리 + for (int i = 0; i < 3; i++) + { + Assert.Equal([_common[i]], _touched[i]); // 읽은 양으로 자른 새로 읽은 만큼만, 읽은 순서대로 + } + } + + // 이어 읽기 (끊긴 보충이 읽은 앞부분 1000 MiB 를 건너뜀) 를 시작할 수 있는 상태. + private Prewarmer ResumableSupplement(ReadOp[] all) + { + _resumeProbe = true; + Prewarmer p = ShortFinishedAndWaiting(all, waitForMemory: false); + _touched.Clear(); + return p; + } + + [Fact] + public void AResumedSupplementTouchesTheSkippedFrontBeforeReadingTheTailAndTheDoneLineShowsIt() + { + ReadOp[] all = HotSet(50); + Prewarmer p = ResumableSupplement(all); + int logBefore = _log.Lines.Count; + _streamer.Batches.Add([.. all.Skip(10).Take(20)]); // 꼬리를 읽는 묶음 + _streamer.Batches.Add([.. all.Skip(30)]); + int touchesWhenTheTailIsRead = -1; + _streamer.BetweenProgressAndTouch = i => + { + if (i == 0) + { + touchesWhenTheTailIsRead = _touched.Count; + } + }; + + Assert.True(p.SupplementWhenIdle(Game("a"), CancellationToken.None)); + + Assert.Equal(1, touchesWhenTheTailIsRead); // 첫 꼬리 묶음을 읽을 때 건너뛴 앞부분은 이미 만짐 + Assert.Equal([.. all.Take(10)], _touched[0]); // 건너뛴 앞 1000 MiB, 목록 순서 + Assert.Equal([.. all.Skip(10).Take(20)], _touched[1]); + Assert.Equal([.. all.Skip(30)], _touched[2]); + Assert.Equal(4, _touched.Count); // 앞부분 + 묶음 2개 + 사다리 + Assert.Contains(_log.Lines.Skip(logBefore), l => l.Contains("a: supplement prewarm read the first") && l.EndsWith("errors), requeued 300 MiB in 1.2 s, the partial window remains pending", StringComparison.Ordinal)); // 앞부분 + 꼬리 묶음 2개 + } + + [Fact] + public void ASkippedFrontTouchThatStopsOnDiskReadsEndsOnlyItselfAndTheTailStillRunsAndIsTouched() + { + ReadOp[] all = HotSet(50); + Prewarmer p = ResumableSupplement(all); + int logBefore = _log.Lines.Count; + _streamer.Batches.Add([.. all.Skip(10)]); + int touch = 0; + _onTouch = () => + { + touch++; + _result = _result with { End = touch == 1 ? TouchEnd.NeedsDiskReads : TouchEnd.Completed, DiskBytes = touch == 1 ? 40 * Mib : 0 }; + }; + + Assert.True(p.SupplementWhenIdle(Game("a"), CancellationToken.None)); + + Assert.Equal([.. all.Take(10)], _touched[0]); // 앞부분 만지기가 디스크 읽기로 멈춤 + Assert.Equal([.. all.Skip(10)], _touched[1]); // 그래도 꼬리 묶음은 만짐 + Assert.Contains(_log.Lines.Skip(logBefore), l => l.Contains("a: supplement prewarm read the first") && l.Contains(", requeued 200 MiB in 0.8 s, requeue stopped by disk reads, the partial window", StringComparison.Ordinal)); // 앞부분 만지기만 멈춤: 꼬리 묶음은 만짐 (앞부분 100 MiB + 꼬리 묶음 100 MiB) + } + + [Fact] + public void TheSupplementDoneLineShowsWhatWasRequeuedWhileReading() + { + ReadOp[] all = HotSet(50); + (_streamer.All, _streamer.ZoneMib) = (all, 5000); + _common = [all[0]]; + _avail = 7000; // 앞부분만 읽음 + Prewarmer p = MakePrewarmer(Game("a")); + Assert.True(p.Run(Game("a"), "service start").Ran); + _streamer.Batches.Add([.. all.Take(25)]); + _streamer.Batches.Add([.. all.Skip(25)]); + _touched.Clear(); + _avail = 20000; + Assert.False(p.SupplementWhenIdle(Game("a"), CancellationToken.None)); + _now += TimeSpan.FromSeconds(61); + + Assert.True(p.SupplementWhenIdle(Game("a"), CancellationToken.None)); + + Assert.False(p.SupplementPending("a")); + Assert.Contains(_log.Lines, l => l.Contains("a: supplement prewarm done") && l.EndsWith(", requeued 300 MiB in 1.2 s", StringComparison.Ordinal)); // 공통 블록 + 묶음 2개 + } + + [Fact] + public void ASkippedFrontIsNotTouchedWhenTheSettingIsOff() + { + ReadOp[] all = HotSet(50); + Prewarmer p = ResumableSupplement(all); + _enabled = false; + int callsBefore = _streamer.Calls.Count; + + Assert.True(p.SupplementWhenIdle(Game("a"), CancellationToken.None)); + + Assert.Equal(callsBefore + 1, _streamer.Calls.Count); // 이어 읽기는 했고 + Assert.Empty(_touched); // 만지기는 없음 + } + + [Fact] + public void ARequeueThatThrowsWarnsOnceAndTheStreamStillSucceeds() + { + _streamer.Batches.AddRange(TwoBatches()); + _onTouch = () => throw new IOException("view failed"); + Prewarmer p = MakePrewarmer(Game("a")); + + PrewarmResult result = p.Run(Game("a"), "service start"); + + Assert.True(result.Ran); + Assert.Equal(30 * Mib, result.Stats!.Bytes); + Assert.Single(_log.Lines, l => l.Contains("a: requeue of the read batches skipped: IOException", StringComparison.Ordinal)); + Assert.DoesNotContain(_log.Lines, l => l.Contains("requeued", StringComparison.Ordinal)); + } + + [Fact] + public void WhenTheStreamIsStoppedAfterABatchTheNextBatchIsNotTouched() + { + Prewarmer? p = null; + _streamer.Batches.AddRange(TwoBatches()); + _streamer.BetweenProgressAndTouch = i => + { + if (i == 1) + { + p!.NoteGameStart("a"); // 두 번째 묶음의 점검이 게임 시작으로 스트림을 멈춤 + } + }; + p = MakePrewarmer(Game("a")); + + PrewarmResult result = p.Run(Game("a"), "service start"); + + Assert.False(result.Ran); + Assert.Single(_touched); // 첫 묶음만, 두 번째와 사다리는 없음 + Assert.Equal([Reads[0], Reads[1]], _touched[0]); + Assert.False(p.Solar.HasMark("a")); + } + [Fact] public void TheTickRefreshesOnlyWhenTheReuseReachesTheThreshold() { @@ -655,14 +930,14 @@ public void WhenTheRefreshNeedsDiskReadsNoRewarmIsCalledAndTheLadderReturnsOnlyA _touched.Clear(); _streamer.Calls.Clear(); GrowReuse(20000); - _result = _result with { SlowPieces = 4, End = TouchEnd.NeedsDiskReads, DiskBytes = 40 * Mib }; + _result = _result with { Bytes = 10 * Mib, SlowPieces = 4, End = TouchEnd.NeedsDiskReads, DiskBytes = 40 * Mib }; _now += TimeSpan.FromMinutes(5); AutoSettings auto = Auto(); p.Tick(auto); // 새로 고침이 디스크 읽기를 요구 -> 표식만 지우고 재예열은 부르지 않음 Assert.Single(_touched); Assert.False(p.Solar.HasMark("a")); - Assert.Contains(_log.Lines, l => l.Contains("a: cache refresh found data missing from memory (4 slow pieces, 40 MiB read from disk), left to the rewarm check")); + Assert.Contains(_log.Lines, l => l.Contains("a: cache refresh found data missing from memory after 10 of 30 MiB (4 slow pieces, 40 MiB read from disk), left to the rewarm check")); _result = _result with { SlowPieces = 0, DiskBytes = 0, End = TouchEnd.Completed }; for (int i = 0; i < 3; i++) @@ -920,7 +1195,9 @@ public void AfterAPartialPrewarmIsSuppliedTheLadderCoversTheWholeHotSetWithTheCo Assert.True(p.Run(Game("a"), "service start").Ran); Assert.True(p.SupplementPending("a")); - ReadOp[] partial = [.. _touched.Single()]; + Assert.Equal(2, _touched.Count); // 공통 블록의 다시 만지기 한 번, 그리고 사다리 한 번 + Assert.Equal(_common, _touched[0]); + ReadOp[] partial = [.. _touched[1]]; Assert.DoesNotContain(all[49], partial); // 읽은 앞부분까지만 Assert.Equal(_common[0], partial[^1]); Assert.True(partial.Length < 51); @@ -932,7 +1209,8 @@ public void AfterAPartialPrewarmIsSuppliedTheLadderCoversTheWholeHotSetWithTheCo Assert.True(p.SupplementWhenIdle(Game("a"), CancellationToken.None)); Assert.False(p.SupplementPending("a")); - ReadOp[] ladder = [.. _touched.Single()]; + Assert.Equal(2, _touched.Count); // 공통 블록의 다시 만지기, 사다리 한 번 (이어 읽을 위치를 확인하는 표본 훅이 없어 처음부터 읽으므로 건너뛴 앞부분은 없음) + ReadOp[] ladder = [.. _touched[^1]]; Assert.Equal([.. Reversed([.. _common, .. all])], ladder); // 보충분의 뒤쪽 -> ... -> 앞부분 -> 공통 블록 Assert.Equal(all[49], ladder[0]); Assert.Equal(_common[0], ladder[^1]); diff --git a/docs/SOLAR.ko.md b/docs/SOLAR.ko.md index 388234d..77588d3 100644 --- a/docs/SOLAR.ko.md +++ b/docs/SOLAR.ko.md @@ -17,7 +17,7 @@ SOLAR 는 두 문제를 모두 일부러 디스크를 읽지 않고 다룹니다 ## 어떻게 판단하나 -1. **사다리를 만듭니다.** 게임의 예열이 목록을 끝까지 읽으면, 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-유지)에 맡깁니다. 여러 게임이 시점이 되면 순위가 낮은 게임부터, 가장 최근에 예열한 게임을 마지막으로 만집니다. @@ -33,6 +33,7 @@ Windows 의 통계를 읽을 수 없으면(예: 권한이 없을 때) 3번은 - **0.4 와 512 MiB 의 근거.** 밀려남 실험(E9)에서 만지지 않은 첫 파일은 다시 쓴 양의 누계가 앞에 있는 양의 0.61~0.81배 늘었을 때 밀려났습니다. 앞에 있는 페이지 일부는 다시 참조되어 빠지므로 실제 시점은 단순 계산보다 일찍 오고, 그래서 여유를 두었습니다. 하한 512 MiB 는 앞에 있는 양이 작을 때 너무 자주 새로 고치지 않게 합니다. 두 값 모두 그 실험 하나에서 나온 것이며 더 다듬지 않았습니다. - **디스크 읽기를 가리는 방법.** 뷰 하나를 만지기 전후로 시스템의 페이지 읽기 누계를 읽습니다. 늘어난 양에서 다른 프로그램의 몫(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` 가 꺼져 있으면 스트림은 전과 완전히 같습니다. - **첫 사다리가 만들어지는 곳.** 게임의 예열 스트림(서비스 시작, 런처, 재예열, 보충)이 끝까지 읽은 직후입니다. 메모리 한도에서 마무리한 스트림은 바로 만들지 않고, 표본이 따뜻한 재예열 점검에서 첫 사다리를 만듭니다. 서비스를 다시 시작하면 표식이 없어지므로, 게임마다 그런 점검에서 새 사다리를 만듭니다. @@ -73,7 +74,8 @@ Windows 의 통계를 읽을 수 없으면(예: 권한이 없을 때) 3번은 - `<게임>: ladder ordered N MiB in S s`: 예열이나 재예열 점검 뒤에 사다리를 만들었습니다. - `<게임>: cache refreshed in memory: N MiB in S s (R MiB repurposed of A MiB ahead)`: 새로 고쳤습니다. `R` 은 다시 쓴 양의 늘어난 값, `A` 는 앞에 있는 양입니다. -- `<게임>: ladder found data missing from memory (K slow pieces, N MiB read from disk), left to the rewarm check`, 그리고 `cache refresh found data missing from memory (...)`: 목록 일부가 캐시에 없었습니다. +- `<게임>: prewarm done (...) ..., requeued N MiB in S s` (`supplement prewarm done` 줄 끝에도 같음): 스트림이 읽는 도중 만진 묶음입니다. 만진 것이 없으면 붙지 않습니다. 만지기가 디스크 읽기를 보고 멈췄으면 `, requeue stopped by disk reads` 가 더 붙습니다. 보충이 창만 읽고 끝나면 `done` 대신 `supplement prewarm read the first …` 줄로 끝나며, 그 줄에도 같은 `requeued` 문구가 붙습니다. +- `<게임>: 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` 에 가까우면 빠진 것은 목록 앞쪽, 곧 가장 중요한 부분입니다. - `<게임>: rewarm check: part of the list is not in memory (found by the ladder), prewarming again (cooldown ignored)`: 다음 재예열 점검이 그 게임을 다시 예열합니다. `..., waiting until N min after the last prewarm attempt` 는 주기가 지나기를 기다린다는 뜻입니다. - `<게임>: cache refresh skipped, the samples are cold (X/Y over Z ms), left to the rewarm check`, 또는 `the samples could not be read`, `there are no readable samples`: 새로 고치기 전에 표본 점검을 했습니다. - `<게임>: ladder stopped after N MiB`, `cache refresh stopped after N MiB`: 게임이 시작했거나 대여가 끝나 멈췄습니다. 표식은 남아 다시 시도합니다. @@ -84,7 +86,7 @@ Windows 의 통계를 읽을 수 없으면(예: 권한이 없을 때) 3번은 - **실제 플레이에서의 효과는 아직 재지 않았습니다.** 실험은 순서를 정할 수 있다는 것과, 아직 메모리에 있는 캐시를 새로 고치는 데는 디스크 읽기가 들지 않는다는 것을 보였습니다. 실행 중인 게임에서 로딩 시간이나 끊김이 얼마나 달라지는지는 보여 주지 않습니다. 식은 것으로 판정되는 재예열 점검의 비율이 줄었는지도 아직 확인하지 않았습니다. - **밀려나는 것을 막지는 않습니다.** 메모리가 정말 모자라면 Windows 는 이 페이지도 다시 씁니다. SOLAR 는 어느 것이 먼저 나가는지와 얼마나 빨리 되돌리는지만 바꿉니다. -- **아주 긴 예열은 자기 앞부분을 잃을 수 있습니다.** 한 번은 Call of Duty 의 11,388 MiB 예열이 322초 걸렸고, 끝난 직후 만든 사다리가 이미 빠진 부분을 찾았습니다. 목록이 대기 목록 용량에 가까워서, 먼저 읽은 부분이 나머지를 읽는 동안 밀려난 것으로 보입니다. 다른 프로그램의 읽기도 더해졌을 수 있습니다. +- **아주 긴 예열은 자기 앞부분을 잃을 수 있습니다.** 한 번은 Call of Duty 의 11,388 MiB 예열이 322초 걸렸고, 끝난 직후 만든 사다리가 이미 빠진 부분을 찾았습니다. 목록이 대기 목록 용량에 가까워서, 먼저 읽은 부분이 나머지를 읽는 동안 밀려난 것으로 보입니다. 다른 프로그램의 읽기도 더해졌을 수 있습니다. 또 빠진 부분만 읽는 재예열은 그 게임의 나머지 페이지를 이전 예열 때의 오래된 순서로 남기고 Windows 는 그것부터 다시 쓰므로, 사다리는 빠진 부분을 맨 끝에서 만나 멈췄고 예열이 약 20분마다 되풀이됐습니다. 읽은 묶음을 곧바로 다시 만지는 것은 이를 막으려는 것입니다. **설치본에서는 아직 확인하지 않았습니다.** 목록이 대기 목록이 담을 수 있는 양보다 크면 먼저 읽은 부분이 나머지를 읽는 동안 밀려날 수는 여전히 있고, 로그의 `after T of N MiB` 로 사다리가 어디까지 만졌는지 볼 수 있습니다. - **8곳 표본 점검은 일부만 식은 캐시를 놓칠 수 있습니다.** 사다리는 목록 전체를 보므로, 표본이 따뜻해 보여도 사다리가 빠진 부분을 찾으면 재예열로 이어집니다. - **이 통계는 Windows 가 문서화하지 않은 인터페이스입니다.** Windows 11 10.0.26200 에서만 확인했습니다. 읽을 수 없으면 10분 주기로 바뀝니다. - **밀려나는 순서는 HDD 한 대의 시험 파일로, 우선순위 5 의 페이지에서만 확인했습니다.** 페이지 우선순위를 6 이나 7 로 올리는 것은 Windows 가 거부해서(`STATUS_INVALID_PARAMETER`) 쓰지 않습니다. diff --git a/docs/SOLAR.md b/docs/SOLAR.md index 4273912..bd678a3 100644 --- a/docs/SOLAR.md +++ b/docs/SOLAR.md @@ -17,7 +17,7 @@ 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. +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. @@ -33,6 +33,7 @@ If Windows' statistics cannot be read (for example because the privilege is miss - **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. - **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. @@ -73,7 +74,8 @@ Each line goes to `service.log`. `` is the game's name, and a tail such as - `: ladder ordered N MiB in S s`: a ladder was made after a prewarm or a re-prewarm check. - `: 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. -- `: ladder found data missing from memory (K slow pieces, N MiB read from disk), left to the rewarm check`, and the same for `cache refresh found data missing from memory (...)`: part of the list was not in the cache. +- `: 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. `, 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. +- `: 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. - `: rewarm check: part of the list is not in memory (found by the ladder), prewarming again (cooldown ignored)`: the next re-prewarm check re-prewarms the game. `..., waiting until N min after the last prewarm attempt` means it waits for the period to pass. - `: cache refresh skipped, the samples are cold (X/Y over Z ms), left to the rewarm check`, or `the samples could not be read` or `there are no readable samples`: the sample check came before the refresh. - `: ladder stopped after N MiB`, `cache refresh stopped after N MiB`: stopped because a game started or the lease ended; the note stays and a refresh is tried again. @@ -84,7 +86,7 @@ Each line goes to `service.log`. `` is the game's name, and a tail such as - **The effect in real play has not been measured.** The experiments show that the order can be set and that refreshing cache that is still in memory needs no disk read. They do not show how much load time or stutter changes in a running game. Whether the share of cold re-prewarm checks has fallen has not been checked either. - **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. -- **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 seems to have been pushed out while the rest was being read; other programs' reads may have added to it. +- **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 seems to have been pushed out while the rest was being read; other programs' reads may have added to it. A re-prewarm that reads only the missing part also left the rest of the game's pages in the old order of the earlier prewarm, which Windows reuses first, so the ladder met the missing part last and the prewarm repeated about every 20 minutes. Touching each batch right after it is read is meant to prevent that. **This has not yet been checked on an installed build.** 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. - **The 8-place sample check can miss a partly cold cache.** The ladder looks at the whole list, which is why a ladder that finds missing parts leads to a re-prewarm even when the samples look warm. - **The statistic is an undocumented Windows interface.** It was checked on Windows 11 10.0.26200 only. If it cannot be read, the 10-minute fallback is used. - **The eviction order was checked with test files on one HDD,** for pages of priority 5 only. Raising the page priority to 6 or 7 was refused by Windows (`STATUS_INVALID_PARAMETER`), so it is not used.