diff --git a/.github/workflows/sync-schema.yaml b/.github/workflows/sync-schema.yaml index 07f5d76a3..89891735b 100644 --- a/.github/workflows/sync-schema.yaml +++ b/.github/workflows/sync-schema.yaml @@ -2,7 +2,7 @@ name: Synchronize - Schema on: schedule: - - cron: '0 8 * * *' + - cron: "0 8 * * *" workflow_dispatch: jobs: @@ -20,7 +20,9 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0.x' + dotnet-version: "8.0.x" + + - uses: denoland/setup-deno@v2 - name: Install lftp run: sudo apt-get update && sudo apt-get install -y lftp @@ -37,9 +39,9 @@ jobs: mkdir -p ~/.lftp echo "set sftp:auto-confirm yes" >> ~/.lftp/rc echo "set ssl:verify-certificate no" >> ~/.lftp/rc - chmod +x eng/update-schema.sh + chmod +x eng/update-schema.ts chmod +x eng/rcon - ./eng/update-schema.sh + ./eng/update-schema.ts - name: Get patch version id: version @@ -68,12 +70,12 @@ jobs: title: "chore: Update Schema Definitions to ${{ steps.version.outputs.patch_version }}" body: | This PR was automatically generated by the schema update workflow. - + ## Changes - Updated schema definitions from the game server - Regenerated schema code using `CounterStrikeSharp.SchemaGen` - Patch Version: `${{ steps.version.outputs.patch_version }}` - + --- *This is an automated PR. Please review the changes before merging.* branch: chore/automated/schema-update @@ -81,4 +83,4 @@ jobs: - name: No changes detected if: steps.changes.outputs.has_changes == 'false' - run: echo "::notice::No schema changes detected. No PR will be created." \ No newline at end of file + run: echo "::notice::No schema changes detected. No PR will be created." diff --git a/eng/run-benchmarks.ts b/eng/run-benchmarks.ts new file mode 100755 index 000000000..05efd1de9 --- /dev/null +++ b/eng/run-benchmarks.ts @@ -0,0 +1,162 @@ +#!/usr/bin/env -S deno run --allow-run --allow-read --allow-env --allow-net --allow-write + +import $ from "https://deno.land/x/dax@0.39.2/mod.ts"; +import { load } from "https://deno.land/std@0.224.0/dotenv/mod.ts"; +import { z } from "https://deno.land/x/zod@v3.22.4/mod.ts"; + +const env = { ...Deno.env.toObject(), ...await load({ export: true }) }; + +const config = z.object({ + GS_HOST: z.string().min(1), + GS_PORT: z.string().min(1), + GS_PASS: z.string().min(1), + SFTP_HOST: z.string().min(1), + SFTP_USER: z.string().min(1), + SFTP_PASS: z.string().min(1), + GS_PLUGIN_DIR: z.string().default("/game/csgo/addons/counterstrikesharp/plugins/NativeTestsPlugin"), + GS_API_DIR: z.string().default("/game/csgo/addons/counterstrikesharp/api"), + GS_NATIVE_DIR: z.string().default("/game/csgo/addons/counterstrikesharp/bin/linuxsteamrt64"), + PTERO_URL: z.string().min(1), + PTERO_API_KEY: z.string().min(1), + PTERO_SERVER_ID: z.string().min(1), + GS_RESTART_TIMEOUT: z.string().default("120"), +}).parse(env); + +const HERE = $.path(import.meta).parentOrThrow(); +const ROOT = HERE.parentOrThrow(); + +const paths = { + apiProject: ROOT.join("managed/CounterStrikeSharp.API"), + apiBuild: ROOT.join("managed/CounterStrikeSharp.API/bin/Release/net8.0"), + testProject: ROOT.join("managed/CounterStrikeSharp.Tests.Native/NativeTestsPlugin.csproj"), + testBuild: ROOT.join("managed/CounterStrikeSharp.Tests.Native/bin/Debug/net8.0"), + nativeSo: ROOT.join("build/addons/counterstrikesharp/bin/linuxsteamrt64/counterstrikesharp.so"), + results: ROOT.join("TestResults/Benchmarks"), +}; + +const rcon = `${HERE}/rcon`; +const remoteJson = `${config.GS_PLUGIN_DIR}/benchmark-results.json`; +const remoteMd = `${config.GS_PLUGIN_DIR}/benchmark-results.md`; + +async function poll(timeoutSec: number, intervalMs: number, label: string, check: () => Promise) { + const start = Date.now(); + while (Date.now() - start < timeoutSec * 1000) { + try { + await check(); + return; + } catch { + const elapsed = Math.round((Date.now() - start) / 1000); + $.logLight(` ${label} (${elapsed}s elapsed)...`); + } + await $.sleep(intervalMs); + } + throw new Error(`${label}: timed out after ${timeoutSec}s`); +} + +// ── Build ─────────────────────────────────────────────────────────────── + +$.logStep("Building API..."); +await $`dotnet build -c Release`.cwd(paths.apiProject.toString()); + +$.logStep("Building NativeTestsPlugin..."); +await $`dotnet build ${paths.testProject} -c Debug`; + +// ── Upload ────────────────────────────────────────────────────────────── + +$.logStep("Uploading API..."); +await $`lftp -u ${config.SFTP_USER},${config.SFTP_PASS} ${config.SFTP_HOST} -e ${`set xfer:clobber on; mkdir -p ${config.GS_API_DIR}; mirror -R --delete ${paths.apiBuild} ${config.GS_API_DIR}; bye`}`.quiet(); + +if (await Deno.stat(paths.nativeSo.toString()).then(() => true).catch(() => false)) { + $.logStep("Uploading native .so..."); + await $`lftp -u ${config.SFTP_USER},${config.SFTP_PASS} ${config.SFTP_HOST} -e ${`set xfer:clobber on; mkdir -p ${config.GS_NATIVE_DIR}; put ${paths.nativeSo} -o ${config.GS_NATIVE_DIR}/counterstrikesharp.so; bye`}`.quiet(); +} + +$.logStep("Uploading NativeTestsPlugin..."); +await $`lftp -u ${config.SFTP_USER},${config.SFTP_PASS} ${config.SFTP_HOST} -e ${`set xfer:clobber on; mkdir -p ${config.GS_PLUGIN_DIR}; mirror -R --delete ${paths.testBuild} ${config.GS_PLUGIN_DIR}; bye`}`.quiet(); + +// ── Restart server ────────────────────────────────────────────────────── + +const pteroHeaders = { + "Authorization": `Bearer ${config.PTERO_API_KEY}`, + "Content-Type": "application/json", + "Accept": "application/json", +}; + +$.logStep("Restarting game server..."); +const resp = await fetch(`${config.PTERO_URL}/api/client/servers/${config.PTERO_SERVER_ID}/power`, { + method: "POST", + headers: pteroHeaders, + body: JSON.stringify({ signal: "restart" }), +}); +if (!resp.ok) { + $.logError(`Restart failed (${resp.status}): ${await resp.text()}`); + Deno.exit(1); +} + +await $.sleep(10_000); + +const restartTimeout = parseInt(config.GS_RESTART_TIMEOUT, 10); +await poll(restartTimeout, 5_000, "Server not ready yet", async () => { + const out = await $`${rcon} -a ${config.GS_HOST}:${config.GS_PORT} -p ${config.GS_PASS} "status"`.text(); + if (!out) throw new Error(); +}); + +$.logStep("Server is back online."); +await $.sleep(5_000); + +// ── Run benchmarks ────────────────────────────────────────────────────── + +$.logStep("Loading plugin..."); +try { + await $`${rcon} -a ${config.GS_HOST}:${config.GS_PORT} -p ${config.GS_PASS} "css_plugins load NativeTestsPlugin"`.text(); +} catch { + await $`${rcon} -a ${config.GS_HOST}:${config.GS_PORT} -p ${config.GS_PASS} "css_plugins reload NativeTestsPlugin"`.text(); +} + +// Delete stale results so we can detect when new ones land +$.logStep("Clearing old results..."); +try { + await $`lftp -u ${config.SFTP_USER},${config.SFTP_PASS} ${config.SFTP_HOST} -e ${`rm -f ${remoteJson}; rm -f ${remoteMd}; bye`}`.quiet(); +} catch { /* may not exist */ } + +$.logStep("Running: css_itest benchmark"); +console.log(await $`${rcon} -a ${config.GS_HOST}:${config.GS_PORT} -p ${config.GS_PASS} -T 120s "css_itest benchmark"`.text()); + +// Async benchmarks (entity creation) span multiple frames, so the RCON +// command returns before results are written. Poll until the file appears. +await poll(300, 5_000, "Waiting for results", async () => { + await $`lftp -u ${config.SFTP_USER},${config.SFTP_PASS} ${config.SFTP_HOST} -e ${`cat ${remoteJson}; bye`}`.quiet(); +}); + +await $.sleep(2_000); // let the .md flush too + +// ── Download results ──────────────────────────────────────────────────── + +$.logStep("Downloading results..."); +await Deno.mkdir(paths.results.toString(), { recursive: true }); + +const localJson = paths.results.join("benchmark-results.json").toString(); +const localMd = paths.results.join("benchmark-results.md").toString(); + +await $`lftp -u ${config.SFTP_USER},${config.SFTP_PASS} ${config.SFTP_HOST} -e ${`set xfer:clobber on; get ${remoteJson} -o ${localJson}; get ${remoteMd} -o ${localMd}; bye`}`.quiet(); + +// Stamp git metadata into the results (only available on the build machine) +const gitBranch = (await $`git -C ${ROOT} rev-parse --abbrev-ref HEAD`.text()).trim(); +const gitCommit = (await $`git -C ${ROOT} rev-parse --short HEAD`.text()).trim(); + +const report = JSON.parse(await Deno.readTextFile(localJson)); +report.gitBranch = gitBranch; +report.gitCommit = gitCommit; +await Deno.writeTextFile(localJson, JSON.stringify(report) + "\n"); + +// Prepend git info to the markdown too +let md = await Deno.readTextFile(localMd); +md = md.replace( + "# Benchmark Results\n", + `# Benchmark Results\n\n- **Branch:** ${gitBranch}\n- **Commit:** ${gitCommit}\n`, +); +await Deno.writeTextFile(localMd, md); + +$.logLight(` JSON: ${localJson}`); +$.logLight(` MD: ${localMd}`); +console.log("\n" + md); diff --git a/eng/update-schema.ts b/eng/update-schema.ts new file mode 100755 index 000000000..bb40e18ad --- /dev/null +++ b/eng/update-schema.ts @@ -0,0 +1,40 @@ +#!/usr/bin/env -S deno run --allow-run --allow-read --allow-env --allow-net + +import $ from "https://deno.land/x/dax@0.39.2/mod.ts"; +import { load } from "https://deno.land/std@0.224.0/dotenv/mod.ts"; +import { z } from "https://deno.land/x/zod@v3.22.4/mod.ts"; + +const env = { ...Deno.env.toObject(), ...await load({ export: true }) }; + +const config = z.object({ + // Game server RCON details + GS_HOST: z.string().min(1, "GS_HOST env var is required"), + GS_PORT: z.string().min(1, "GS_PORT env var is required"), + GS_PASS: z.string().min(1, "GS_PASS env var is required"), + // SFTP connection details + SFTP_HOST: z.string().min(1, "SFTP_HOST env var is required"), + SFTP_USER: z.string().min(1, "SFTP_USER env var is required"), + SFTP_PASS: z.string().min(1, "SFTP_PASS env var is required"), +}).parse(env); + +const HERE = $.path(import.meta).parentOrThrow(); + +$.logStep("Dumping schema from game server via RCON..."); +const output = await $`${HERE}/rcon -a ${config.GS_HOST}:${config.GS_PORT} -p ${config.GS_PASS} "dump_schema all"`.text(); + +// Extract the file path from RCON output +const match = output.match(/Wrote file output to (.+)/); +if (!match) { + throw new Error("Could not find schema output path in RCON response"); +} +const filepath = match[1].trim(); +const trimmedPath = filepath.replace(/^\/home\/container/, ""); + +$.logStep("Downloading schema file from game server via SFTP..."); +const schemaOutput = `${HERE}/../managed/CounterStrikeSharp.SchemaGen/Schema/server.json`; +await $`lftp -u ${config.SFTP_USER},${config.SFTP_PASS} ${config.SFTP_HOST} -e ${"set xfer:clobber on; get " + trimmedPath + " -o " + schemaOutput + "; bye"}`.quiet(); + +const schemaGenProject = `${HERE}/../managed/CounterStrikeSharp.SchemaGen/CounterStrikeSharp.SchemaGen.csproj`; +const generatedSchemaDir = `${HERE}/../managed/CounterStrikeSharp.API/Generated/Schema`; +$.logStep("Generating C# schema classes..."); +await $`dotnet run --project ${schemaGenProject} -- ${generatedSchemaDir}`; diff --git a/managed/CounterStrikeSharp.Tests.Native/NativeTestsPlugin.cs b/managed/CounterStrikeSharp.Tests.Native/NativeTestsPlugin.cs index 8f5582d59..8a9391a16 100644 --- a/managed/CounterStrikeSharp.Tests.Native/NativeTestsPlugin.cs +++ b/managed/CounterStrikeSharp.Tests.Native/NativeTestsPlugin.cs @@ -40,9 +40,12 @@ public class NativeTestsPlugin : BasePlugin public static int gameThreadId; + public static NativeTestsPlugin Instance { get; private set; } = null!; + public override void Load(bool hotReload) { gameThreadId = Thread.CurrentThread.ManagedThreadId; + Instance = this; // Loading blocks the game thread, so we use NextFrame to run our tests asynchronously. // Uncomment to run the tests on load // Server.NextWorldUpdate(() => RunTests()); @@ -135,6 +138,9 @@ public async Task RunTests(string? filter = null) Console.WriteLine($"[{ModuleName}] Test run finished."); Console.WriteLine(reporter.GetSummary()); Console.WriteLine("*****************************************************************"); + + // Export benchmark results if any were collected + ScriptContextBenchmarks.ExportResults(); } catch (Exception ex) { diff --git a/managed/CounterStrikeSharp.Tests.Native/ScriptContextBenchmarks.cs b/managed/CounterStrikeSharp.Tests.Native/ScriptContextBenchmarks.cs new file mode 100644 index 000000000..20ac589fc --- /dev/null +++ b/managed/CounterStrikeSharp.Tests.Native/ScriptContextBenchmarks.cs @@ -0,0 +1,461 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Modules.Memory; +using CounterStrikeSharp.API.Modules.UserMessages; +using Xunit; + +namespace NativeTestsPlugin; + +public class BenchmarkResult +{ + public string Name { get; set; } = ""; + public string Category { get; set; } = ""; + public long TotalCalls { get; set; } + public double TotalMs { get; set; } + public double NsPerCall { get; set; } + public double CallsPerSecond { get; set; } +} + +public class BenchmarkReport +{ + public string Timestamp { get; set; } = ""; + public int Iterations { get; set; } + public int WarmupIterations { get; set; } + public List Results { get; set; } = new(); +} + +public class ScriptContextBenchmarks +{ + private const int Iterations = 1_000_000; + private const int WarmupIterations = 100_000; + + private static readonly List _results = new(); + private static readonly object _lock = new(); + + private static CWorld? GetWorld() => + Utilities.FindAllEntitiesByDesignerName("worldent").FirstOrDefault(); + + // ── Primitive returns, no args ────────────────────────────────────── + + [Fact] + public void Benchmark_GetTickCount() + { + Run("GetTickCount (int, no args)", "Primitive Return", () => NativeAPI.GetTickCount()); + } + + [Fact] + public void Benchmark_GetTickInterval() + { + Run("GetTickInterval (float, no args)", "Primitive Return", () => NativeAPI.GetTickInterval()); + } + + [Fact] + public void Benchmark_GetServer_CurrentTime() + { + Run("GetServerCurrentTime (float, no args)", "Primitive Return", () => + { + var z = Server.CurrentTime; + }); + } + + [Fact] + public void Benchmark_GetEngineTime() + { + Run("GetEngineTime (double, no args)", "Primitive Return", () => NativeAPI.GetEngineTime()); + } + + [Fact] + public void Benchmark_GetMaxClients() + { + Run("GetMaxClients (int, no args)", "Primitive Return", () => NativeAPI.GetMaxClients()); + } + + [Fact] + public void Benchmark_IsServerPaused() + { + Run("IsServerPaused (bool, no args)", "Primitive Return", () => NativeAPI.IsServerPaused()); + } + + // ── String returns ────────────────────────────────────────────────── + + [Fact] + public void Benchmark_GetMapName() + { + Run("GetMapName (string, no args)", "String Return", () => NativeAPI.GetMapName()); + } + + [Fact] + public void Benchmark_GetConvarName() + { + var idx = NativeAPI.GetConvarAccessIndexByName("sv_cheats"); + Run("GetConvarName (ushort → string)", "String Return", () => NativeAPI.GetConvarName(idx)); + } + + // ── Primitive arg → primitive return ──────────────────────────────── + + [Fact] + public void Benchmark_GetConvarFlags() + { + var idx = NativeAPI.GetConvarAccessIndexByName("sv_cheats"); + Run("GetConvarFlags (ushort → ulong)", "Primitive Args", () => NativeAPI.GetConvarFlags(idx)); + } + + [Fact] + public void Benchmark_GetConvarType() + { + var idx = NativeAPI.GetConvarAccessIndexByName("sv_cheats"); + Run("GetConvarType (ushort → short)", "Primitive Args", () => NativeAPI.GetConvarType(idx)); + } + + // ── String push cost ──────────────────────────────────────────────── + + [Fact] + public void Benchmark_FindConvar() + { + Run("FindConvar (string → pointer)", "String Push", () => NativeAPI.FindConvar("sv_cheats")); + } + + [Fact] + public void Benchmark_PushString_Short() + { + Run("PushString 9 bytes", "String Push", () => NativeAPI.GetConvarAccessIndexByName("sv_cheats")); + } + + [Fact] + public void Benchmark_PushString_Medium() + { + var str = "sv_cheats" + new string('x', 191); // 200 bytes + Run("PushString 200 bytes", "String Push", () => NativeAPI.GetConvarAccessIndexByName(str)); + } + + [Fact] + public void Benchmark_PushString_Long() + { + var str = "sv_cheats" + new string('x', 1991); // 2000 bytes + Run("PushString 2000 bytes", "String Push", () => NativeAPI.GetConvarAccessIndexByName(str)); + } + + [Fact] + public void Benchmark_PushString_Overflow() + { + var str = new string('x', 9000); // exceeds 8192 arena + Run("PushString 9000 bytes (overflow)", "String Push", () => NativeAPI.GetConvarAccessIndexByName(str)); + } + + // ── Mixed workload ────────────────────────────────────────────────── + + [Fact] + public void Benchmark_Mixed() + { + var convarIdx = NativeAPI.GetConvarAccessIndexByName("sv_cheats"); + + Run("Mixed (4 natives/iter)", "Mixed", () => + { + NativeAPI.GetTickCount(); + NativeAPI.GetMapName(); + NativeAPI.FindConvar("sv_cheats"); + NativeAPI.GetConvarFlags(convarIdx); + }, callsPerIteration: 4); + } + + // ── Schema ────────────────────────────────────────────────────────── + + [Fact] + public void Benchmark_SchemaOffset_Cached() + { + Schema.GetSchemaOffset("CBaseEntity", "m_iHealth"); // prime cache + Run("SchemaOffset cached", "Schema", () => Schema.GetSchemaOffset("CBaseEntity", "m_iHealth")); + } + + [Fact] + public void Benchmark_SchemaOffset_MultipleKeys() + { + Schema.GetSchemaOffset("CBaseEntity", "m_iHealth"); + Schema.GetSchemaOffset("CBaseEntity", "m_iTeamNum"); + Schema.GetSchemaOffset("CBaseEntity", "m_fFlags"); + Schema.GetSchemaOffset("CBasePlayerPawn", "m_vecAbsVelocity"); + + Run("SchemaOffset 4 keys", "Schema", () => + { + Schema.GetSchemaOffset("CBaseEntity", "m_iHealth"); + Schema.GetSchemaOffset("CBaseEntity", "m_iTeamNum"); + Schema.GetSchemaOffset("CBaseEntity", "m_fFlags"); + Schema.GetSchemaOffset("CBasePlayerPawn", "m_vecAbsVelocity"); + }, callsPerIteration: 4); + } + + [Fact] + public void Benchmark_GetDeclaredClass() + { + var world = GetWorld(); + if (world == null) + { + Skip("no world entity"); + return; + } + + Run("GetDeclaredClass (CBodyComponent)", "Schema", () => _ = world.CBodyComponent); + } + + [Fact] + public void Benchmark_GetSchemaValue_Int() + { + var world = GetWorld(); + if (world == null) + { + Skip("no world entity"); + return; + } + + Run("GetSchemaValue (Health)", "Schema", () => _ = world.Health); + } + + // ── Virtual function invocation ──────────────────────────────────── + + [Fact] + public void Benchmark_VFunc_PreCreatedDelegate() + { + var world = GetWorld(); + if (world == null) + { + Skip("no world entity"); + return; + } + + // Pre-create the delegate so we only measure the invoke path, not + // GameData.GetOffset or VirtualFunction.Create overhead. + var offset = GameData.GetOffset("CBaseEntity_IsPlayerPawn"); + var isPlayerPawn = VirtualFunction.Create(world.Handle, offset); + var handle = world.Handle; + + Run("VFunc IsPlayerPawn (pre-created delegate)", "Virtual Function", () => isPlayerPawn(handle)); + } + + [Fact] + public void Benchmark_VFunc_HighLevelApi() + { + var world = GetWorld(); + if (world == null) + { + Skip("no world entity"); + return; + } + + // Full path: Guard.IsValidEntity + GameData.GetOffset + + // VirtualFunction.Create + invoke on every call. + Run("VFunc IsPlayerPawn (high-level API)", "Virtual Function", () => world.IsPlayerPawn()); + } + + [Fact] + public void Benchmark_NetMessage_Send() + { + Run("NetMessage Send", "NetMessage", () => + { + var msg = UserMessage.FromId(120); + msg.SetFloat("frequency", 0.5f); + msg.Recipients.AddAllPlayers(); + msg.Send(); + }); + } + + [Fact] + public void Benchmark_GameEvent_Fire() + { + var player = Utilities.GetPlayerFromSlot(0)!; + Run("GameEvent Fire", "GameEvent", () => + { + var @event = new EventShowSurvivalRespawnStatus(true) + { + LocToken = "#LOC_TOKEN", + Duration = 1 + }; + + @event.FireEventToClient(player); + }); + } + + // ── Entity lifecycle ──────────────────────────────────────────────── + + [Fact] + public void Benchmark_GetPlayerFromSlot() + { + Run("GetPlayerFromSlot", "Entity Lifecycle", () => { Utilities.GetPlayerFromSlot(0); }); + } + + [Fact] + public async Task Benchmark_EntityCreateAndDelete() + { + // Create info_target entities in batches of 4096, timing only the + // creation. After each batch, remove them and wait a frame so the + // engine frees the slots before the next round. + const int batchSize = 4096; + int batches = Iterations / batchSize; + + // Warmup batch + var buf = new CBaseEntity[batchSize]; + for (int i = 0; i < batchSize; i++) + { + buf[i] = Utilities.CreateEntityByName("info_target")!; + buf[i].DispatchSpawn(); + } + + for (int i = 0; i < batchSize; i++) + buf[i].Remove(); + await TestUtils.WaitOneFrame(); + + var sw = new Stopwatch(); + + for (int b = 0; b < batches; b++) + { + sw.Start(); + for (int i = 0; i < batchSize; i++) + { + buf[i] = Utilities.CreateEntityByName("info_target")!; + buf[i].DispatchSpawn(); + } + + sw.Stop(); + + for (int i = 0; i < batchSize; i++) + buf[i].Remove(); + await TestUtils.WaitOneFrame(); + } + + Record("Entity create+spawn info_target", "Entity Lifecycle", sw, (long)batches * batchSize); + } + + // ── Export ─────────────────────────────────────────────────────────── + + public static void ExportResults() + { + List snapshot; + lock (_lock) + { + snapshot = _results.OrderBy(r => r.Category).ThenBy(r => r.Name).ToList(); + _results.Clear(); + } + + if (snapshot.Count == 0) + { + Console.WriteLine("[BENCH] No results to export."); + return; + } + + var report = new BenchmarkReport + { + Timestamp = DateTime.UtcNow.ToString("o"), + Iterations = Iterations, + WarmupIterations = WarmupIterations, + Results = snapshot + }; + + var dir = TryGetPluginDirectory() ?? Path.GetTempPath(); + Directory.CreateDirectory(dir); + + var jsonPath = Path.Combine(dir, "benchmark-results.json"); + File.WriteAllText(jsonPath, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true })); + + var mdPath = Path.Combine(dir, "benchmark-results.md"); + File.WriteAllText(mdPath, FormatMarkdown(report)); + + Console.WriteLine("=== BENCHMARK EXPORT ==="); + Console.WriteLine($" JSON: {jsonPath}"); + Console.WriteLine($" MD: {mdPath}"); + Console.WriteLine($" {snapshot.Count} benchmark(s) exported."); + Console.WriteLine("========================"); + } + + // ── Helpers ────────────────────────────────────────────────────────── + + /// + /// Warmup + measure a synchronous action. If the action body contains + /// multiple logical calls, pass so + /// the per-call stats are correct. + /// + private static void Run(string name, string category, Action body, int callsPerIteration = 1) + { + for (int i = 0; i < WarmupIterations; i++) + body(); + + var sw = Stopwatch.StartNew(); + for (int i = 0; i < Iterations; i++) + body(); + sw.Stop(); + + Record(name, category, sw, (long)Iterations * callsPerIteration); + } + + private static void Record(string name, string category, Stopwatch sw, long totalCalls) + { + var elapsed = sw.Elapsed; + var nsPerCall = elapsed.TotalNanoseconds / totalCalls; + var callsPerSec = totalCalls / elapsed.TotalSeconds; + + lock (_lock) + { + _results.Add(new BenchmarkResult + { + Name = name, + Category = category, + TotalCalls = totalCalls, + TotalMs = Math.Round(elapsed.TotalMilliseconds, 2), + NsPerCall = Math.Round(nsPerCall, 0), + CallsPerSecond = Math.Round(callsPerSec, 0) + }); + } + + Console.WriteLine($"[BENCH] {name}"); + Console.WriteLine($" {totalCalls:N0} calls in {elapsed.TotalMilliseconds:F2} ms" + + $" | {nsPerCall:F0} ns/call | {callsPerSec:N0} calls/sec"); + } + + private static void Skip(string reason) => + Console.WriteLine($"[BENCH] SKIP: {reason}"); + + private static string? TryGetPluginDirectory() + { + try + { + var path = NativeTestsPlugin.Instance?.ModulePath; + return path != null ? Path.GetDirectoryName(path) : null; + } + catch + { + return null; + } + } + + private static string FormatMarkdown(BenchmarkReport report) + { + var sb = new StringBuilder(); + sb.AppendLine("# Benchmark Results"); + sb.AppendLine(); + sb.AppendLine($"- **Date:** {report.Timestamp}"); + sb.AppendLine($"- **Iterations:** {report.Iterations:N0}"); + sb.AppendLine($"- **Warmup:** {report.WarmupIterations:N0}"); + sb.AppendLine(); + + foreach (var group in report.Results.GroupBy(r => r.Category).OrderBy(g => g.Key)) + { + sb.AppendLine($"## {group.Key}"); + sb.AppendLine(); + sb.AppendLine("| Benchmark | ns/call | calls/sec | total ms |"); + sb.AppendLine("|:----------|--------:|----------:|---------:|"); + + foreach (var r in group) + sb.AppendLine($"| {r.Name} | {r.NsPerCall:N0} | {r.CallsPerSecond:N0} | {r.TotalMs:F2} |"); + + sb.AppendLine(); + } + + return sb.ToString(); + } +}