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: 4 additions & 0 deletions Endpoints/EngineEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ public static void MapEngineEndpoints(this WebApplication app)
app.MapPost("/api/memory/purge", async (VramOrchestrator orchestrator, IGpuTelemetryProvider telemetryProvider, ISettingsService settingsService, CancellationToken ct) =>
{
var eviction = await orchestrator.PurgeAllCachesAsync(stopIdleEngines: true, ct);
System.Runtime.GCSettings.LargeObjectHeapCompactionMode = System.Runtime.GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect(2, GCCollectionMode.Aggressive, blocking: true, compacting: true);
GC.WaitForPendingFinalizers();
GC.Collect(2, GCCollectionMode.Aggressive, blocking: true, compacting: true);
return Results.Ok(new
{
status = "purged",
Expand Down
31 changes: 31 additions & 0 deletions LocalLLMServerManager.Tests/HardwareEndpointsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -306,4 +306,35 @@ public void GpuTelemetryProvider_GetSystemMemoryInfo_ReturnsValidHostMetrics()
Assert.True(snapshot.ProcessWorkingSetBytes > 0);
Assert.True(snapshot.NonPagedPoolBytes >= 0);
}

[Fact]
public async Task GpuTelemetryProvider_GetTelemetryAsync_ReturnsValidTelemetryAndCachesWithinTtl()
{
var provider = new LocalLLMServerManager.Services.GpuTelemetryProvider(TimeSpan.FromSeconds(3));
var first = await provider.GetTelemetryAsync();
var second = await provider.GetTelemetryAsync();

Assert.NotNull(first);
Assert.False(string.IsNullOrWhiteSpace(first.GpuName));
Assert.True(first.TotalVramMb > 0);
Assert.True(first.UsedVramMb >= 0);
Assert.True(first.FreeVramMb >= 0);
Assert.InRange(first.UtilizationPercent, 0.0, 100.0);
Assert.Same(first, second);
}

[Fact]
public async Task PostMemoryPurge_CompactsHeapAndReturnsPurgedStatusWithMemoryTelemetry()
{
var response = await _client.PostAsync("/api/memory/purge", null);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);

using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
var root = doc.RootElement;
Assert.Equal("purged", root.GetProperty("status").GetString());
Assert.True(root.TryGetProperty("eviction", out _));
Assert.True(root.TryGetProperty("memory", out var memory));
Assert.True(memory.GetProperty("totalRamBytes").GetInt64() > 0);
Assert.True(memory.GetProperty("processWorkingSetMb").GetDouble() > 0);
}
}
2 changes: 2 additions & 0 deletions LocalLLMServerManager.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
<FileVersion>4.1.0.0</FileVersion>
<ApplicationIcon>Assets\app-icon.ico</ApplicationIcon>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ServerGarbageCollection>false</ServerGarbageCollection>
<GarbageCollectionAdaptationMode>1</GarbageCollectionAdaptationMode>
</PropertyGroup>

<ItemGroup>
Expand Down
76 changes: 75 additions & 1 deletion Services/GpuTelemetryProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,37 @@
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;

namespace LocalLLMServerManager.Services;

public class GpuTelemetryProvider : IGpuTelemetryProvider
{
private readonly IAiEngineManager? _engineManager;
private readonly TimeSpan _cacheTtl = TimeSpan.FromSeconds(3);
private readonly object _cacheLock = new();
private (string GpuName, long TotalVramBytes, long UsedVramBytes)? _cachedGpuInfo;
private GpuTelemetryResult? _cachedTelemetry;
private DateTimeOffset _cacheExpiresAtUtc = DateTimeOffset.MinValue;

[ActivatorUtilitiesConstructor]
public GpuTelemetryProvider(IAiEngineManager? engineManager = null)
{
_engineManager = engineManager;
}

public GpuTelemetryProvider(IAiEngineManager? engineManager, TimeSpan cacheTtl)
{
_engineManager = engineManager;
_cacheTtl = cacheTtl;
}

public GpuTelemetryProvider(TimeSpan cacheTtl)
: this(null, cacheTtl)
{
}

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
private struct MEMORYSTATUSEX
{
Expand Down Expand Up @@ -160,15 +179,70 @@ private static long GetSafeProcessWorkingSet(Process? proc)
{
if (proc.HasExited) return 0;
proc.Refresh();
return proc.WorkingSet64;
return Math.Max(0, proc.WorkingSet64);
}
catch
{
return 0;
}
}

public Task<GpuTelemetryResult> GetTelemetryAsync()
{
var now = DateTimeOffset.UtcNow;
lock (_cacheLock)
{
if (_cachedTelemetry != null && now < _cacheExpiresAtUtc)
{
return Task.FromResult(_cachedTelemetry);
}
}

var (gpuName, totalBytes, usedBytes) = GetGpuInfo();
lock (_cacheLock)
{
if (_cachedTelemetry != null)
{
return Task.FromResult(_cachedTelemetry);
}

var totalMb = totalBytes / (1024 * 1024);
var usedMb = usedBytes / (1024 * 1024);
var freeMb = Math.Max(0, (totalBytes - usedBytes) / (1024 * 1024));
var utilPercent = totalBytes > 0 ? Math.Round((double)usedBytes / totalBytes * 100.0, 1) : 0.0;
_cachedTelemetry = new GpuTelemetryResult(gpuName, totalMb, usedMb, freeMb, utilPercent);
return Task.FromResult(_cachedTelemetry);
}
}

public (string GpuName, long TotalVramBytes, long UsedVramBytes) GetGpuInfo()
{
var now = DateTimeOffset.UtcNow;
lock (_cacheLock)
{
if (_cachedGpuInfo.HasValue && now < _cacheExpiresAtUtc)
{
return _cachedGpuInfo.Value;
}
}

var info = QueryGpuInfoCore();

lock (_cacheLock)
{
_cachedGpuInfo = info;
var totalMb = info.TotalVramBytes / (1024 * 1024);
var usedMb = info.UsedVramBytes / (1024 * 1024);
var freeMb = Math.Max(0, (info.TotalVramBytes - info.UsedVramBytes) / (1024 * 1024));
var utilPercent = info.TotalVramBytes > 0 ? Math.Round((double)info.UsedVramBytes / info.TotalVramBytes * 100.0, 1) : 0.0;
_cachedTelemetry = new GpuTelemetryResult(info.GpuName, totalMb, usedMb, freeMb, utilPercent);
_cacheExpiresAtUtc = DateTimeOffset.UtcNow.Add(_cacheTtl);
}

return info;
}

private (string GpuName, long TotalVramBytes, long UsedVramBytes) QueryGpuInfoCore()
{
try
{
Expand Down
24 changes: 24 additions & 0 deletions Services/MemoryLifecycleWatchdog.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

Expand All @@ -10,17 +11,30 @@ public sealed class MemoryLifecycleWatchdog : BackgroundService
{
private readonly VramOrchestrator _orchestrator;
private readonly ILogger<MemoryLifecycleWatchdog> _logger;
private readonly IGpuTelemetryProvider? _telemetryProvider;
private readonly TimeSpan _checkInterval;
private readonly TimeProvider _timeProvider;

public MemoryLifecycleWatchdog(
VramOrchestrator orchestrator,
ILogger<MemoryLifecycleWatchdog> logger,
TimeSpan? checkInterval,
TimeProvider? timeProvider = null)
: this(orchestrator, logger, null, checkInterval, timeProvider)
{
}

[ActivatorUtilitiesConstructor]
public MemoryLifecycleWatchdog(
VramOrchestrator orchestrator,
ILogger<MemoryLifecycleWatchdog> logger,
IGpuTelemetryProvider? telemetryProvider = null,
TimeSpan? checkInterval = null,
TimeProvider? timeProvider = null)
{
_orchestrator = orchestrator;
_logger = logger;
_telemetryProvider = telemetryProvider;
_checkInterval = checkInterval ?? TimeSpan.FromSeconds(30);
_timeProvider = timeProvider ?? TimeProvider.System;
}
Expand All @@ -35,6 +49,16 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
try
{
await _orchestrator.EvaluateCacheLifetimesAsync(stoppingToken);

var memInfo = (_telemetryProvider ?? new GpuTelemetryProvider()).GetSystemMemoryInfo();
if (memInfo.ProcessWorkingSetBytes > 512L * 1024 * 1024 ||
GC.GetTotalMemory(false) > 256L * 1024 * 1024 ||
memInfo.RamUsedPercent >= 75.0)
{
System.Runtime.GCSettings.LargeObjectHeapCompactionMode = System.Runtime.GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect(2, GCCollectionMode.Aggressive, blocking: true, compacting: true);
GC.WaitForPendingFinalizers();
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
Expand Down
Loading