diff --git a/csharp/src/AsterCache.Service/LoadBoundary.cs b/csharp/src/AsterCache.Service/LoadBoundary.cs index 20163cc..3bfa144 100644 --- a/csharp/src/AsterCache.Service/LoadBoundary.cs +++ b/csharp/src/AsterCache.Service/LoadBoundary.cs @@ -58,7 +58,8 @@ public static class LoadBoundaryWatch { now ??= () => DateTimeOffset.UtcNow; sleep ??= d => ct.WaitHandle.WaitOne(d); - probeWithStop ??= probe is null ? (s, stop) => CacheProbe.Probe(s, SlowMs, stop) : (s, _) => probe(s); + // 4 KiB 표본은 1 쪽만 늘어도 되어 페이지 읽기 조건이 거르는 힘이 없으므로 시간만으로 판정 + probeWithStop ??= probe is null ? (s, stop) => CacheProbe.Probe(s, SlowMs, stop, pageReads: () => null) : (s, _) => probe(s); if ((boundary ?? LoadTiers.For)(game) is not { Tier0.Count: > 0 } b) { return null; diff --git a/csharp/src/AsterCache.Windows/CacheProbe.cs b/csharp/src/AsterCache.Windows/CacheProbe.cs index 9d6c21c..0d258d6 100644 --- a/csharp/src/AsterCache.Windows/CacheProbe.cs +++ b/csharp/src/AsterCache.Windows/CacheProbe.cs @@ -22,6 +22,10 @@ public static class CacheProbe /// 느린 표본이 이만큼 (표본 8 곳 중) 이상이면 캐시에서 빠진 것으로 봅니다: 한두 곳의 일시적 지연으로 다시 예열하지 않게. public const int ColdSlowSamples = 2; + /// 느린 표본으로 세려면 읽는 동안 시스템의 페이지 읽기 누계도 읽은 쪽 수의 1/4 (하한 16 쪽, 표본이 더 작으면 그 쪽 수) 이상 늘어야 합니다: + /// 1 MiB 가 디스크에서 읽히면 256 쪽이 늘고, 다른 프로세스의 평소 읽기는 초당 수백 쪽이라 0.8 ms 안팎에는 수십 쪽을 넘기 어려움 (드문 몰림과 겹치는 오판은 남음). 다른 작업이 CPU 를 쓸 때 읽는 스레드가 밀려 캐시 적중이 느려 보이는 것을 거름. + public const int MinGrownPages = 16; + /// 접근 순서 앞쪽 (게임 시작에 먼저 필요한 부분) 에서 고정으로 고르는 표본 수와, 나머지에서 고르게 고르는 표본 수. public const int HeadSamples = 4; public const int SpreadSamples = 4; @@ -54,10 +58,18 @@ public static class CacheProbe /// key (게임 이름) 별 점검 회차 번호 (0 부터): PickSamples 의 round 로 넘겨 점검마다 다른 위치를 읽게 합니다. public static int NextRound(string key) => Rounds.AddOrUpdate(key, 0, (_, n) => n + 1); - /// 표본마다 읽어 걸린 시간 (ms) 을 재고 slowMs 를 넘으면 느린 것으로 판정합니다 (기본은 1 MiB 용 기준). 열 수 없거나 읽을 것이 없는 표본은 뺍니다. 모두 빠지면 null. + /// 읽은 바이트 수에 걸맞게 페이지 읽기가 늘었다고 볼 최소 쪽 수. + public static long PagesNeeded(int bytes) + { + long pages = (bytes + 4095L) / 4096; + return Math.Max(pages / 4, Math.Min(MinGrownPages, pages)); + } + + /// 표본마다 읽어 걸린 시간 (ms) 을 재고 slowMs 를 넘고 그동안 pageReads (시스템의 페이지 읽기 누계, 기본은 실제 조회) 도 PagesNeeded 이상 늘었으면 느린 것으로 판정합니다 (기본은 1 MiB 용 기준). 누계를 읽을 수 없으면 (null) 시간만으로 봅니다. 열 수 없거나 읽을 것이 없는 표본은 뺍니다. 모두 빠지면 null. /// ct 가 취소되면 표본 사이에서 멈추고 null (부분 결과로 판정하지 않음). - public static ProbeResult? Probe(IReadOnlyList<(string Path, long Offset, int Bytes)> samples, double slowMs = ColdThresholdMs, CancellationToken ct = default, Func? canIssue = null) + public static ProbeResult? Probe(IReadOnlyList<(string Path, long Offset, int Bytes)> samples, double slowMs = ColdThresholdMs, CancellationToken ct = default, Func? canIssue = null, Func? pageReads = null) { + pageReads ??= SystemPageReads.Query; byte[] buf = new byte[SampleBytes]; double total = 0; int ok = 0; @@ -73,15 +85,17 @@ public static class CacheProbe { using var f = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 1, FileOptions.None); f.Seek(offset, SeekOrigin.Begin); + uint? before = pageReads(); var watch = Stopwatch.StartNew(); int got = f.Read(buf, 0, bytes); watch.Stop(); + uint? after = pageReads(); if (got > 0) { double ms = watch.Elapsed.TotalMilliseconds; total += ms; ok++; - slow += ms > slowMs ? 1 : 0; + slow += ms > slowMs && (before is not { } b || after is not { } a || unchecked(a - b) >= PagesNeeded(got)) ? 1 : 0; } } catch (Exception e) when (e is IOException or UnauthorizedAccessException) diff --git a/csharp/tests/AsterCache.Tests/Service/LoadBoundaryWatchTests.cs b/csharp/tests/AsterCache.Tests/Service/LoadBoundaryWatchTests.cs index 80d87f8..309c21f 100644 --- a/csharp/tests/AsterCache.Tests/Service/LoadBoundaryWatchTests.cs +++ b/csharp/tests/AsterCache.Tests/Service/LoadBoundaryWatchTests.cs @@ -204,8 +204,8 @@ public void SmallReadThresholdIsHalfAMillisecondAndAppliedToTheProbe() var samples = new List<(string, long, int)> { (file, 0, LoadBoundaryWatch.SampleBytes) }; Assert.Equal(0.5, LoadBoundaryWatch.SlowMs); - Assert.Equal(1, CacheProbe.Probe(samples, -1)!.Slow); // 기준보다 느리면 느림 - Assert.Equal(0, CacheProbe.Probe(samples, 1e9)!.Slow); + Assert.Equal(1, CacheProbe.Probe(samples, -1, pageReads: () => null)!.Slow); // 기준보다 느리면 느림 (경계 감시는 시간만 봄) + Assert.Equal(0, CacheProbe.Probe(samples, 1e9, pageReads: () => null)!.Slow); } [Fact] diff --git a/csharp/tests/AsterCache.Tests/Windows/CacheProbeTests.cs b/csharp/tests/AsterCache.Tests/Windows/CacheProbeTests.cs index c052197..5fdcc2e 100644 --- a/csharp/tests/AsterCache.Tests/Windows/CacheProbeTests.cs +++ b/csharp/tests/AsterCache.Tests/Windows/CacheProbeTests.cs @@ -140,4 +140,84 @@ public void SlowThresholdIsBelowASataDiskCacheHitAndAboveARamHit() Assert.Equal(0.8, CacheProbe.ColdThresholdMs); Assert.InRange(CacheProbe.ColdThresholdMs, 0.5, 1.7); } + + // 표본마다 읽기 전후로 누계를 한 번씩 읽으므로, 두 번째 호출마다 perSample 쪽 늘어난 가짜 누계 (넘침 시험은 start 를 끝 가까이로). + private static Func Pages(uint perSample, uint start = 0) + { + int calls = 0; + uint value = start; + return () => + { + if (calls++ % 2 == 1) + { + value = unchecked(value + perSample); + } + + return value; + }; + } + + // 시간 기준은 -1 (항상 넘음) 이나 1e9 (절대 안 넘음) 로 고정해 실제 경과 시간에 기대지 않음. + private static ProbeResult EightSamples(int bytes, double slowMs, Func pageReads) + { + using var tmp = new TempDir(); + tmp.WriteRandom("a.bin", 1 << 20); + string path = tmp.Combine("a.bin"); + + return CacheProbe.Probe(Enumerable.Range(0, 8).Select(_ => (path, 0L, bytes)).ToList(), slowMs, pageReads: pageReads)!; + } + + [Fact] + public void SamplesOverTheTimeLimitWithFewPageReadsAreNotSlow() + { + ProbeResult r = EightSamples(1 << 20, -1, Pages(5)); + + Assert.Equal((8, 0), (r.Samples, r.Slow)); + Assert.False(r.Cold); + } + + [Fact] + public void SamplesOverTheTimeLimitWithPageReadsFittingTheirSizeAreSlowAndCold() + { + ProbeResult r = EightSamples(1 << 20, -1, Pages(256)); + + Assert.Equal((8, 8), (r.Samples, r.Slow)); + Assert.True(r.Cold); + } + + [Fact] + public void ManyPageReadsWithinTheTimeLimitAreNotSlow() + { + Assert.Equal(0, EightSamples(1 << 20, 1e9, Pages(256)).Slow); + } + + [Theory] + [InlineData(1 << 20, 63, 0)] // 1/4 (64 쪽) 바로 아래 + [InlineData(1 << 20, 64, 8)] // 1/4 + [InlineData(1 << 17, 15, 0)] // 128 KiB (32 쪽): 1/4 는 8 쪽이라 하한 16 쪽이 우선 + [InlineData(1 << 17, 16, 8)] + [InlineData(1 << 14, 3, 0)] // 16 KiB (4 쪽): 하한이 표본의 쪽 수로 낮아짐 + [InlineData(1 << 14, 4, 8)] + [InlineData(4096, 0, 0)] // 4 KiB (1 쪽) + [InlineData(4096, 1, 8)] + public void PageReadGrowthMustReachAQuarterOfTheSampleWithAFloor(int bytes, uint pages, int slow) + { + Assert.Equal(slow, EightSamples(bytes, -1, Pages(pages)).Slow); + } + + [Fact] + public void WithoutAPageReadCountTheTimeAloneDecides() + { + Assert.Equal(8, EightSamples(1 << 20, -1, () => null).Slow); + Assert.Equal(0, EightSamples(1 << 20, 1e9, () => null).Slow); + int calls = 0; + Assert.Equal(8, EightSamples(1 << 20, -1, () => calls++ % 2 == 0 ? 0u : null).Slow); // 읽은 뒤의 누계만 실패해도 시간만 + } + + [Fact] + public void AWrappedPageReadCountIsComparedWithUnsignedSubtraction() + { + Assert.Equal(8, EightSamples(1 << 20, -1, Pages(256, uint.MaxValue - 100)).Slow); + Assert.Equal(0, EightSamples(1 << 20, -1, Pages(5, uint.MaxValue - 2)).Slow); + } } diff --git a/docs/AUTO_MODE.ko.md b/docs/AUTO_MODE.ko.md index fc1b495..92dbba0 100644 --- a/docs/AUTO_MODE.ko.md +++ b/docs/AUTO_MODE.ko.md @@ -230,7 +230,7 @@ **재예열(예열 유지)** -- 존의 8곳(접근 순서 앞 4곳과 균등한 4곳)을 1 MiB씩 읽어 잽니다. 그중 2곳 이상이 2 ms 를 넘으면 파일 캐시에서 밀려난 것이므로, `min_free_ram_mib` 여유를 지키며 다시 예열합니다. +- 존의 8곳(접근 순서 앞 4곳과 균등한 4곳)을 1 MiB씩 읽어 잽니다. 그중 2곳 이상이 2 ms 를 넘으면 파일 캐시에서 밀려난 것이므로, `min_free_ram_mib` 여유를 지키며 다시 예열합니다. 느린 곳으로 세는 것은 읽는 시간이 기준을 넘고 그동안 시스템의 페이지 읽기 수도 표본 크기에 걸맞게(읽은 쪽 수의 4분의 1 이상, 최소 16쪽) 늘었을 때뿐입니다. 다른 작업이 CPU 를 쓰는 중에 캐시에 있는 표본이 느리게 읽혀 식었다고 오판하는 것을 줄이려는 것입니다. 페이지 읽기 수를 읽을 수 없으면 시간만으로 판정합니다. 설치본에서는 아직 확인하지 않았습니다. - 예열 예산 안에 드는 게임만, 예열 점수(최근 실행·시간대 습관) 순서로 합니다. 예산 밖 게임은 식어도 다시 예열하지 않고 사유를 로그에 남깁니다. - 실행 중인 게임이 하나 있다고 다른 게임의 점검을 멈추지 않고 대상별로 판단합니다. - [SOLAR](#solar-캐시-유지와-순서) 사다리가 목록 일부가 캐시에 없음을 발견했으면, 이 점검은 표본이 따뜻해도 그 게임을 다시 예열합니다(로그: `<게임>: rewarm check: part of the list is not in memory (found by the ladder), prewarming again (cooldown ignored)`). 캐시에 있는 부분은 디스크 시간이 거의 들지 않으므로 실제로는 빠진 부분만 읽힙니다. 게임마다 마지막 예열 시도로부터 `rewarm_minutes` 에 한 번을 넘지 않으며, 보충이 대기 중이거나 런처의 예열이 기다리는 동안에는 하지 않습니다. 자동 모드가 꺼져 있거나 `rewarm_minutes` 가 0 이면 하지 않습니다. diff --git a/docs/AUTO_MODE.md b/docs/AUTO_MODE.md index a60ee40..506b924 100644 --- a/docs/AUTO_MODE.md +++ b/docs/AUTO_MODE.md @@ -230,7 +230,7 @@ When the first real run records a trace, the plan made from it replaces the earl **Re-prewarm (keeping the prewarm)** -- Reads 1 MiB from each of 8 places in the zone (the first 4 in read order and 4 evenly spaced). If at least 2 of them take more than 2 ms, the data has probably been evicted from the file cache, so it prewarms again while keeping `min_free_ram_mib` free. +- Reads 1 MiB from each of 8 places in the zone (the first 4 in read order and 4 evenly spaced). If at least 2 of them take more than 2 ms, the data has probably been evicted from the file cache, so it prewarms again while keeping `min_free_ram_mib` free. A place counts as slow only if the read took longer than the limit and, during that read, the system's page-read count grew in proportion to the sample size (at least a quarter of the pages read, 16 pages at the least). This keeps a cached sample that was read slowly while another program was using the CPU from being taken for a cold one. If the count cannot be read, time alone decides. Not yet checked on an installed build. - Only games inside the prewarm budget are re-warmed, in prewarm-score order (recent play and time-of-day habits). A game outside the budget is not re-warmed after it cools, and the reason is logged. - One running game does not stop the check for other games; each target is judged separately. - If the [SOLAR](#solar-keeping-the-cache-in-order) ladder found part of the list missing from the cache, this check prewarms that game again even when its samples are warm (log: `: rewarm check: part of the list is not in memory (found by the ladder), prewarming again (cooldown ignored)`). Parts still in the cache cost almost no disk time, so in effect only the missing parts are read. It happens at most once per `rewarm_minutes` for each game, counted from the last prewarm attempt, and not while a top-up is pending or a launcher's prewarm is waiting. It does nothing when automatic mode is off or `rewarm_minutes` is 0. diff --git a/docs/FAQ.ko.md b/docs/FAQ.ko.md index a052272..d8ca1cf 100644 --- a/docs/FAQ.ko.md +++ b/docs/FAQ.ko.md @@ -28,7 +28,7 @@ `config.json` 에 `"memory": {"background_yield": false}` 를 넣으십시오. 설정 탭에는 스위치가 없습니다. 서비스가 5초 안에 파일을 다시 읽으며, 게임 세션 중에 껐으면 다음 30초 점검 때 우선순위를 되돌립니다. 이전 버전으로 되돌릴 때는 1.0.2 이하 버전이 `memory` 키를 거부하므로 먼저 지우십시오([설정](CONFIGURATION.ko.md)). **"캐시가 식는다"는 무슨 뜻인가요?** -예열한 데이터는 파일 캐시에 있습니다. 파일 캐시는 보통 RAM 이라서, 다른 프로그램이 메모리를 필요로 하거나 다른 파일을 읽으면 Windows 가 도로 가져갑니다. 게임 데이터의 일부를 가져가면 그 부분의 캐시가 식은 것이고, 그 부분을 읽으면 다시 HDD 로 갑니다. 재예열 점검은 8곳에서 1 MiB 씩 읽어 그중 2곳 이상이 2 ms 를 넘으면 캐시가 식었다고 봅니다. 시험 PC 에서 전체 예열이 끝난 뒤 처음 식었다고 판정한 점검까지 걸린 시간의 중앙값은 League of Legends 29.0분, Call of Duty 71.5분이었고(점검은 약 10분마다 돕니다), 2026-11-11 이후 점검의 47%(League of Legends)와 71%(Call of Duty)가 캐시가 식었거나 일부가 식었다고 판정했습니다. [SOLAR](SOLAR.ko.md)는 캐시를 메모리 안에서 다시 만져 더 오래 남게 합니다. 실제 플레이에서 도움이 되는지는 재지 않았습니다. +예열한 데이터는 파일 캐시에 있습니다. 파일 캐시는 보통 RAM 이라서, 다른 프로그램이 메모리를 필요로 하거나 다른 파일을 읽으면 Windows 가 도로 가져갑니다. 게임 데이터의 일부를 가져가면 그 부분의 캐시가 식은 것이고, 그 부분을 읽으면 다시 HDD 로 갑니다. 재예열 점검은 8곳에서 1 MiB 씩 읽어 그중 2곳 이상이 2 ms 를 넘으면 캐시가 식었다고 봅니다. 읽는 동안 시스템의 페이지 읽기 수도 표본 크기에 걸맞게 늘었을 때만 느린 곳으로 세므로, 다른 작업이 CPU 를 쓰는 중에 캐시에 있는 표본이 느리게 읽힌 것은 식었다고 보지 않습니다(설치본에서는 아직 확인하지 않았고, 아래 수치는 이 규칙 이전에 잰 값입니다). 시험 PC 에서 전체 예열이 끝난 뒤 처음 식었다고 판정한 점검까지 걸린 시간의 중앙값은 League of Legends 29.0분, Call of Duty 71.5분이었고(점검은 약 10분마다 돕니다), 2026-11-11 이후 점검의 47%(League of Legends)와 71%(Call of Duty)가 캐시가 식었거나 일부가 식었다고 판정했습니다. [SOLAR](SOLAR.ko.md)는 캐시를 메모리 안에서 다시 만져 더 오래 남게 합니다. 실제 플레이에서 도움이 되는지는 재지 않았습니다. **SOLAR 를 끄려면 어떻게 하나요?** `config.json` 에 `"memory": {"standby_ladder": false}` 를 넣으십시오. 설정 탭에는 스위치가 없습니다. 서비스가 5초 안에 파일을 다시 읽고 그때부터 캐시를 만지지 않으며, 예열 자체와 재예열 점검은 영향이 없습니다. 1.0.3 이하 버전으로 되돌릴 때는 그 버전들이 `memory.standby_ladder` 를 거부하므로 먼저 지우십시오([설정](CONFIGURATION.ko.md)). diff --git a/docs/FAQ.md b/docs/FAQ.md index 81c9446..856a63e 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -28,7 +28,7 @@ This is background memory yield. When a game session starts, AsterCache lowers t Add `"memory": {"background_yield": false}` to `config.json`. There is no switch for it in the Settings tab. The service re-reads the file within 5 seconds; if you turn it off during a game session, the priorities are restored at the next 30-second check. If you roll back to an older version, remove the `memory` key first, because versions 1.0.2 and earlier reject it ([Configuration](CONFIGURATION.md)). **What does "the cache goes cold" mean?** -Prewarmed data sits in the file cache, which is ordinary RAM that Windows takes back when other programs need memory or read other files. When part of a game's data has been taken back, the cache is cold there: the next read of it goes to the HDD again. The re-prewarm check reads 1 MiB at 8 places and calls the cache cold when at least 2 of them take more than 2 ms. On the test PC, the median time from a complete prewarm to the first cold check was 29.0 minutes for League of Legends and 71.5 minutes for Call of Duty (the checks run about every 10 minutes), and since 2026-11-11 47% and 71% of the checks found the cache cold or partly cold. [SOLAR](SOLAR.md) touches the cache again in memory to keep it longer; whether that helps in real play has not been measured. +Prewarmed data sits in the file cache, which is ordinary RAM that Windows takes back when other programs need memory or read other files. When part of a game's data has been taken back, the cache is cold there: the next read of it goes to the HDD again. The re-prewarm check reads 1 MiB at 8 places and calls the cache cold when at least 2 of them take more than 2 ms. A place counts as slow only if the system's page-read count also grew in proportion to the sample size during the read, so a cached sample that was read slowly while another program was using the CPU is not taken for a cold one (not yet checked on an installed build; the figures below were measured before this rule). On the test PC, the median time from a complete prewarm to the first cold check was 29.0 minutes for League of Legends and 71.5 minutes for Call of Duty (the checks run about every 10 minutes), and since 2026-11-11 47% and 71% of the checks found the cache cold or partly cold. [SOLAR](SOLAR.md) touches the cache again in memory to keep it longer; whether that helps in real play has not been measured. **How do I turn SOLAR off?** Add `"memory": {"standby_ladder": false}` to `config.json`. There is no switch for it in the Settings tab. The service re-reads the file within 5 seconds, and it then stops touching the cache; the prewarm itself and the re-prewarm check are not affected. If you roll back to version 1.0.3 or earlier, remove the key first, because those versions reject `memory.standby_ladder` ([Configuration](CONFIGURATION.md)). diff --git a/docs/SOLAR.ko.md b/docs/SOLAR.ko.md index b86d81f..3644e86 100644 --- a/docs/SOLAR.ko.md +++ b/docs/SOLAR.ko.md @@ -97,6 +97,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곳 표본 점검은 일부만 식은 캐시를 놓칠 수 있습니다.** 사다리는 목록 전체를 보므로, 표본이 따뜻해 보여도 사다리가 빠진 부분을 찾으면 재예열로 이어집니다. +- **느린 표본은 시스템의 페이지 읽기 수도 늘었을 때만 셉니다.** 읽는 시간이 기준을 넘고 그동안 시스템의 페이지 읽기 수도 표본 크기에 걸맞게(읽은 쪽 수의 4분의 1 이상, 최소 16쪽) 늘었을 때만 느린 곳으로 셉니다. 이전에는 다른 작업이 CPU 를 쓰는 중에 캐시에 있는 표본이 느리게 읽혀 식었다고 오판할 수 있었습니다. 페이지 읽기 수를 읽을 수 없으면 시간만으로 판정합니다. 다른 프로그램의 읽기가 드물게 몰려 표본과 겹치면 오판은 여전히 남습니다. 설치본에서는 아직 확인하지 않았습니다. - **다른 프로그램의 읽기가 디스크 읽기 수에 여전히 섞일 수 있고, 한도가 있습니다.** 페이지 읽기 누계는 시스템 전체 값이라 시간 상한은 짧은 몰림이 뷰 전체로 세어지는 것만 막습니다. 실제로 오래 걸린 뷰(느리거나 바쁜 디스크)와 몰림이 겹치면 그 시간이 허용하는 양까지는 더해질 수 있습니다. 가짜 멈춤(`requeue stopped by disk reads`, `ladder found data missing`)이 설치본에서 사라졌는지는 아직 확인하지 않았습니다. - **이 통계는 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 5c79051..1e144d6 100644 --- a/docs/SOLAR.md +++ b/docs/SOLAR.md @@ -97,6 +97,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. +- **A slow sample counts only when the system's page-read count grew too.** A place is counted as slow only if its read took longer than the limit and, during that read, the system's page-read count grew in proportion to the sample size (at least a quarter of the pages read, 16 pages at the least). Before this, a sample that was in the cache could read slowly while another program was using the CPU and be taken for a cold one. If the count cannot be read, time alone decides. A rare burst of other programs' reads that overlaps a sample can still cause a wrong call. Not yet checked on an installed build. - **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.