From fd80a7f00676ce1fa6a1a6d8152aad4c52a10409 Mon Sep 17 00:00:00 2001 From: DO0OG Date: Sat, 14 Nov 2026 09:59:25 +0900 Subject: [PATCH] =?UTF-8?q?Fix:=20=EC=8A=A4=ED=8C=8C=EC=8A=A4=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EC=9D=84=20=EB=8B=A4=EC=8B=9C=20=EB=A7=8C=EC=A7=80?= =?UTF-8?q?=EB=A9=B4=20=EB=A7=A4=EB=B2=88=20=EB=94=94=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=9D=BD=ED=98=80=20=EC=9E=AC=EC=98=88?= =?UTF-8?q?=EC=97=B4=EC=9D=B4=20=EB=90=98=ED=92=80=EC=9D=B4=EB=90=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 스파스 속성 파일은 새 매핑으로 만지면 방금 예열로 읽은 범위도 디스크에서 다시 읽혔습니다. 캐시 다시 만지기가 그런 파일에서 디스크를 읽고 멈추면서 목록 일부가 메모리에 없다고 판정해, 재예열 점검이 목록 전체를 10~20분마다 다시 예열했습니다. - 다시 만지기는 파일 속성을 먼저 조회해 스파스 파일의 범위를 매핑을 만들지 않고 건너뜀 - 건너뛴 양을 결과에 따로 세고 사다리·새로 고침·예열 완료 줄 끝에 남김 - 만질 것이 하나도 없는 게임은 그 판정을 기억해 재예열 점검마다 다시 시도하지 않음 - 스파스가 아닌 파일에서의 디스크 읽기 감지는 그대로 둠 - 사다리가 멈춘 줄의 전체 크기를 사다리 전체로 고정함 - `docs/SOLAR.md`, `docs/SOLAR.ko.md` 의 로그 설명과 한계 절에 반영함 Issues #191 --- .../src/AsterCache.Service/Rewarm.Ladder.cs | 4 +- csharp/src/AsterCache.Service/Solar.cs | 64 +++++++-- .../src/AsterCache.Service/StreamRequeue.cs | 15 +- csharp/src/AsterCache.Windows/FileGuards.cs | 5 +- csharp/src/AsterCache.Windows/StandbyTouch.cs | 45 +++++- .../AsterCache.Tests/Service/SolarTests.cs | 131 ++++++++++++++++++ .../Windows/StandbyTouchLiveTests.cs | 30 ++++ .../Windows/StandbyTouchTests.cs | 71 ++++++++++ docs/SOLAR.ko.md | 11 +- docs/SOLAR.md | 11 +- 10 files changed, 355 insertions(+), 32 deletions(-) diff --git a/csharp/src/AsterCache.Service/Rewarm.Ladder.cs b/csharp/src/AsterCache.Service/Rewarm.Ladder.cs index 16af6ae..a992a27 100644 --- a/csharp/src/AsterCache.Service/Rewarm.Ladder.cs +++ b/csharp/src/AsterCache.Service/Rewarm.Ladder.cs @@ -71,7 +71,7 @@ private void Refresh(GameSettings g, CancellationToken ct) private void LadderUnmarked(GameSettings g, CancellationToken ct) { Solar solar = _prewarm.Solar; - if (solar.Enabled && !solar.HasMark(g.Name) && !solar.HasMissing(g.Name) && !_prewarm.TriggerWaitPending() && _prewarm.LadderReads(_prewarm.CoveredPart(g)) is { Count: > 0 } reads) + if (solar.Enabled && !solar.HasMark(g.Name) && !solar.HasMissing(g.Name) && !solar.HasNothingToTouch(g.Name) && !_prewarm.TriggerWaitPending() && _prewarm.LadderReads(_prewarm.CoveredPart(g)) is { Count: > 0 } reads) { Run(g, ct, token => solar.Order(g, reads, token), "cache ladder"); } @@ -81,7 +81,7 @@ private void LadderUnmarked(GameSettings g, CancellationToken ct) private void LadderWaitingSupplement(GameSettings g, CancellationToken ct) { Solar solar = _prewarm.Solar; - if (solar.Enabled && !solar.HasMark(g.Name) && !solar.HasMissing(g.Name) && !_prewarm.TriggerWaitPending() && _prewarm.WaitingSupplementLadderReads(g) is { Count: > 0 } reads) + if (solar.Enabled && !solar.HasMark(g.Name) && !solar.HasMissing(g.Name) && !solar.HasNothingToTouch(g.Name) && !_prewarm.TriggerWaitPending() && _prewarm.WaitingSupplementLadderReads(g) is { Count: > 0 } reads) { Run(g, ct, token => solar.Order(g, reads, token), "cache ladder"); } diff --git a/csharp/src/AsterCache.Service/Solar.cs b/csharp/src/AsterCache.Service/Solar.cs index 7006510..cec2d7c 100644 --- a/csharp/src/AsterCache.Service/Solar.cs +++ b/csharp/src/AsterCache.Service/Solar.cs @@ -14,6 +14,7 @@ internal sealed class Solar(PrewarmEnv env, ILog log, Func now) private readonly object _lock = new(); private readonly Dictionary _marks = new(StringComparer.OrdinalIgnoreCase); // 마지막으로 만든 사다리의 표식, 메모리에만 둠 (서비스를 다시 시작하면 없음), _lock 으로 보호 private readonly HashSet _missing = new(StringComparer.OrdinalIgnoreCase); // 사다리가 목록의 일부가 캐시에 없음을 발견한 게임 (재예열 점검이 다시 채울 때까지), 메모리에만 둠, _lock 으로 보호 + private readonly HashSet _untouchable = new(StringComparer.OrdinalIgnoreCase); // 목록이 전부 스파스 파일이라 만질 것이 없었던 게임 (다음 예열 스트림 뒤 사다리가 다시 판정할 때까지 재예열 점검이 사다리를 다시 시도하지 않음), 메모리에만 둠, _lock 으로 보호 public bool Enabled => env.StandbyLadderEnabled(); @@ -34,6 +35,15 @@ public bool HasMissing(string game) } } + /// 사다리가 만질 것이 하나도 없었던 (목록 전체가 스파스 파일인) 게임인가. + public bool HasNothingToTouch(string game) + { + lock (_lock) + { + return _untouchable.Contains(game); + } + } + /// 다시 채워야 한다는 표시를 지우고 있었는지 돌려줍니다 (재예열을 시도하는 시점에 부름: 읽기 전에 건너뛰면 호출한 쪽이 RestoreMissing 으로 되돌리고, 빈도는 호출한 쪽의 대기 조건이 정함). public bool TakeMissing(string game) { @@ -54,6 +64,7 @@ public void Prune(IReadOnlySet live) } _ = _missing.RemoveWhere(k => !live.Contains(k)); + _ = _untouchable.RemoveWhere(k => !live.Contains(k)); } } @@ -75,12 +86,22 @@ public void Prune(IReadOnlySet live) switch (r.End) { case TouchEnd.Completed: + if (Untouched(r)) + { + if (NoteNothingToTouch(game.Name)) + { + log.Info($"{game.Name}: ladder has nothing to touch ({r.SkippedBytes >> 20} MiB in sparse files not touched)"); // 처음 한 번만 + } + + break; + } + SetMark(game.Name, ladder, r); - log.Info($"{game.Name}: ladder ordered {r.Bytes >> 20} MiB in {r.Seconds:F1} s{Errors(r)}"); + log.Info($"{game.Name}: ladder ordered {r.Bytes >> 20} MiB in {r.Seconds:F1} s{Errors(r)}{Sparse(r)}"); break; case TouchEnd.Canceled: Forget(game.Name); // 순서가 반쯤만 만들어짐: 표식 없이 둠 - log.Info($"{game.Name}: ladder stopped after {r.Bytes >> 20} MiB{Errors(r)}"); + log.Info($"{game.Name}: ladder stopped after {r.Bytes >> 20} MiB{Errors(r)}{Sparse(r)}"); break; default: if (!NoteIncomplete(game.Name, r)) @@ -89,7 +110,7 @@ public void Prune(IReadOnlySet live) break; } - log.Info($"{game.Name}: ladder found data missing from memory {Missing(r, ladder)}, left to the rewarm check"); + log.Info($"{game.Name}: ladder found data missing from memory {Missing(r, ladder)}, left to the rewarm check{Sparse(r)}"); break; } @@ -116,11 +137,19 @@ public void Prune(IReadOnlySet live) switch (r.End) { case TouchEnd.Completed: - SetMark(game.Name, mark.Ladder, r); - 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)}"); + if (Untouched(r)) + { + _ = NoteNothingToTouch(game.Name); // 표식을 지워 틱마다 되풀이하지 않음 + } + else + { + SetMark(game.Name, mark.Ladder, r); + } + + 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)}"); break; case TouchEnd.Canceled: - log.Info($"{game.Name}: cache refresh stopped after {r.Bytes >> 20} MiB{Errors(r)}"); // 표식은 그대로 둠: 다음 틱에 다시 시도 + log.Info($"{game.Name}: cache refresh stopped after {r.Bytes >> 20} MiB{Errors(r)}{Sparse(r)}"); // 표식은 그대로 둠: 다음 틱에 다시 시도 break; default: if (!NoteIncomplete(game.Name, r)) @@ -129,7 +158,7 @@ public void Prune(IReadOnlySet live) break; } - log.Info($"{game.Name}: cache refresh found data missing from memory {Missing(r, mark.Ladder)}, 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{Sparse(r)}"); break; } @@ -168,9 +197,23 @@ private void SetMark(string game, IReadOnlyList ladder, TouchResult r) { _marks[game] = mark; _ = _missing.Remove(game); // 목록 전체가 메모리에 있음 + _ = _untouchable.Remove(game); + } + } + + // 목록이 전부 스파스 파일이라 만진 것이 없음 (건너뛴 것만 있음): 표식도 빠짐 표시도 세우지 않고 기존 표식은 지우며, 처음 기억할 때만 true. + private bool NoteNothingToTouch(string game) + { + lock (_lock) + { + _ = _marks.Remove(game); + _ = _missing.Remove(game); + return _untouchable.Add(game); } } + private static bool Untouched(TouchResult r) => r.Bytes == 0 && r.SkippedBytes > 0; + // 표식을 지우고, 디스크 읽기를 실제로 본 경우에만 다시 채움 표시를 남깁니다 (표본이 warm 이어도 재예열 점검이 다시 읽게 함). 시간 기준이나 읽기 실패만으로 끝났다면 재예열로 고쳐지지 않으므로 표시 없이 점검의 표본 판정에 맡깁니다. 표시를 세웠으면 true. private bool NoteIncomplete(string game, TouchResult r) { @@ -178,6 +221,7 @@ private bool NoteIncomplete(string game, TouchResult r) lock (_lock) { _ = _marks.Remove(game); + _ = _untouchable.Remove(game); // 디스크 읽기를 만났으면 만질 것이 있음 if (diskSeen) { _ = _missing.Add(game); @@ -205,12 +249,16 @@ public void Forget(string game) lock (_lock) { _ = _marks.Remove(game); + _ = _untouchable.Remove(game); } } - // 다시 채움 로그의 꼬리: 사다리 (역순) 를 몇 MiB 만지고 멈췄는지 (만진 양이 전체에 가까우면 빠진 것은 가장 중요한 앞쪽), 느린 조각 수와 디스크에서 읽힌 양, 오류 수 + // 다시 채움 로그의 꼬리: 사다리 (역순) 를 몇 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; + // 줄 끝에 붙이는 오류 수와 디스크에서 읽힌 것으로 본 양 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 index 8d08054..9bbefa4 100644 --- a/csharp/src/AsterCache.Service/StreamRequeue.cs +++ b/csharp/src/AsterCache.Service/StreamRequeue.cs @@ -4,13 +4,13 @@ namespace AsterCache.Service; -/// 한 예열 스트림이 만지는 데 쓴 양: 만진 바이트, 걸린 시간 (초), 디스크 읽기가 감지돼 만지기가 멈췄는가. -internal readonly record struct RequeueTally(long Bytes, double Seconds, bool DiskStopped) +/// 한 예열 스트림이 만지는 데 쓴 양: 만진 바이트, 걸린 시간 (초), 디스크 읽기가 감지돼 만지기가 멈췄는가, 스파스 파일이라 만지지 않은 바이트. +internal readonly record struct RequeueTally(long Bytes, double Seconds, bool DiskStopped, long SkippedBytes = 0) { - public static RequeueTally operator +(RequeueTally a, RequeueTally b) => new(a.Bytes + b.Bytes, a.Seconds + b.Seconds, a.DiskStopped || b.DiskStopped); + 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 string Note() => Bytes > 0 || DiskStopped ? $", requeued {Bytes >> 20} MiB in {Seconds:F1} s{(DiskStopped ? ", requeue stopped by disk reads" : string.Empty)}" : string.Empty; + /// 예열 완료 로그 줄 끝에 붙이는 말 (만진 것도 멈춘 것도 건너뛴 것도 없으면 빈 문자열). + 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; } /// 예열 스트림이 묶음을 읽을 때마다 그 범위를 곧바로 다시 만집니다 (SOLAR 의 만지기, 디스크 읽기 없음이 전제). @@ -30,7 +30,9 @@ internal sealed class StreamRequeue(Func, CancellationToke public bool DiskStopped { get; private set; } - public RequeueTally Tally => new(Bytes, Seconds, DiskStopped); + public long SkippedBytes { get; private set; } + + public RequeueTally Tally => new(Bytes, Seconds, DiskStopped, SkippedBytes); /// 방금 읽은 범위를 읽은 순서대로 만집니다. 스트림이 멈췄거나 (stop) 게임이 시작했거나 트리거가 멈추게 했으면 (halted), 또는 앞 묶음의 만지기가 디스크 읽기로 멈췄으면 만지지 않습니다. public void Touch(IReadOnlyList ranges, CancellationToken stop) => Run(ranges, stop, skipped: false); @@ -50,6 +52,7 @@ private void Run(IReadOnlyList ranges, CancellationToken stop, bool skip TouchResult r = touch(ranges, stop); Bytes += r.Bytes; Seconds += r.Seconds; + SkippedBytes += r.SkippedBytes; onTouched?.Invoke(r.Seconds); if (r.End == TouchEnd.NeedsDiskReads) { diff --git a/csharp/src/AsterCache.Windows/FileGuards.cs b/csharp/src/AsterCache.Windows/FileGuards.cs index 4a9ab26..b79dce6 100644 --- a/csharp/src/AsterCache.Windows/FileGuards.cs +++ b/csharp/src/AsterCache.Windows/FileGuards.cs @@ -7,11 +7,14 @@ namespace AsterCache.Windows; /// 옮기면 안 되는 파일 판정. Python mover.file_attrs / skip_reason. public static class FileGuards { + /// FILE_ATTRIBUTE_SPARSE_FILE + public const uint SparseAttribute = 0x200; + // 디렉터리, 오프라인, 스파스, 재해석 지점, 압축, 암호화: v1 은 옮기지 않습니다. 판정 순서는 Python dict 순서. // https://learn.microsoft.com/en-us/windows/win32/fileio/file-attribute-constants private static readonly (uint Bit, string Name)[] SkipAttrs = { - (0x10, "directory"), (0x1000, "offline"), (0x200, "sparse"), (0x400, "reparse"), (0x800, "compressed"), (0x4000, "encrypted"), + (0x10, "directory"), (0x1000, "offline"), (SparseAttribute, "sparse"), (0x400, "reparse"), (0x800, "compressed"), (0x4000, "encrypted"), }; private const string UnreadablePrefix = "attrs_unreadable: "; diff --git a/csharp/src/AsterCache.Windows/StandbyTouch.cs b/csharp/src/AsterCache.Windows/StandbyTouch.cs index 7458a1b..c0e174a 100644 --- a/csharp/src/AsterCache.Windows/StandbyTouch.cs +++ b/csharp/src/AsterCache.Windows/StandbyTouch.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Diagnostics; using System.Runtime.InteropServices; using AsterCache.Core.Prefetch; @@ -14,12 +15,14 @@ public enum TouchEnd NeedsDiskReads, } -/// 만지기 결과: 만진 바이트, 걸린 시간 (초), 느린 조각 수, 열기·매핑·읽기 오류 수, 끝난 사유, 디스크에서 읽힌 것으로 본 바이트. -public readonly record struct TouchResult(long Bytes, double Seconds, int SlowPieces, int Errors, TouchEnd End, long DiskBytes = 0); +/// 만지기 결과: 만진 바이트, 걸린 시간 (초), 느린 조각 수, 열기·매핑·읽기 오류 수, 끝난 사유, 디스크에서 읽힌 것으로 본 바이트, +/// 스파스 파일이라 열지 않고 건너뛴 바이트 (파일 크기로 자르지 않은 요청 길이, 만진 것도 빠진 것도 아님). +public readonly record struct TouchResult(long Bytes, double Seconds, int SlowPieces, int Errors, TouchEnd End, long DiskBytes = 0, long SkippedBytes = 0); /// 파일 캐시에 이미 있는 범위를 순서대로 다시 읽어 (작업 집합에 올렸다 놓음) 대기 목록의 맨 뒤로 보냅니다. 디스크 읽기는 하지 않는 것이 전제이고, 캐시에 없는 페이지를 만나면 읽기가 되므로 뷰마다 시스템의 페이지 읽기 누계 (SystemPageReads) 가 얼마나 늘었는지 봐서 디스크에서 읽힌 양이 쌓이면 멈춥니다. /// 범위마다 뷰를 따로 매핑해 만지고 바로 풀어야 만진 순서대로 밀려납니다 (한 뷰에서 여러 범위를 만들고 끝에 한 번에 풀면 주소 순서). 고정 (잠금) 은 하지 않으며 뷰는 최대 ViewBytes 만 한 번에 열어 둡니다. /// 읽기는 ReadProcessMemory (자기 프로세스) 로 해서 디스크 오류가 예외가 아니라 반환값으로 옵니다. +/// 스파스 속성 파일은 섹션을 열기 전에 건너뜁니다: 같은 PC 의 시험 파일에서 스파스 파일은 새 섹션으로 만지면 캐시에 있어도 디스크에서 다시 읽혔고 일반 파일은 그렇지 않았습니다. 속성은 읽기만 합니다. public static unsafe class StandbyTouch { public const int PieceBytes = 1 << 20; @@ -64,7 +67,10 @@ private sealed class Tally public int Errors; public double SlowMs; public long Disk; + public long Skipped; public required Func PageReads; + public required Func Attributes; + public readonly Dictionary Sparse = new(StringComparer.OrdinalIgnoreCase); // 이 Run 에서 이미 속성을 본 파일 public TouchEnd End = TouchEnd.Completed; } @@ -82,10 +88,13 @@ public static long DiskBytesOf(uint pagesRead, long viewBytes, double ms) public static TouchResult Run(IReadOnlyList ranges, CancellationToken ct) => Run(ranges, ct, SystemPageReads.Query); /// pageReads: 시스템 전체의 디스크에서 읽어 들인 쪽 수 누계 (읽을 수 없으면 null, 시험은 가짜로 바꿈). - public static TouchResult Run(IReadOnlyList ranges, CancellationToken ct, Func pageReads) + public static TouchResult Run(IReadOnlyList ranges, CancellationToken ct, Func pageReads) => Run(ranges, ct, pageReads, FileGuards.FileAttributes); + + /// attributes: 파일 속성 조회 (예외를 던지면 스파스로 보지 않고 열기를 시도함, 시험은 가짜로 바꿈). + public static TouchResult Run(IReadOnlyList ranges, CancellationToken ct, Func pageReads, Func attributes) { long started = Stopwatch.GetTimestamp(); - var tally = new Tally { PageReads = pageReads }; + var tally = new Tally { PageReads = pageReads, Attributes = attributes }; var sections = new Dictionary(StringComparer.OrdinalIgnoreCase); byte* buffer = (byte*)NativeMemory.Alloc(PieceBytes); try @@ -115,11 +124,17 @@ public static TouchResult Run(IReadOnlyList ranges, CancellationToken ct NativeMemory.Free(buffer); } - return new TouchResult(tally.Bytes, Stopwatch.GetElapsedTime(started).TotalSeconds, tally.Slow, tally.Errors, tally.End, tally.Disk); + return new TouchResult(tally.Bytes, Stopwatch.GetElapsedTime(started).TotalSeconds, tally.Slow, tally.Errors, tally.End, tally.Disk, tally.Skipped); } private static void TouchRange(ReadOp range, Dictionary sections, byte* buffer, Tally tally, CancellationToken ct) { + if (IsSparse(range.Path, tally)) + { + tally.Skipped += Math.Max(0, range.Length); + return; + } + if (!sections.TryGetValue(range.Path, out Section? section)) { if (sections.Count >= MaxOpenFiles) @@ -176,6 +191,26 @@ private static void TouchRange(ReadOp range, Dictionary sectio } } + // 스파스 속성 (FILE_ATTRIBUTE_SPARSE_FILE) 인가: 파일마다 한 번만 조회합니다. 조회할 수 없으면 추측하지 않고 false (열기를 시도하고, 실패하면 오류로 셈). + private static bool IsSparse(string path, Tally tally) + { + if (!tally.Sparse.TryGetValue(path, out bool sparse)) + { + try + { + sparse = (tally.Attributes(path) & FileGuards.SparseAttribute) != 0; + } + catch (Exception e) when (e is IOException or Win32Exception or UnauthorizedAccessException) + { + sparse = false; + } + + tally.Sparse[path] = sparse; + } + + return sparse; + } + // 끝까지 만진 뷰에서 디스크로 읽힌 양: 페이지 읽기 누계가 있으면 그 증가분으로, 없으면 시간으로 (느리면 뷰 전체) 봅니다. 쌓인 양이 DiskReadStopBytes 를 넘으면 멈춤. private static void NoteDiskReads(Tally tally, uint? before, uint? after, long viewBytes, double ms) { diff --git a/csharp/tests/AsterCache.Tests/Service/SolarTests.cs b/csharp/tests/AsterCache.Tests/Service/SolarTests.cs index c46357c..b767360 100644 --- a/csharp/tests/AsterCache.Tests/Service/SolarTests.cs +++ b/csharp/tests/AsterCache.Tests/Service/SolarTests.cs @@ -227,6 +227,75 @@ public void ErrorsAreAppendedToTheLogLine() Assert.EndsWith("MiB in 0.4 s, 2 errors", _log.Lines.Single()); } + [Fact] + public void SparseBytesNotTouchedAreAppendedToEveryLadderAndRefreshLineAndTheMissingLineKeepsTheWholeListAsN() + { + Solar solar = Make(); + _result = _result with { SkippedBytes = 12 * Mib }; + + _ = solar.Order(Game("a"), Reads, CancellationToken.None); + _ = solar.Refresh(Game("a"), CancellationToken.None); + Assert.EndsWith("MiB in 0.4 s, 12 MiB in sparse files not touched", _log.Lines.ElementAt(0)); + Assert.EndsWith("MiB ahead), 12 MiB in sparse files not touched", _log.Lines.ElementAt(1)); + + _result = _result with { End = TouchEnd.Canceled }; + _ = solar.Refresh(Game("a"), CancellationToken.None); + _ = solar.Order(Game("a"), Reads, CancellationToken.None); + Assert.EndsWith("cache refresh stopped after 100 MiB, 12 MiB in sparse files not touched", _log.Lines.ElementAt(2)); + Assert.EndsWith("ladder stopped after 100 MiB, 12 MiB in sparse files not touched", _log.Lines.ElementAt(3)); + + _result = _result with { End = TouchEnd.Completed }; + _ = solar.Order(Game("a"), Reads, CancellationToken.None); + _result = new TouchResult(6 * Mib, 0.6, 3, 0, TouchEnd.NeedsDiskReads, 37 * Mib, 12 * Mib); + _ = solar.Refresh(Game("a"), CancellationToken.None); + _ = solar.Order(Game("a"), Reads, CancellationToken.None); + Assert.Equal("Info: a: cache refresh found data missing from memory after 6 of 30 MiB (3 slow pieces, 37 MiB read from disk), left to the rewarm check, 12 MiB in sparse files not touched", _log.Lines.ElementAt(5)); + Assert.Equal("Info: a: ladder found data missing from memory after 6 of 30 MiB (3 slow pieces, 37 MiB read from disk), left to the rewarm check, 12 MiB in sparse files not touched", _log.Lines.ElementAt(6)); + } + + [Fact] + public void ALadderWhoseRangesAreAllInSparseFilesTouchedNothingSoItLeavesNoMarkNoMissingFlagAndLogsOnce() + { + Solar solar = Make(); + _ = solar.Order(Game("a"), Reads, CancellationToken.None); + solar.RestoreMissing("b"); + _result = new TouchResult(0, 0.0, 0, 0, TouchEnd.Completed, 0, 30 * Mib); + + Assert.Equal(TouchEnd.Completed, solar.Order(Game("b"), Reads, CancellationToken.None)); + Assert.Equal(TouchEnd.Completed, solar.Order(Game("b"), Reads, CancellationToken.None)); + Assert.Equal(TouchEnd.Completed, solar.Refresh(Game("a"), CancellationToken.None)); + + Assert.False(solar.HasMark("b")); + Assert.False(solar.HasMissing("b")); // 이미 서 있던 빠짐 표시도 지움 + Assert.True(solar.HasNothingToTouch("b")); + Assert.Single(_log.Lines, l => l == "Info: b: ladder has nothing to touch (30 MiB in sparse files not touched)"); // 두 번 판정해도 줄은 한 번 + Assert.False(solar.HasMark("a")); // 만진 것이 없는 새로 고침은 표식을 지워 틱마다 되풀이하지 않음 + Assert.False(solar.HasMissing("a")); + Assert.EndsWith("a: cache refreshed in memory: 0 MiB in 0.0 s (0 MiB repurposed of 7900 MiB ahead), 30 MiB in sparse files not touched", _log.Lines.Last()); + } + + [Fact] + public void TheNothingToTouchMemoryEndsWhenALadderTouchesSomethingFindsDataMissingIsForgottenOrThePruneDropsTheGame() + { + Solar solar = Make(); + TouchResult allSparse = new(0, 0.0, 0, 0, TouchEnd.Completed, 0, 30 * Mib); + string[] games = ["a", "b", "c", "d"]; + _result = allSparse; + foreach (string g in games) + { + _ = solar.Order(Game(g), Reads, CancellationToken.None); + } + + _result = new TouchResult(100 * Mib, 0.4, 0, 0, TouchEnd.Completed); + _ = solar.Order(Game("a"), Reads, CancellationToken.None); // 이번에는 만짐 + _result = new TouchResult(12 * Mib, 0.6, 3, 0, TouchEnd.NeedsDiskReads, 37 * Mib); + _ = solar.Order(Game("b"), Reads, CancellationToken.None); // 디스크 읽기를 만남 + solar.Forget("c"); + solar.Prune(new HashSet(["a", "b", "c"], StringComparer.OrdinalIgnoreCase)); + + Assert.All(games, g => Assert.False(solar.HasNothingToTouch(g))); + } + [Fact] public void RefreshTouchesTheStoredLadderAgainAndLogsTheReuseAgainstWhatWasAhead() { @@ -603,6 +672,29 @@ public void WithTheSettingOffOrKeepStreamOnGameStartNoBatchIsTouchedAndTheStream Assert.DoesNotContain(_log.Lines, l => (l.StartsWith("Info: b:", StringComparison.Ordinal) || l.StartsWith("Info: c:", StringComparison.Ordinal)) && l.Contains("requeued", StringComparison.Ordinal)); } + [Fact] + public void TheDoneLineShowsTheSparseBytesTheBatchTouchesSkippedAndAllSparseBatchesLeaveNoMark() + { + _streamer.Batches.AddRange(TwoBatches()); + _result = _result with { SkippedBytes = 20 * Mib }; + Prewarmer mixed = MakePrewarmer(Game("a")); + + Assert.True(mixed.Run(Game("a"), "service start").Ran); + + Assert.Contains(_log.Lines, l => l.Contains("a: prewarm done (service start)") && l.EndsWith(", requeued 200 MiB in 0.8 s (40 MiB in sparse files not touched)", StringComparison.Ordinal)); + + _streamer.Batches.Clear(); + _streamer.Batches.AddRange(TwoBatches()); + _result = new TouchResult(0, 0.0, 0, 0, TouchEnd.Completed, 0, 50 * Mib); + Prewarmer allSparse = MakePrewarmer(Game("b")); + + Assert.True(allSparse.Run(Game("b"), "service start").Ran); + + Assert.Contains(_log.Lines, l => l.Contains("b: prewarm done (service start)") && l.EndsWith(", requeued 0 MiB in 0.0 s (100 MiB in sparse files not touched)", StringComparison.Ordinal)); + Assert.False(allSparse.Solar.HasMark("b")); + Assert.False(allSparse.Solar.HasMissing("b")); + } + [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")] // 취소로 끝난 만지기는 디스크 읽기가 아니므로 다음 묶음도 만짐 @@ -1079,6 +1171,45 @@ public void ARewarmThatIsSkippedBeforeReadingKeepsTheFlagForTheNextCheck() Assert.True(p.Solar.HasMark("a")); } + // 목록 전체가 스파스 파일이라 만질 것이 없었던 게임 하나, 첫 틱까지 지난 상태. + private Prewarmer AllSparseGame(out AutoSettings auto) + { + _result = new TouchResult(0, 0.0, 0, 0, TouchEnd.Completed, 0, 100 * Mib); + Prewarmer p = MakePrewarmer(Game("a")); + _ = p.Run(Game("a"), "service start"); + Assert.True(p.Solar.HasNothingToTouch("a")); + Assert.False(p.Solar.HasMark("a")); + _touched.Clear(); + _streamer.Calls.Clear(); + auto = Auto(); + p.Tick(auto); + return p; + } + + [Fact] + public void RewarmChecksDoNotTryTheLadderAgainForAGameWhoseListIsAllSparseButANewStreamJudgesItAgain() + { + Prewarmer p = AllSparseGame(out AutoSettings auto); + int logBefore = _log.Lines.Count; + + _now += TimeSpan.FromMinutes(10.5); + p.Tick(auto); + _now += TimeSpan.FromMinutes(10.5); + p.Tick(auto); + + Assert.Empty(_touched); // 점검이 둘 돌아도 만지기 호출이 없고 + Assert.Empty(_streamer.Calls); + Assert.DoesNotContain(_log.Lines.Skip(logBefore), l => l.Contains("ladder", StringComparison.Ordinal)); // 사다리 줄도 없음 + Assert.Single(_log.Lines, l => l.Contains("ladder has nothing to touch", StringComparison.Ordinal)); // 처음 한 줄뿐 + + _result = new TouchResult(100 * Mib, 0.4, 0, 0, TouchEnd.Completed); // 파일 속성이 바뀌었다면 + Assert.True(p.Run(Game("a"), "service start", ignoreCooldown: true).Ran); + + Assert.NotEmpty(_touched); // 새 예열 스트림 뒤의 사다리는 다시 판정함 + Assert.True(p.Solar.HasMark("a")); + Assert.False(p.Solar.HasNothingToTouch("a")); + } + [Fact] public void WhileTheFlagStandsTheLadderTickDoesNotTouchTheGame() { diff --git a/csharp/tests/AsterCache.Tests/Windows/StandbyTouchLiveTests.cs b/csharp/tests/AsterCache.Tests/Windows/StandbyTouchLiveTests.cs index 38ad3f1..6aefac2 100644 --- a/csharp/tests/AsterCache.Tests/Windows/StandbyTouchLiveTests.cs +++ b/csharp/tests/AsterCache.Tests/Windows/StandbyTouchLiveTests.cs @@ -1,5 +1,7 @@ +using System.Runtime.InteropServices; using AsterCache.Core.Prefetch; using AsterCache.Windows; +using Microsoft.Win32.SafeHandles; using Xunit; namespace AsterCache.Tests.Windows; @@ -10,6 +12,16 @@ public sealed class StandbyTouchLiveTests { private const int Mib = 1 << 20; + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool DeviceIoControl(SafeFileHandle device, uint code, nint input, uint inputSize, nint output, uint outputSize, out uint returned, nint overlapped); + + // FSCTL_SET_SPARSE: 시험 임시 폴더의 작은 파일에만 씀 (게임 파일의 속성은 건드리지 않음) + private static void MarkSparse(string path) + { + using SafeFileHandle h = File.OpenHandle(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + Assert.True(DeviceIoControl(h, 0x900C4, 0, 0, 0, 0, out _, 0)); + } + [Fact] public void TouchesTwoRangesOfACachedFileAndReportsTheBytesAndCompletion() { @@ -38,6 +50,24 @@ public void AMissingFileCountsAsAnErrorAndTheRestStillRuns() Assert.Equal(Mib, r.Bytes); } + [Fact] + public void ARealSparseFileIsSkippedWithoutBeingTouchedWhileAnOrdinaryFileIsTouched() + { + using var dir = new TempDir(); + _ = dir.WriteRandom("sparse.bin", 4 * Mib); + _ = dir.WriteRandom("t.bin", 2 * Mib); + MarkSparse(dir.Combine("sparse.bin")); + _ = File.ReadAllBytes(dir.Combine("t.bin")); + + TouchResult r = StandbyTouch.Run([new ReadOp(dir.Combine("sparse.bin"), 0, 4 * Mib), new ReadOp(dir.Combine("t.bin"), 0, 2 * Mib)], CancellationToken.None); + + Assert.True((FileGuards.FileAttributes(dir.Combine("sparse.bin")) & FileGuards.SparseAttribute) != 0); + Assert.Equal(TouchEnd.Completed, r.End); + Assert.Equal(2L * Mib, r.Bytes); + Assert.Equal(4L * Mib, r.SkippedBytes); + Assert.Equal(0, r.Errors); + } + [Fact] public void ACanceledTokenStopsBeforeTouchingAnything() { diff --git a/csharp/tests/AsterCache.Tests/Windows/StandbyTouchTests.cs b/csharp/tests/AsterCache.Tests/Windows/StandbyTouchTests.cs index eec6392..7907e8b 100644 --- a/csharp/tests/AsterCache.Tests/Windows/StandbyTouchTests.cs +++ b/csharp/tests/AsterCache.Tests/Windows/StandbyTouchTests.cs @@ -93,6 +93,77 @@ public void WithoutAPageReadCountTheViewTimeRuleIsUsedInstead() Assert.True(StandbyTouch.ViewTooSlow(5, 128 << 10)); } + private const uint Archive = 0x20; + private const uint Sparse = FileGuards.SparseAttribute; + + [Fact] + public void ARangeOfASparseFileIsSkippedBeforeAnySectionIsOpenedAndCountedApartFromTouchedBytesAndErrors() + { + string notThere = _dir.Combine("sparse.bin"); // 열려고 하면 오류로 셈: 오류 0 이면 열지 않은 것 + var asked = new List(); + + TouchResult r = StandbyTouch.Run([new ReadOp(notThere, 0, 2 * Mib), new ReadOp(notThere, 4 * Mib, 3 * Mib)], CancellationToken.None, () => 1_000_000, path => + { + asked.Add(path); + return Archive | Sparse; + }); + + Assert.Equal(TouchEnd.Completed, r.End); + Assert.Equal(5L * Mib, r.SkippedBytes); + Assert.Equal((0L, 0, 0L, 0), (r.Bytes, r.Errors, r.DiskBytes, r.SlowPieces)); + Assert.Equal([notThere], asked); // 파일마다 한 번만 조회 + } + + [Fact] + public void SparseAndOrdinaryFilesMixedTouchOnlyTheOrdinaryOnesAndEachFilesAttributesAreReadOnce() + { + string sparse = _dir.Combine("s.bin"); + _ = _dir.WriteRandom("s.bin", 2 * Mib); + var asked = new List(); + + TouchResult r = StandbyTouch.Run([new ReadOp(sparse, 0, 2 * Mib), new ReadOp(_path, 0, 2 * Mib), new ReadOp(sparse, 0, Mib), new ReadOp(_path, 0, Mib)], CancellationToken.None, () => 1_000_000, path => + { + asked.Add(path); + return path == sparse ? Archive | Sparse : Archive; + }); + + Assert.Equal(TouchEnd.Completed, r.End); + Assert.Equal(3L * Mib, r.Bytes); + Assert.Equal(3L * Mib, r.SkippedBytes); + Assert.Equal(0, r.Errors); + Assert.Equal([sparse, _path], asked); + } + + [Fact] + public void AnAttributeReadThatThrowsIsNotTakenForSparseSoTheFileIsOpenedAsBefore() + { + string notThere = _dir.Combine("none.bin"); + Func broken = _ => throw new IOException("attributes unreadable"); + + TouchResult r = StandbyTouch.Run([new ReadOp(_path, 0, 2 * Mib), new ReadOp(notThere, 0, Mib)], CancellationToken.None, () => 1_000_000, broken); + + Assert.Equal(TouchEnd.Completed, r.End); + Assert.Equal(2L * Mib, r.Bytes); // 열 수 있는 파일은 만지고 + Assert.Equal(1, r.Errors); // 없는 파일은 기존대로 오류 + Assert.Equal(0, r.SkippedBytes); + } + + [Fact] + public void AFilesAttributesAreNotReadAgainAfterTheOpenSectionsAreClearedForTooManyFiles() + { + string[] paths = [.. Enumerable.Range(0, 70).Select(i => _dir.Combine($"none{i}.bin"))]; // 65번째부터 열린 섹션을 모두 닫고 비움 (없는 파일은 오류로 세고 기억됨) + var asked = new List(); + + TouchResult r = StandbyTouch.Run([.. paths.Select(p => new ReadOp(p, 0, Mib)), new ReadOp(paths[0], 0, Mib)], CancellationToken.None, () => 1_000_000, path => + { + asked.Add(path); + return Archive; + }); + + Assert.Equal(71, r.Errors); + Assert.Equal(paths, asked); // 비운 뒤에 다시 만난 첫 파일도 다시 조회하지 않음 + } + [Theory] [InlineData(0u, 1 << 20, 0.0, 0L)] [InlineData(2u, 1 << 20, 0.0, 0L)] // 허용치 이하 diff --git a/docs/SOLAR.ko.md b/docs/SOLAR.ko.md index 77588d3..f9b7b75 100644 --- a/docs/SOLAR.ko.md +++ b/docs/SOLAR.ko.md @@ -70,12 +70,12 @@ Windows 의 통계를 읽을 수 없으면(예: 권한이 없을 때) 3번은 ## 켜졌는지 확인하는 법 -아래 줄은 `service.log` 에 남습니다. `<게임>` 은 게임 이름이고, `, N MiB read from disk` 나 `, N errors` 같은 꼬리는 0 이 아닐 때만 붙습니다. +아래 줄은 `service.log` 에 남습니다. `<게임>` 은 게임 이름이고, `, N MiB read from disk`, `, N errors`, `, K MiB in sparse files not touched` 같은 꼬리는 0 이 아닐 때만 붙습니다. -- `<게임>: ladder ordered N MiB in S s`: 예열이나 재예열 점검 뒤에 사다리를 만들었습니다. +- `<게임>: ladder ordered N MiB in S s`: 예열이나 재예열 점검 뒤에 사다리를 만들었습니다. `<게임>: ladder has nothing to touch (K MiB in sparse files not touched)` 는 목록 전체가 스파스 파일이라 사다리를 만들지 않았다는 뜻이며, 점검마다가 아니라 그 판정이 새로 나올 때만(서비스를 다시 시작하거나 새 예열 뒤에는 다시) 남습니다. - `<게임>: cache refreshed in memory: N MiB in S s (R MiB repurposed of A MiB ahead)`: 새로 고쳤습니다. `R` 은 다시 쓴 양의 늘어난 값, `A` 는 앞에 있는 양입니다. -- `<게임>: prewarm done (...) ..., 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` 에 가까우면 빠진 것은 목록 앞쪽, 곧 가장 중요한 부분입니다. +- `<게임>: 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` 문구가 붙습니다. +- `<게임>: 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` 는 주기가 지나기를 기다린다는 뜻입니다. - `<게임>: 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`: 게임이 시작했거나 대여가 끝나 멈췄습니다. 표식은 남아 다시 시도합니다. @@ -86,7 +86,8 @@ Windows 의 통계를 읽을 수 없으면(예: 권한이 없을 때) 3번은 - **실제 플레이에서의 효과는 아직 재지 않았습니다.** 실험은 순서를 정할 수 있다는 것과, 아직 메모리에 있는 캐시를 새로 고치는 데는 디스크 읽기가 들지 않는다는 것을 보였습니다. 실행 중인 게임에서 로딩 시간이나 끊김이 얼마나 달라지는지는 보여 주지 않습니다. 식은 것으로 판정되는 재예열 점검의 비율이 줄었는지도 아직 확인하지 않았습니다. - **밀려나는 것을 막지는 않습니다.** 메모리가 정말 모자라면 Windows 는 이 페이지도 다시 씁니다. SOLAR 는 어느 것이 먼저 나가는지와 얼마나 빨리 되돌리는지만 바꿉니다. -- **아주 긴 예열은 자기 앞부분을 잃을 수 있습니다.** 한 번은 Call of Duty 의 11,388 MiB 예열이 322초 걸렸고, 끝난 직후 만든 사다리가 이미 빠진 부분을 찾았습니다. 목록이 대기 목록 용량에 가까워서, 먼저 읽은 부분이 나머지를 읽는 동안 밀려난 것으로 보입니다. 다른 프로그램의 읽기도 더해졌을 수 있습니다. 또 빠진 부분만 읽는 재예열은 그 게임의 나머지 페이지를 이전 예열 때의 오래된 순서로 남기고 Windows 는 그것부터 다시 쓰므로, 사다리는 빠진 부분을 맨 끝에서 만나 멈췄고 예열이 약 20분마다 되풀이됐습니다. 읽은 묶음을 곧바로 다시 만지는 것은 이를 막으려는 것입니다. **설치본에서는 아직 확인하지 않았습니다.** 목록이 대기 목록이 담을 수 있는 양보다 크면 먼저 읽은 부분이 나머지를 읽는 동안 밀려날 수는 여전히 있고, 로그의 `after T of N MiB` 로 사다리가 어디까지 만졌는지 볼 수 있습니다. +- **스파스 속성 파일은 다시 만지지 않습니다.** 같은 PC 한 대의 HDD 에서 시험 파일로 잰 결과입니다. 예열 읽기 직후 새 매핑(섹션)으로 만지면 일반 파일은 디스크를 읽지 않았고(47회), 스파스 속성 파일은 매번 디스크에서 다시 읽었습니다(64 MiB 에 약 1.1초). 그래서 SOLAR 는 섹션을 열기 전에 파일 속성을 조회해(메타데이터만, 한 번의 실행에서 파일마다 한 번) 스파스이면 건너뜁니다. 건너뛴 양은 로그에 `K MiB in sparse files not touched` 로 남고, 만진 양에도 오류에도 빠진 데이터에도 넣지 않습니다. 그런 파일의 데이터는 SOLAR 의 순서·유지 대상이 아니며 예열과 재예열 점검(버퍼드 읽기 표본)에만 의존합니다. 이것은 그 PC 에서 잰 것이며 Windows 가 일반적으로 그렇게 동작한다는 말은 아닙니다. SOLAR 는 속성을 읽기만 하고 파일을 바꾸지 않습니다. 한 게임의 목록 전체가 스파스 파일이면 로그에 `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` 로 사다리가 어디까지 만졌는지 볼 수 있습니다. - **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 bd678a3..c21c30d 100644 --- a/docs/SOLAR.md +++ b/docs/SOLAR.md @@ -70,12 +70,12 @@ 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` or `, N errors` 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. -- `: ladder ordered N MiB in S s`: a ladder was made after a prewarm or a re-prewarm check. +- `: 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. -- `: 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. +- `: 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. +- `: 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. - `: 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. @@ -86,7 +86,8 @@ Each line goes to `service.log`. `` is the game's name, and a tail such as - **The effect in real play has not been measured.** The experiments show that the order can be set and that refreshing cache that is still in memory needs no disk read. They do not show how much load time or stutter changes in a running game. Whether the share of cold re-prewarm checks has fallen has not been checked either. - **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 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. +- **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. 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. - **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.