Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion csharp/src/AsterCache.Service/LoadBoundary.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 17 additions & 3 deletions csharp/src/AsterCache.Windows/CacheProbe.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<bool>? canIssue = null)
public static ProbeResult? Probe(IReadOnlyList<(string Path, long Offset, int Bytes)> samples, double slowMs = ColdThresholdMs, CancellationToken ct = default, Func<bool>? canIssue = null, Func<uint?>? pageReads = null)
{
pageReads ??= SystemPageReads.Query;
byte[] buf = new byte[SampleBytes];
double total = 0;
int ok = 0;
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
80 changes: 80 additions & 0 deletions csharp/tests/AsterCache.Tests/Windows/CacheProbeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<uint?> 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<uint?> 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);
}
}
2 changes: 1 addition & 1 deletion docs/AUTO_MODE.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 이면 하지 않습니다.
Expand Down
2 changes: 1 addition & 1 deletion docs/AUTO_MODE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<game>: 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.
Expand Down
2 changes: 1 addition & 1 deletion docs/FAQ.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
2 changes: 1 addition & 1 deletion docs/FAQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
Loading