From ffdbbe42c670cc0c722edcbe69dea159f6f4926c Mon Sep 17 00:00:00 2001 From: Michael Wilson Date: Sat, 21 Mar 2026 09:36:46 +0000 Subject: [PATCH 1/8] feat: add benchmarking script --- eng/run-benchmarks.ts | 76 +++ eng/update-schema.ts | 40 ++ .../NativeTestsPlugin.cs | 6 + .../ScriptContextBenchmarks.cs | 511 ++++++++++++++++++ 4 files changed, 633 insertions(+) create mode 100755 eng/run-benchmarks.ts create mode 100755 eng/update-schema.ts create mode 100644 managed/CounterStrikeSharp.Tests.Native/ScriptContextBenchmarks.cs diff --git a/eng/run-benchmarks.ts b/eng/run-benchmarks.ts new file mode 100755 index 000000000..668f76dbd --- /dev/null +++ b/eng/run-benchmarks.ts @@ -0,0 +1,76 @@ +#!/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 = 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"), + // Remote plugin path on the game server (under /home/container) + GS_PLUGIN_DIR: z.string().default("/game/csgo/addons/counterstrikesharp/plugins/NativeTestsPlugin"), +}).parse(env); + +const HERE = $.path(import.meta).parentOrThrow(); +const PROJECT_ROOT = HERE.parentOrThrow(); +const NATIVE_TESTS_PROJECT = PROJECT_ROOT.join("managed/CounterStrikeSharp.Tests.Native/NativeTestsPlugin.csproj"); +const BUILD_OUTPUT = PROJECT_ROOT.join("managed/CounterStrikeSharp.Tests.Native/bin/Debug/net8.0"); +const RESULTS_OUTPUT = PROJECT_ROOT.join("benchmark-results"); + +// ── Step 1: Build the native tests plugin ─────────────────────────────── + +$.logStep("Building NativeTestsPlugin..."); +await $`dotnet build ${NATIVE_TESTS_PROJECT} -c Debug`; + +// ── Step 2: Upload the built plugin to the game server via SFTP ───────── + +$.logStep("Uploading NativeTestsPlugin to game server..."); +const remotePluginDir = config.GS_PLUGIN_DIR; + +await $`lftp -u ${config.SFTP_USER},${config.SFTP_PASS} ${config.SFTP_HOST} -e ${`set xfer:clobber on; mkdir -p ${remotePluginDir}; mirror -R --delete ${BUILD_OUTPUT} ${remotePluginDir}; bye` + }`.quiet(); + +// ── Step 3: Reload the plugin and run benchmarks via RCON ─────────────── + +$.logStep("Reloading plugin via RCON..."); +const rcon = `${HERE}/rcon`; +try { + await $`${rcon} -a ${config.GS_HOST}:${config.GS_PORT} -p ${config.GS_PASS} "css_plugins load NativeTestsPlugin"`.text(); +} catch { + // Plugin may already be loaded; try reloading + await $`${rcon} -a ${config.GS_HOST}:${config.GS_PORT} -p ${config.GS_PASS} "css_plugins reload NativeTestsPlugin"`.text(); +} + +$.logStep("Running benchmarks via RCON: css_itest benchmark ..."); +const benchOutput = await $`${rcon} -a ${config.GS_HOST}:${config.GS_PORT} -p ${config.GS_PASS} -T 120s "css_itest benchmark"`.text(); +console.log(benchOutput); + +const remoteJsonPath = `${remotePluginDir}/benchmark-results.json`; +const remoteMdPath = `${remotePluginDir}/benchmark-results.md`; + +// ── Step 4: Download the benchmark results ────────────────────────────── + +$.logStep("Downloading benchmark results..."); +await Deno.mkdir(RESULTS_OUTPUT.toString(), { recursive: true }); + +const localJsonPath = RESULTS_OUTPUT.join("benchmark-results.json").toString(); +const localMdPath = RESULTS_OUTPUT.join("benchmark-results.md").toString(); + +await $`lftp -u ${config.SFTP_USER},${config.SFTP_PASS} ${config.SFTP_HOST} -e ${`set xfer:clobber on; get ${remoteJsonPath} -o ${localJsonPath}; get ${remoteMdPath} -o ${localMdPath}; bye` + }`.quiet(); + +$.logStep("Benchmark results downloaded successfully!"); +$.logLight(` JSON: ${localJsonPath}`); +$.logLight(` MD: ${localMdPath}`); + +// Print the markdown summary +const mdContent = await Deno.readTextFile(localMdPath); +console.log("\n" + mdContent); diff --git a/eng/update-schema.ts b/eng/update-schema.ts new file mode 100755 index 000000000..0f73cfaa3 --- /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 = 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('.'); + +$.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..b8cb2a4e3 --- /dev/null +++ b/managed/CounterStrikeSharp.Tests.Native/ScriptContextBenchmarks.cs @@ -0,0 +1,511 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Modules.Memory; +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 string MapName { 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(); + + // ── Primitive returns, no args ────────────────────────────────────── + + [Fact] + public void Benchmark_GetTickCount_PrimitiveIntReturn() + { + for (int i = 0; i < WarmupIterations; i++) + NativeAPI.GetTickCount(); + + var sw = Stopwatch.StartNew(); + for (int i = 0; i < Iterations; i++) + NativeAPI.GetTickCount(); + sw.Stop(); + + Record("GetTickCount (int return, no args)", "Primitive Return", sw); + } + + [Fact] + public void Benchmark_GetTickInterval_PrimitiveFloatReturn() + { + for (int i = 0; i < WarmupIterations; i++) + NativeAPI.GetTickInterval(); + + var sw = Stopwatch.StartNew(); + for (int i = 0; i < Iterations; i++) + NativeAPI.GetTickInterval(); + sw.Stop(); + + Record("GetTickInterval (float return, no args)", "Primitive Return", sw); + } + + [Fact] + public void Benchmark_GetEngineTime_PrimitiveDoubleReturn() + { + for (int i = 0; i < WarmupIterations; i++) + NativeAPI.GetEngineTime(); + + var sw = Stopwatch.StartNew(); + for (int i = 0; i < Iterations; i++) + NativeAPI.GetEngineTime(); + sw.Stop(); + + Record("GetEngineTime (double return, no args)", "Primitive Return", sw); + } + + [Fact] + public void Benchmark_GetMaxClients_PrimitiveIntReturn() + { + for (int i = 0; i < WarmupIterations; i++) + NativeAPI.GetMaxClients(); + + var sw = Stopwatch.StartNew(); + for (int i = 0; i < Iterations; i++) + NativeAPI.GetMaxClients(); + sw.Stop(); + + Record("GetMaxClients (int return, no args)", "Primitive Return", sw); + } + + [Fact] + public void Benchmark_IsServerPaused_PrimitiveBoolReturn() + { + for (int i = 0; i < WarmupIterations; i++) + NativeAPI.IsServerPaused(); + + var sw = Stopwatch.StartNew(); + for (int i = 0; i < Iterations; i++) + NativeAPI.IsServerPaused(); + sw.Stop(); + + Record("IsServerPaused (bool return, no args)", "Primitive Return", sw); + } + + // ── String returns, no args ───────────────────────────────────────── + + [Fact] + public void Benchmark_GetMapName_StringReturn() + { + for (int i = 0; i < WarmupIterations; i++) + NativeAPI.GetMapName(); + + var sw = Stopwatch.StartNew(); + for (int i = 0; i < Iterations; i++) + NativeAPI.GetMapName(); + sw.Stop(); + + Record("GetMapName (string return, no args)", "String Return", sw); + } + + // ── Primitive arg → primitive return ──────────────────────────────── + + [Fact] + public void Benchmark_GetConvarFlags_UshortArgUlongReturn() + { + var index = NativeAPI.GetConvarAccessIndexByName("sv_cheats"); + + for (int i = 0; i < WarmupIterations; i++) + NativeAPI.GetConvarFlags(index); + + var sw = Stopwatch.StartNew(); + for (int i = 0; i < Iterations; i++) + NativeAPI.GetConvarFlags(index); + sw.Stop(); + + Record("GetConvarFlags (ushort arg, ulong return)", "Primitive Args", sw); + } + + [Fact] + public void Benchmark_GetConvarType_UshortArgShortReturn() + { + var index = NativeAPI.GetConvarAccessIndexByName("sv_cheats"); + + for (int i = 0; i < WarmupIterations; i++) + NativeAPI.GetConvarType(index); + + var sw = Stopwatch.StartNew(); + for (int i = 0; i < Iterations; i++) + NativeAPI.GetConvarType(index); + sw.Stop(); + + Record("GetConvarType (ushort arg, short return)", "Primitive Args", sw); + } + + // ── Primitive arg → string return ─────────────────────────────────── + + [Fact] + public void Benchmark_GetConvarName_UshortArgStringReturn() + { + var index = NativeAPI.GetConvarAccessIndexByName("sv_cheats"); + + for (int i = 0; i < WarmupIterations; i++) + NativeAPI.GetConvarName(index); + + var sw = Stopwatch.StartNew(); + for (int i = 0; i < Iterations; i++) + NativeAPI.GetConvarName(index); + sw.Stop(); + + Record("GetConvarName (ushort arg, string return)", "String Return", sw); + } + + // ── String arg → pointer return (measures string push cost) ───────── + + [Fact] + public void Benchmark_FindConvar_StringArgPointerReturn() + { + for (int i = 0; i < WarmupIterations; i++) + NativeAPI.FindConvar("sv_cheats"); + + var sw = Stopwatch.StartNew(); + for (int i = 0; i < Iterations; i++) + NativeAPI.FindConvar("sv_cheats"); + sw.Stop(); + + Record("FindConvar (string arg, pointer return)", "String Push", sw); + } + + // ── String push scaling (short / medium / long) ───────────────────── + + [Fact] + public void Benchmark_PushString_ShortString() + { + for (int i = 0; i < WarmupIterations; i++) + NativeAPI.GetConvarAccessIndexByName("sv_cheats"); + + var sw = Stopwatch.StartNew(); + for (int i = 0; i < Iterations; i++) + NativeAPI.GetConvarAccessIndexByName("sv_cheats"); + sw.Stop(); + + Record("PushString short 'sv_cheats' (9 bytes)", "String Push", sw); + } + + [Fact] + public void Benchmark_PushString_MediumString() + { + var mediumStr = "sv_cheats" + new string('x', 191); // 200 bytes total + + for (int i = 0; i < WarmupIterations; i++) + NativeAPI.GetConvarAccessIndexByName(mediumStr); + + var sw = Stopwatch.StartNew(); + for (int i = 0; i < Iterations; i++) + NativeAPI.GetConvarAccessIndexByName(mediumStr); + sw.Stop(); + + Record("PushString medium (200 bytes)", "String Push", sw); + } + + [Fact] + public void Benchmark_PushString_LongString() + { + var longStr = "sv_cheats" + new string('x', 1991); // 2000 bytes total + + for (int i = 0; i < WarmupIterations; i++) + NativeAPI.GetConvarAccessIndexByName(longStr); + + var sw = Stopwatch.StartNew(); + for (int i = 0; i < Iterations; i++) + NativeAPI.GetConvarAccessIndexByName(longStr); + sw.Stop(); + + Record("PushString long (2000 bytes)", "String Push", sw); + } + + [Fact] + public void Benchmark_PushString_OverflowString() + { + var hugeStr = new string('x', 9000); // exceeds 8192 arena + + for (int i = 0; i < WarmupIterations; i++) + NativeAPI.GetConvarAccessIndexByName(hugeStr); + + var sw = Stopwatch.StartNew(); + for (int i = 0; i < Iterations; i++) + NativeAPI.GetConvarAccessIndexByName(hugeStr); + sw.Stop(); + + Record("PushString overflow (9000 bytes)", "String Push", sw); + } + + // ── Mixed workload ────────────────────────────────────────────────── + + [Fact] + public void Benchmark_MixedSummary() + { + var convarIndex = NativeAPI.GetConvarAccessIndexByName("sv_cheats"); + + for (int i = 0; i < WarmupIterations; i++) + { + NativeAPI.GetTickCount(); + NativeAPI.GetMapName(); + NativeAPI.FindConvar("sv_cheats"); + NativeAPI.GetConvarFlags(convarIndex); + } + + var sw = Stopwatch.StartNew(); + for (int i = 0; i < Iterations; i++) + { + NativeAPI.GetTickCount(); + NativeAPI.GetMapName(); + NativeAPI.FindConvar("sv_cheats"); + NativeAPI.GetConvarFlags(convarIndex); + } + + sw.Stop(); + + Record("Mixed workload (4 natives per iteration)", "Mixed", sw, Iterations * 4); + } + + // ── Schema property access ───────────────────────────────────────── + + [Fact] + public void Benchmark_SchemaOffset_CachedLookup() + { + // Prime the cache + Schema.GetSchemaOffset("CBaseEntity", "m_iHealth"); + + for (int i = 0; i < WarmupIterations; i++) + Schema.GetSchemaOffset("CBaseEntity", "m_iHealth"); + + var sw = Stopwatch.StartNew(); + for (int i = 0; i < Iterations; i++) + Schema.GetSchemaOffset("CBaseEntity", "m_iHealth"); + sw.Stop(); + + Record("Schema.GetSchemaOffset cached (record struct key)", "Schema", sw); + } + + [Fact] + public void Benchmark_SchemaOffset_MultipleDifferentKeys() + { + // Prime caches for several different fields + Schema.GetSchemaOffset("CBaseEntity", "m_iHealth"); + Schema.GetSchemaOffset("CBaseEntity", "m_iTeamNum"); + Schema.GetSchemaOffset("CBaseEntity", "m_fFlags"); + Schema.GetSchemaOffset("CBasePlayerPawn", "m_vecAbsVelocity"); + + for (int i = 0; i < WarmupIterations; i++) + { + Schema.GetSchemaOffset("CBaseEntity", "m_iHealth"); + Schema.GetSchemaOffset("CBaseEntity", "m_iTeamNum"); + Schema.GetSchemaOffset("CBaseEntity", "m_fFlags"); + Schema.GetSchemaOffset("CBasePlayerPawn", "m_vecAbsVelocity"); + } + + var sw = Stopwatch.StartNew(); + for (int i = 0; i < Iterations; i++) + { + Schema.GetSchemaOffset("CBaseEntity", "m_iHealth"); + Schema.GetSchemaOffset("CBaseEntity", "m_iTeamNum"); + Schema.GetSchemaOffset("CBaseEntity", "m_fFlags"); + Schema.GetSchemaOffset("CBasePlayerPawn", "m_vecAbsVelocity"); + } + + sw.Stop(); + + Record("Schema.GetSchemaOffset 4 different keys", "Schema", sw, Iterations * 4); + } + + [Fact] + public void Benchmark_GetDeclaredClass() + { + var world = Utilities.FindAllEntitiesByDesignerName("worldent").FirstOrDefault(); + if (world == null) + { + Console.WriteLine("[BENCH] SKIP: GetDeclaredClass - no world entity"); + return; + } + + for (int i = 0; i < WarmupIterations; i++) + _ = world.CBodyComponent; + + var sw = Stopwatch.StartNew(); + for (int i = 0; i < Iterations; i++) + _ = world.CBodyComponent; + sw.Stop(); + + Record("GetDeclaredClass via CBodyComponent (FastNew)", "Schema", sw); + } + + [Fact] + public void Benchmark_GetSchemaValue_Int() + { + var world = Utilities.FindAllEntitiesByDesignerName("worldent").FirstOrDefault(); + if (world == null) + { + Console.WriteLine("[BENCH] SKIP: GetSchemaValue - no world entity"); + return; + } + + for (int i = 0; i < WarmupIterations; i++) + _ = world.Health; + + var sw = Stopwatch.StartNew(); + for (int i = 0; i < Iterations; i++) + _ = world.Health; + sw.Stop(); + + Record("GetSchemaValue via Health property", "Schema", sw); + } + + // ── Export ─────────────────────────────────────────────────────────── + + public static void ExportResults() + { + List snapshot; + lock (_lock) + { + snapshot = new List(_results.OrderBy(r => r.Category).ThenBy(r => r.Name)); + _results.Clear(); + } + + if (snapshot.Count == 0) + { + Console.WriteLine("[BENCH] No benchmark results to export."); + return; + } + + var report = new BenchmarkReport + { + Timestamp = DateTime.UtcNow.ToString("o"), + MapName = TryGetMapName(), + Iterations = Iterations, + WarmupIterations = WarmupIterations, + Results = snapshot + }; + + var outputDir = TryGetPluginDirectory() ?? Path.GetTempPath(); + Directory.CreateDirectory(outputDir); + + var jsonPath = Path.Combine(outputDir, "benchmark-results.json"); + var jsonOptions = new JsonSerializerOptions { WriteIndented = true }; + File.WriteAllText(jsonPath, JsonSerializer.Serialize(report, jsonOptions)); + + var mdPath = Path.Combine(outputDir, "benchmark-results.md"); + File.WriteAllText(mdPath, GenerateMarkdown(report)); + + Console.WriteLine("=== BENCHMARK EXPORT ==="); + Console.WriteLine($" JSON: {jsonPath}"); + Console.WriteLine($" MD: {mdPath}"); + Console.WriteLine($" {snapshot.Count} benchmark(s) exported."); + Console.WriteLine("========================"); + } + + // ── Internal helpers ──────────────────────────────────────────────── + + private static void Record(string name, string category, Stopwatch sw, long? totalCalls = null) + { + var calls = totalCalls ?? Iterations; + var elapsed = sw.Elapsed; + var nsPerCall = elapsed.TotalNanoseconds / calls; + var callsPerSecond = calls / elapsed.TotalSeconds; + + var result = new BenchmarkResult + { + Name = name, + Category = category, + TotalCalls = calls, + TotalMs = Math.Round(elapsed.TotalMilliseconds, 2), + NsPerCall = Math.Round(nsPerCall, 0), + CallsPerSecond = Math.Round(callsPerSecond, 0) + }; + + lock (_lock) + { + _results.Add(result); + } + + Console.WriteLine($"[BENCH] {name}"); + Console.WriteLine( + $" {calls:N0} calls in {elapsed.TotalMilliseconds:F2} ms | {nsPerCall:F0} ns/call | {callsPerSecond:N0} calls/sec"); + } + + private static string TryGetMapName() + { + try + { + return NativeAPI.GetMapName(); + } + catch + { + return "unknown"; + } + } + + private static string? TryGetPluginDirectory() + { + try + { + return NativeTestsPlugin.Instance?.ModulePath != null ? Path.GetDirectoryName(NativeTestsPlugin.Instance.ModulePath) : null; + } + catch + { + return null; + } + } + + private static string GenerateMarkdown(BenchmarkReport report) + { + var sb = new StringBuilder(); + sb.AppendLine("# Benchmark Results"); + sb.AppendLine(); + sb.AppendLine($"- **Date:** {report.Timestamp}"); + sb.AppendLine($"- **Map:** {report.MapName}"); + sb.AppendLine($"- **Iterations:** {report.Iterations:N0}"); + sb.AppendLine($"- **Warmup:** {report.WarmupIterations:N0}"); + sb.AppendLine(); + + var categories = report.Results + .GroupBy(r => r.Category) + .OrderBy(g => g.Key); + + foreach (var group in categories) + { + 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(); + } +} From a44389544b137a5a464f4b4ea505465a045b5081 Mon Sep 17 00:00:00 2001 From: Michael Wilson Date: Mon, 23 Mar 2026 03:08:14 +0000 Subject: [PATCH 2/8] fix: add upload of api/native code and restart a pterodactyl managed server --- eng/run-benchmarks.ts | 98 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 90 insertions(+), 8 deletions(-) diff --git a/eng/run-benchmarks.ts b/eng/run-benchmarks.ts index 668f76dbd..fe8639f2d 100755 --- a/eng/run-benchmarks.ts +++ b/eng/run-benchmarks.ts @@ -15,33 +15,115 @@ const config = z.object({ 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"), - // Remote plugin path on the game server (under /home/container) + // Remote paths on the game server 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"), + // Pterodactyl panel details for server restart + PTERO_URL: z.string().min(1, "PTERO_URL env var is required (e.g. https://panel.example.com)"), + PTERO_API_KEY: z.string().min(1, "PTERO_API_KEY env var is required (client API key)"), + PTERO_SERVER_ID: z.string().min(1, "PTERO_SERVER_ID env var is required (short server identifier)"), + // How long to wait for the server to come back after restart (seconds) + GS_RESTART_TIMEOUT: z.string().default("120"), }).parse(env); const HERE = $.path(import.meta).parentOrThrow(); const PROJECT_ROOT = HERE.parentOrThrow(); +const API_PROJECT = PROJECT_ROOT.join("managed/CounterStrikeSharp.API"); +const API_BUILD_OUTPUT = PROJECT_ROOT.join("managed/CounterStrikeSharp.API/bin/Release/net8.0"); const NATIVE_TESTS_PROJECT = PROJECT_ROOT.join("managed/CounterStrikeSharp.Tests.Native/NativeTestsPlugin.csproj"); const BUILD_OUTPUT = PROJECT_ROOT.join("managed/CounterStrikeSharp.Tests.Native/bin/Debug/net8.0"); -const RESULTS_OUTPUT = PROJECT_ROOT.join("benchmark-results"); +const NATIVE_SO = PROJECT_ROOT.join("build/addons/counterstrikesharp/bin/linuxsteamrt64/counterstrikesharp.so"); +const RESULTS_OUTPUT = PROJECT_ROOT.join("TestResults/Benchmarks"); -// ── Step 1: Build the native tests plugin ─────────────────────────────── +// ── Step 1: Build the API and the native tests plugin ─────────────────── + +$.logStep("Building CounterStrikeSharp.API..."); +await $`dotnet build -c Release`.cwd(API_PROJECT.toString()); $.logStep("Building NativeTestsPlugin..."); await $`dotnet build ${NATIVE_TESTS_PROJECT} -c Debug`; -// ── Step 2: Upload the built plugin to the game server via SFTP ───────── +// ── Step 2: Upload the API and plugin to the game server via SFTP ─────── -$.logStep("Uploading NativeTestsPlugin to game server..."); +$.logStep("Uploading CounterStrikeSharp.API to game server..."); +const remoteApiDir = config.GS_API_DIR; const remotePluginDir = config.GS_PLUGIN_DIR; +await $`lftp -u ${config.SFTP_USER},${config.SFTP_PASS} ${config.SFTP_HOST} -e ${`set xfer:clobber on; mkdir -p ${remoteApiDir}; mirror -R --delete ${API_BUILD_OUTPUT} ${remoteApiDir}; bye` + }`.quiet(); + +// Upload native .so if it exists (built with ninja) +if (await Deno.stat(NATIVE_SO.toString()).then(() => true).catch(() => false)) { + $.logStep("Uploading native counterstrikesharp.so to game server..."); + const remoteNativeDir = config.GS_NATIVE_DIR; + await $`lftp -u ${config.SFTP_USER},${config.SFTP_PASS} ${config.SFTP_HOST} -e ${`set xfer:clobber on; mkdir -p ${remoteNativeDir}; put ${NATIVE_SO} -o ${remoteNativeDir}/counterstrikesharp.so; bye` + }`.quiet(); +} + +$.logStep("Uploading NativeTestsPlugin to game server..."); await $`lftp -u ${config.SFTP_USER},${config.SFTP_PASS} ${config.SFTP_HOST} -e ${`set xfer:clobber on; mkdir -p ${remotePluginDir}; mirror -R --delete ${BUILD_OUTPUT} ${remotePluginDir}; bye` }`.quiet(); -// ── Step 3: Reload the plugin and run benchmarks via RCON ─────────────── +// ── Step 3: Restart the game server via Pterodactyl and wait ──────────── -$.logStep("Reloading plugin via RCON..."); const rcon = `${HERE}/rcon`; +const restartTimeout = parseInt(config.GS_RESTART_TIMEOUT, 10); +const pteroHeaders = { + "Authorization": `Bearer ${config.PTERO_API_KEY}`, + "Content-Type": "application/json", + "Accept": "application/json", +}; +const pteroBaseUrl = `${config.PTERO_URL}/api/client/servers/${config.PTERO_SERVER_ID}`; + +$.logStep("Restarting game server via Pterodactyl..."); +const restartResp = await fetch(`${pteroBaseUrl}/power`, { + method: "POST", + headers: pteroHeaders, + body: JSON.stringify({ signal: "restart" }), +}); + +if (!restartResp.ok) { + const body = await restartResp.text(); + $.logError(`Pterodactyl restart failed (${restartResp.status}): ${body}`); + Deno.exit(1); +} + +$.logStep(`Waiting for game server to come back (timeout: ${restartTimeout}s)...`); +// Wait a bit before polling to give the server time to shut down +await $.sleep(10_000); + +const startTime = Date.now(); +const pollInterval = 5_000; // 5 seconds between polls +let serverReady = false; + +while (Date.now() - startTime < restartTimeout * 1000) { + try { + const response = await $`${rcon} -a ${config.GS_HOST}:${config.GS_PORT} -p ${config.GS_PASS} "status"`.text(); + if (response) { + serverReady = true; + break; + } + } catch { + const elapsed = Math.round((Date.now() - startTime) / 1000); + $.logLight(` Server not ready yet (${elapsed}s elapsed)...`); + } + await $.sleep(pollInterval); +} + +if (!serverReady) { + $.logError(`Game server did not come back within ${restartTimeout}s!`); + Deno.exit(1); +} + +$.logStep("Game server is back online!"); + +// Give the server a few extra seconds to fully initialize plugins +await $.sleep(5_000); + +// ── Step 4: Load the plugin and run benchmarks via RCON ───────────────── + +$.logStep("Loading plugin via RCON..."); try { await $`${rcon} -a ${config.GS_HOST}:${config.GS_PORT} -p ${config.GS_PASS} "css_plugins load NativeTestsPlugin"`.text(); } catch { @@ -56,7 +138,7 @@ console.log(benchOutput); const remoteJsonPath = `${remotePluginDir}/benchmark-results.json`; const remoteMdPath = `${remotePluginDir}/benchmark-results.md`; -// ── Step 4: Download the benchmark results ────────────────────────────── +// ── Step 5: Download the benchmark results ────────────────────────────── $.logStep("Downloading benchmark results..."); await Deno.mkdir(RESULTS_OUTPUT.toString(), { recursive: true }); From 8768e3635991ca56534d6f44689a0ae189a3fa6a Mon Sep 17 00:00:00 2001 From: Michael Wilson Date: Mon, 23 Mar 2026 04:45:45 +0000 Subject: [PATCH 3/8] feat: update benchmarks, add entity benchmarks --- eng/run-benchmarks.ts | 196 ++++---- .../ScriptContextBenchmarks.cs | 454 +++++++----------- 2 files changed, 268 insertions(+), 382 deletions(-) diff --git a/eng/run-benchmarks.ts b/eng/run-benchmarks.ts index fe8639f2d..83401b004 100755 --- a/eng/run-benchmarks.ts +++ b/eng/run-benchmarks.ts @@ -7,152 +7,144 @@ import { z } from "https://deno.land/x/zod@v3.22.4/mod.ts"; const env = 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"), - // Remote paths on the game server + 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"), - // Pterodactyl panel details for server restart - PTERO_URL: z.string().min(1, "PTERO_URL env var is required (e.g. https://panel.example.com)"), - PTERO_API_KEY: z.string().min(1, "PTERO_API_KEY env var is required (client API key)"), - PTERO_SERVER_ID: z.string().min(1, "PTERO_SERVER_ID env var is required (short server identifier)"), - // How long to wait for the server to come back after restart (seconds) + 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 PROJECT_ROOT = HERE.parentOrThrow(); -const API_PROJECT = PROJECT_ROOT.join("managed/CounterStrikeSharp.API"); -const API_BUILD_OUTPUT = PROJECT_ROOT.join("managed/CounterStrikeSharp.API/bin/Release/net8.0"); -const NATIVE_TESTS_PROJECT = PROJECT_ROOT.join("managed/CounterStrikeSharp.Tests.Native/NativeTestsPlugin.csproj"); -const BUILD_OUTPUT = PROJECT_ROOT.join("managed/CounterStrikeSharp.Tests.Native/bin/Debug/net8.0"); -const NATIVE_SO = PROJECT_ROOT.join("build/addons/counterstrikesharp/bin/linuxsteamrt64/counterstrikesharp.so"); -const RESULTS_OUTPUT = PROJECT_ROOT.join("TestResults/Benchmarks"); +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"), +}; -// ── Step 1: Build the API and the native tests plugin ─────────────────── +const remoteJson = `${config.GS_PLUGIN_DIR}/benchmark-results.json`; +const remoteMd = `${config.GS_PLUGIN_DIR}/benchmark-results.md`; -$.logStep("Building CounterStrikeSharp.API..."); -await $`dotnet build -c Release`.cwd(API_PROJECT.toString()); +function lftp(commands: string) { + return $`lftp -u ${config.SFTP_USER},${config.SFTP_PASS} ${config.SFTP_HOST} -e ${commands}`.quiet(); +} -$.logStep("Building NativeTestsPlugin..."); -await $`dotnet build ${NATIVE_TESTS_PROJECT} -c Debug`; +function rcon(command: string) { + return $`${HERE}/rcon -a ${config.GS_HOST}:${config.GS_PORT} -p ${config.GS_PASS} ${command}`; +} -// ── Step 2: Upload the API and plugin to the game server via SFTP ─────── +async function poll(opts: { timeout: number; interval: number; label: string }, check: () => Promise) { + const start = Date.now(); + while (Date.now() - start < opts.timeout * 1000) { + try { + await check(); + return; + } catch { + const elapsed = Math.round((Date.now() - start) / 1000); + $.logLight(` ${opts.label} (${elapsed}s elapsed)...`); + } + await $.sleep(opts.interval); + } + throw new Error(`${opts.label}: timed out after ${opts.timeout}s`); +} -$.logStep("Uploading CounterStrikeSharp.API to game server..."); -const remoteApiDir = config.GS_API_DIR; -const remotePluginDir = config.GS_PLUGIN_DIR; +// ── Build ─────────────────────────────────────────────────────────────── -await $`lftp -u ${config.SFTP_USER},${config.SFTP_PASS} ${config.SFTP_HOST} -e ${`set xfer:clobber on; mkdir -p ${remoteApiDir}; mirror -R --delete ${API_BUILD_OUTPUT} ${remoteApiDir}; bye` - }`.quiet(); +$.logStep("Building API..."); +await $`dotnet build -c Release`.cwd(paths.apiProject.toString()); -// Upload native .so if it exists (built with ninja) -if (await Deno.stat(NATIVE_SO.toString()).then(() => true).catch(() => false)) { - $.logStep("Uploading native counterstrikesharp.so to game server..."); - const remoteNativeDir = config.GS_NATIVE_DIR; - await $`lftp -u ${config.SFTP_USER},${config.SFTP_PASS} ${config.SFTP_HOST} -e ${`set xfer:clobber on; mkdir -p ${remoteNativeDir}; put ${NATIVE_SO} -o ${remoteNativeDir}/counterstrikesharp.so; bye` - }`.quiet(); +$.logStep("Building NativeTestsPlugin..."); +await $`dotnet build ${paths.testProject} -c Debug`; + +// ── Upload ────────────────────────────────────────────────────────────── + +$.logStep("Uploading API..."); +await lftp(`set xfer:clobber on; mkdir -p ${config.GS_API_DIR}; mirror -R --delete ${paths.apiBuild} ${config.GS_API_DIR}; bye`); + +if (await Deno.stat(paths.nativeSo.toString()).then(() => true).catch(() => false)) { + $.logStep("Uploading native .so..."); + await lftp(`set xfer:clobber on; mkdir -p ${config.GS_NATIVE_DIR}; put ${paths.nativeSo} -o ${config.GS_NATIVE_DIR}/counterstrikesharp.so; bye`); } -$.logStep("Uploading NativeTestsPlugin to game server..."); -await $`lftp -u ${config.SFTP_USER},${config.SFTP_PASS} ${config.SFTP_HOST} -e ${`set xfer:clobber on; mkdir -p ${remotePluginDir}; mirror -R --delete ${BUILD_OUTPUT} ${remotePluginDir}; bye` - }`.quiet(); +$.logStep("Uploading NativeTestsPlugin..."); +await lftp(`set xfer:clobber on; mkdir -p ${config.GS_PLUGIN_DIR}; mirror -R --delete ${paths.testBuild} ${config.GS_PLUGIN_DIR}; bye`); -// ── Step 3: Restart the game server via Pterodactyl and wait ──────────── +// ── Restart server ────────────────────────────────────────────────────── -const rcon = `${HERE}/rcon`; -const restartTimeout = parseInt(config.GS_RESTART_TIMEOUT, 10); const pteroHeaders = { "Authorization": `Bearer ${config.PTERO_API_KEY}`, "Content-Type": "application/json", "Accept": "application/json", }; -const pteroBaseUrl = `${config.PTERO_URL}/api/client/servers/${config.PTERO_SERVER_ID}`; -$.logStep("Restarting game server via Pterodactyl..."); -const restartResp = await fetch(`${pteroBaseUrl}/power`, { +$.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 (!restartResp.ok) { - const body = await restartResp.text(); - $.logError(`Pterodactyl restart failed (${restartResp.status}): ${body}`); +if (!resp.ok) { + $.logError(`Restart failed (${resp.status}): ${await resp.text()}`); Deno.exit(1); } -$.logStep(`Waiting for game server to come back (timeout: ${restartTimeout}s)...`); -// Wait a bit before polling to give the server time to shut down -await $.sleep(10_000); - -const startTime = Date.now(); -const pollInterval = 5_000; // 5 seconds between polls -let serverReady = false; - -while (Date.now() - startTime < restartTimeout * 1000) { - try { - const response = await $`${rcon} -a ${config.GS_HOST}:${config.GS_PORT} -p ${config.GS_PASS} "status"`.text(); - if (response) { - serverReady = true; - break; - } - } catch { - const elapsed = Math.round((Date.now() - startTime) / 1000); - $.logLight(` Server not ready yet (${elapsed}s elapsed)...`); - } - await $.sleep(pollInterval); -} - -if (!serverReady) { - $.logError(`Game server did not come back within ${restartTimeout}s!`); - Deno.exit(1); -} +await $.sleep(5_000); -$.logStep("Game server is back online!"); +const restartTimeout = parseInt(config.GS_RESTART_TIMEOUT, 10); +await poll({ timeout: restartTimeout, interval: 5_000, label: "Server not ready yet" }, async () => { + const out = await rcon('"status"').text(); + if (!out) throw new Error(); +}); -// Give the server a few extra seconds to fully initialize plugins +$.logStep("Server is back online."); await $.sleep(5_000); -// ── Step 4: Load the plugin and run benchmarks via RCON ───────────────── +// ── Run benchmarks ────────────────────────────────────────────────────── -$.logStep("Loading plugin via RCON..."); +$.logStep("Loading plugin..."); try { - await $`${rcon} -a ${config.GS_HOST}:${config.GS_PORT} -p ${config.GS_PASS} "css_plugins load NativeTestsPlugin"`.text(); + await rcon('"css_plugins load NativeTestsPlugin"').text(); } catch { - // Plugin may already be loaded; try reloading - await $`${rcon} -a ${config.GS_HOST}:${config.GS_PORT} -p ${config.GS_PASS} "css_plugins reload NativeTestsPlugin"`.text(); + await rcon('"css_plugins reload NativeTestsPlugin"').text(); } -$.logStep("Running benchmarks via RCON: css_itest benchmark ..."); -const benchOutput = await $`${rcon} -a ${config.GS_HOST}:${config.GS_PORT} -p ${config.GS_PASS} -T 120s "css_itest benchmark"`.text(); -console.log(benchOutput); +// Delete stale results so we can detect when new ones land +$.logStep("Clearing old results..."); +try { await lftp(`rm -f ${remoteJson}; rm -f ${remoteMd}; bye`); } catch { /* noop */ } + +$.logStep("Running: css_itest benchmark"); +console.log(await rcon('-T 120s "css_itest benchmark"').text()); -const remoteJsonPath = `${remotePluginDir}/benchmark-results.json`; -const remoteMdPath = `${remotePluginDir}/benchmark-results.md`; +// Benchmarks with async tests (entity creation) return before they're +// done, so poll until ExportResults() writes the JSON file. +await poll({ timeout: 300, interval: 5_000, label: "Waiting for results" }, () => + lftp(`cat ${remoteJson}; bye`) +); -// ── Step 5: Download the benchmark results ────────────────────────────── +await $.sleep(2_000); // let the .md flush too -$.logStep("Downloading benchmark results..."); -await Deno.mkdir(RESULTS_OUTPUT.toString(), { recursive: true }); +// ── Download results ──────────────────────────────────────────────────── -const localJsonPath = RESULTS_OUTPUT.join("benchmark-results.json").toString(); -const localMdPath = RESULTS_OUTPUT.join("benchmark-results.md").toString(); +$.logStep("Downloading results..."); +await Deno.mkdir(paths.results.toString(), { recursive: true }); -await $`lftp -u ${config.SFTP_USER},${config.SFTP_PASS} ${config.SFTP_HOST} -e ${`set xfer:clobber on; get ${remoteJsonPath} -o ${localJsonPath}; get ${remoteMdPath} -o ${localMdPath}; bye` - }`.quiet(); +const localJson = paths.results.join("benchmark-results.json").toString(); +const localMd = paths.results.join("benchmark-results.md").toString(); -$.logStep("Benchmark results downloaded successfully!"); -$.logLight(` JSON: ${localJsonPath}`); -$.logLight(` MD: ${localMdPath}`); +await lftp(`set xfer:clobber on; get ${remoteJson} -o ${localJson}; get ${remoteMd} -o ${localMd}; bye`); -// Print the markdown summary -const mdContent = await Deno.readTextFile(localMdPath); -console.log("\n" + mdContent); +$.logLight(` JSON: ${localJson}`); +$.logLight(` MD: ${localMd}`); +console.log("\n" + await Deno.readTextFile(localMd)); diff --git a/managed/CounterStrikeSharp.Tests.Native/ScriptContextBenchmarks.cs b/managed/CounterStrikeSharp.Tests.Native/ScriptContextBenchmarks.cs index b8cb2a4e3..56b08c0d3 100644 --- a/managed/CounterStrikeSharp.Tests.Native/ScriptContextBenchmarks.cs +++ b/managed/CounterStrikeSharp.Tests.Native/ScriptContextBenchmarks.cs @@ -5,6 +5,7 @@ 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; @@ -39,345 +40,235 @@ public class ScriptContextBenchmarks 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_PrimitiveIntReturn() + public void Benchmark_GetTickCount() { - for (int i = 0; i < WarmupIterations; i++) - NativeAPI.GetTickCount(); - - var sw = Stopwatch.StartNew(); - for (int i = 0; i < Iterations; i++) - NativeAPI.GetTickCount(); - sw.Stop(); - - Record("GetTickCount (int return, no args)", "Primitive Return", sw); + Run("GetTickCount (int, no args)", "Primitive Return", () => NativeAPI.GetTickCount()); } [Fact] - public void Benchmark_GetTickInterval_PrimitiveFloatReturn() + public void Benchmark_GetTickInterval() { - for (int i = 0; i < WarmupIterations; i++) - NativeAPI.GetTickInterval(); - - var sw = Stopwatch.StartNew(); - for (int i = 0; i < Iterations; i++) - NativeAPI.GetTickInterval(); - sw.Stop(); - - Record("GetTickInterval (float return, no args)", "Primitive Return", sw); + Run("GetTickInterval (float, no args)", "Primitive Return", () => NativeAPI.GetTickInterval()); } [Fact] - public void Benchmark_GetEngineTime_PrimitiveDoubleReturn() + public void Benchmark_GetEngineTime() { - for (int i = 0; i < WarmupIterations; i++) - NativeAPI.GetEngineTime(); - - var sw = Stopwatch.StartNew(); - for (int i = 0; i < Iterations; i++) - NativeAPI.GetEngineTime(); - sw.Stop(); - - Record("GetEngineTime (double return, no args)", "Primitive Return", sw); + Run("GetEngineTime (double, no args)", "Primitive Return", () => NativeAPI.GetEngineTime()); } [Fact] - public void Benchmark_GetMaxClients_PrimitiveIntReturn() + public void Benchmark_GetMaxClients() { - for (int i = 0; i < WarmupIterations; i++) - NativeAPI.GetMaxClients(); - - var sw = Stopwatch.StartNew(); - for (int i = 0; i < Iterations; i++) - NativeAPI.GetMaxClients(); - sw.Stop(); - - Record("GetMaxClients (int return, no args)", "Primitive Return", sw); + Run("GetMaxClients (int, no args)", "Primitive Return", () => NativeAPI.GetMaxClients()); } [Fact] - public void Benchmark_IsServerPaused_PrimitiveBoolReturn() + public void Benchmark_IsServerPaused() { - for (int i = 0; i < WarmupIterations; i++) - NativeAPI.IsServerPaused(); - - var sw = Stopwatch.StartNew(); - for (int i = 0; i < Iterations; i++) - NativeAPI.IsServerPaused(); - sw.Stop(); - - Record("IsServerPaused (bool return, no args)", "Primitive Return", sw); + Run("IsServerPaused (bool, no args)", "Primitive Return", () => NativeAPI.IsServerPaused()); } - // ── String returns, no args ───────────────────────────────────────── + // ── String returns ────────────────────────────────────────────────── [Fact] - public void Benchmark_GetMapName_StringReturn() + public void Benchmark_GetMapName() { - for (int i = 0; i < WarmupIterations; i++) - NativeAPI.GetMapName(); - - var sw = Stopwatch.StartNew(); - for (int i = 0; i < Iterations; i++) - NativeAPI.GetMapName(); - sw.Stop(); - - Record("GetMapName (string return, no args)", "String Return", sw); + Run("GetMapName (string, no args)", "String Return", () => NativeAPI.GetMapName()); } - // ── Primitive arg → primitive return ──────────────────────────────── - [Fact] - public void Benchmark_GetConvarFlags_UshortArgUlongReturn() + public void Benchmark_GetConvarName() { - var index = NativeAPI.GetConvarAccessIndexByName("sv_cheats"); - - for (int i = 0; i < WarmupIterations; i++) - NativeAPI.GetConvarFlags(index); - - var sw = Stopwatch.StartNew(); - for (int i = 0; i < Iterations; i++) - NativeAPI.GetConvarFlags(index); - sw.Stop(); - - Record("GetConvarFlags (ushort arg, ulong return)", "Primitive Args", sw); + var idx = NativeAPI.GetConvarAccessIndexByName("sv_cheats"); + Run("GetConvarName (ushort → string)", "String Return", () => NativeAPI.GetConvarName(idx)); } + // ── Primitive arg → primitive return ──────────────────────────────── + [Fact] - public void Benchmark_GetConvarType_UshortArgShortReturn() + public void Benchmark_GetConvarFlags() { - var index = NativeAPI.GetConvarAccessIndexByName("sv_cheats"); - - for (int i = 0; i < WarmupIterations; i++) - NativeAPI.GetConvarType(index); - - var sw = Stopwatch.StartNew(); - for (int i = 0; i < Iterations; i++) - NativeAPI.GetConvarType(index); - sw.Stop(); - - Record("GetConvarType (ushort arg, short return)", "Primitive Args", sw); + var idx = NativeAPI.GetConvarAccessIndexByName("sv_cheats"); + Run("GetConvarFlags (ushort → ulong)", "Primitive Args", () => NativeAPI.GetConvarFlags(idx)); } - // ── Primitive arg → string return ─────────────────────────────────── - [Fact] - public void Benchmark_GetConvarName_UshortArgStringReturn() + public void Benchmark_GetConvarType() { - var index = NativeAPI.GetConvarAccessIndexByName("sv_cheats"); - - for (int i = 0; i < WarmupIterations; i++) - NativeAPI.GetConvarName(index); - - var sw = Stopwatch.StartNew(); - for (int i = 0; i < Iterations; i++) - NativeAPI.GetConvarName(index); - sw.Stop(); - - Record("GetConvarName (ushort arg, string return)", "String Return", sw); + var idx = NativeAPI.GetConvarAccessIndexByName("sv_cheats"); + Run("GetConvarType (ushort → short)", "Primitive Args", () => NativeAPI.GetConvarType(idx)); } - // ── String arg → pointer return (measures string push cost) ───────── + // ── String push cost ──────────────────────────────────────────────── [Fact] - public void Benchmark_FindConvar_StringArgPointerReturn() + public void Benchmark_FindConvar() { - for (int i = 0; i < WarmupIterations; i++) - NativeAPI.FindConvar("sv_cheats"); - - var sw = Stopwatch.StartNew(); - for (int i = 0; i < Iterations; i++) - NativeAPI.FindConvar("sv_cheats"); - sw.Stop(); - - Record("FindConvar (string arg, pointer return)", "String Push", sw); + Run("FindConvar (string → pointer)", "String Push", () => NativeAPI.FindConvar("sv_cheats")); } - // ── String push scaling (short / medium / long) ───────────────────── - [Fact] - public void Benchmark_PushString_ShortString() + public void Benchmark_PushString_Short() { - for (int i = 0; i < WarmupIterations; i++) - NativeAPI.GetConvarAccessIndexByName("sv_cheats"); - - var sw = Stopwatch.StartNew(); - for (int i = 0; i < Iterations; i++) - NativeAPI.GetConvarAccessIndexByName("sv_cheats"); - sw.Stop(); - - Record("PushString short 'sv_cheats' (9 bytes)", "String Push", sw); + Run("PushString 9 bytes", "String Push", () => NativeAPI.GetConvarAccessIndexByName("sv_cheats")); } [Fact] - public void Benchmark_PushString_MediumString() + public void Benchmark_PushString_Medium() { - var mediumStr = "sv_cheats" + new string('x', 191); // 200 bytes total - - for (int i = 0; i < WarmupIterations; i++) - NativeAPI.GetConvarAccessIndexByName(mediumStr); - - var sw = Stopwatch.StartNew(); - for (int i = 0; i < Iterations; i++) - NativeAPI.GetConvarAccessIndexByName(mediumStr); - sw.Stop(); - - Record("PushString medium (200 bytes)", "String Push", sw); + var str = "sv_cheats" + new string('x', 191); // 200 bytes + Run("PushString 200 bytes", "String Push", () => NativeAPI.GetConvarAccessIndexByName(str)); } [Fact] - public void Benchmark_PushString_LongString() + public void Benchmark_PushString_Long() { - var longStr = "sv_cheats" + new string('x', 1991); // 2000 bytes total - - for (int i = 0; i < WarmupIterations; i++) - NativeAPI.GetConvarAccessIndexByName(longStr); - - var sw = Stopwatch.StartNew(); - for (int i = 0; i < Iterations; i++) - NativeAPI.GetConvarAccessIndexByName(longStr); - sw.Stop(); - - Record("PushString long (2000 bytes)", "String Push", sw); + var str = "sv_cheats" + new string('x', 1991); // 2000 bytes + Run("PushString 2000 bytes", "String Push", () => NativeAPI.GetConvarAccessIndexByName(str)); } [Fact] - public void Benchmark_PushString_OverflowString() + public void Benchmark_PushString_Overflow() { - var hugeStr = new string('x', 9000); // exceeds 8192 arena - - for (int i = 0; i < WarmupIterations; i++) - NativeAPI.GetConvarAccessIndexByName(hugeStr); - - var sw = Stopwatch.StartNew(); - for (int i = 0; i < Iterations; i++) - NativeAPI.GetConvarAccessIndexByName(hugeStr); - sw.Stop(); - - Record("PushString overflow (9000 bytes)", "String Push", sw); + 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_MixedSummary() + public void Benchmark_Mixed() { - var convarIndex = NativeAPI.GetConvarAccessIndexByName("sv_cheats"); + var convarIdx = NativeAPI.GetConvarAccessIndexByName("sv_cheats"); - for (int i = 0; i < WarmupIterations; i++) + Run("Mixed (4 natives/iter)", "Mixed", () => { NativeAPI.GetTickCount(); NativeAPI.GetMapName(); NativeAPI.FindConvar("sv_cheats"); - NativeAPI.GetConvarFlags(convarIndex); - } - - var sw = Stopwatch.StartNew(); - for (int i = 0; i < Iterations; i++) - { - NativeAPI.GetTickCount(); - NativeAPI.GetMapName(); - NativeAPI.FindConvar("sv_cheats"); - NativeAPI.GetConvarFlags(convarIndex); - } - - sw.Stop(); - - Record("Mixed workload (4 natives per iteration)", "Mixed", sw, Iterations * 4); + NativeAPI.GetConvarFlags(convarIdx); + }, callsPerIteration: 4); } - // ── Schema property access ───────────────────────────────────────── + // ── Schema ────────────────────────────────────────────────────────── [Fact] - public void Benchmark_SchemaOffset_CachedLookup() + public void Benchmark_SchemaOffset_Cached() { - // Prime the cache - Schema.GetSchemaOffset("CBaseEntity", "m_iHealth"); - - for (int i = 0; i < WarmupIterations; i++) - Schema.GetSchemaOffset("CBaseEntity", "m_iHealth"); - - var sw = Stopwatch.StartNew(); - for (int i = 0; i < Iterations; i++) - Schema.GetSchemaOffset("CBaseEntity", "m_iHealth"); - sw.Stop(); - - Record("Schema.GetSchemaOffset cached (record struct key)", "Schema", sw); + Schema.GetSchemaOffset("CBaseEntity", "m_iHealth"); // prime cache + Run("SchemaOffset cached", "Schema", () => Schema.GetSchemaOffset("CBaseEntity", "m_iHealth")); } [Fact] - public void Benchmark_SchemaOffset_MultipleDifferentKeys() + public void Benchmark_SchemaOffset_MultipleKeys() { - // Prime caches for several different fields Schema.GetSchemaOffset("CBaseEntity", "m_iHealth"); Schema.GetSchemaOffset("CBaseEntity", "m_iTeamNum"); Schema.GetSchemaOffset("CBaseEntity", "m_fFlags"); Schema.GetSchemaOffset("CBasePlayerPawn", "m_vecAbsVelocity"); - for (int i = 0; i < WarmupIterations; i++) + 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); + } - var sw = Stopwatch.StartNew(); - for (int i = 0; i < Iterations; i++) - { - Schema.GetSchemaOffset("CBaseEntity", "m_iHealth"); - Schema.GetSchemaOffset("CBaseEntity", "m_iTeamNum"); - Schema.GetSchemaOffset("CBaseEntity", "m_fFlags"); - Schema.GetSchemaOffset("CBasePlayerPawn", "m_vecAbsVelocity"); - } + [Fact] + public void Benchmark_GetDeclaredClass() + { + var world = GetWorld(); + if (world == null) { Skip("no world entity"); return; } - sw.Stop(); + Run("GetDeclaredClass (CBodyComponent)", "Schema", () => _ = world.CBodyComponent); + } + + [Fact] + public void Benchmark_GetSchemaValue_Int() + { + var world = GetWorld(); + if (world == null) { Skip("no world entity"); return; } - Record("Schema.GetSchemaOffset 4 different keys", "Schema", sw, Iterations * 4); + Run("GetSchemaValue (Health)", "Schema", () => _ = world.Health); } + // ── Virtual function invocation ──────────────────────────────────── + [Fact] - public void Benchmark_GetDeclaredClass() + public void Benchmark_VFunc_PreCreatedDelegate() { - var world = Utilities.FindAllEntitiesByDesignerName("worldent").FirstOrDefault(); - if (world == null) - { - Console.WriteLine("[BENCH] SKIP: GetDeclaredClass - no world entity"); - return; - } + var world = GetWorld(); + if (world == null) { Skip("no world entity"); return; } - for (int i = 0; i < WarmupIterations; i++) - _ = world.CBodyComponent; + // 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; - var sw = Stopwatch.StartNew(); - for (int i = 0; i < Iterations; i++) - _ = world.CBodyComponent; - sw.Stop(); + 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; } - Record("GetDeclaredClass via CBodyComponent (FastNew)", "Schema", sw); + // Full path: Guard.IsValidEntity + GameData.GetOffset + + // VirtualFunction.Create + invoke on every call. + Run("VFunc IsPlayerPawn (high-level API)", "Virtual Function", () => world.IsPlayerPawn()); } + // ── Entity lifecycle ──────────────────────────────────────────────── + [Fact] - public void Benchmark_GetSchemaValue_Int() + public async Task Benchmark_EntityCreateAndDelete() { - var world = Utilities.FindAllEntitiesByDesignerName("worldent").FirstOrDefault(); - if (world == null) + // 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++) { - Console.WriteLine("[BENCH] SKIP: GetSchemaValue - no world entity"); - return; + buf[i] = Utilities.CreateEntityByName("info_target")!; + buf[i].DispatchSpawn(); } + for (int i = 0; i < batchSize; i++) + buf[i].Remove(); + await TestUtils.WaitOneFrame(); - for (int i = 0; i < WarmupIterations; i++) - _ = world.Health; + var sw = new Stopwatch(); - var sw = Stopwatch.StartNew(); - for (int i = 0; i < Iterations; i++) - _ = world.Health; - sw.Stop(); + 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("GetSchemaValue via Health property", "Schema", sw); + Record("Entity create+spawn info_target", "Entity Lifecycle", sw, (long)batches * batchSize); } // ── Export ─────────────────────────────────────────────────────────── @@ -387,13 +278,13 @@ public static void ExportResults() List snapshot; lock (_lock) { - snapshot = new List(_results.OrderBy(r => r.Category).ThenBy(r => r.Name)); + snapshot = _results.OrderBy(r => r.Category).ThenBy(r => r.Name).ToList(); _results.Clear(); } if (snapshot.Count == 0) { - Console.WriteLine("[BENCH] No benchmark results to export."); + Console.WriteLine("[BENCH] No results to export."); return; } @@ -406,15 +297,14 @@ public static void ExportResults() Results = snapshot }; - var outputDir = TryGetPluginDirectory() ?? Path.GetTempPath(); - Directory.CreateDirectory(outputDir); + var dir = TryGetPluginDirectory() ?? Path.GetTempPath(); + Directory.CreateDirectory(dir); - var jsonPath = Path.Combine(outputDir, "benchmark-results.json"); - var jsonOptions = new JsonSerializerOptions { WriteIndented = true }; - File.WriteAllText(jsonPath, JsonSerializer.Serialize(report, jsonOptions)); + var jsonPath = Path.Combine(dir, "benchmark-results.json"); + File.WriteAllText(jsonPath, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true })); - var mdPath = Path.Combine(outputDir, "benchmark-results.md"); - File.WriteAllText(mdPath, GenerateMarkdown(report)); + var mdPath = Path.Combine(dir, "benchmark-results.md"); + File.WriteAllText(mdPath, FormatMarkdown(report)); Console.WriteLine("=== BENCHMARK EXPORT ==="); Console.WriteLine($" JSON: {jsonPath}"); @@ -423,60 +313,70 @@ public static void ExportResults() Console.WriteLine("========================"); } - // ── Internal helpers ──────────────────────────────────────────────── + // ── Helpers ────────────────────────────────────────────────────────── - private static void Record(string name, string category, Stopwatch sw, long? totalCalls = null) + /// + /// 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) { - var calls = totalCalls ?? Iterations; - var elapsed = sw.Elapsed; - var nsPerCall = elapsed.TotalNanoseconds / calls; - var callsPerSecond = calls / elapsed.TotalSeconds; + for (int i = 0; i < WarmupIterations; i++) + body(); - var result = new BenchmarkResult - { - Name = name, - Category = category, - TotalCalls = calls, - TotalMs = Math.Round(elapsed.TotalMilliseconds, 2), - NsPerCall = Math.Round(nsPerCall, 0), - CallsPerSecond = Math.Round(callsPerSecond, 0) - }; + 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(result); + _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( - $" {calls:N0} calls in {elapsed.TotalMilliseconds:F2} ms | {nsPerCall:F0} ns/call | {callsPerSecond:N0} calls/sec"); + 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 TryGetMapName() { - try - { - return NativeAPI.GetMapName(); - } - catch - { - return "unknown"; - } + try { return NativeAPI.GetMapName(); } + catch { return "unknown"; } } private static string? TryGetPluginDirectory() { try { - return NativeTestsPlugin.Instance?.ModulePath != null ? Path.GetDirectoryName(NativeTestsPlugin.Instance.ModulePath) : null; - } - catch - { - return null; + var path = NativeTestsPlugin.Instance?.ModulePath; + return path != null ? Path.GetDirectoryName(path) : null; } + catch { return null; } } - private static string GenerateMarkdown(BenchmarkReport report) + private static string FormatMarkdown(BenchmarkReport report) { var sb = new StringBuilder(); sb.AppendLine("# Benchmark Results"); @@ -487,11 +387,7 @@ private static string GenerateMarkdown(BenchmarkReport report) sb.AppendLine($"- **Warmup:** {report.WarmupIterations:N0}"); sb.AppendLine(); - var categories = report.Results - .GroupBy(r => r.Category) - .OrderBy(g => g.Key); - - foreach (var group in categories) + foreach (var group in report.Results.GroupBy(r => r.Category).OrderBy(g => g.Key)) { sb.AppendLine($"## {group.Key}"); sb.AppendLine(); @@ -499,9 +395,7 @@ private static string GenerateMarkdown(BenchmarkReport report) sb.AppendLine("|:----------|--------:|----------:|---------:|"); foreach (var r in group) - { sb.AppendLine($"| {r.Name} | {r.NsPerCall:N0} | {r.CallsPerSecond:N0} | {r.TotalMs:F2} |"); - } sb.AppendLine(); } From ae604f1640ecbc0d8d41c2b75c482f5dc1198f13 Mon Sep 17 00:00:00 2001 From: Michael Wilson Date: Mon, 23 Mar 2026 04:52:38 +0000 Subject: [PATCH 4/8] fix: script --- eng/run-benchmarks.ts | 53 +++++++++---------- .../ScriptContextBenchmarks.cs | 40 +++++++++----- 2 files changed, 50 insertions(+), 43 deletions(-) diff --git a/eng/run-benchmarks.ts b/eng/run-benchmarks.ts index 83401b004..3917f8493 100755 --- a/eng/run-benchmarks.ts +++ b/eng/run-benchmarks.ts @@ -34,30 +34,23 @@ const paths = { 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`; -function lftp(commands: string) { - return $`lftp -u ${config.SFTP_USER},${config.SFTP_PASS} ${config.SFTP_HOST} -e ${commands}`.quiet(); -} - -function rcon(command: string) { - return $`${HERE}/rcon -a ${config.GS_HOST}:${config.GS_PORT} -p ${config.GS_PASS} ${command}`; -} - -async function poll(opts: { timeout: number; interval: number; label: string }, check: () => Promise) { +async function poll(timeoutSec: number, intervalMs: number, label: string, check: () => Promise) { const start = Date.now(); - while (Date.now() - start < opts.timeout * 1000) { + while (Date.now() - start < timeoutSec * 1000) { try { await check(); return; } catch { const elapsed = Math.round((Date.now() - start) / 1000); - $.logLight(` ${opts.label} (${elapsed}s elapsed)...`); + $.logLight(` ${label} (${elapsed}s elapsed)...`); } - await $.sleep(opts.interval); + await $.sleep(intervalMs); } - throw new Error(`${opts.label}: timed out after ${opts.timeout}s`); + throw new Error(`${label}: timed out after ${timeoutSec}s`); } // ── Build ─────────────────────────────────────────────────────────────── @@ -71,15 +64,15 @@ await $`dotnet build ${paths.testProject} -c Debug`; // ── Upload ────────────────────────────────────────────────────────────── $.logStep("Uploading API..."); -await lftp(`set xfer:clobber on; mkdir -p ${config.GS_API_DIR}; mirror -R --delete ${paths.apiBuild} ${config.GS_API_DIR}; bye`); +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(`set xfer:clobber on; mkdir -p ${config.GS_NATIVE_DIR}; put ${paths.nativeSo} -o ${config.GS_NATIVE_DIR}/counterstrikesharp.so; bye`); + 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(`set xfer:clobber on; mkdir -p ${config.GS_PLUGIN_DIR}; mirror -R --delete ${paths.testBuild} ${config.GS_PLUGIN_DIR}; bye`); +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 ────────────────────────────────────────────────────── @@ -100,11 +93,11 @@ if (!resp.ok) { Deno.exit(1); } -await $.sleep(5_000); +await $.sleep(10_000); const restartTimeout = parseInt(config.GS_RESTART_TIMEOUT, 10); -await poll({ timeout: restartTimeout, interval: 5_000, label: "Server not ready yet" }, async () => { - const out = await rcon('"status"').text(); +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(); }); @@ -115,23 +108,25 @@ await $.sleep(5_000); $.logStep("Loading plugin..."); try { - await rcon('"css_plugins load NativeTestsPlugin"').text(); + await $`${rcon} -a ${config.GS_HOST}:${config.GS_PORT} -p ${config.GS_PASS} "css_plugins load NativeTestsPlugin"`.text(); } catch { - await rcon('"css_plugins reload NativeTestsPlugin"').text(); + 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(`rm -f ${remoteJson}; rm -f ${remoteMd}; bye`); } catch { /* noop */ } +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('-T 120s "css_itest benchmark"').text()); +console.log(await $`${rcon} -a ${config.GS_HOST}:${config.GS_PORT} -p ${config.GS_PASS} -T 120s "css_itest benchmark"`.text()); -// Benchmarks with async tests (entity creation) return before they're -// done, so poll until ExportResults() writes the JSON file. -await poll({ timeout: 300, interval: 5_000, label: "Waiting for results" }, () => - lftp(`cat ${remoteJson}; bye`) -); +// 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 @@ -143,7 +138,7 @@ 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(`set xfer:clobber on; get ${remoteJson} -o ${localJson}; get ${remoteMd} -o ${localMd}; bye`); +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(); $.logLight(` JSON: ${localJson}`); $.logLight(` MD: ${localMd}`); diff --git a/managed/CounterStrikeSharp.Tests.Native/ScriptContextBenchmarks.cs b/managed/CounterStrikeSharp.Tests.Native/ScriptContextBenchmarks.cs index 56b08c0d3..0069520d8 100644 --- a/managed/CounterStrikeSharp.Tests.Native/ScriptContextBenchmarks.cs +++ b/managed/CounterStrikeSharp.Tests.Native/ScriptContextBenchmarks.cs @@ -26,7 +26,6 @@ public class BenchmarkResult public class BenchmarkReport { public string Timestamp { get; set; } = ""; - public string MapName { get; set; } = ""; public int Iterations { get; set; } public int WarmupIterations { get; set; } public List Results { get; set; } = new(); @@ -187,7 +186,11 @@ public void Benchmark_SchemaOffset_MultipleKeys() public void Benchmark_GetDeclaredClass() { var world = GetWorld(); - if (world == null) { Skip("no world entity"); return; } + if (world == null) + { + Skip("no world entity"); + return; + } Run("GetDeclaredClass (CBodyComponent)", "Schema", () => _ = world.CBodyComponent); } @@ -196,7 +199,11 @@ public void Benchmark_GetDeclaredClass() public void Benchmark_GetSchemaValue_Int() { var world = GetWorld(); - if (world == null) { Skip("no world entity"); return; } + if (world == null) + { + Skip("no world entity"); + return; + } Run("GetSchemaValue (Health)", "Schema", () => _ = world.Health); } @@ -207,7 +214,11 @@ public void Benchmark_GetSchemaValue_Int() public void Benchmark_VFunc_PreCreatedDelegate() { var world = GetWorld(); - if (world == null) { Skip("no world entity"); return; } + 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. @@ -222,7 +233,11 @@ public void Benchmark_VFunc_PreCreatedDelegate() public void Benchmark_VFunc_HighLevelApi() { var world = GetWorld(); - if (world == null) { Skip("no world entity"); return; } + if (world == null) + { + Skip("no world entity"); + return; + } // Full path: Guard.IsValidEntity + GameData.GetOffset + // VirtualFunction.Create + invoke on every call. @@ -247,6 +262,7 @@ public async Task Benchmark_EntityCreateAndDelete() buf[i] = Utilities.CreateEntityByName("info_target")!; buf[i].DispatchSpawn(); } + for (int i = 0; i < batchSize; i++) buf[i].Remove(); await TestUtils.WaitOneFrame(); @@ -261,6 +277,7 @@ public async Task Benchmark_EntityCreateAndDelete() buf[i] = Utilities.CreateEntityByName("info_target")!; buf[i].DispatchSpawn(); } + sw.Stop(); for (int i = 0; i < batchSize; i++) @@ -291,7 +308,6 @@ public static void ExportResults() var report = new BenchmarkReport { Timestamp = DateTime.UtcNow.ToString("o"), - MapName = TryGetMapName(), Iterations = Iterations, WarmupIterations = WarmupIterations, Results = snapshot @@ -360,12 +376,6 @@ private static void Record(string name, string category, Stopwatch sw, long tota private static void Skip(string reason) => Console.WriteLine($"[BENCH] SKIP: {reason}"); - private static string TryGetMapName() - { - try { return NativeAPI.GetMapName(); } - catch { return "unknown"; } - } - private static string? TryGetPluginDirectory() { try @@ -373,7 +383,10 @@ private static string TryGetMapName() var path = NativeTestsPlugin.Instance?.ModulePath; return path != null ? Path.GetDirectoryName(path) : null; } - catch { return null; } + catch + { + return null; + } } private static string FormatMarkdown(BenchmarkReport report) @@ -382,7 +395,6 @@ private static string FormatMarkdown(BenchmarkReport report) sb.AppendLine("# Benchmark Results"); sb.AppendLine(); sb.AppendLine($"- **Date:** {report.Timestamp}"); - sb.AppendLine($"- **Map:** {report.MapName}"); sb.AppendLine($"- **Iterations:** {report.Iterations:N0}"); sb.AppendLine($"- **Warmup:** {report.WarmupIterations:N0}"); sb.AppendLine(); From bd2f7934a20c0d3d8a72eeb9ce6024e94cdb92fb Mon Sep 17 00:00:00 2001 From: Michael Wilson Date: Mon, 23 Mar 2026 06:13:01 +0000 Subject: [PATCH 5/8] feat: add a couple more benchmarks --- eng/run-benchmarks.ts | 19 +++++++- .../ScriptContextBenchmarks.cs | 44 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/eng/run-benchmarks.ts b/eng/run-benchmarks.ts index 3917f8493..a0afc31fc 100755 --- a/eng/run-benchmarks.ts +++ b/eng/run-benchmarks.ts @@ -140,6 +140,23 @@ 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" + await Deno.readTextFile(localMd)); +console.log("\n" + md); diff --git a/managed/CounterStrikeSharp.Tests.Native/ScriptContextBenchmarks.cs b/managed/CounterStrikeSharp.Tests.Native/ScriptContextBenchmarks.cs index 0069520d8..20ac589fc 100644 --- a/managed/CounterStrikeSharp.Tests.Native/ScriptContextBenchmarks.cs +++ b/managed/CounterStrikeSharp.Tests.Native/ScriptContextBenchmarks.cs @@ -9,6 +9,7 @@ using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Modules.Memory; +using CounterStrikeSharp.API.Modules.UserMessages; using Xunit; namespace NativeTestsPlugin; @@ -56,6 +57,15 @@ 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() { @@ -244,8 +254,42 @@ public void Benchmark_VFunc_HighLevelApi() 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() { From ed8d16332166b47d32521a618ee6e7e8e7e6640b Mon Sep 17 00:00:00 2001 From: Michael Wilson Date: Mon, 23 Mar 2026 06:15:33 +0000 Subject: [PATCH 6/8] fix: update sync schema script to use typescript --- .github/workflows/sync-schema.yaml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) 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." From e111c2482dde4a0d15e943dbb3f925c61f5bf2ab Mon Sep 17 00:00:00 2001 From: Michael Wilson Date: Mon, 23 Mar 2026 06:18:50 +0000 Subject: [PATCH 7/8] fix: env --- eng/run-benchmarks.ts | 2 +- eng/update-schema.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/eng/run-benchmarks.ts b/eng/run-benchmarks.ts index a0afc31fc..05efd1de9 100755 --- a/eng/run-benchmarks.ts +++ b/eng/run-benchmarks.ts @@ -4,7 +4,7 @@ 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 = await load({ export: true }); +const env = { ...Deno.env.toObject(), ...await load({ export: true }) }; const config = z.object({ GS_HOST: z.string().min(1), diff --git a/eng/update-schema.ts b/eng/update-schema.ts index 0f73cfaa3..8b3a9a831 100755 --- a/eng/update-schema.ts +++ b/eng/update-schema.ts @@ -4,7 +4,7 @@ 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 = await load({ export: true }); +const env = { ...Deno.env.toObject(), ...await load({ export: true }) }; const config = z.object({ // Game server RCON details From cd77985905e0397ae2b4d421ae191571abb6ffe6 Mon Sep 17 00:00:00 2001 From: Michael Wilson Date: Mon, 23 Mar 2026 06:21:36 +0000 Subject: [PATCH 8/8] fix: path --- eng/update-schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eng/update-schema.ts b/eng/update-schema.ts index 8b3a9a831..bb40e18ad 100755 --- a/eng/update-schema.ts +++ b/eng/update-schema.ts @@ -17,7 +17,7 @@ const config = z.object({ SFTP_PASS: z.string().min(1, "SFTP_PASS env var is required"), }).parse(env); -const HERE = $.path('.'); +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();