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
6 changes: 6 additions & 0 deletions csharp/src/AsterCache.Service/LoadTiers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ public static class LoadTiers
{
private static readonly ConcurrentDictionary<string, (DateTime Written, long Length, LoadBoundary? Boundary)> Cache = new(StringComparer.OrdinalIgnoreCase);

private static int _generation;

/// 캐시가 새 경계로 바뀔 때마다 늘어남 (캐시에서만 찾는 예산 추정의 재사용 키).
internal static int Generation => Volatile.Read(ref _generation);

public static LoadBoundary? For(GameSettings game) => For(game, default);

// allowParse 가 거짓이면 이미 캐시에 있는 경계만 돌려줍니다 (없으면 null, 원시 트레이스를 파싱하지 않음).
Expand Down Expand Up @@ -59,6 +64,7 @@ public static class LoadTiers
return new FileInfo(p).Length;
}, cancellationToken));
Cache[raw] = (written, length, b);
_ = Interlocked.Increment(ref _generation);
return b;
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException or PyValueException)
Expand Down
1 change: 1 addition & 0 deletions csharp/src/AsterCache.Service/Prewarm.Games.cs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ public void SetGames(IReadOnlyList<GameSettings> games)
}
}

_estimates.Retain(live);
_rewarm.SetGames(games);
_pins.Reconcile(games);
bool budgetWaiting;
Expand Down
25 changes: 20 additions & 5 deletions csharp/src/AsterCache.Service/Prewarm.Memory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,16 +94,31 @@ internal static IReadOnlyList<GameSettings> OrderByValuePerByte(IReadOnlyList<Ga
return [.. list.Take(keep), .. list.Skip(keep).Select((g, n) => (G: g, V: 1.0 / (keep + n + 1) / (zoneMib(g) is > 0 and long z ? z : DefaultFullPrewarmMib))).OrderByDescending(x => x.V).Select(x => x.G)];
}

private long ZoneMib(GameSettings game) => ((_env.ZoneBytes(game) ?? 0) + (1 << 20) - 1) >> 20;
// estimate: 예산 계산의 추정으로만 쓰는 호출 (입력 파일·설정이 같고 MaxAge 안이면 이전 값 재사용). 읽을 범위·양을 정하는 호출은 false 로 매번 새로 읽음.
private long ZoneMib(GameSettings game, bool estimate = false) =>
(((estimate ? EstimatedZoneBytes(game) : _env.ZoneBytes(game)) ?? 0) + (1 << 20) - 1) >> 20;

private long ObservedMib(GameSettings game, long totalMib, bool allowSourceRead = true)
private long? EstimatedZoneBytes(GameSettings game) => Estimated("zone", game, false, () => _env.ZoneBytes(game), true);

// 추정의 재사용 키: 입력 파일 서명 + ZoneBytes·ObservedBytes 가 쓰는 게임 설정. 서명을 만들 수 없으면 (시험의 가짜 env 포함) null = 재사용 안 함.
private string? EstimateStampOf(GameSettings game, bool fromMemoryCaches) =>
_env.EstimateStamp?.Invoke(game, fromMemoryCaches) is { } files
? $"{files}|{game.Plan}|{game.Trace}|{game.OriginRoot}|{game.MaxStreamSet}|{game.MaxStreamMib}"
: null;

private long? Estimated(string kind, GameSettings game, bool fromMemoryCaches, Func<long?> compute, bool estimate) =>
_estimates.Get(kind, game.Name, estimate ? EstimateStampOf(game, fromMemoryCaches) : null, compute);

private long ObservedMib(GameSettings game, long totalMib, bool allowSourceRead = true, bool estimate = false)
{
if (totalMib <= 0)
{
return totalMib;
}

long? observedBytes = allowSourceRead ? _env.ObservedBytes(game) : _env.ObservedBytesIfCached(game);
long? observedBytes = allowSourceRead
? Estimated("observed", game, false, () => _env.ObservedBytes(game), estimate)
: Estimated("observed-cached", game, true, () => _env.ObservedBytesIfCached(game), estimate);
if (observedBytes is not { } bytes)
{
return totalMib;
Expand Down Expand Up @@ -195,7 +210,7 @@ internal IReadOnlyList<GameSettings> Warmed()
var observedMib = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
foreach (GameSettings g in ordered)
{
long total = ZoneMib(g);
long total = ZoneMib(g, estimate: true);
bool hasZoneList = total > 0;
long coldHeld;
bool pending;
Expand All @@ -219,7 +234,7 @@ internal IReadOnlyList<GameSettings> Warmed()
total = Math.Min(total, cap);
long observed = unknownPendingSize ? 0 : total <= 0 || !hasZoneList
? total
: ObservedMib(g, total, allowSourceRead: !DriveHasRunningGame(g)); // 같은 디스크에서 게임이 실행 중이면 출처를 읽지 않고, 출처를 모르면 전부 관찰된 것으로 셈
: ObservedMib(g, total, allowSourceRead: !DriveHasRunningGame(g), estimate: true); // 같은 디스크에서 게임이 실행 중이면 출처를 읽지 않고, 출처를 모르면 전부 관찰된 것으로 셈
totalMib[g.Name] = total;
observedMib[g.Name] = observed;
}
Expand Down
40 changes: 37 additions & 3 deletions csharp/src/AsterCache.Service/Prewarm.Supplement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -470,8 +470,11 @@ internal bool Supplement(GameSettings game, CancellationToken ct, long? allocate
return false;
}

long? zoneBytes = _env.ZoneBytes(game);
p = RaiseHotMib(game.Name, p, zoneBytes); // 기억한 목록 크기가 현재 목록보다 작으면 올림: 첫 관문·창 상한·표시가 현재 목록을 따름
// 읽을지 기다릴지 정하는 판정까지는 예산 계산과 같은 추정 (읽지 않고 돌아가는 틱은 계획을 해석하지 않음). 추정으로는 지역 값만 고치고 지속되는 상태는 새로 읽은 값으로만 고침.
string? zoneStamp = EstimateStampOf(game, false);
long? zoneBytes = _estimates.Get("zone", game.Name, zoneStamp, () => _env.ZoneBytes(game));
(long WarmedMib, long HotMib) remembered = p;
p = (p.WarmedMib, Math.Max(p.HotMib, ZoneListMib(zoneBytes))); // 기억한 목록 크기가 현재 목록보다 작으면 올림 (_partial 은 읽기 직전에)
long avail = _env.AvailableRamMib();
long physCap = PhysCapMib();
long budgetCap = allocatedMib is { } allocated ? Math.Min(p.HotMib, allocated) : p.HotMib;
Expand All @@ -490,6 +493,12 @@ internal bool Supplement(GameSettings game, CancellationToken ct, long? allocate
long plannedCapMib = partialCap ?? p.HotMib;
string? inputStamp = PrewarmRun.InputStamp(game);
long progressBefore = SupplementProgressBytes(game, p, inputStamp);
SupplementRetry? retryBefore;
lock (_gate)
{
_supplementRetry.TryGetValue(game.Name, out retryBefore);
}

if (SupplementDeferred(game, plannedCapMib, p.HotMib, progressBefore, inputStamp))
{
return false;
Expand Down Expand Up @@ -557,6 +566,29 @@ internal bool Supplement(GameSettings game, CancellationToken ct, long? allocate
return false;
}

long? freshZone = _env.ZoneBytes(game); // 읽기로 정해진 뒤에는 계획을 새로 읽음
if (ZoneListMib(freshZone) != ZoneListMib(zoneBytes))
{
// 추정이 낡았음: 새 값을 추정에 넣고 이번 회는 읽지 않음 (재시도 기한 상태도 되돌림), 다음 보충이 새 값으로 판정
_estimates.Set("zone", game.Name, zoneStamp, freshZone);
lock (_gate)
{
if (retryBefore is null)
{
_supplementRetry.Remove(game.Name);
}
else
{
_supplementRetry[game.Name] = retryBefore;
}
}

return false;
}

zoneBytes = freshZone;
p = RaiseHotMib(game.Name, remembered, zoneBytes); // 기억한 목록 크기가 현재 목록보다 작으면 올림: 창 상한·표시가 현재 목록을 따름

using PrewarmRun run = PrewarmRun.Begin();
long? finalReadBytes = ReadSetBytes(warm, partial: windowMib is not null || skipBytes > 0);
avail = _env.AvailableRamMib();
Expand Down Expand Up @@ -883,10 +915,12 @@ private void LogSupplementWaitsForMemory(string game, long fitMib, long warmedMi
_log.Info($"{game}: supplement waits for memory (read {warmedMib} MiB, room for {fitMib} MiB)");
}

private static long ZoneListMib(long? zoneBytes) => zoneBytes is > 0 ? (zoneBytes.Value + (1 << 20) - 1) >> 20 : 0;

// 기억한 목록 크기 (HotMib) 를 현재 목록 (zoneBytes, MiB 올림) 이 더 크면 올려 돌려주고 _partial·_memoryPartial 도 같게 고침. 늘어날 때만: 줄이면 끝난 것으로 잘못 칠 수 있음.
private (long WarmedMib, long HotMib) RaiseHotMib(string game, (long WarmedMib, long HotMib) p, long? zoneBytes)
{
long listMib = zoneBytes is > 0 ? (zoneBytes.Value + (1 << 20) - 1) >> 20 : 0;
long listMib = ZoneListMib(zoneBytes);
if (listMib <= p.HotMib)
{
return p;
Expand Down
2 changes: 2 additions & 0 deletions csharp/src/AsterCache.Service/Prewarm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public sealed partial class Prewarmer
private readonly ILog _log;
private readonly string? _statusPath;
private readonly Func<DateTimeOffset> _now;
private readonly WarmEstimateCache _estimates; // 예산 계산 (WarmBudget) 의 게임별 추정 재사용, 읽을 범위를 정하는 호출은 거치지 않음
private readonly object _gate = new(); // 짧은 메모리 상태만 보호: 스트림이 도는 동안에도 LastWarmed·Warmed 가 막히지 않게
private readonly object _run = new(); // 예열을 한 번에 하나로
private readonly Dictionary<string, int> _activeWork = new(StringComparer.OrdinalIgnoreCase); // 설정 재적재 때 진행 중인 게임 상태를 보존, _gate 로 보호
Expand Down Expand Up @@ -123,6 +124,7 @@ public Prewarmer(IReadOnlyList<GameSettings> games, IGameStreamer streamer, Prew
_statusPath = statusPath;
_now = now ?? (() => DateTimeOffset.UtcNow);
_coordinator = coordinator;
_estimates = new WarmEstimateCache(_now);
_pins = new PinKeeper(env, log, _now) { Coordinator = coordinator };
_solar = new Solar(env, log, _now);
_rewarm = new Rewarmer(this, env, log, _now);
Expand Down
5 changes: 5 additions & 0 deletions csharp/src/AsterCache.Service/PrewarmEnv.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ public sealed class PrewarmEnv
/// 게임이 실행 중일 때는 이미 캐시에 있는 출처 정보만 사용합니다 (원시 트레이스 파일을 새로 읽지 않음).
public Func<GameSettings, long?> ObservedBytesIfCached { get; init; } = _ => null;

/// ZoneBytes·ObservedBytes 가 읽는 입력 파일들의 서명 (두 번째 인자: 캐시에서만 출처를 찾는 ObservedBytesIfCached 용인가), 만들 수 없으면 null.
/// 예산 계산 (WarmBudget) 의 추정 재사용 키입니다. 시험 기본값은 null (재사용 안 함).
public Func<GameSettings, bool, string?>? EstimateStamp { get; init; }

public required Func<GameSettings, bool> TraceExists { get; init; }

/// 계획을 만들 때의 유효 존 상한 (바이트), 모르면 null. 틈 메우기 예산의 상한이며 현재 존 크기와 무관합니다.
Expand Down Expand Up @@ -291,6 +295,7 @@ public static PrewarmEnv Create(OrbitCalibration? orbitCalibration = null, Orbit
ZoneBytes = g => PrefetchStreamer.WarmBytes(g) is { } z ? Math.Min(z, g.StreamCapBytes(z)) : null,
ObservedBytes = PrefetchStreamer.ObservedBytes,
ObservedBytesIfCached = g => PrefetchStreamer.ObservedBytes(g, allowSourceRead: false),
EstimateStamp = PrefetchStreamer.EstimateStamp,
TraceExists = g => File.Exists(g.Trace),
EffectiveZoneBytes = EffectiveZoneFromPlan,
};
Expand Down
27 changes: 25 additions & 2 deletions csharp/src/AsterCache.Service/Streaming.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ private sealed record TraceSourceCacheEntry(long Length, long LastWriteTicks, IR
/// 시험용: 원시 트레이스를 실제로 파싱한 횟수 (경로별).
internal static readonly ConcurrentDictionary<string, int> SourceTraceParses = new(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, TraceSourceCacheEntry> TraceSourceCache = new(StringComparer.OrdinalIgnoreCase);
private static int _sourceCacheGeneration; // TraceSourceCache 가 바뀔 때마다 늘림 (캐시에서만 찾는 예산 추정의 재사용 키)

internal static void ForgetRemovedGames(IEnumerable<GameSettings> games)
{
Expand All @@ -110,6 +111,8 @@ internal static void ForgetRemovedGames(IEnumerable<GameSettings> games)
{
_ = TraceSourceCache.Remove(path + "|" + game.OriginRoot);
}

_ = Interlocked.Increment(ref _sourceCacheGeneration);
}

foreach (string path in paths)
Expand Down Expand Up @@ -242,6 +245,22 @@ private static long HeadBytesOf(GameSettings game, CancellationToken cancellatio
}
}

// 예산 추정 (WarmBytes·ObservedBytes) 이 읽는 입력의 (존재, 길이, 수정 시각): 목록의 입력 (PrewarmRun.InputStamp) 에 원시·boot 트레이스를 더함. 메타데이터 조회뿐이며 만들 수 없으면 null.
// ZoneBytes·LoadRows·LoadSourceTraceRows·LoadTiers.For 의 입력이 바뀌면 여기도 고칠 것.
// fromMemoryCaches: 출처를 메모리 캐시에서만 찾는 추정은 그 캐시의 세대도 입력이라 서명에 더함.
internal static string? EstimateStamp(GameSettings game, bool fromMemoryCaches)
{
string? files = PrewarmRun.InputStamp(game);
string? traces = PrewarmRun.Stamp(() => [AutoTracer.RawTracePath(game), AutoTracer.BootTracePath(game)]);
if (files is null || traces is null)
{
return null;
}

string stamp = $"{files}|{traces}";
return fromMemoryCaches ? $"{stamp}|{Volatile.Read(ref _sourceCacheGeneration)}:{LoadTiers.Generation}" : stamp;
}

/// 접근 순서 앞쪽 HeadBytesOf 만큼만 읽습니다 (스케줄러가 앞에 둔 부분과 같음).
public void StreamHead(GameSettings game, CancellationToken stop) => StreamHead(game, stop, null);

Expand Down Expand Up @@ -741,6 +760,7 @@ private static List<TraceEvent> LoadRows(GameSettings game, bool withWarmOnly, o
lock (TraceSourceCacheLock)
{
TraceSourceCache[key] = new TraceSourceCacheEntry(length, written, rows);
_ = Interlocked.Increment(ref _sourceCacheGeneration);
}

return rows;
Expand Down Expand Up @@ -1017,11 +1037,14 @@ internal static void NoteCalibrationCapped()
}

// 목록의 입력 (계획·트레이스·예열 전용 옆 파일·가변 표지) 의 (존재, 길이, 수정 시각). 메타데이터 조회뿐이며 읽지 못하면 null (재사용 안 함).
internal static string? InputStamp(GameSettings game)
internal static string? InputStamp(GameSettings game) =>
Stamp(() => [game.Plan, game.Trace, PlanOutput.WarmOnlyPath(game.Trace), AutoTracer.VolatilePath(game)]);

internal static string? Stamp(Func<string[]> paths)
{
try
{
return string.Join('|', new[] { game.Plan, game.Trace, PlanOutput.WarmOnlyPath(game.Trace), AutoTracer.VolatilePath(game) }.Select(path =>
return string.Join('|', paths().Select(path =>
{
var info = new FileInfo(path);
return info.Exists ? $"{info.Length}:{info.LastWriteTimeUtc.Ticks}" : "-";
Expand Down
79 changes: 79 additions & 0 deletions csharp/src/AsterCache.Service/WarmEstimateCache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
namespace AsterCache.Service;

/// 예산 계산 (WarmBudget) 이 쓰는 게임별 추정값 (전체 크기, 관찰량) 을 입력 서명이 같고 MaxAge 안이면 다시 계산하지 않고 돌려줍니다.
/// 값 (모름 = null 포함) 과 서명만 보관합니다: 계획·목록 해석 결과는 붙잡지 않음. 예열이 실제로 읽을 범위를 정하는 호출은 이 캐시를 거치지 않습니다.
internal sealed class WarmEstimateCache(Func<DateTimeOffset> now)
{
// 입력 파일이 그대로여도 게임 파일 크기 (게임 갱신) 는 서명에 없어서, 이 시간 안에 반영되게 합니다.
internal static readonly TimeSpan MaxAge = TimeSpan.FromMinutes(10);

private sealed record Entry(string Stamp, DateTimeOffset At, long? Value);

private readonly object _lock = new();
private readonly Dictionary<string, Entry> _entries = new(StringComparer.OrdinalIgnoreCase);

/// stamp 가 null 이면 (서명을 만들 수 없음) 저장하지 않고 compute 를 그대로 부릅니다. compute 는 잠금 밖에서 돕니다.
public long? Get(string kind, string game, string? stamp, Func<long?> compute)
{
if (stamp is null)
{
return compute();
}

string key = kind + "|" + game;
DateTimeOffset at = now();
lock (_lock)
{
if (_entries.TryGetValue(key, out Entry? hit) && hit.Stamp == stamp && at >= hit.At && at - hit.At < MaxAge)
{
return hit.Value;
}
}

long? value = compute();
lock (_lock)
{
_entries[key] = new Entry(stamp, at, value);
}

return value;
}

/// 새로 읽은 값을 넣습니다. stamp 는 읽기 전에 잡은 것 (읽는 동안 입력이 바뀌면 다음에 다시 계산됨), null 이면 넣지 않음.
public void Set(string kind, string game, string? stamp, long? value)
{
if (stamp is null)
{
return;
}

DateTimeOffset at = now();
lock (_lock)
{
_entries[kind + "|" + game] = new Entry(stamp, at, value);
}
}

/// 목록에 없는 게임의 항목을 지웁니다 (games 는 대소문자 무시 집합) (항목 수가 등록 게임 수를 넘지 않게).
public void Retain(IReadOnlySet<string> games)
{
lock (_lock)
{
foreach (string key in _entries.Keys.Where(k => !games.Contains(k[(k.IndexOf('|') + 1)..])).ToList())
{
_ = _entries.Remove(key);
}
}
}

internal int Count
{
get
{
lock (_lock)
{
return _entries.Count;
}
}
}
}
Loading