diff --git a/csharp/src/AsterCache.Windows/StandbyTouch.cs b/csharp/src/AsterCache.Windows/StandbyTouch.cs index 3fa0ec4..2878016 100644 --- a/csharp/src/AsterCache.Windows/StandbyTouch.cs +++ b/csharp/src/AsterCache.Windows/StandbyTouch.cs @@ -40,6 +40,10 @@ public static unsafe class StandbyTouch /// 디스크에서 읽힌 것으로 본 양이 이 값을 넘으면 멈춥니다. public const long DiskReadStopBytes = 32L << 20; + /// 뷰를 만지는 데 걸린 시간 동안 디스크가 읽을 수 있다고 보는 양 (초당): 256 MiB/s. 이 서비스가 예열하는 대상은 HDD 의 게임이라 연속 읽기의 넉넉한 상한이고, 더 빠른 장치에서는 실제 읽기가 덜 세어질 뿐 읽기 자체가 빨라 해가 작습니다. + // 페이지 읽기 누계는 시스템 전체 값이라 다른 프로세스의 몰림 (같은 PC 에서 0.25 초에 3만~8만 쪽) 이 뷰를 만지는 순간과 겹치면 뷰 전체가 디스크 읽기로 보이기 때문에, 메모리에서 20 ms 에 끝난 뷰는 이 상한으로 몇 MiB 까지만 셉니다. + public const long MaxDiskBytesPerSecond = 256L << 20; + /// 페이지 읽기 누계를 읽을 수 없을 때만 쓰는 뷰 단위 시간 기준: 64 MiB 당 이 시간 (ms) 에 비례한 시간을 넘으면 그 뷰는 디스크에서 읽힌 것으로 봅니다 (캐시는 64 MiB 에 약 30 ms, HDD 는 250 ms 이상). public const double ViewSlowMsPer64Mib = 256.0; @@ -69,15 +73,17 @@ private sealed class Tally public long Skipped; public required Func PageReads; public required Func Attributes; + public required Func Timestamp; public readonly Dictionary Sparse = new(StringComparer.OrdinalIgnoreCase); // 이 Run 에서 이미 속성을 본 파일 public TouchEnd End = TouchEnd.Completed; } - /// 뷰를 ms 동안 만지는 사이 늘어난 페이지 읽기 수 (부호 없는 뺄셈 결과) 가운데 디스크에서 읽힌 것으로 볼 바이트: 잡음 허용치를 빼고 뷰 크기를 넘지 않게. + /// 뷰를 ms 동안 만지는 사이 늘어난 페이지 읽기 수 (부호 없는 뺄셈 결과) 가운데 디스크에서 읽힌 것으로 볼 바이트: 잡음 허용치를 빼고 뷰 크기와 그 시간 동안 디스크가 읽을 수 있는 양 (MaxDiskBytesPerSecond) 을 넘지 않게. public static long DiskBytesOf(uint pagesRead, long viewBytes, double ms) { long allowed = NoisePages + (long)(NoisePagesPerSecond * ms / 1000.0); - return Math.Clamp((pagesRead - allowed) * 4096L, 0, Math.Max(0, viewBytes)); + long bytes = Math.Clamp((pagesRead - allowed) * 4096L, 0, Math.Max(0, viewBytes)); + return Math.Min(bytes, (long)(MaxDiskBytesPerSecond * ms / 1000.0)); } /// 페이지 읽기 누계가 없을 때: 뷰를 만지는 데 걸린 시간이 바이트에 비례한 기준 (하한 MinSlowMs) 을 넘었는가. @@ -90,10 +96,11 @@ public static long DiskBytesOf(uint pagesRead, long viewBytes, double ms) 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) + /// timestamp: 뷰를 만지는 데 걸린 시간을 재는 시계 (Stopwatch 틱, 기본은 Stopwatch.GetTimestamp, 시험은 가짜로 바꿈). + public static TouchResult Run(IReadOnlyList ranges, CancellationToken ct, Func pageReads, Func attributes, Func? timestamp = null) { long started = Stopwatch.GetTimestamp(); - var tally = new Tally { PageReads = pageReads, Attributes = attributes }; + var tally = new Tally { PageReads = pageReads, Attributes = attributes, Timestamp = timestamp ?? Stopwatch.GetTimestamp }; var sections = new Dictionary(StringComparer.OrdinalIgnoreCase); byte* buffer = (byte*)NativeMemory.Alloc(PieceBytes); try @@ -170,7 +177,7 @@ private static void TouchRange(ReadOp range, Dictionary sectio try { uint? before = tally.PageReads(); - long t0 = Stopwatch.GetTimestamp(); + long t0 = tally.Timestamp(); if (!TouchView(view, aligned, pos, viewEnd, buffer, tally, ct)) { return; @@ -178,7 +185,7 @@ private static void TouchRange(ReadOp range, Dictionary sectio if (tally.End == TouchEnd.Completed) { - NoteDiskReads(tally, before, tally.PageReads(), viewEnd - pos, Stopwatch.GetElapsedTime(t0).TotalMilliseconds); + NoteDiskReads(tally, before, tally.PageReads(), viewEnd - pos, Stopwatch.GetElapsedTime(t0, tally.Timestamp()).TotalMilliseconds); } } finally diff --git a/csharp/tests/AsterCache.Tests/Windows/StandbyTouchTests.cs b/csharp/tests/AsterCache.Tests/Windows/StandbyTouchTests.cs index 7907e8b..3e7b9f9 100644 --- a/csharp/tests/AsterCache.Tests/Windows/StandbyTouchTests.cs +++ b/csharp/tests/AsterCache.Tests/Windows/StandbyTouchTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using AsterCache.Core.Prefetch; using AsterCache.Windows; using Xunit; @@ -20,7 +21,8 @@ public StandbyTouchTests() public void Dispose() => _dir.Dispose(); // 범위마다 하나의 뷰: 2 MiB 범위 n 개. 누계는 뷰 전후 두 번 읽히고, 두 번째 읽기에 perView 쪽을 더함 (null 이면 조회 불가). - private TouchResult Run(int ranges, uint? perView) + // viewMs: 뷰마다 가짜 시계가 돌려줄 만지는 시간 (ms) — 시간 상한이 걸린 시간으로 정해지므로 실제 시간에 기대지 않고 정확히 정함. + private TouchResult Run(int ranges, uint? perView, double viewMs = 0) { uint value = 1_000_000; int calls = 0; @@ -38,7 +40,15 @@ private TouchResult Run(int ranges, uint? perView) return value; }; - return StandbyTouch.Run([.. Enumerable.Repeat(new ReadOp(_path, 0, 2 * Mib), ranges)], CancellationToken.None, counter); + return StandbyTouch.Run([.. Enumerable.Repeat(new ReadOp(_path, 0, 2 * Mib), ranges)], CancellationToken.None, counter, _ => Archive, FakeClock(viewMs)); + } + + // 뷰 하나를 재는 두 번의 호출 (만지기 전, 후) 사이에 ms 가 흐르는 시계 (Stopwatch 틱). + private static Func FakeClock(double viewMs) + { + long ticks = 0; + int calls = 0; + return () => ++calls % 2 == 0 ? ticks += (long)(Stopwatch.Frequency * viewMs / 1000.0) : ticks; } [Fact] @@ -63,7 +73,7 @@ public void AFewPagesReadByOtherProcessesAreNotCountedAsDiskReads() [Fact] public void ViewsReadFromDiskStopOnceMoreThan32MiBAccumulated() { - TouchResult r = Run(40, 512); // 2 MiB 뷰 전체가 디스크에서 읽힘 + TouchResult r = Run(40, 512, 20); // 2 MiB 뷰 전체가 디스크에서 읽힘 (20 ms 동안 디스크는 5 MiB 까지 읽을 수 있음) Assert.Equal(TouchEnd.NeedsDiskReads, r.End); Assert.True(r.DiskBytes > StandbyTouch.DiskReadStopBytes); @@ -73,13 +83,36 @@ public void ViewsReadFromDiskStopOnceMoreThan32MiBAccumulated() [Fact] public void ViewsThatAreOnlyPartlyReadFromDiskStillAddUpAndStop() { - TouchResult r = Run(400, 256); // 뷰마다 절반만 디스크에서 읽힘: 뷰 단위 판정이면 놓치던 경우 + TouchResult r = Run(400, 256, 20); // 뷰마다 절반만 디스크에서 읽힘: 뷰 단위 판정이면 놓치던 경우 Assert.Equal(TouchEnd.NeedsDiskReads, r.End); Assert.True(r.DiskBytes > StandbyTouch.DiskReadStopBytes); Assert.True(r.Bytes < 400L * 2 * Mib); } + [Fact] + public void ViewsTouchedInMemoryNeverAddUpToAStopEvenWhenOtherProcessesReadAHugeNumberOfPagesMeanwhile() + { + _ = _dir.WriteRandom("v.bin", 16 * Mib); + uint value = 1_000_000; + int calls = 0; + Func counter = () => + { + if (++calls % 2 == 0 && calls / 2 % 7 == 0) // 7 번째마다 한 뷰에 다른 프로세스의 몰림 (16 MiB 뷰 전체에 해당하는 쪽 수) 이 겹침 + { + value += 4096 * 4; + } + + return value; + }; + + TouchResult r = StandbyTouch.Run([.. Enumerable.Repeat(new ReadOp(_dir.Combine("v.bin"), 0, 16 * Mib), 21)], CancellationToken.None, counter, _ => Archive, FakeClock(5.0)); // 몰림 3 번, 뷰마다 메모리에서 5 ms + + Assert.Equal(TouchEnd.Completed, r.End); + Assert.Equal(21L * 16 * Mib, r.Bytes); + Assert.InRange(r.DiskBytes, 4_000_000, 4_050_000); // 몰린 뷰 3 개가 5 ms 동안 디스크가 읽을 수 있는 양 (약 1.28 MiB) 씩만 셈 + } + [Fact] public void WithoutAPageReadCountTheViewTimeRuleIsUsedInstead() { @@ -167,21 +200,33 @@ public void AFilesAttributesAreNotReadAgainAfterTheOpenSectionsAreClearedForTooM [Theory] [InlineData(0u, 1 << 20, 0.0, 0L)] [InlineData(2u, 1 << 20, 0.0, 0L)] // 허용치 이하 - [InlineData(3u, 1 << 20, 0.0, 4096L)] - [InlineData(258u, 1 << 20, 0.0, 1L << 20)] - [InlineData(10000u, 1 << 20, 0.0, 1L << 20)] // 뷰 크기를 넘지 않음 + [InlineData(3u, 1 << 20, 0.02, 4096L)] + [InlineData(300u, 1 << 20, 4.0, 1L << 20)] + [InlineData(10000u, 1 << 20, 4.0, 1L << 20)] // 뷰 크기를 넘지 않음 [InlineData(2050u, 64 << 20, 1000.0, 0L)] // 1 초 동안의 허용치 2 + 2048 쪽 [InlineData(2051u, 64 << 20, 1000.0, 4096L)] [InlineData(5u, 0, 0.0, 0L)] - public void DiskBytesAreThePagesReadBeyondTheNoiseAllowanceCappedAtTheView(uint pagesRead, long viewBytes, double ms, long expected) => + [InlineData(16384u, 64 << 20, 0.0, 0L)] // 시간이 0 이면 디스크가 읽을 수 있는 양도 0 + [InlineData(16384u, 64 << 20, 20.0, 5368709L)] // 메모리에서 20 ms: 쪽 수는 뷰 전체여도 20 ms 동안 디스크가 읽을 수 있는 양 (256 MiB/s) 까지만 + [InlineData(20000u, 64 << 20, 250.0, 64L << 20)] // 시간 상한이 뷰 크기와 같아지는 경계 + [InlineData(16384u, 64 << 20, 500.0, 62906368L)] // 실제로 느린 뷰: 상한 (128 MiB) 이 넉넉해 쪽 수에서 허용치를 뺀 양 그대로 + public void DiskBytesAreThePagesReadBeyondTheNoiseAllowanceCappedAtTheViewAndAtWhatTheDiskCouldReadInThatTime(uint pagesRead, long viewBytes, double ms, long expected) => Assert.Equal(expected, StandbyTouch.DiskBytesOf(pagesRead, viewBytes, ms)); + [Fact] + public void ASlowViewThatReadItsWholeSizeFromDiskIsCountedInFullPastTheStop() + { + long one = StandbyTouch.DiskBytesOf(16384, 64L << 20, 500.0); // 64 MiB 뷰를 0.5 초 (HDD 속도) 에 만짐 + + Assert.True(one > StandbyTouch.DiskReadStopBytes); + } + [Fact] public void ACounterThatWrapsAroundIsDifferencedAsUnsigned() { uint before = uint.MaxValue - 10; uint after = unchecked(before + 600); // 넘쳐서 작아짐 - Assert.Equal(2L * Mib, StandbyTouch.DiskBytesOf(unchecked(after - before), 2 * Mib, 0)); + Assert.Equal(2L * Mib, StandbyTouch.DiskBytesOf(unchecked(after - before), 2 * Mib, 10.0)); } } diff --git a/docs/SOLAR.ko.md b/docs/SOLAR.ko.md index 57e295f..4d6d30d 100644 --- a/docs/SOLAR.ko.md +++ b/docs/SOLAR.ko.md @@ -31,7 +31,7 @@ Windows 의 통계를 읽을 수 없으면(예: 권한이 없을 때) 3번은 - **만지는 방식.** 범위마다 최대 64 MiB 의 읽기 전용 뷰를 따로 매핑하고, 서비스 자기 프로세스 안에서 1 MiB 씩 읽은 뒤 바로 풉니다. 잠금도, 열어 두는 매핑도 없습니다. 한 번에 최대 64개 파일을 엽니다. 프로그램이 그 페이지를 평소처럼 읽은 것과 같은 결과입니다. - **범위마다 뷰를 쓰는 이유.** 시험 PC 에서 파일 안의 범위들이 만진 순서대로 밀려난 것은 범위마다 따로 뷰를 만들어 바로 푼 경우뿐이었습니다. 한 뷰로 모든 범위를 만들면 주소 순서로 밀려났습니다(실험 E10, E11). - **0.4 와 512 MiB 의 근거.** 밀려남 실험(E9)에서 만지지 않은 첫 파일은 다시 쓴 양의 누계가 앞에 있는 양의 0.61~0.81배 늘었을 때 밀려났습니다. 앞에 있는 페이지 일부는 다시 참조되어 빠지므로 실제 시점은 단순 계산보다 일찍 오고, 그래서 여유를 두었습니다. 하한 512 MiB 는 앞에 있는 양이 작을 때 너무 자주 새로 고치지 않게 합니다. 두 값 모두 그 실험 하나에서 나온 것이며 더 다듬지 않았습니다. 남은 여유 2 GiB 는 설치된 PC 에서 잰 실험에서 30초에 약 1.9 GiB 의 다시 쓰기가 몰린 것이 두 번 관측된 데서 나왔습니다([측정 기록](#측정-기록) 참고). 그 PC 와 그 시간대의 값이며 일반값이 아닙니다. -- **디스크 읽기를 가리는 방법.** 뷰 하나를 만지기 전후로 시스템의 페이지 읽기 누계를 읽습니다. 늘어난 양에서 다른 프로그램의 몫(2쪽과 초당 약 8 MiB)을 뺀 나머지를 디스크에서 읽힌 것으로 세며, 뷰 크기를 넘지 않게 합니다. 32 MiB 를 넘게 세어지면 만지기를 끝내므로, 최악에는 멈추기 전에 디스크에서 약 100 MiB 가 읽힙니다(센 32 MiB 와 만지던 뷰 하나). 누계를 읽을 수 없으면 64 MiB 당 약 256 ms 를 넘게 걸린 뷰를 디스크에서 읽힌 것으로 봅니다. 더 느슨한 안전망으로, 1 MiB 당 20 ms 넘게 걸린 조각의 시간이 합쳐 2초를 넘어도 멈춥니다. +- **디스크 읽기를 가리는 방법.** 뷰 하나를 만지기 전후로 시스템의 페이지 읽기 누계를 읽습니다. 늘어난 양에서 다른 프로그램의 몫(2쪽과 초당 약 8 MiB)을 뺀 나머지를 디스크에서 읽힌 것으로 세며, 뷰 크기와 그 뷰를 만지는 데 걸린 시간 동안 디스크가 읽을 수 있는 양(초당 256 MiB)을 넘지 않게 합니다. 이 누계는 시스템 전체 값이라 다른 프로그램의 읽기가 섞입니다. 시험 PC 에서는 다른 프로세스가 0.25초에 3만~8만 쪽을 읽는 몰림이 약 90초에 한 번 있었고, 그 순간과 뷰를 만지는 때가 겹치면 시간 상한이 없을 경우 뷰 전체가 디스크 읽기로 세어집니다. 64 MiB 를 메모리에서 만지면 약 20 ms, HDD 에서 읽으면 0.4~1초가 걸렸으므로, 20 ms 걸린 뷰는 많아야 약 5 MiB 만 세어지고 실제로 HDD 에서 읽힌 뷰는 전과 같이 세어집니다. 32 MiB 를 넘게 세어지면 만지기를 끝내므로, 최악에는 멈추기 전에 디스크에서 약 100 MiB 가 읽힙니다(센 32 MiB 와 만지던 뷰 하나). 누계를 읽을 수 없으면 64 MiB 당 약 256 ms 를 넘게 걸린 뷰를 디스크에서 읽힌 것으로 봅니다. 더 느슨한 안전망으로, 1 MiB 당 20 ms 넘게 걸린 조각의 시간이 합쳐 2초를 넘어도 멈춥니다. - **무엇을 만지나.** 예열이 먼저 읽는 공통 블록 다음에 읽은 목록 전체를 읽은 순서대로 늘어놓은 것의 역순입니다. 앞부분만 읽은 예열이면 읽은 부분입니다. 같은 파일에서 앞 범위에 이어지는 범위는 하나로 합칩니다. - **읽는 도중 만지기.** 예열 스트림이 묶음 (읽기 약 64 MiB) 을 읽을 때마다, 그리고 공통 블록을 읽은 뒤에, 방금 읽은 범위를 같은 스레드에서 스트림과 같은 디스크 읽기 대여·중지 신호 아래 다시 만집니다. 스트림이 묶음마다 하는 기존 점검 (메모리, 외부 부하, 게임 실행) 뒤에 하므로 그 점검의 순서와 의미는 그대로입니다. 게임이 시작하거나 대여가 끝나면 그 뒤 묶음은 만지지 않습니다. 만지기가 빠진 부분을 만나거나 예외가 나거나 취소돼도 스트림은 계속하고 결과 (읽은 양, 성공 여부) 는 같으며, 예외는 `requeue of the read batches skipped` 로 한 번만 남깁니다. 한 묶음의 만지기가 디스크 읽기를 만나 멈추면 그 스트림의 남은 묶음은 만지지 않고 (묶음마다 디스크 읽기를 되풀이하지 않게), 완료 줄에 `requeue stopped by disk reads` 를 남깁니다. 건너뛴 앞부분 만지기가 같은 이유로 멈추면 그 만지기만 끝납니다. 만지는 데 쓴 시간은 디스크 읽기가 아니므로 `yielded` 시간과 스트림의 노출 시간에서 뺍니다. 조건은 사다리와 같고 (아래), `memory.standby_ladder` 가 꺼져 있으면 스트림은 전과 완전히 같습니다. - **스트림 앞의 보호.** 예열 스트림(서비스 시작, 재예열, 보충)이 디스크를 읽기 시작하기 직전에, 표식이 있는 다른 게임들을 순위가 낮은 것부터 한 번 만집니다. 묶음 새로 고침과 같은 함수이고 스트림의 게임만 뺍니다. 그러면 스트림이 새로 읽는 페이지가 다른 게임보다 먼저 보통 캐시와 그 게임 자신의 오래된 페이지를 밀어냅니다. 스트림과 같은 디스크 읽기 대여·중지 신호 아래에서, 게임 시작이나 런처 선점 표시가 서 있지 않을 때만 돕니다. 표본 점검과 관문은 새로 고침과 같습니다. 런처 예열은 이 보호를 하지 않습니다. 게임 시작이 임박해 예열을 늦추면 안 되기 때문이며, 대신 그 스트림이 끝난 뒤 사다리가 다른 게임의 앞에 있는 양만 줄입니다. 걸린 시간은 `yielded` 시간과 스트림의 노출 시간에서 빼고, 완료 줄에 `, protected K games (N MiB in S s)` 를 덧붙입니다(만진 것이 없으면 붙이지 않음). 실패하거나 취소돼도 스트림은 계속하고 결과는 같습니다. @@ -93,6 +93,7 @@ Windows 의 통계를 읽을 수 없으면(예: 권한이 없을 때) 3번은 - **스파스 속성 파일은 다시 만지지 않습니다.** 같은 PC 한 대의 HDD 에서 시험 파일로 잰 결과입니다. 예열 읽기 직후 새 매핑(섹션)으로 만지면 일반 파일은 디스크를 읽지 않았고(47회), 스파스 속성 파일은 매번 디스크에서 다시 읽었습니다(64 MiB 에 약 1.1초). 그래서 SOLAR 는 섹션을 열기 전에 파일 속성을 조회해(메타데이터만, 한 번의 실행에서 파일마다 한 번) 스파스이면 건너뜁니다. 건너뛴 양은 로그에 `K MiB in sparse files not touched` 로 남고, 만진 양에도 오류에도 빠진 데이터에도 넣지 않습니다. 그런 파일의 데이터는 SOLAR 의 순서·유지 대상이 아니며 예열과 재예열 점검(버퍼드 읽기 표본)에만 의존합니다. 이것은 그 PC 에서 잰 것이며 Windows 가 일반적으로 그렇게 동작한다는 말은 아닙니다. SOLAR 는 속성을 읽기만 하고 파일을 바꾸지 않습니다. 예열도 그런 파일은 일반 파일과 다르게 읽습니다. 매핑하지 않고 같은 범위를 같은 순서로 보통의 버퍼드 읽기로 읽습니다. 그 PC 에서 스파스 시험 파일은 매핑을 만들면 앞서 읽은 그 파일 전체의 캐시 페이지가 버려졌고, 버퍼드 읽기만 쓰면 이어졌기 때문입니다. 이 읽기는 캐시 관리자의 앞읽기를 끕니다. 켜 두면 그 PC 에서 64~256 KiB 조각이 흩어진 목록(요청 256 MiB)이 디스크에서 요청보다 13~23% 더 읽혔고, 끄면 잰 회들에서 요청의 약 1% 이내였습니다. 실제 게임에서의 효과는 아직 재지 않았습니다. 한 게임의 목록 전체가 스파스 파일이면 로그에 `ladder has nothing to touch (K MiB in sparse files not touched)` 가 한 번 남고, 다음 예열 스트림이 다시 판정할 때까지 재예열 점검이 사다리를 다시 시도하지 않습니다. - **아주 긴 예열은 자기 앞부분을 잃을 수 있습니다.** 한 번은 Call of Duty 의 11,388 MiB 예열이 322초 걸렸고, 끝난 직후 만든 사다리가 이미 빠진 부분을 찾았습니다. 목록이 대기 목록 용량에 가까워서, 먼저 읽은 부분이 나머지를 읽는 동안 밀려났을 수 있습니다. 다른 프로그램의 읽기도 더해졌을 수 있습니다. 따로, 설치본 1.0.4-dev.7 에서 Call of Duty 의 묶음 다시 만지기가 서로 다른 스트림에서 같은 위치(3,379 MiB)에서 `requeue stopped by disk reads` 로 멈췄고 사다리도 빠진 데이터를 만나 재예열이 되풀이됐습니다. 파일 속성을 집계하니(메타데이터만, 2026-11-14) 그 게임 폴더의 `.xsub` 210개 중 78개(50.6 GiB)가 스파스 속성이었고, 사다리가 디스크 읽기 없이 끝나던 다른 게임의 폴더에는 스파스 파일이 없었습니다. 이 세 가지로 되풀이의 원인을 스파스 파일로 판단했습니다. 멈춘 위치의 파일이 스파스였는지는 확인하지 않았고, 처음 사례에서 밀려남이 얼마나였는지도 가려내지 못했습니다. 또 빠진 부분만 읽는 재예열은 그 게임의 나머지 페이지를 이전 예열 때의 오래된 순서로 남기고 Windows 는 그것부터 다시 쓰는데, 읽은 묶음을 곧바로 다시 만지는 것은 이를 막으려는 것입니다. **이 변경 뒤 되풀이가 멈추는지는 설치본에서 아직 확인하지 않았습니다.** 목록이 대기 목록이 담을 수 있는 양보다 크면 먼저 읽은 부분이 나머지를 읽는 동안 밀려날 수는 여전히 있고, 로그의 `after T of N MiB` 로 사다리가 어디까지 만졌는지 볼 수 있습니다. - **8곳 표본 점검은 일부만 식은 캐시를 놓칠 수 있습니다.** 사다리는 목록 전체를 보므로, 표본이 따뜻해 보여도 사다리가 빠진 부분을 찾으면 재예열로 이어집니다. +- **다른 프로그램의 읽기가 디스크 읽기 수에 여전히 섞일 수 있고, 한도가 있습니다.** 페이지 읽기 누계는 시스템 전체 값이라 시간 상한은 짧은 몰림이 뷰 전체로 세어지는 것만 막습니다. 실제로 오래 걸린 뷰(느리거나 바쁜 디스크)와 몰림이 겹치면 그 시간이 허용하는 양까지는 더해질 수 있습니다. 가짜 멈춤(`requeue stopped by disk reads`, `ladder found data missing`)이 설치본에서 사라졌는지는 아직 확인하지 않았습니다. - **이 통계는 Windows 가 문서화하지 않은 인터페이스입니다.** Windows 11 10.0.26200 에서만 확인했습니다. 읽을 수 없으면 10분 주기로 바뀝니다. - **밀려나는 순서는 HDD 한 대의 시험 파일로, 우선순위 5 의 페이지에서만 확인했습니다.** 페이지 우선순위를 6 이나 7 로 올리는 것은 Windows 가 거부해서(`STATUS_INVALID_PARAMETER`) 쓰지 않습니다. - SSD 에 설치된 게임은 예열 대상이 아니므로 SOLAR 가 할 일도 없습니다. diff --git a/docs/SOLAR.md b/docs/SOLAR.md index e92916e..e1c7274 100644 --- a/docs/SOLAR.md +++ b/docs/SOLAR.md @@ -31,7 +31,7 @@ If Windows' statistics cannot be read (for example because the privilege is miss - **How a touch is made.** Each range is mapped as its own read-only view of at most 64 MiB, read 1 MiB at a time from inside the service's own process, and unmapped at once. Nothing is locked and no mapping is kept. Up to 64 files are open at a time. The result is the same as if a program had read the pages normally. - **Why a view per range.** On the test PC, a file's ranges left the cache in the order they were touched only when each range had its own view that was released right after. With one view for all ranges they left in address order (experiments E10 and E11). - **Where 0.4 and 512 MiB come from.** In the eviction experiment (E9) the first untouched file left the cache when the reuse count had grown by 0.61 to 0.81 times the amount ahead. Some pages ahead are used again and drop out of the way, so the real moment comes earlier than a plain count suggests, and a margin was added. The lower bound of 512 MiB keeps a small amount ahead from triggering refreshes too often. Both values come from that one experiment and have not been tuned further. The 2 GiB of room comes from a reuse burst of about 1.9 GiB within 30 seconds that was seen twice in one measurement on the installed PC (see the measurement record); it is a value of that PC and time of day, not a general one. -- **How disk reads are told.** The system's page-read count is read before and after each view. The increase, minus an allowance for other programs (2 pages and about 8 MiB per second), counts as read from the disk, at most the size of the view. More than 32 MiB counted ends the touch, so in the worst case about 100 MiB is read from the disk before it stops (the 32 MiB counted, plus the view being touched). If the count cannot be read, a view that takes more than about 256 ms per 64 MiB counts as read from the disk. A looser safety net also stops the touch when pieces that took more than 20 ms per MiB add up to more than 2 seconds. +- **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 and at most what a disk could read in the time the view took (256 MiB per second). The count is for the whole system, so reads by other programs are mixed in; on the test PC another process read 30,000 to 80,000 pages within 0.25 s about once every 90 seconds, and a touch that happens to overlap such a burst would otherwise count a whole view as read from the disk. Touching 64 MiB from memory took about 20 ms and reading it from an HDD 0.4 to 1 s, so a view that took 20 ms counts at most about 5 MiB, while a view that really came from an HDD is counted as before. 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. - **Protecting the games with a note before a stream.** Right before a prewarm stream (service start, re-prewarm, top-up) reads from the disk, the other games that have a note are touched once, the lowest-ranked first, by the same function as the group refresh (the stream's own game is left out). The pages the stream then reads push out the plain cache, and the stream's own old pages, before they push out another game. It runs under the stream's disk read lease and stop signal and only while no game start or launcher preemption flag stands. The sample check and the gates are the same as for a refresh. A launcher prewarm does not do this, because a game start is close and its prewarm must not be delayed; after that stream the ladder only lowers the amount ahead of the other games. The time it takes is left out of the `yielded` time and of the stream's exposure time, and the done line gets `, protected K games (N MiB in S s)` (left out when none was touched). If it fails or is cancelled, the stream goes on and its result is the same. @@ -93,6 +93,7 @@ Each line goes to `service.log`. `` is the game's name, and a tail such as - **Files with the sparse attribute are not touched.** Measured on one PC (an HDD, test files): right after a prewarm read, an ordinary file touched through a new mapping (a section) caused no disk read (47 runs), while a sparse file was read again from the disk every time (64 MiB in about 1.1 s). So before it opens a section SOLAR reads the file's attributes (metadata only, once per file in a run) and skips a sparse file. The log shows the skipped amount (`K MiB in sparse files not touched`); it is not counted as touched, as errors or as data missing. The data of such files is not part of SOLAR's order or upkeep and depends only on the prewarm and the re-prewarm check (a buffered-read sample). This is what was measured on that PC, not a statement about how Windows behaves in general. SOLAR only reads the attribute and never changes a file. The prewarm reads such files differently from ordinary ones as well: it does not map them but reads them with an ordinary buffered read of the same ranges in the same order, because on that PC creating a mapping for a sparse test file discarded the cache pages read earlier from that whole file, while buffered reads alone kept them. These reads turn the cache manager's read-ahead off: with it on, a list of scattered 64 to 256 KiB pieces (256 MiB requested) read 13 to 23% more than requested from the disk on that PC, and with it off the disk read stayed within about 1% of the request in the measured runs. How much this helps in a real game has not been measured yet. If every range of a game's list is in sparse files, the log says `ladder has nothing to touch (K MiB in sparse files not touched)` once, and the re-prewarm check does not try the ladder again until the next prewarm stream has judged it again. - **A very long prewarm can lose its own start.** Once, a prewarm of 11,388 MiB for Call of Duty took 322 seconds, and the ladder made right after it already found data missing. The list was close to the capacity of the standby list, so the part read first may have been pushed out while the rest was being read; other programs' reads may have added to it. Separately, in the installed build 1.0.4-dev.7 the batch touch for Call of Duty stopped with `requeue stopped by disk reads` at the same position (3,379 MiB) in different streams, and the ladder also found data missing, so the re-prewarm repeated. A count of file attributes (metadata only, 2026-11-14) found 78 of the 210 `.xsub` files in that game's folder (50.6 GiB) sparse, and no sparse files in the folder of another game whose ladder ended without disk reads. From these three the repetition was judged to come from sparse files; that the file at the stopping position is sparse was not checked, and how much of the first case was pushing out has not been separated. A re-prewarm that reads only the missing part also leaves the rest of the game's pages in the old order of the earlier prewarm, which Windows reuses first; touching each batch right after it is read is meant to prevent that. **Whether the repetition stops after this change has not been checked on an installed build yet.** If the list is larger than what the standby list can hold, the part read first can still be pushed out while the rest is read; the `after T of N MiB` in the log shows how far the ladder got. - **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. +- **Other programs' reads can still add to the disk read count, up to a limit.** The page-read count is system-wide, so the time cap only keeps a short burst from counting as a whole view; a burst that overlaps a view that really took long (on a slow or busy disk) can still add up to what that time allows. Whether the false stops (`requeue stopped by disk reads`, `ladder found data missing`) have stopped on an installed build has not been checked yet. - **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. - Games on an SSD are skipped by the prewarm, so SOLAR has nothing to do for them.