using System.Diagnostics; using System.Globalization; using System.Runtime.CompilerServices; using System.Text; using AtomUI.Localization; using Avalonia.Controls; using Avalonia.Styling; namespace AtomUI.Localization.Performance; internal static class LocalizationScenarios { private const int MemoryRetentionSampleCount = 16; private static readonly LanguageTag[] s_languagePool = [ LanguageTags.EnUS, LanguageTags.ZhCN, LanguageTags.ZhTW, LanguageTags.JaJP, LanguageTags.ArSA, LanguageTags.FrFR ]; internal static int Run(int count, string? markdownPath, bool verifyStates) { if (verifyStates && !LocalizationStateVerification.Run()) { return 1; } var startup = MeasureStartupBuild(); var hotPath = MeasureGetHotPath(Math.Max(1_000, count * 1_000)); var switching = MeasureLanguageSwitching(Math.Max(10, count * 10)); var memory = MeasureSnapshotMemory(); Console.WriteLine("Localization performance"); Console.WriteLine("------------------------"); Console.WriteLine($"Snapshot startup build: {startup.Elapsed.TotalMilliseconds:0.00} ms, " + $"{startup.AllocatedBytes / 1024.0:0.0} KiB allocated " + $"({startup.CatalogCount} catalogs, {startup.UnitCount} units, {startup.LanguageCount} languages)"); Console.WriteLine($"ILocalizer.Get hot path: {hotPath.Elapsed.TotalMilliseconds:0.00} ms, " + $"{hotPath.AllocatedBytes / 1024.0:0.0} KiB for {hotPath.OperationCount:N0} calls " + $"({hotPath.Elapsed.TotalNanoseconds() / hotPath.OperationCount:0.0} ns/call)"); Console.WriteLine($"Language switch commit: {switching.Elapsed.TotalMilliseconds:0.00} ms, " + $"{switching.AllocatedBytes / 1024.0:0.0} KiB for {switching.OperationCount:N0} changes, " + $"{switching.ResourceNotificationCount:N0} resource notifications, " + $"{switching.LanguageEventCount:N0} language events"); Console.WriteLine(); Console.WriteLine("Snapshot memory matrix"); Console.WriteLine("----------------------"); Console.WriteLine("Catalogs Units Languages RetainedKiB LogicalTextKiB SnapshotSlots"); foreach (var point in memory) { Console.WriteLine($"{point.CatalogCount,7} {point.UnitCount,5} {point.LanguageCount,9} " + $"{point.RetainedBytes / 1024.0,12:0.0} {point.LogicalTextBytes / 1024.0,14:0.0} {point.SnapshotSlotCount,13}"); } if (!string.IsNullOrWhiteSpace(markdownPath)) { var fullPath = Path.GetFullPath(markdownPath); Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); File.WriteAllText(fullPath, RenderMarkdown(startup, hotPath, switching, memory), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); Console.WriteLine(); Console.WriteLine($"Wrote markdown baseline: {fullPath}"); } return 0; } internal static LocalizationRuntime BuildRuntime( int catalogCount, int unitCount, int languageCount) { return BuildRuntime(CreateFixture(catalogCount, unitCount, languageCount)); } private static LocalizationFixture CreateFixture( int catalogCount, int unitCount, int languageCount) { if (catalogCount is < 1 or > 8) { throw new ArgumentOutOfRangeException(nameof(catalogCount), catalogCount, "Use 1..8 catalogs."); } if (unitCount is < 1 or > 128) { throw new ArgumentOutOfRangeException(nameof(unitCount), unitCount, "Use 1..128 units."); } if (languageCount is < 1 or > 6) { throw new ArgumentOutOfRangeException(nameof(languageCount), languageCount, "Use 1..6 languages."); } var languages = s_languagePool[..languageCount]; var catalogs = new LanguageCatalogDescriptor[catalogCount]; var bundles = new List(catalogCount * languageCount); for (var catalogIndex = 0; catalogIndex < catalogCount; catalogIndex++) { var catalogId = $"Perf:AtomUI.Performance.LocalizationCatalog{catalogIndex}"; var units = Enumerable.Range(0, unitCount) .Select(static unitIndex => new LanguageCatalogUnitDescriptor(unitIndex + 1, $"Unit{unitIndex}")) .ToArray(); catalogs[catalogIndex] = CreateCatalog(catalogIndex, catalogId, units); foreach (var language in languages) { var values = Enumerable.Range(0, unitCount) .Select(unitIndex => $"{language.Value}:{catalogIndex}:{unitIndex}") .Cast() .ToArray(); bundles.Add(new TranslationBundleDescriptor( catalogId, 1, language, TranslationSourceKind.ModuleBuiltIn, $"LocalizationPerf.Catalog{catalogIndex}.{language.Value}", values)); } } return new LocalizationFixture(catalogs, bundles, languages); } private static LocalizationRuntime BuildRuntime(LocalizationFixture fixture) { var builder = new LocalizationBuilder(); foreach (var catalog in fixture.Catalogs) { builder.AddCatalog(catalog); } foreach (var bundle in fixture.Bundles) { builder.AddTranslationBundle(bundle); } builder.ConfigureLanguages(fixture.Languages[0], fixture.Languages); return builder.Build(static () => true); } private static LocalizationStartupResult MeasureStartupBuild() { var fixture = CreateFixture(catalogCount: 4, unitCount: 32, languageCount: 4); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); var before = GC.GetAllocatedBytesForCurrentThread(); var stopwatch = Stopwatch.StartNew(); using var runtime = BuildRuntime(fixture); stopwatch.Stop(); var allocated = GC.GetAllocatedBytesForCurrentThread() - before; GC.KeepAlive(runtime); return new LocalizationStartupResult(stopwatch.Elapsed, allocated, 4, 32, 4); } private static LocalizationHotPathResult MeasureGetHotPath(int operationCount) { using var runtime = BuildRuntime(catalogCount: 4, unitCount: 32, languageCount: 4); ILocalizer localizer = runtime.Localizer; _ = localizer.Get(Catalog0ResourceKind.Value); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); var before = GC.GetAllocatedBytesForCurrentThread(); var stopwatch = Stopwatch.StartNew(); var checksum = 0; for (var index = 0; index < operationCount; index++) { checksum += localizer.Get(Catalog0ResourceKind.Value).Length; } stopwatch.Stop(); var allocated = GC.GetAllocatedBytesForCurrentThread() - before; if (checksum == 0) { throw new InvalidOperationException("The localization hot path returned an empty value."); } return new LocalizationHotPathResult(stopwatch.Elapsed, allocated, operationCount); } private static LocalizationSwitchResult MeasureLanguageSwitching(int operationCount) { using var runtime = BuildRuntime(catalogCount: 4, unitCount: 32, languageCount: 4); var host = new Border(); host.Resources.MergedDictionaries.Add(runtime.ResourceProvider); var resourceNotifications = 0; var languageEvents = 0; ((IResourceHost)host).ResourcesChanged += (_, _) => resourceNotifications++; runtime.LanguageManager.LanguageChanged += (_, _) => languageEvents++; GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); var before = GC.GetAllocatedBytesForCurrentThread(); var stopwatch = Stopwatch.StartNew(); for (var index = 0; index < operationCount; index++) { var language = s_languagePool[(index % 3) + 1]; runtime.LanguageManager.ChangeLanguage(language); } stopwatch.Stop(); var allocated = GC.GetAllocatedBytesForCurrentThread() - before; return new LocalizationSwitchResult( stopwatch.Elapsed, allocated, operationCount, resourceNotifications, languageEvents); } private static IReadOnlyList MeasureSnapshotMemory() { var matrix = new (int Catalogs, int Units, int Languages)[] { (1, 8, 2), (4, 8, 2), (8, 8, 2), (1, 32, 2), (1, 64, 2), (1, 8, 4), (1, 8, 6), (4, 32, 4), (8, 64, 6) }; var points = new List(matrix.Length); foreach (var dimensions in matrix) { points.Add(MeasureSnapshotMemoryPoint(dimensions)); } return points; } [MethodImpl(MethodImplOptions.NoInlining)] private static LocalizationMemoryPoint MeasureSnapshotMemoryPoint( (int Catalogs, int Units, int Languages) dimensions) { var runtimes = new LocalizationRuntime[MemoryRetentionSampleCount]; GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); var before = GC.GetTotalMemory(forceFullCollection: true); try { for (var index = 0; index < runtimes.Length; index++) { runtimes[index] = BuildRuntime( dimensions.Catalogs, dimensions.Units, dimensions.Languages); } var after = GC.GetTotalMemory(forceFullCollection: true); var logicalTextBytes = CountSnapshotTextBytes(runtimes[0]); var snapshotSlotCount = (long)runtimes[0].Snapshots.Count * dimensions.Catalogs * dimensions.Units; GC.KeepAlive(runtimes); return new LocalizationMemoryPoint( dimensions.Catalogs, dimensions.Units, dimensions.Languages, Math.Max(0, (after - before) / MemoryRetentionSampleCount), logicalTextBytes, snapshotSlotCount); } finally { foreach (var runtime in runtimes) { runtime?.Dispose(); } } } private static long CountSnapshotTextBytes(LocalizationRuntime runtime) { long total = 0; for (var snapshotIndex = 0; snapshotIndex < runtime.Snapshots.Count; snapshotIndex++) { var snapshot = runtime.Snapshots.Values.ElementAt(snapshotIndex); for (var catalogSlot = 0; catalogSlot < runtime.Registry.Catalogs.Count; catalogSlot++) { var catalog = runtime.Registry.Catalogs[catalogSlot]; for (var unitSlot = 0; unitSlot < catalog.Units.Count; unitSlot++) { total += snapshot.GetEntry(catalogSlot, unitSlot).Text.Length * sizeof(char); } } } return total; } private static LanguageCatalogDescriptor CreateCatalog( int catalogIndex, string catalogId, IReadOnlyList units) { return catalogIndex switch { 0 => new LanguageCatalogDescriptor(catalogId, 1, units, static _ => 0), 1 => new LanguageCatalogDescriptor(catalogId, 1, units, static _ => 0), 2 => new LanguageCatalogDescriptor(catalogId, 1, units, static _ => 0), 3 => new LanguageCatalogDescriptor(catalogId, 1, units, static _ => 0), 4 => new LanguageCatalogDescriptor(catalogId, 1, units, static _ => 0), 5 => new LanguageCatalogDescriptor(catalogId, 1, units, static _ => 0), 6 => new LanguageCatalogDescriptor(catalogId, 1, units, static _ => 0), 7 => new LanguageCatalogDescriptor(catalogId, 1, units, static _ => 0), _ => throw new ArgumentOutOfRangeException(nameof(catalogIndex)) }; } private static string RenderMarkdown( LocalizationStartupResult startup, LocalizationHotPathResult hotPath, LocalizationSwitchResult switching, IReadOnlyList memory) { var builder = new StringBuilder(); builder.AppendLine("# Localization Baseline"); builder.AppendLine(); builder.AppendLine($"- Date: {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}"); builder.AppendLine("- Runner: `tools/performances/AtomUI.Performance`"); builder.AppendLine("- Runtime input: generated Catalog descriptors and compiled translation strings; no XLIFF parsing"); builder.AppendLine(); builder.AppendLine("| Metric | Value |"); builder.AppendLine("| --- | ---: |"); builder.AppendLine($"| Snapshot startup build (ms) | {startup.Elapsed.TotalMilliseconds:0.00} |"); builder.AppendLine($"| Snapshot startup allocation (KiB) | {startup.AllocatedBytes / 1024.0:0.0} |"); builder.AppendLine($"| ILocalizer.Get calls | {hotPath.OperationCount} |"); builder.AppendLine($"| ILocalizer.Get (ns/call) | {hotPath.Elapsed.TotalNanoseconds() / hotPath.OperationCount:0.0} |"); builder.AppendLine($"| ILocalizer.Get allocation (KiB) | {hotPath.AllocatedBytes / 1024.0:0.0} |"); builder.AppendLine($"| Language switch changes | {switching.OperationCount} |"); builder.AppendLine($"| Language switch (ns/change) | {switching.Elapsed.TotalNanoseconds() / switching.OperationCount:0.0} |"); builder.AppendLine($"| Language switch allocation (KiB) | {switching.AllocatedBytes / 1024.0:0.0} |"); builder.AppendLine($"| Resource notifications | {switching.ResourceNotificationCount} |"); builder.AppendLine($"| Language events | {switching.LanguageEventCount} |"); builder.AppendLine(); builder.AppendLine("## Snapshot Memory"); builder.AppendLine(); builder.AppendLine("| Catalogs | Units/Catalog | Languages | Retained KiB | Logical text KiB | Snapshot slots |"); builder.AppendLine("| ---: | ---: | ---: | ---: | ---: | ---: |"); foreach (var point in memory) { builder.AppendLine($"| {point.CatalogCount} | {point.UnitCount} | {point.LanguageCount} | " + $"{point.RetainedBytes / 1024.0:0.0} | {point.LogicalTextBytes / 1024.0:0.0} | {point.SnapshotSlotCount} |"); } builder.AppendLine(); builder.AppendLine("Notes:"); builder.AppendLine(); builder.AppendLine("- Measurements run in a deterministic in-process runtime with UI-thread checks disabled only for the benchmark harness."); builder.AppendLine($"- Retained memory is a coarse process GC measurement averaged across {MemoryRetentionSampleCount} live runtimes; logical text bytes and slot counts show the expected scaling signal."); return builder.ToString(); } private static double TotalNanoseconds(this TimeSpan timeSpan) { return timeSpan.TotalMilliseconds * 1_000_000; } private sealed record LocalizationStartupResult( TimeSpan Elapsed, long AllocatedBytes, int CatalogCount, int UnitCount, int LanguageCount); private sealed record LocalizationHotPathResult(TimeSpan Elapsed, long AllocatedBytes, int OperationCount); private sealed record LocalizationSwitchResult( TimeSpan Elapsed, long AllocatedBytes, int OperationCount, int ResourceNotificationCount, int LanguageEventCount); internal sealed record LocalizationMemoryPoint( int CatalogCount, int UnitCount, int LanguageCount, long RetainedBytes, long LogicalTextBytes, long SnapshotSlotCount); private sealed record LocalizationFixture( IReadOnlyList Catalogs, IReadOnlyList Bundles, IReadOnlyList Languages); internal enum Catalog0ResourceKind { Value } private enum Catalog1ResourceKind { Value } private enum Catalog2ResourceKind { Value } private enum Catalog3ResourceKind { Value } private enum Catalog4ResourceKind { Value } private enum Catalog5ResourceKind { Value } private enum Catalog6ResourceKind { Value } private enum Catalog7ResourceKind { Value } }