diff --git a/tools/perf-automation/Azure.Sdk.Tools.PerfAutomation.Tests/GoTests.cs b/tools/perf-automation/Azure.Sdk.Tools.PerfAutomation.Tests/GoTests.cs new file mode 100644 index 00000000000..5dabe67b07f --- /dev/null +++ b/tools/perf-automation/Azure.Sdk.Tools.PerfAutomation.Tests/GoTests.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.IO; +using NUnit.Framework; +using System.Reflection; + +namespace Azure.Sdk.Tools.PerfAutomation.Tests +{ + [TestFixture] + public class GoTests + { + [Test] + public void ParseOpsPerSecond_ValidOutput_ReturnsValue() + { + var output = "Completed 721 operations in a weighted-average of 30.00s (24.033 ops/s, 0.042 s/op)"; + var value = InvokePrivateStaticDouble("ParseOpsPerSecond", output); + + Assert.That(value, Is.EqualTo(24.033).Within(0.0001)); + } + + [Test] + public void ParseOpsPerSecond_CommaFormattedOutput_ReturnsValue() + { + var output = "Completed 1,234 operations in a weighted-average of 1.00s (12,557.81 ops/s, 0.000 s/op)"; + var value = InvokePrivateStaticDouble("ParseOpsPerSecond", output); + + Assert.That(value, Is.EqualTo(12557.81).Within(0.0001)); + } + + [Test] + public void ParseOpsPerSecond_MissingPattern_ReturnsMinusOne() + { + var output = "No throughput line present"; + var value = InvokePrivateStaticDouble("ParseOpsPerSecond", output); + + Assert.That(value, Is.EqualTo(-1d)); + } + + [Test] + public void FilterRuntimePackageVersions_KeepsExpectedPackages() + { + var go = new Go(); + + var input = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["github.com/Azure/azure-sdk-for-go/sdk/storage/azblob"] = "v1.6.4", + ["golang.org/x/net"] = "v0.53.0", + ["go"] = "go1.23.0", + ["github.com/some/other"] = "v1.0.0" + }; + + var filtered = go.FilterRuntimePackageVersions(input); + + Assert.That(filtered, Is.Not.Null); + Assert.That(filtered.ContainsKey("github.com/Azure/azure-sdk-for-go/sdk/storage/azblob"), Is.True); + Assert.That(filtered.ContainsKey("golang.org/x/net"), Is.True); + Assert.That(filtered.ContainsKey("go"), Is.True); + Assert.That(filtered.ContainsKey("github.com/some/other"), Is.False); + } + + [Test] + public void ResolveSourcePath_AzurePackage_ReturnsExpectedPath() + { + var go = new Go(); + go.WorkingDirectory = Path.Combine(Path.DirectorySeparatorChar.ToString(), "tmp", "azure-sdk-for-go"); + + var result = InvokePrivateInstanceString(go, "ResolveSourcePath", "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob"); + var expected = Path.Combine(go.WorkingDirectory, "sdk", "storage", "azblob"); + + Assert.That(result, Is.EqualTo(expected)); + } + + [Test] + public void ResolveSourcePath_NonAzurePackage_Throws() + { + var go = new Go(); + go.WorkingDirectory = Path.Combine(Path.DirectorySeparatorChar.ToString(), "tmp", "azure-sdk-for-go"); + + var ex = Assert.Throws(() => + InvokePrivateInstanceString(go, "ResolveSourcePath", "github.com/not-azure/pkg")); + + Assert.That(ex, Is.Not.Null); + Assert.That(ex.InnerException, Is.TypeOf()); + Assert.That(ex.InnerException?.Message, Does.Contain("Cannot resolve source path")); + } + + private static double InvokePrivateStaticDouble(string methodName, string arg) + { + var method = typeof(Go).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Static); + Assert.That(method, Is.Not.Null, $"Method {methodName} not found"); + + var result = method!.Invoke(null, new object[] { arg }); + Assert.That(result, Is.TypeOf()); + + return (double)result!; + } + + private static string InvokePrivateInstanceString(object instance, string methodName, string arg) + { + var method = instance.GetType().GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance); + Assert.That(method, Is.Not.Null, $"Method {methodName} not found"); + + var result = method!.Invoke(instance, new object[] { arg }); + Assert.That(result, Is.TypeOf()); + + return (string)result!; + } + } +} \ No newline at end of file diff --git a/tools/perf-automation/Azure.Sdk.Tools.PerfAutomation/Go.cs b/tools/perf-automation/Azure.Sdk.Tools.PerfAutomation/Go.cs new file mode 100644 index 00000000000..839bb04bedb --- /dev/null +++ b/tools/perf-automation/Azure.Sdk.Tools.PerfAutomation/Go.cs @@ -0,0 +1,278 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; +using Azure.Sdk.Tools.PerfAutomation.Models; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +namespace Azure.Sdk.Tools.PerfAutomation +{ + public class Go : LanguageBase + { + protected override Language Language => Language.Go; + + private static readonly Regex _opsRegex = + new Regex(@"\(([-\d\.,]+)\s+ops/s", RegexOptions.IgnoreCase | RegexOptions.RightToLeft | RegexOptions.Compiled); + + public override async Task<(string output, string error, object context)> SetupAsync( + string project, + string languageVersion, + string primaryPackage, + IDictionary packageVersions, + bool debug) + { + var projectDirectory = Path.Combine(WorkingDirectory, project); + + var outputBuilder = new StringBuilder(); + var errorBuilder = new StringBuilder(); + + var goModPath = Path.Combine(projectDirectory, "go.mod"); + var goSumPath = Path.Combine(projectDirectory, "go.sum"); + + BackupFile(goModPath); + BackupFile(goSumPath); + + // Drop any existing replace directives for all requested packages to avoid stale state. + foreach (var pkg in packageVersions.Keys) + { + await DropReplaceAsync(projectDirectory, pkg, outputBuilder, errorBuilder); + } + + foreach (var kvp in packageVersions) + { + var packageName = kvp.Key; + var packageVersion = kvp.Value; + + if (string.Equals(packageVersion, Program.PackageVersionSource, StringComparison.OrdinalIgnoreCase)) + { + var localPath = ResolveSourcePath(packageName); + await AddReplaceAsync(projectDirectory, packageName, localPath, outputBuilder, errorBuilder); + } + else + { + // Ensure published version resolution for this package. + await Util.RunAsync( + "go", + $"get {packageName}@{packageVersion}", + projectDirectory, + outputBuilder: outputBuilder, + errorBuilder: errorBuilder); + } + } + + await Util.RunAsync( + "go", + "mod tidy", + projectDirectory, + outputBuilder: outputBuilder, + errorBuilder: errorBuilder); + + return (outputBuilder.ToString(), errorBuilder.ToString(), null); + } + + public override async Task RunAsync( + string project, + string languageVersion, + string primaryPackage, + IDictionary packageVersions, + string testName, + string arguments, + bool profile, + string profilerOptions, + object context) + { + var projectDirectory = Path.Combine(WorkingDirectory, project); + + var outputBuilder = new StringBuilder(); + var errorBuilder = new StringBuilder(); + + // Go perf runners in this scenario are CLI test apps. + var processResult = await Util.RunAsync( + "go", + $"run . {testName} {arguments}", + projectDirectory, + outputBuilder: outputBuilder, + errorBuilder: errorBuilder, + throwOnError: false); + + // Capture combined output for parsing and return. + var combinedOutput = (processResult.StandardOutput ?? string.Empty) + Environment.NewLine + (processResult.StandardError ?? string.Empty); + var opsPerSecond = ParseOpsPerSecond(combinedOutput); + + var runtimePackageVersions = await GetRuntimePackageVersionsAsync(projectDirectory); + + return new IterationResult + { + PackageVersions = runtimePackageVersions, + OperationsPerSecond = opsPerSecond, + StandardOutput = processResult.StandardOutput ?? outputBuilder.ToString(), + StandardError = processResult.StandardError ?? errorBuilder.ToString() + }; + } + + public override async Task CleanupAsync(string project) + { + var projectDirectory = Path.Combine(WorkingDirectory, project); + + RestoreBackup(Path.Combine(projectDirectory, "go.mod")); + RestoreBackup(Path.Combine(projectDirectory, "go.sum")); + + // Best-effort cleanup to ensure module graph is consistent after restore. + try + { + await Util.RunAsync( + "go", + "mod tidy", + projectDirectory, + throwOnError: false); + } + catch + { + // Ignore cleanup failures. + } + } + + public override IDictionary FilterRuntimePackageVersions(IDictionary runtimePackageVersions) + { + // Keep Azure packages and a minimal runtime signal. + return runtimePackageVersions? + .Where(kvp => + kvp.Key.StartsWith("github.com/Azure/", StringComparison.OrdinalIgnoreCase) || + kvp.Key.Equals("go", StringComparison.OrdinalIgnoreCase) || + kvp.Key.StartsWith("golang.org/x/", StringComparison.OrdinalIgnoreCase)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + } + + private static double ParseOpsPerSecond(string output) + { + if (string.IsNullOrWhiteSpace(output)) + { + return -1d; + } + + var match = _opsRegex.Match(output); + if (!match.Success) + { + return -1d; + } + + var raw = match.Groups[1].Value.Replace(",", string.Empty).Trim(); + + if (double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)) + { + return value; + } + + return -1d; + } + + private async Task> GetRuntimePackageVersionsAsync(string projectDirectory) + { + var runtimePackageVersions = new Dictionary(StringComparer.OrdinalIgnoreCase); + + try + { + var result = await Util.RunAsync( + "go", + "list -m all", + projectDirectory, + throwOnError: false); + + var lines = (result.StandardOutput ?? string.Empty) + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + + foreach (var line in lines) + { + // Formats: + // module version + // module version => replacement + // module => replacement (workspace/local edge cases) + var parts = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2) + { + continue; + } + + var module = parts[0]; + var version = parts[1]; + + if (parts.Length > 2 && parts[2] == "=>") + { + version = string.Join(" ", parts.Skip(1)); + } + + runtimePackageVersions[module] = version; + } + } + catch + { + // Best effort: return what we have. + } + + return runtimePackageVersions; + } + + private string ResolveSourcePath(string packageName) + { + const string repoPrefix = "github.com/Azure/azure-sdk-for-go/"; + if (!packageName.StartsWith(repoPrefix, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Cannot resolve source path for package '{packageName}'. Expected prefix '{repoPrefix}'."); + } + + var relative = packageName.Substring(repoPrefix.Length).Replace('/', Path.DirectorySeparatorChar); + return Path.Combine(WorkingDirectory, relative); + } + + private static async Task DropReplaceAsync( + string projectDirectory, + string packageName, + StringBuilder outputBuilder, + StringBuilder errorBuilder) + { + await Util.RunAsync( + "go", + $"mod edit -dropreplace={packageName}", + projectDirectory, + outputBuilder: outputBuilder, + errorBuilder: errorBuilder, + throwOnError: false); + } + + private static async Task AddReplaceAsync( + string projectDirectory, + string packageName, + string localPath, + StringBuilder outputBuilder, + StringBuilder errorBuilder) + { + await Util.RunAsync( + "go", + $"mod edit -replace={packageName}={localPath}", + projectDirectory, + outputBuilder: outputBuilder, + errorBuilder: errorBuilder); + } + + private static void BackupFile(string path) + { + if (File.Exists(path)) + { + File.Copy(path, path + ".bak", overwrite: true); + } + } + + private static void RestoreBackup(string path) + { + var backup = path + ".bak"; + if (File.Exists(backup)) + { + File.Move(backup, path, overwrite: true); + } + } + } +} \ No newline at end of file diff --git a/tools/perf-automation/Azure.Sdk.Tools.PerfAutomation/Models/Language.cs b/tools/perf-automation/Azure.Sdk.Tools.PerfAutomation/Models/Language.cs index 83a14b08c89..5fe26665488 100644 --- a/tools/perf-automation/Azure.Sdk.Tools.PerfAutomation/Models/Language.cs +++ b/tools/perf-automation/Azure.Sdk.Tools.PerfAutomation/Models/Language.cs @@ -7,6 +7,7 @@ public enum Language Net, Python, Cpp, - Rust + Rust, + Go } } diff --git a/tools/perf-automation/Azure.Sdk.Tools.PerfAutomation/Program.cs b/tools/perf-automation/Azure.Sdk.Tools.PerfAutomation/Program.cs index dbce2e6d4a3..61f524a6d32 100644 --- a/tools/perf-automation/Azure.Sdk.Tools.PerfAutomation/Program.cs +++ b/tools/perf-automation/Azure.Sdk.Tools.PerfAutomation/Program.cs @@ -27,7 +27,8 @@ public static class Program { Language.Net, new Net() }, { Language.Python, new Python() }, { Language.Cpp, new Cpp() }, - { Language.Rust, new Rust() } + { Language.Rust, new Rust() }, + { Language.Go, new Go() } }; public static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions @@ -153,6 +154,11 @@ private static async Task Run(Options options) // Rust is sync-only options.NoAsync = true; } + else if (options.Language == Language.Go) + { + // Go is sync-only + options.NoAsync = true; + } var serviceInfo = DeserializeYaml(options.TestsFile); var selectedPackageVersions = serviceInfo.PackageVersions.Where(d => diff --git a/tools/perf-automation/README.md b/tools/perf-automation/README.md index 399223106ea..4c3fecd3531 100644 --- a/tools/perf-automation/README.md +++ b/tools/perf-automation/README.md @@ -14,6 +14,28 @@ 5. Run `dotnet run -- --help` to view available command-line arguments -6. Example: `dotnet run -- run --languages net java --services sample --dry-run` +6. Example (.NET): `dotnet run -- run --language Net --language-version 8 --repo-root --tests-file /sdk/storage/Azure.Storage.Blobs/perf-tests.yml --tests download --arguments 10240 --dry-run` -7. View results in file `results/results.json` +7. Example (Go): `dotnet run -- run --language Go --language-version 1.25 --repo-root --tests-file /sdk/storage/azblob/perf-tests.yml --tests download --arguments 10240 --dry-run` + +8. View results in file `results/results.json` + +## Supported Languages + +| Language | `--language` value | Example `--language-version` | +| -------- | ------------------ | ---------------------------- | +| .NET | `Net` | `8` | +| Java | `Java` | `17` | +| JS | `JS` | `18` | +| Python | `Python` | `3.11` | +| Cpp | `Cpp` | `N/A` | +| Rust | `Rust` | `N/A` | +| Go | `Go` | `1.25` | + +### Go-specific notes + +- Go perf runs are sync-only; the `--no-async` flag is applied automatically. +- The `--repo-root` must point to a local clone of [`Azure/azure-sdk-for-go`](https://github.com/Azure/azure-sdk-for-go). +- The `--tests-file` should point at a service's `perf-tests.yml` inside the Go SDK repo (for example `sdk/storage/azblob/perf-tests.yml`). +- When `PackageVersions` is set to `source`, the runner adds a `go mod edit -replace==` against the module under `--repo-root`, runs `go mod tidy`, and restores the original `go.mod` / `go.sum` on cleanup. +- Throughput is parsed from lines matching the standard perf format, e.g. `Completed 721 operations in a weighted-average of 30.00s (24.033 ops/s, 0.042 s/op)`. diff --git a/tools/perf-automation/tests.yml b/tools/perf-automation/tests.yml index a9a77494bc9..0ea33e24e69 100644 --- a/tools/perf-automation/tests.yml +++ b/tools/perf-automation/tests.yml @@ -65,6 +65,20 @@ parameters: - name: CppServiceDirectory type: string default: 'storage/azure-storage-blobs' +- name: IncludeGo + displayName: Include Go + type: boolean + default: true +- name: GoRepoCommitish + type: string + default: 'main' +- name: GoLanguageVersion + displayName: GoLanguageVersion (1.22, 1.23, 1.24, 1.25) + type: string + default: '1.25' +- name: GoServiceDirectory + type: string + default: 'storage/azblob' - name: PackageVersions type: string default: '12|source' @@ -131,6 +145,11 @@ resources: endpoint: Azure name: Azure/azure-sdk-for-cpp ref: main + - repository: azure-sdk-for-go + type: github + endpoint: Azure + name: Azure/azure-sdk-for-go + ref: main variables: ToolsRepoCommitish: $(Build.SourceVersion) @@ -307,6 +326,38 @@ stages: Profile: ${{ parameters.Profile }} AdditionalArguments: ${{ parameters.AdditionalArguments }} + - ${{ if parameters.IncludeGo }}: + - template: /eng/common/pipelines/templates/jobs/perf.yml + parameters: + JobName: 'Perf_Go' + TimeoutInMinutes: ${{ parameters.TimeoutInMinutes }} + LinuxPool: ${{ parameters.LinuxPool }} + LinuxVmImage: ${{ parameters.LinuxVmImage }} + WindowsPool: ${{ parameters.WindowsPool }} + WindowsVmImage: ${{ parameters.WindowsVmImage }} + Language: 'Go' + LanguageVersion: ${{ parameters.GoLanguageVersion }} + LanguageRepoName: 'Azure/azure-sdk-for-go' + LanguageRepoCommitish: ${{ parameters.GoRepoCommitish }} + ToolsRepoCommitish: $(ToolsRepoCommitish) + OperatingSystems: ${{ parameters.OperatingSystems }} + InstallLanguageSteps: + - task: GoTool@0 + displayName: 'Use Go ${{ parameters.GoLanguageVersion }}' + inputs: + version: ${{ parameters.GoLanguageVersion }} + - script: | + go version + go env + displayName: Print Go Versions + ServiceDirectory: ${{ parameters.GoServiceDirectory }} + PackageVersions: ${{ parameters.PackageVersions }} + Tests: ${{ parameters.Tests }} + Arguments: ${{ parameters.Arguments }} + Iterations: ${{ parameters.Iterations }} + Profile: ${{ parameters.Profile }} + AdditionalArguments: ${{ parameters.AdditionalArguments }} + - stage: Print_Results displayName: Print Results pool: @@ -374,3 +425,11 @@ stages: workingDirectory: $(Pipeline.Workspace) displayName: Cpp condition: succeededOrFailed() + + - ${{ if parameters.IncludeGo }}: + - pwsh: | + write-host results-Go-Linux/results.txt + get-content results-Go-Linux/results.txt + workingDirectory: $(Pipeline.Workspace) + displayName: Go + condition: succeededOrFailed()