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
4 changes: 2 additions & 2 deletions csharp/src/AsterCache.Service/Rewarm.Ladder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand All @@ -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");
}
Expand Down
64 changes: 56 additions & 8 deletions csharp/src/AsterCache.Service/Solar.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ internal sealed class Solar(PrewarmEnv env, ILog log, Func<DateTimeOffset> now)
private readonly object _lock = new();
private readonly Dictionary<string, LadderMark> _marks = new(StringComparer.OrdinalIgnoreCase); // 마지막으로 만든 사다리의 표식, 메모리에만 둠 (서비스를 다시 시작하면 없음), _lock 으로 보호
private readonly HashSet<string> _missing = new(StringComparer.OrdinalIgnoreCase); // 사다리가 목록의 일부가 캐시에 없음을 발견한 게임 (재예열 점검이 다시 채울 때까지), 메모리에만 둠, _lock 으로 보호
private readonly HashSet<string> _untouchable = new(StringComparer.OrdinalIgnoreCase); // 목록이 전부 스파스 파일이라 만질 것이 없었던 게임 (다음 예열 스트림 뒤 사다리가 다시 판정할 때까지 재예열 점검이 사다리를 다시 시도하지 않음), 메모리에만 둠, _lock 으로 보호

public bool Enabled => env.StandbyLadderEnabled();

Expand All @@ -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)
{
Expand All @@ -54,6 +64,7 @@ public void Prune(IReadOnlySet<string> live)
}

_ = _missing.RemoveWhere(k => !live.Contains(k));
_ = _untouchable.RemoveWhere(k => !live.Contains(k));
}
}

Expand All @@ -75,12 +86,22 @@ public void Prune(IReadOnlySet<string> 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))
Expand All @@ -89,7 +110,7 @@ public void Prune(IReadOnlySet<string> 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;
}

Expand All @@ -116,11 +137,19 @@ public void Prune(IReadOnlySet<string> 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))
Expand All @@ -129,7 +158,7 @@ public void Prune(IReadOnlySet<string> 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;
}

Expand Down Expand Up @@ -168,16 +197,31 @@ private void SetMark(string game, IReadOnlyList<ReadOp> 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)
{
bool diskSeen = r.DiskBytes > 0;
lock (_lock)
{
_ = _marks.Remove(game);
_ = _untouchable.Remove(game); // 디스크 읽기를 만났으면 만질 것이 있음
if (diskSeen)
{
_ = _missing.Add(game);
Expand Down Expand Up @@ -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<ReadOp> 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);
}
15 changes: 9 additions & 6 deletions csharp/src/AsterCache.Service/StreamRequeue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 의 만지기, 디스크 읽기 없음이 전제).
Expand All @@ -30,7 +30,9 @@ internal sealed class StreamRequeue(Func<IReadOnlyList<ReadOp>, 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<ReadOp> ranges, CancellationToken stop) => Run(ranges, stop, skipped: false);
Expand All @@ -50,6 +52,7 @@ private void Run(IReadOnlyList<ReadOp> 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)
{
Expand Down
5 changes: 4 additions & 1 deletion csharp/src/AsterCache.Windows/FileGuards.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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: ";
Expand Down
45 changes: 40 additions & 5 deletions csharp/src/AsterCache.Windows/StandbyTouch.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;
using AsterCache.Core.Prefetch;
Expand All @@ -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;
Expand Down Expand Up @@ -64,7 +67,10 @@ private sealed class Tally
public int Errors;
public double SlowMs;
public long Disk;
public long Skipped;
public required Func<uint?> PageReads;
public required Func<string, uint> Attributes;
public readonly Dictionary<string, bool> Sparse = new(StringComparer.OrdinalIgnoreCase); // 이 Run 에서 이미 속성을 본 파일
public TouchEnd End = TouchEnd.Completed;
}

Expand All @@ -82,10 +88,13 @@ public static long DiskBytesOf(uint pagesRead, long viewBytes, double ms)
public static TouchResult Run(IReadOnlyList<ReadOp> ranges, CancellationToken ct) => Run(ranges, ct, SystemPageReads.Query);

/// pageReads: 시스템 전체의 디스크에서 읽어 들인 쪽 수 누계 (읽을 수 없으면 null, 시험은 가짜로 바꿈).
public static TouchResult Run(IReadOnlyList<ReadOp> ranges, CancellationToken ct, Func<uint?> pageReads)
public static TouchResult Run(IReadOnlyList<ReadOp> ranges, CancellationToken ct, Func<uint?> pageReads) => Run(ranges, ct, pageReads, FileGuards.FileAttributes);

/// attributes: 파일 속성 조회 (예외를 던지면 스파스로 보지 않고 열기를 시도함, 시험은 가짜로 바꿈).
public static TouchResult Run(IReadOnlyList<ReadOp> ranges, CancellationToken ct, Func<uint?> pageReads, Func<string, uint> attributes)
{
long started = Stopwatch.GetTimestamp();
var tally = new Tally { PageReads = pageReads };
var tally = new Tally { PageReads = pageReads, Attributes = attributes };
var sections = new Dictionary<string, Section?>(StringComparer.OrdinalIgnoreCase);
byte* buffer = (byte*)NativeMemory.Alloc(PieceBytes);
try
Expand Down Expand Up @@ -115,11 +124,17 @@ public static TouchResult Run(IReadOnlyList<ReadOp> 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<string, Section?> 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)
Expand Down Expand Up @@ -176,6 +191,26 @@ private static void TouchRange(ReadOp range, Dictionary<string, Section?> 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)
{
Expand Down
Loading