From 27ea778c855d394a31a6f52dcedc5d3648e861eb Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Fri, 4 Sep 2026 23:02:50 +0000 Subject: [PATCH 01/18] Materialize method comparisons instead of rebuilding them for every aggregate Cache the join, unique-method lists and filtered deltas. Add dependency-free parser and CLI regression coverage, including optional baseline comparison. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/jit-analyze/Program.cs | 9 +- test/jit-analyze/Regression/README.md | 43 +++ test/jit-analyze/Regression/Regression.csproj | 12 + test/jit-analyze/Regression/Tests.cs | 323 ++++++++++++++++++ 4 files changed, 383 insertions(+), 4 deletions(-) create mode 100644 test/jit-analyze/Regression/README.md create mode 100644 test/jit-analyze/Regression/Regression.csproj create mode 100644 test/jit-analyze/Regression/Tests.cs diff --git a/src/jit-analyze/Program.cs b/src/jit-analyze/Program.cs index 7d030175..3cc793f6 100644 --- a/src/jit-analyze/Program.cs +++ b/src/jit-analyze/Program.cs @@ -405,7 +405,8 @@ public IEnumerable Comparator(IEnumerable baseInfo, baseOffsets = x.functionOffsets, diffOffsets = y.functionOffsets }) - .OrderByDescending(r => r.deltaMetrics.GetMetric(metricName).Value); + .OrderByDescending(r => r.deltaMetrics.GetMetric(metricName).Value) + .ToList(); FileDelta f = new FileDelta { @@ -416,9 +417,9 @@ public IEnumerable Comparator(IEnumerable baseInfo, deltaMetrics = jointList.Sum(x => x.deltaMetrics), relDeltaMetrics = jointList.Sum(x => x.relDeltaMetrics), methodsInBoth = jointList.Count(), - methodsOnlyInBase = b.methodList.Except(d.methodList, methodInfoComparer), - methodsOnlyInDiff = d.methodList.Except(b.methodList, methodInfoComparer), - methodDeltaList = jointList.Where(x => x.deltaMetrics.GetMetric(metricName).Value != 0) + methodsOnlyInBase = b.methodList.Except(d.methodList, methodInfoComparer).ToList(), + methodsOnlyInDiff = d.methodList.Except(b.methodList, methodInfoComparer).ToList(), + methodDeltaList = jointList.Where(x => x.deltaMetrics.GetMetric(metricName).Value != 0).ToList() }; if (_reconcile) diff --git a/test/jit-analyze/Regression/README.md b/test/jit-analyze/Regression/README.md new file mode 100644 index 00000000..087ba9ed --- /dev/null +++ b/test/jit-analyze/Regression/README.md @@ -0,0 +1,43 @@ +# jit-analyze regression tests + +Run from the repository root with the .NET SDK required by `src/Directory.Build.props`: + +```sh +dotnet run --project test/jit-analyze/Regression -c Release +``` + +This small console test links the real analyzer sources, including its command-line +parser, and reuses its existing build properties/dependency. It adds no test packages +or production test hooks. Failures return a nonzero exit code. Generated tiny fixtures +live under the test output directory and are removed even on failure. + +Optionally compare all CLI stdout, TSV and exit codes against an older executable: + +```sh +dotnet run --project test/jit-analyze/Regression -c Release -- /path/to/baseline/jit-analyze +``` + +Only CRLF output endings are normalized; numeric formatting is invariant. Normal +runs use explicit assertions, not a baseline executable or the historical +`../baseline*.out` goldens. Textual git diffs are disabled to isolate analyzer behavior. + +Coverage includes all 12 metrics; repeated/Unicode method names; absent optional +metrics; both perf-score spellings; zero-byte records; debug info; concatenated-file +offsets; LF, CRLF and CR; missing final newlines; empty files; long selected and +ignored lines; and UTF-8/CRLF around 64 KiB boundaries. CLI tests exercise reconciliation, +warnings, filtering, multiple metrics, TSV, concatenation and unequal single filenames. + +Intentionally preserved behavior: + +* Offsets are zero-based across all input files, but offset zero is omitted. +* Zero-byte summaries count as functions as well as assembly listings. +* Debug records without method names aggregate into the empty-name group and + contribute offsets/function counts. +* Extra allocation bytes are computed after grouping. Missing allocation means zero + extra bytes; integer-only spill/resolution weights do not match. +* Explicit files compare despite different names. Unmatched directory files are + warned about, not reconciled; concatenation instead treats them as one logical file. +* Each selected metric appends a complete TSV header and its own selected method + rows. Reconciled rows appear even when that selected metric is zero. TSV uses the + base filename, fractional percentages (zero for zero bases), and a trailing tab. +* Each metric with a nonzero total delta contributes `-1` to the exit code. diff --git a/test/jit-analyze/Regression/Regression.csproj b/test/jit-analyze/Regression/Regression.csproj new file mode 100644 index 00000000..cde8c3c4 --- /dev/null +++ b/test/jit-analyze/Regression/Regression.csproj @@ -0,0 +1,12 @@ + + + + + Exe + JitAnalyzeRegression.Tests + + + + + + diff --git a/test/jit-analyze/Regression/Tests.cs b/test/jit-analyze/Regression/Tests.cs new file mode 100644 index 00000000..92619256 --- /dev/null +++ b/test/jit-analyze/Regression/Tests.cs @@ -0,0 +1,323 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using ManagedCodeGen; +using Analyzer = ManagedCodeGen.Program; + +namespace JitAnalyzeRegression; + +internal static class Tests +{ + private static readonly string[] MetricNames = + { + "CodeSize", "PrologSize", "PerfScore", "InstrCount", "AllocSize", "ExtraAllocBytes", + "DebugClauseCount", "DebugVarCount", "SpillCount", "SpillWeight", "ResolutionCount", "ResolutionWeight" + }; + + private static string root; + private static string baseline; + private static int checks; + + private static int Main(string[] args) + { + CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; + CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture; + if (args.Length > 1) + { + Console.Error.WriteLine("Usage: dotnet run --project test/jit-analyze/Regression [-c Release] -- [baseline-executable]"); + return 1; + } + + baseline = args.SingleOrDefault(); + root = Path.Combine(AppContext.BaseDirectory, "fixtures-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try + { + ExtractMetrics(); + LineBoundaries(); + CommandLine(); + Console.WriteLine($"PASS: {checks} assertions (parser, line boundaries, CLI and TSV)."); + return 0; + } + catch (Exception e) + { + Console.Error.WriteLine(e); + return 1; + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + private static void Equal(T expected, T actual, string context) + { + checks++; + if (!EqualityComparer.Default.Equals(expected, actual)) + throw new Exception($"{context}: expected <{expected}>, actual <{actual}>"); + } + + private static void Contains(string text, string expected) + { + Equal(true, text.Contains(expected, StringComparison.Ordinal), $"output contains {expected}"); + } + + private static string Write(string name, string contents) + { + string path = Path.Combine(root, name); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + File.WriteAllText(path, contents, new UTF8Encoding(false)); + return path; + } + + private static string Listing(string name) => $"; Assembly listing for method {name}"; + + private static string Summary(string name, int size, int prolog = 0, int perf = 0) => + $"; Total bytes of code {size}, prolog size {prolog}, PerfScore {perf} for method {name}"; + + private static string Method(string name, int size, int prolog = 0, int perf = 0) => + Listing(name) + "\n" + Summary(name, size, prolog, perf) + "\n"; + + private static void CheckMethod(Analyzer.MethodInfo method, string name, int count, string offsets, params double[] values) + { + Equal(name, method.name, "method name"); + Equal(count, method.functionCount, $"{name} function count"); + Equal(offsets, string.Join(",", method.functionOffsets), $"{name} offsets"); + for (int i = 0; i < MetricNames.Length; i++) + Equal(values.Length > i ? values[i] : 0, method.Metrics.GetMetric(MetricNames[i]).Value, $"{name} {MetricNames[i]}"); + } + + private static void ExtractMetrics() + { + Equal(string.Join(",", MetricNames), string.Join(",", MetricCollection.AllMetrics.Select(m => m.Name)), "metric schema"); + const string repeated = "Namespace.类型:方法(é, 😀)"; + string first = Write("metrics-first.dasm", string.Join("\r\n", new[] + { + Listing(repeated), + $"; Total bytes of code 10, prolog size 2, PerfScore 1.25, instruction count 3, allocated bytes for code 16, SpillCount 2 SpillCountWt 3.50 ResolutionMovs 4 ResolutionMovsWt 5.25 for method {repeated}", + "; Variable debug info: 4 live range(s), 2 var(s)", + "ignored for method not-a-method", + Listing("Zero"), + "; Total bytes of code 0 for method Zero", + "; Variable debug info: 5 live range(s), 2 var(s)" + })); + string second = Write("metrics-second.dasm", string.Join("\n", new[] + { + Listing(repeated), + $"; Total bytes of code 20, prolog size 3, perf score 2.50, instruction count 6, allocated bytes for code 24, SpillCount 3 SpillCountWt 4.25 ResolutionMovs 5 ResolutionMovsWt 6.50 for method {repeated}", + Listing("Optional"), + "; Total bytes of code 7 for method Optional" + })); + var methods = Analyzer.ExtractMethodInfo(new[] { first, second }).ToArray(); + Equal(4, methods.Length, "group count"); + CheckMethod(methods[0], repeated, 2, "7", 30, 5, 3.75, 9, 40, 10, 0, 0, 5, 7.75, 9, 11.75); + CheckMethod(methods[1], "", 2, "2,6", 0, 0, 0, 0, 0, 0, 9, 4); + CheckMethod(methods[2], "Zero", 2, "4"); + CheckMethod(methods[3], "Optional", 1, "9", 7); + + Equal(0, Analyzer.ExtractMethodInfo(Array.Empty()).Count(), "no input files"); + Equal(0, Analyzer.ExtractMethodInfo(new[] { Write("empty.dasm", "") }).Count(), "empty file"); + + // Extra allocation is computed after grouping, not independently per record. + string mixed = Write("mixed.dasm", Listing("Mixed") + "\n; Total bytes of code 4 for method Mixed\n" + + Listing("Mixed") + "\n; Total bytes of code 6, allocated bytes for code 16 for method Mixed"); + CheckMethod(Analyzer.ExtractMethodInfo(new[] { mixed }).Single(), "Mixed", 2, "2", 10, 0, 0, 0, 16, 6); + + // The weights require a decimal point; malformed selected lines still form groups. + string malformed = Write("malformed.dasm", + "; Total bytes of code invalid for method Odd\n" + + "; Total bytes of code 1, SpillCount 2 SpillCountWt 3 ResolutionMovs 4 ResolutionMovsWt 5 for method Odd"); + CheckMethod(Analyzer.ExtractMethodInfo(new[] { malformed }).Single(), "Odd", 1, "", 1); + } + + private static void LineBoundaries() + { + foreach (string newline in new[] { "\n", "\r\n", "\r" }) + foreach (bool terminalNewline in new[] { false, true }) + foreach (int length in new[] { 0, 1, 65534, 65535, 65536, 65537, 131073 }) + { + string name = "类:é😀" + new string('x', length); + string path = Write("boundary.dasm", new string(' ', length) + newline + + Listing(name) + newline + Summary(name, 17, 3, 2) + + (terminalNewline ? newline : "")); + CheckMethod(Analyzer.ExtractMethodInfo(new[] { path }).Single(), name, 1, "1", 17, 3, 2); + } + + // Place record starts, CRLF pairs and multibyte UTF-8 around a 64 KiB byte boundary. + foreach (int offset in Enumerable.Range(65532, 9)) + { + string path = Write("aligned.dasm", new string(' ', offset) + "\r\n" + + Listing("é😀") + "\r\n" + Summary("é😀", 9)); + CheckMethod(Analyzer.ExtractMethodInfo(new[] { path }).Single(), "é😀", 1, "1", 9); + path = Write("utf8-aligned.dasm", new string(' ', offset) + "é😀\r\n" + + Listing("Unicode") + "\r\n" + Summary("Unicode", 9)); + CheckMethod(Analyzer.ExtractMethodInfo(new[] { path }).Single(), "Unicode", 1, "1", 9); + path = Write("metric-aligned.dasm", "; Total bytes of code 9" + + new string(' ', offset - "; Total bytes of code 9".Length) + + "PerfScore 12.25, instruction count 7 for method Boundary"); + CheckMethod(Analyzer.ExtractMethodInfo(new[] { path }).Single(), "Boundary", 0, "", 9, 0, 12.25, 7); + } + + string mixed = Write("newlines.dasm", "\r\n" + Listing("Mixed") + "\r" + + Summary("Mixed", 2) + "\n\n" + Listing("Mixed") + "\r\n" + Summary("Mixed", 3)); + CheckMethod(Analyzer.ExtractMethodInfo(new[] { mixed }).Single(), "Mixed", 2, "1,4", 5); + + string bom = Path.Combine(root, "bom.dasm"); + File.WriteAllText(bom, Listing("BOM:é😀") + "\n" + Summary("BOM:é😀", 3), new UTF8Encoding(true)); + CheckMethod(Analyzer.ExtractMethodInfo(new[] { bom }).Single(), "BOM:é😀", 1, "", 3); + } + + private static void CommandLine() + { + string baseFile = Write("base/keep.dasm", + Method("Shared", 10, 2) + Method("Removed", 5, 1) + Method("Stable", 7, 1) + Method("PerfOnly", 3, 1, 1)); + string diffFile = Write("diff/keep.dasm", + Method("Shared", 14, 1) + Method("Added", 8, 2) + Method("Stable", 7, 1) + Method("PerfOnly", 3, 1, 2)); + Write("base/baseOnly.dasm", Method("BaseUnique", 100)); + Write("diff/diffOnly.dasm", Method("DiffUnique", 200)); + string baseDir = Path.GetDirectoryName(baseFile); + string diffDir = Path.GetDirectoryName(diffFile); + + var reconciled = Invoke(baseDir, diffDir, "--warn"); + Equal(-1, reconciled.Code, "reconciled exit code"); + Totals(reconciled.Output, 25, 32, 7); + Contains(reconciled.Output, "Total byte diff includes 3 bytes from reconciling methods"); + Contains(reconciled.Output, "Warning: 1 files in base but not in diff."); + Contains(reconciled.Output, "Warning: 1 files in diff but not in base."); + Contains(reconciled.Output, "Mismatched methods in keep.dasm\nBase:\n Removed\nDiff:\n Added"); + CheckTsv(reconciled.Tsv, new[] { "Shared", "Removed", "Added" }); + + var common = Invoke(baseDir, diffDir, "--no-reconcile", "--warn"); + Equal(-1, common.Code, "unreconciled exit code"); + Totals(common.Output, 20, 24, 4); + Equal(false, common.Output.Contains("from reconciling methods"), "reconciliation disabled"); + CheckTsv(common.Tsv, new[] { "Shared" }); + + var filtered = Invoke(baseDir, diffDir, "--filter", "keep", "--warn", "--metrics", "CodeSize", "--metrics", "PerfScore"); + Equal(-2, filtered.Code, "multiple changed metrics exit code"); + Totals(filtered.Output, 25, 32, 7); + Contains(filtered.Output, "Summary of Perf Score diffs: (using filter 'keep')"); + Contains(filtered.Output, "Total PerfScoreUnits of base: 1\nTotal PerfScoreUnits of diff: 2"); + Equal(false, filtered.Output.Contains("files in base but not"), "filter excludes unique files"); + CheckTsv(filtered.Tsv, new[] { "Shared", "Removed", "Added", "PerfOnly", "Removed", "Added" }, headers: 2); + + var concat = Invoke(baseDir, diffDir, "--concat-files", "--warn"); + Equal(-1, concat.Code, "concatenated exit code"); + Totals(concat.Output, 125, 232, 107); + Equal(false, concat.Output.Contains("files in base but not"), "concat suppresses file mismatch warnings"); + Contains(concat.Output, "BaseUnique"); + Contains(concat.Output, "DiffUnique"); + var concatCommon = Invoke(baseDir, diffDir, "--concat-files", "--no-reconcile"); + Totals(concatCommon.Output, 20, 24, 4); + + string renamed = Write("renamed.dasm", File.ReadAllText(diffFile)); + var single = Invoke(baseFile, renamed, "--warn"); + Equal(-1, single.Code, "unequal single filenames exit code"); + Totals(single.Output, 25, 32, 7); + CheckTsv(single.Tsv, new[] { "Shared", "Removed", "Added" }); + Equal(false, single.Output.Contains("files in base but not"), "explicit files match despite unequal names"); + + var identical = Invoke(baseFile, baseFile, "--warn"); + Equal(0, identical.Code, "identical input exit code"); + Totals(identical.Output, 25, 25, 0); + CheckTsv(identical.Tsv, Array.Empty()); + } + + private static void Totals(string output, int before, int after, int delta) + { + Contains(output, $"Total bytes of base: {before}\nTotal bytes of diff: {after}\nTotal bytes of delta: {delta} ("); + } + + private static void CheckTsv(string tsv, string[] methods, int headers = 1) + { + string expectedHeader = "File\tMethod" + string.Concat(MetricNames.Select(n => + $"\tBase {n}\tDiff {n}\tDelta {n}\tPercentage {n}")); + string[] lines = tsv.Split('\n', StringSplitOptions.RemoveEmptyEntries); + Equal(headers, lines.Count(l => l == expectedHeader), "TSV headers (one per metric)"); + string[] rows = lines.Where(l => l != expectedHeader).ToArray(); + Equal(string.Join(",", methods), string.Join(",", rows.Select(l => l.Split('\t')[1])), "TSV row order"); + foreach (string row in rows) + { + string[] fields = row.Split('\t'); + Equal(51, fields.Length, "TSV field count including trailing tab"); + Equal("keep.dasm", fields[0], "TSV uses base filename"); + double[] before = fields[1] switch + { + "Shared" => new double[] { 10, 2, 0 }, + "Removed" => new double[] { 5, 1, 0 }, + "Added" => new double[] { 0, 0, 0 }, + "PerfOnly" => new double[] { 3, 1, 1 }, + _ => throw new Exception("Unexpected TSV method") + }; + double[] after = fields[1] switch + { + "Shared" => new double[] { 14, 1, 0 }, + "Removed" => new double[] { 0, 0, 0 }, + "Added" => new double[] { 8, 2, 0 }, + "PerfOnly" => new double[] { 3, 1, 2 }, + _ => throw new Exception("Unexpected TSV method") + }; + for (int i = 0; i < MetricNames.Length; i++) + { + double b = i < before.Length ? before[i] : 0; + double d = i < after.Length ? after[i] : 0; + double[] expected = { b, d, d - b, b == 0 ? 0 : (d - b) / b }; + for (int j = 0; j < expected.Length; j++) + Equal(expected[j].ToString(CultureInfo.InvariantCulture), fields[2 + i * 4 + j], $"TSV {fields[1]} {MetricNames[i]} column {j}"); + } + Equal("", fields[^1], "TSV trailing tab"); + } + } + + private static (int Code, string Output, string Tsv) Invoke(string before, string after, params string[] options) + { + string tsv = Path.Combine(root, "result.tsv"); + File.Delete(tsv); + string[] args = new[] { "--base", before, "--diff", after, "--skip-text-diff", "--tsv", tsv }.Concat(options).ToArray(); + TextWriter oldOut = Console.Out; + TextWriter oldError = Console.Error; + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + int code; + try + { + Console.SetOut(stdout); + Console.SetError(stderr); + code = new JitAnalyzeRootCommand(args).Parse(args).Invoke(); + } + finally + { + Console.SetOut(oldOut); + Console.SetError(oldError); + } + Equal("", stderr.ToString(), "CLI stderr"); + string output = stdout.ToString().Replace("\r\n", "\n"); + string table = File.ReadAllText(tsv).Replace("\r\n", "\n"); + if (baseline != null) + { + using var process = new Process(); + process.StartInfo = new ProcessStartInfo(baseline) { RedirectStandardOutput = true, RedirectStandardError = true }; + foreach (string arg in args) + process.StartInfo.ArgumentList.Add(arg); + process.StartInfo.Environment["LC_ALL"] = "C"; + process.StartInfo.Environment["DOTNET_SYSTEM_GLOBALIZATION_INVARIANT"] = "1"; + File.Delete(tsv); + process.Start(); + var baselineOut = process.StandardOutput.ReadToEndAsync(); + var baselineError = process.StandardError.ReadToEndAsync(); + process.WaitForExit(); + Equal(code & 255, process.ExitCode & 255, "baseline exit code"); + Equal("", baselineError.GetAwaiter().GetResult(), "baseline stderr"); + Equal(output, baselineOut.GetAwaiter().GetResult().Replace("\r\n", "\n"), "baseline stdout"); + Equal(table, File.ReadAllText(tsv).Replace("\r\n", "\n"), "baseline TSV"); + } + return (code, output, table); + } +} From b9738657326894eebc1319b3ff323221664c5318 Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Fri, 4 Sep 2026 23:06:04 +0000 Subject: [PATCH 02/18] Analyze file pairs in bounded parallel workers Enumerate paths without parsing, then parse and compare each pair for all requested metrics before releasing unchanged method data. Preserve result order while bounding concurrent work. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/jit-analyze/Program.cs | 99 ++++++++++++++++++++++---------------- 1 file changed, 58 insertions(+), 41 deletions(-) diff --git a/src/jit-analyze/Program.cs b/src/jit-analyze/Program.cs index 3cc793f6..ebe48fdb 100644 --- a/src/jit-analyze/Program.cs +++ b/src/jit-analyze/Program.cs @@ -56,7 +56,7 @@ public Program(JitAnalyzeRootCommand command) public class FileInfo { public string name; - public IEnumerable methodList; + public string[] paths; public bool isExplicitOnlyFile; public override string ToString() @@ -256,17 +256,17 @@ public List ExtractFileInfo(string path, string filter, string fileExt new FileInfo { name = Path.GetFileName(path), - methodList = ExtractMethodInfo(Directory.EnumerateFiles(fullRootPath, searchPattern, searchOption).ToArray()), + paths = Directory.EnumerateFiles(fullRootPath, searchPattern, searchOption).ToArray(), isExplicitOnlyFile = true, }, }; } return Directory.EnumerateFiles(fullRootPath, searchPattern, searchOption) - .AsParallel().Select(p => new FileInfo + .Select(p => new FileInfo { name = p.Substring(fullRootPath.Length).TrimStart(Path.DirectorySeparatorChar), - methodList = ExtractMethodInfo(new[] { p }) + paths = new[] { p } }).ToList(); } else @@ -277,7 +277,7 @@ public List ExtractFileInfo(string path, string filter, string fileExt { new FileInfo { name = Path.GetFileName(path), - methodList = ExtractMethodInfo(new[] {path }), + paths = new[] { path }, isExplicitOnlyFile = true, } }; @@ -390,45 +390,59 @@ public static IEnumerable ExtractMethodInfo(string[] filePaths) // numbers are regressions. (lower is better) // // Todo: handle metrics where "higher is better" - public IEnumerable Comparator(IEnumerable baseInfo, - IEnumerable diffInfo, string metricName) + public FileDelta[][] Comparator(IEnumerable baseInfo, + IEnumerable diffInfo, string[] metricNames) + { + // Keep only one pair's parsed methods per worker, and reuse them for every metric. + return baseInfo.Join(diffInfo, b => b.isExplicitOnlyFile ? "" : b.name, + d => d.isExplicitOnlyFile ? "" : d.name, (b, d) => (Base: b, Diff: d)) + .AsParallel().AsOrdered() + .WithDegreeOfParallelism(Math.Min(Environment.ProcessorCount, 8)) + .Select(pair => + { + var baseMethods = ExtractMethodInfo(pair.Base.paths); + var diffMethods = ExtractMethodInfo(pair.Diff.paths); + return metricNames.Select(metricName => + CompareFile(pair.Base.name, pair.Diff.name, baseMethods, diffMethods, metricName)).ToArray(); + }).ToArray(); + } + + private FileDelta CompareFile(string baseName, string diffName, + IEnumerable baseMethods, IEnumerable diffMethods, string metricName) { MethodInfoComparer methodInfoComparer = new MethodInfoComparer(); - return baseInfo.Join(diffInfo, b => b.isExplicitOnlyFile ? "" : b.name, d => d.isExplicitOnlyFile ? "" : d.name, (b, d) => + var jointList = baseMethods.Join(diffMethods, + x => x.name, y => y.name, (x, y) => new MethodDelta + { + name = x.name, + baseMetrics = new MetricCollection(x.Metrics), + diffMetrics = new MetricCollection(y.Metrics), + baseOffsets = x.functionOffsets, + diffOffsets = y.functionOffsets + }) + .OrderByDescending(r => r.deltaMetrics.GetMetric(metricName).Value) + .ToList(); + + FileDelta f = new FileDelta { - var jointList = b.methodList.Join(d.methodList, - x => x.name, y => y.name, (x, y) => new MethodDelta - { - name = x.name, - baseMetrics = new MetricCollection(x.Metrics), - diffMetrics = new MetricCollection(y.Metrics), - baseOffsets = x.functionOffsets, - diffOffsets = y.functionOffsets - }) - .OrderByDescending(r => r.deltaMetrics.GetMetric(metricName).Value) - .ToList(); - - FileDelta f = new FileDelta - { - baseName = b.name, - diffName = d.name, - baseMetrics = jointList.Sum(x => x.baseMetrics), - diffMetrics = jointList.Sum(x => x.diffMetrics), - deltaMetrics = jointList.Sum(x => x.deltaMetrics), - relDeltaMetrics = jointList.Sum(x => x.relDeltaMetrics), - methodsInBoth = jointList.Count(), - methodsOnlyInBase = b.methodList.Except(d.methodList, methodInfoComparer).ToList(), - methodsOnlyInDiff = d.methodList.Except(b.methodList, methodInfoComparer).ToList(), - methodDeltaList = jointList.Where(x => x.deltaMetrics.GetMetric(metricName).Value != 0).ToList() - }; + baseName = baseName, + diffName = diffName, + baseMetrics = jointList.Sum(x => x.baseMetrics), + diffMetrics = jointList.Sum(x => x.diffMetrics), + deltaMetrics = jointList.Sum(x => x.deltaMetrics), + relDeltaMetrics = jointList.Sum(x => x.relDeltaMetrics), + methodsInBoth = jointList.Count(), + methodsOnlyInBase = baseMethods.Except(diffMethods, methodInfoComparer).ToList(), + methodsOnlyInDiff = diffMethods.Except(baseMethods, methodInfoComparer).ToList(), + methodDeltaList = jointList.Where(x => x.deltaMetrics.GetMetric(metricName).Value != 0).ToList() + }; - if (_reconcile) - { - f.Reconcile(); - } + if (_reconcile) + { + f.Reconcile(); + } - return f; - }).ToList(); + return f; } // Summarize differences across all the files. @@ -941,9 +955,12 @@ public int Run() string json = Get(_command.Json); string tsv = Get(_command.Tsv); string md = Get(_command.MD); - foreach (var metricName in Get(_command.Metrics)) + string[] metricNames = Get(_command.Metrics).ToArray(); + FileDelta[][] comparisons = Comparator(baseList, diffList, metricNames); + for (int metricIndex = 0; metricIndex < metricNames.Length; metricIndex++) { - compareList = Comparator(baseList, diffList, metricName); + string metricName = metricNames[metricIndex]; + compareList = comparisons.Select(files => files[metricIndex]).ToArray(); if (tsv != null) { From bc47d7250b40372a49c7858fbe3203ddae85247f Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Fri, 4 Sep 2026 23:08:43 +0000 Subject: [PATCH 03/18] Scan disassembly with pooled line buffers Avoid allocating strings and LINQ records for instruction lines that contain no metrics. Preserve StreamReader encoding and newline handling, including long records split across buffers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/jit-analyze/DisassemblyReader.cs | 85 ++++++++++++++++++++++++++++ src/jit-analyze/Program.cs | 25 ++++++-- 2 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 src/jit-analyze/DisassemblyReader.cs diff --git a/src/jit-analyze/DisassemblyReader.cs b/src/jit-analyze/DisassemblyReader.cs new file mode 100644 index 00000000..bebc8010 --- /dev/null +++ b/src/jit-analyze/DisassemblyReader.cs @@ -0,0 +1,85 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Buffers; +using System.IO; + +namespace ManagedCodeGen +{ + // Return borrowed line spans so the overwhelmingly common instruction lines need no strings. + internal sealed class DisassemblyReader : IDisposable + { + private readonly StreamReader _reader; + private char[] _buffer = ArrayPool.Shared.Rent(64 * 1024); + private int _start; + private int _end; + private bool _skipLF; + private bool _eof; + + public DisassemblyReader(string path) + { + _reader = new StreamReader(path); + } + + public bool ReadLine(out ReadOnlySpan line) + { + int scanned = 0; + while (true) + { + ReadOnlySpan remaining = _buffer.AsSpan(_start, _end - _start); + if (_skipLF && !remaining.IsEmpty) + { + _skipLF = false; + if (remaining[0] == '\n') + { + _start++; + remaining = remaining.Slice(1); + } + } + + int newline = remaining.Slice(scanned).IndexOfAny('\r', '\n'); + if (newline >= 0) + { + newline += scanned; + line = remaining.Slice(0, newline); + _skipLF = remaining[newline] == '\r'; + _start += newline + 1; + return true; + } + + if (_eof) + { + line = remaining; + _start = _end; + return !line.IsEmpty; + } + + scanned = remaining.Length; + if (remaining.Length == _buffer.Length) + { + char[] larger = ArrayPool.Shared.Rent(checked(_buffer.Length * 2)); + remaining.CopyTo(larger); + ArrayPool.Shared.Return(_buffer); + _buffer = larger; + } + else + { + remaining.CopyTo(_buffer); + } + + _start = 0; + _end = scanned; + int read = _reader.Read(_buffer.AsSpan(_end)); + _end += read; + _eof = read == 0; + } + } + + public void Dispose() + { + _reader.Dispose(); + ArrayPool.Shared.Return(_buffer); + } + } +} diff --git a/src/jit-analyze/Program.cs b/src/jit-analyze/Program.cs index ebe48fdb..d8ab39e3 100644 --- a/src/jit-analyze/Program.cs +++ b/src/jit-analyze/Program.cs @@ -289,6 +289,25 @@ public List ExtractFileInfo(string path, string filter, string fileExt // and offset in the file. // // This is the method that knows how to parse jit output and recover the metrics. + private static IEnumerable<(string line, int index)> ReadMetricLines(string[] filePaths) + { + int index = 0; + foreach (string path in filePaths) + { + using var reader = new DisassemblyReader(path); + while (reader.ReadLine(out ReadOnlySpan line)) + { + if (line.StartsWith("; Total bytes of code", StringComparison.Ordinal) || + line.StartsWith("; Assembly listing for method", StringComparison.Ordinal) || + line.StartsWith("; Variable debug info:", StringComparison.Ordinal)) + { + yield return (line.ToString(), index); + } + index = checked(index + 1); + } + } + } + public static IEnumerable ExtractMethodInfo(string[] filePaths) { Regex namePattern = new Regex(@"for method (.*)$"); @@ -303,11 +322,7 @@ public static IEnumerable ExtractMethodInfo(string[] filePaths) Regex resolutionInfoPattern = new Regex(@"ResolutionMovs (\d+) ResolutionMovsWt (\d+\.\d+)"); var result = - filePaths.SelectMany(filePath => File.ReadLines(filePath)) - .Select((x, i) => new { line = x, index = i }) - .Where(l => l.line.StartsWith(@"; Total bytes of code", StringComparison.Ordinal) - || l.line.StartsWith(@"; Assembly listing for method", StringComparison.Ordinal) - || l.line.StartsWith(@"; Variable debug info:", StringComparison.Ordinal)) + ReadMetricLines(filePaths) .Select((x) => { var nameMatch = namePattern.Match(x.line); From bcb13f8a88c3e190e05c1e62f4b588a9d780e20a Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Fri, 4 Sep 2026 23:12:53 +0000 Subject: [PATCH 04/18] Aggregate method metrics while parsing disassembly Use shared generated regexes and span captures, and accumulate directly into per-name method records instead of retaining and repeatedly traversing groups of line records. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/jit-analyze/Program.cs | 169 +++++++++++++++++-------------------- 1 file changed, 79 insertions(+), 90 deletions(-) diff --git a/src/jit-analyze/Program.cs b/src/jit-analyze/Program.cs index d8ab39e3..d0b8e855 100644 --- a/src/jit-analyze/Program.cs +++ b/src/jit-analyze/Program.cs @@ -17,7 +17,7 @@ namespace ManagedCodeGen { - internal sealed class Program + internal sealed partial class Program { private readonly JitAnalyzeRootCommand _command; private readonly bool _reconcile; @@ -92,7 +92,7 @@ public class MethodInfo public MetricCollection Metrics => metrics; public string name; public int functionCount; - public IEnumerable functionOffsets; + public List functionOffsets; public MethodInfo() { @@ -310,96 +310,85 @@ public List ExtractFileInfo(string path, string filter, string fileExt public static IEnumerable ExtractMethodInfo(string[] filePaths) { - Regex namePattern = new Regex(@"for method (.*)$"); - Regex codeSizePattern = new Regex(@"^; Total bytes of code ([0-9]{1,}).* for method "); - Regex prologSizePattern = new Regex(@"prolog size ([0-9]{1,})"); - // use new regex for perf score so we can still parse older files that did not have it. - Regex perfScorePattern = new Regex(@"(PerfScore|perf score) (\d+(\.\d+)?)"); - Regex instrCountPattern = new Regex(@"instruction count ([0-9]{1,})"); - Regex allocSizePattern = new Regex(@"allocated bytes for code ([0-9]{1,})"); - Regex debugInfoPattern = new Regex(@"Variable debug info: ([0-9]{1,}) live range\(s\), ([0-9]{1,}) var\(s\)"); - Regex spillInfoPattern = new Regex(@"SpillCount (\d+) SpillCountWt (\d+\.\d+)"); - Regex resolutionInfoPattern = new Regex(@"ResolutionMovs (\d+) ResolutionMovsWt (\d+\.\d+)"); - - var result = - ReadMetricLines(filePaths) - .Select((x) => - { - var nameMatch = namePattern.Match(x.line); - var codeSizeMatch = codeSizePattern.Match(x.line); - var prologSizeMatch = prologSizePattern.Match(x.line); - var perfScoreMatch = perfScorePattern.Match(x.line); - var instrCountMatch = instrCountPattern.Match(x.line); - var allocSizeMatch = allocSizePattern.Match(x.line); - var debugInfoMatch = debugInfoPattern.Match(x.line); - var spillInfoMatch = spillInfoPattern.Match(x.line); - var resolutionInfoMatch = resolutionInfoPattern.Match(x.line); - return new - { - name = nameMatch.Groups[1].Value, - // Use matched data or default to 0 - totalBytes = codeSizeMatch.Success ? - int.Parse(codeSizeMatch.Groups[1].Value, CultureInfo.InvariantCulture) : 0, - prologBytes = prologSizeMatch.Success ? - int.Parse(prologSizeMatch.Groups[1].Value, CultureInfo.InvariantCulture) : 0, - perfScore = perfScoreMatch.Success ? - double.Parse(perfScoreMatch.Groups[2].Value, CultureInfo.InvariantCulture) : 0, - instrCount = instrCountMatch.Success ? - int.Parse(instrCountMatch.Groups[1].Value, CultureInfo.InvariantCulture) : 0, - allocSize = allocSizeMatch.Success ? - int.Parse(allocSizeMatch.Groups[1].Value, CultureInfo.InvariantCulture) : 0, - debugClauseCount = debugInfoMatch.Success ? - int.Parse(debugInfoMatch.Groups[1].Value, CultureInfo.InvariantCulture) : 0, - debugVarCount = debugInfoMatch.Success ? - int.Parse(debugInfoMatch.Groups[2].Value, CultureInfo.InvariantCulture) : 0, - spillCount = spillInfoMatch.Success ? - int.Parse(spillInfoMatch.Groups[1].Value, CultureInfo.InvariantCulture) : 0, - spillWeight = spillInfoMatch.Success ? - double.Parse(spillInfoMatch.Groups[2].Value, CultureInfo.InvariantCulture) : 0, - resolutionCount = resolutionInfoMatch.Success ? - int.Parse(resolutionInfoMatch.Groups[1].Value, CultureInfo.InvariantCulture) : 0, - resolutionWeight = resolutionInfoMatch.Success ? - double.Parse(resolutionInfoMatch.Groups[2].Value, CultureInfo.InvariantCulture) : 0, - // Use function index only from non-data lines (the name line) - functionOffset = codeSizeMatch.Success ? - 0 : x.index - }; - }) - .GroupBy(x => x.name) - .Select(x => - { - MethodInfo mi = new MethodInfo - { - name = x.Key, - functionCount = x.Select(z => z).Where(z => z.totalBytes == 0).Count(), - // for all non-zero function offsets create list. - functionOffsets = x.Select(z => z) - .Where(z => z.functionOffset != 0) - .Select(z => z.functionOffset).ToList() - }; - - int totalCodeSize = x.Sum(z => z.totalBytes); - int totalAllocSize = x.Sum(z => z.allocSize); - Debug.Assert((totalAllocSize == 0) || (totalCodeSize <= totalAllocSize)); - - mi.Metrics.Add("CodeSize", totalCodeSize); - mi.Metrics.Add("PrologSize", x.Sum(z => z.prologBytes)); - mi.Metrics.Add("PerfScore", x.Sum(z => z.perfScore)); - mi.Metrics.Add("InstrCount", x.Sum(z => z.instrCount)); - mi.Metrics.Add("AllocSize", totalAllocSize); - mi.Metrics.Add("ExtraAllocBytes", totalAllocSize == 0 ? 0 : totalAllocSize - totalCodeSize); - mi.Metrics.Add("DebugClauseCount", x.Sum(z => z.debugClauseCount)); - mi.Metrics.Add("DebugVarCount", x.Sum(z => z.debugVarCount)); - mi.Metrics.Add("SpillCount", x.Sum(z => z.spillCount)); - mi.Metrics.Add("SpillWeight", x.Sum(z => z.spillWeight)); - mi.Metrics.Add("ResolutionCount", x.Sum(z => z.resolutionCount)); - mi.Metrics.Add("ResolutionWeight", x.Sum(z => z.resolutionWeight)); - return mi; - }).ToList(); - - return result; + var methods = new Dictionary(StringComparer.Ordinal); + var lookup = methods.GetAlternateLookup>(); + foreach (var record in ReadMetricLines(filePaths)) + { + string line = record.line; + int nameStart = line.IndexOf("for method ", StringComparison.Ordinal); + ReadOnlySpan name = nameStart < 0 ? ReadOnlySpan.Empty : line.AsSpan(nameStart + 11); + if (!lookup.TryGetValue(name, out MethodInfo method)) + { + method = new MethodInfo { name = name.ToString(), functionOffsets = new List() }; + methods.Add(method.name, method); + } + + Match codeSize = CodeSizePattern().Match(line); + int totalBytes = ReadInt(codeSize); + if (totalBytes == 0) + { + method.functionCount = checked(method.functionCount + 1); + } + if (!codeSize.Success && record.index != 0) + { + method.functionOffsets.Add(record.index); + } + + AddInt(method.Metrics, "CodeSize", totalBytes); + AddInt(method.Metrics, "PrologSize", ReadInt(PrologSizePattern().Match(line))); + method.Metrics.Add("PerfScore", ReadDouble(PerfScorePattern().Match(line), 2)); + AddInt(method.Metrics, "InstrCount", ReadInt(InstrCountPattern().Match(line))); + AddInt(method.Metrics, "AllocSize", ReadInt(AllocSizePattern().Match(line))); + Match debugInfo = DebugInfoPattern().Match(line); + AddInt(method.Metrics, "DebugClauseCount", ReadInt(debugInfo)); + AddInt(method.Metrics, "DebugVarCount", ReadInt(debugInfo, 2)); + Match spillInfo = SpillInfoPattern().Match(line); + AddInt(method.Metrics, "SpillCount", ReadInt(spillInfo)); + method.Metrics.Add("SpillWeight", ReadDouble(spillInfo, 2)); + Match resolutionInfo = ResolutionInfoPattern().Match(line); + AddInt(method.Metrics, "ResolutionCount", ReadInt(resolutionInfo)); + method.Metrics.Add("ResolutionWeight", ReadDouble(resolutionInfo, 2)); + } + + foreach (MethodInfo method in methods.Values) + { + double totalCodeSize = method.Metrics.GetMetric("CodeSize").Value; + double totalAllocSize = method.Metrics.GetMetric("AllocSize").Value; + Debug.Assert(totalAllocSize == 0 || totalCodeSize <= totalAllocSize); + method.Metrics.Add("ExtraAllocBytes", totalAllocSize == 0 ? 0 : totalAllocSize - totalCodeSize); + } + return methods.Values.ToList(); + + static int ReadInt(Match match, int group = 1) => + match.Success ? int.Parse(match.Groups[group].ValueSpan, CultureInfo.InvariantCulture) : 0; + + static double ReadDouble(Match match, int group) => + match.Success ? double.Parse(match.Groups[group].ValueSpan, CultureInfo.InvariantCulture) : 0; + + static void AddInt(MetricCollection metrics, string name, int value) + { + Metric metric = metrics.GetMetric(name); + metric.Value = checked((int)metric.Value + value); + } } + [GeneratedRegex(@"^; Total bytes of code ([0-9]{1,}).* for method ")] + private static partial Regex CodeSizePattern(); + [GeneratedRegex(@"prolog size ([0-9]{1,})")] + private static partial Regex PrologSizePattern(); + [GeneratedRegex(@"(PerfScore|perf score) (\d+(\.\d+)?)")] + private static partial Regex PerfScorePattern(); + [GeneratedRegex(@"instruction count ([0-9]{1,})")] + private static partial Regex InstrCountPattern(); + [GeneratedRegex(@"allocated bytes for code ([0-9]{1,})")] + private static partial Regex AllocSizePattern(); + [GeneratedRegex(@"Variable debug info: ([0-9]{1,}) live range\(s\), ([0-9]{1,}) var\(s\)")] + private static partial Regex DebugInfoPattern(); + [GeneratedRegex(@"SpillCount (\d+) SpillCountWt (\d+\.\d+)")] + private static partial Regex SpillInfoPattern(); + [GeneratedRegex(@"ResolutionMovs (\d+) ResolutionMovsWt (\d+\.\d+)")] + private static partial Regex ResolutionInfoPattern(); + // Compare base and diff file lists and produce a sorted list of method // deltas by file. Delta is computed diffBytes - baseBytes so positive // numbers are regressions. (lower is better) From c8829242722e95d1e88321f62e74f174af171454 Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Fri, 4 Sep 2026 23:15:15 +0000 Subject: [PATCH 05/18] Store metric values compactly and avoid copying parsed metrics Keep doubles in a single array per collection rather than allocating a polymorphic object per metric. Materialize metric display objects only for reports and reuse parsed values when forming deltas. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/jit-analyze/MetricCollection.cs | 47 ++++++++++++++++------------- src/jit-analyze/Program.cs | 33 +++++++++----------- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/src/jit-analyze/MetricCollection.cs b/src/jit-analyze/MetricCollection.cs index 35307b2c..5cc3fb20 100644 --- a/src/jit-analyze/MetricCollection.cs +++ b/src/jit-analyze/MetricCollection.cs @@ -32,16 +32,14 @@ static MetricCollection() } } + private readonly double[] _values; + [JsonInclude] - private Metric[] metrics; + private Metric[] metrics => s_metrics.Select(m => GetMetric(m.Name)).ToArray(); public MetricCollection() { - metrics = new Metric[s_metrics.Length]; - for (int i = 0; i < s_metrics.Length; i++) - { - metrics[i] = s_metrics[i].Clone(); - } + _values = new double[s_metrics.Length]; } public MetricCollection(MetricCollection other) : this() @@ -51,16 +49,27 @@ public MetricCollection(MetricCollection other) : this() public static IEnumerable AllMetrics => s_metrics; + // Materialize display metadata only for reports; analysis uses the compact values directly. public Metric GetMetric(string metricName) { int index; if (s_metricNameToIndex.TryGetValue(metricName, out index)) { - return metrics[index]; + Metric metric = s_metrics[index].Clone(); + metric.Value = _values[index]; + return metric; } return null; } + public double GetValue(string metricName) => _values[s_metricNameToIndex[metricName]]; + + public void AddInt(string metricName, int value) + { + int index = s_metricNameToIndex[metricName]; + _values[index] = checked((int)_values[index] + value); + } + public static bool ValidateMetric(string name) { return s_metricNameToIndex.TryGetValue(name, out _); @@ -104,47 +113,43 @@ public override string ToString() public void Add(MetricCollection other) { - for (int i = 0; i < metrics.Length; i++) + for (int i = 0; i < _values.Length; i++) { - metrics[i].Add(other.metrics[i]); + _values[i] += other._values[i]; } } public void Add(string metricName, double value) { - Metric m = GetMetric(metricName); - m.Value += value; + _values[s_metricNameToIndex[metricName]] += value; } public void Sub(MetricCollection other) { - for (int i = 0; i < metrics.Length; i++) + for (int i = 0; i < _values.Length; i++) { - metrics[i].Sub(other.metrics[i]); + _values[i] -= other._values[i]; } } public void Rel(MetricCollection other) { - for (int i = 0; i < metrics.Length; i++) + for (int i = 0; i < _values.Length; i++) { - metrics[i].Rel(other.metrics[i]); + _values[i] = (_values[i] - other._values[i]) / other._values[i]; } } public void SetValueFrom(MetricCollection other) { - for (int i = 0; i < metrics.Length; i++) - { - metrics[i].SetValueFrom(other.metrics[i]); - } + other._values.CopyTo(_values, 0); } public bool IsZero() { - for (int i = 0; i < metrics.Length; i++) + for (int i = 0; i < _values.Length; i++) { - if (metrics[i].Value != 0) return false; + if (_values[i] != 0) return false; } return true; } diff --git a/src/jit-analyze/Program.cs b/src/jit-analyze/Program.cs index d0b8e855..fd29bf3c 100644 --- a/src/jit-analyze/Program.cs +++ b/src/jit-analyze/Program.cs @@ -334,26 +334,26 @@ public static IEnumerable ExtractMethodInfo(string[] filePaths) method.functionOffsets.Add(record.index); } - AddInt(method.Metrics, "CodeSize", totalBytes); - AddInt(method.Metrics, "PrologSize", ReadInt(PrologSizePattern().Match(line))); + method.Metrics.AddInt("CodeSize", totalBytes); + method.Metrics.AddInt("PrologSize", ReadInt(PrologSizePattern().Match(line))); method.Metrics.Add("PerfScore", ReadDouble(PerfScorePattern().Match(line), 2)); - AddInt(method.Metrics, "InstrCount", ReadInt(InstrCountPattern().Match(line))); - AddInt(method.Metrics, "AllocSize", ReadInt(AllocSizePattern().Match(line))); + method.Metrics.AddInt("InstrCount", ReadInt(InstrCountPattern().Match(line))); + method.Metrics.AddInt("AllocSize", ReadInt(AllocSizePattern().Match(line))); Match debugInfo = DebugInfoPattern().Match(line); - AddInt(method.Metrics, "DebugClauseCount", ReadInt(debugInfo)); - AddInt(method.Metrics, "DebugVarCount", ReadInt(debugInfo, 2)); + method.Metrics.AddInt("DebugClauseCount", ReadInt(debugInfo)); + method.Metrics.AddInt("DebugVarCount", ReadInt(debugInfo, 2)); Match spillInfo = SpillInfoPattern().Match(line); - AddInt(method.Metrics, "SpillCount", ReadInt(spillInfo)); + method.Metrics.AddInt("SpillCount", ReadInt(spillInfo)); method.Metrics.Add("SpillWeight", ReadDouble(spillInfo, 2)); Match resolutionInfo = ResolutionInfoPattern().Match(line); - AddInt(method.Metrics, "ResolutionCount", ReadInt(resolutionInfo)); + method.Metrics.AddInt("ResolutionCount", ReadInt(resolutionInfo)); method.Metrics.Add("ResolutionWeight", ReadDouble(resolutionInfo, 2)); } foreach (MethodInfo method in methods.Values) { - double totalCodeSize = method.Metrics.GetMetric("CodeSize").Value; - double totalAllocSize = method.Metrics.GetMetric("AllocSize").Value; + double totalCodeSize = method.Metrics.GetValue("CodeSize"); + double totalAllocSize = method.Metrics.GetValue("AllocSize"); Debug.Assert(totalAllocSize == 0 || totalCodeSize <= totalAllocSize); method.Metrics.Add("ExtraAllocBytes", totalAllocSize == 0 ? 0 : totalAllocSize - totalCodeSize); } @@ -365,11 +365,6 @@ static int ReadInt(Match match, int group = 1) => static double ReadDouble(Match match, int group) => match.Success ? double.Parse(match.Groups[group].ValueSpan, CultureInfo.InvariantCulture) : 0; - static void AddInt(MetricCollection metrics, string name, int value) - { - Metric metric = metrics.GetMetric(name); - metric.Value = checked((int)metric.Value + value); - } } [GeneratedRegex(@"^; Total bytes of code ([0-9]{1,}).* for method ")] @@ -419,12 +414,12 @@ private FileDelta CompareFile(string baseName, string diffName, x => x.name, y => y.name, (x, y) => new MethodDelta { name = x.name, - baseMetrics = new MetricCollection(x.Metrics), - diffMetrics = new MetricCollection(y.Metrics), + baseMetrics = x.Metrics, + diffMetrics = y.Metrics, baseOffsets = x.functionOffsets, diffOffsets = y.functionOffsets }) - .OrderByDescending(r => r.deltaMetrics.GetMetric(metricName).Value) + .OrderByDescending(r => r.deltaMetrics.GetValue(metricName)) .ToList(); FileDelta f = new FileDelta @@ -438,7 +433,7 @@ private FileDelta CompareFile(string baseName, string diffName, methodsInBoth = jointList.Count(), methodsOnlyInBase = baseMethods.Except(diffMethods, methodInfoComparer).ToList(), methodsOnlyInDiff = diffMethods.Except(baseMethods, methodInfoComparer).ToList(), - methodDeltaList = jointList.Where(x => x.deltaMetrics.GetMetric(metricName).Value != 0).ToList() + methodDeltaList = jointList.Where(x => x.deltaMetrics.GetValue(metricName) != 0).ToList() }; if (_reconcile) From bb910adcd464b4768681b0e0242c1bc5c86d9318 Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Fri, 4 Sep 2026 23:18:23 +0000 Subject: [PATCH 06/18] Parallelize textual diffs and bypass git for identical files Bound git workers, compare equal-size inputs with pooled buffers, cache text counts across metrics, pass paths without shell quoting, and surface git failures. Match absolute count keys when reporting text-only changes, fixing the previously missing section. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/jit-analyze/Program.cs | 151 +++++++++++++++++---------- test/jit-analyze/Regression/Tests.cs | 44 ++++++++ 2 files changed, 137 insertions(+), 58 deletions(-) diff --git a/src/jit-analyze/Program.cs b/src/jit-analyze/Program.cs index fd29bf3c..1ff2e22d 100644 --- a/src/jit-analyze/Program.cs +++ b/src/jit-analyze/Program.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System; +using System.Buffers; using System.Collections.Generic; using System.CommandLine; using System.CommandLine.Parsing; @@ -27,6 +28,7 @@ internal sealed partial class Program private readonly int _count; private readonly string _basePath; private readonly string _diffPath; + private Dictionary _textDiffCounts; private static string METRIC_SEP = new string('-', 80); @@ -660,11 +662,14 @@ void DisplayMethodMetric(string headerText, string subtext, int methodCount, dyn { // Show files with text diffs but no metric diffs. - Dictionary diffCounts = DiffInText(_diffPath, _basePath); + Dictionary diffCounts = _textDiffCounts ??= DiffInText(_diffPath, _basePath); // TODO: resolve diffs to particular methods in the files. - var zeroDiffFilesWithDiffs = fileDeltaList.Where(x => diffCounts.ContainsKey(x.diffName) && (x.deltaMetrics.IsZero())) - .OrderByDescending(x => diffCounts[x.baseName]); + string baseDirectory = Directory.Exists(_basePath) ? _basePath : Path.GetDirectoryName(Path.GetFullPath(_basePath)); + var zeroDiffFilesWithDiffs = fileDeltaList + .Select(file => (File: file, Path: Path.GetFullPath(Path.Combine(baseDirectory, file.baseName)))) + .Where(x => !Get(_command.ConcatFiles) && diffCounts.ContainsKey(x.Path) && x.File.deltaMetrics.IsZero()) + .OrderByDescending(x => diffCounts[x.Path]); int zeroDiffFilesWithDiffCount = zeroDiffFilesWithDiffs.Count(); if (zeroDiffFilesWithDiffCount > 0) @@ -672,7 +677,7 @@ void DisplayMethodMetric(string headerText, string subtext, int methodCount, dyn summaryContents.AppendLine($"\n{zeroDiffFilesWithDiffCount} files had text diffs but no metric diffs."); foreach (var zerofile in zeroDiffFilesWithDiffs.Take(_count)) { - summaryContents.AppendLine($"{zerofile.baseName} had {diffCounts[zerofile.baseName]} diffs"); + summaryContents.AppendLine($"{zerofile.File.baseName} had {diffCounts[zerofile.Path]} diffs"); } } } @@ -858,68 +863,98 @@ public static StringBuilder GenerateTSV(IEnumerable compareList) // public static Dictionary DiffInText(string diffPath, string basePath) { - // run get diff command to see if we have textual diffs. - // (use git diff since it's already a dependency and cross platform) - List commandArgs = new List(); - commandArgs.Add("diff"); - commandArgs.Add("--no-index"); - commandArgs.Add("--diff-filter=M"); - commandArgs.Add("--exit-code"); - commandArgs.Add("--numstat"); - commandArgs.Add("-z"); - commandArgs.Add(basePath); - commandArgs.Add(diffPath); - - ProcessResult result = Utility.ExecuteProcess("git", commandArgs, true); - Dictionary fileToTextDiffCount = new Dictionary(); ; - - if (result.ExitCode != 0) + basePath = Path.GetFullPath(basePath); + diffPath = Path.GetFullPath(diffPath); + IEnumerable<(string Base, string Diff)> pairs; + if (Directory.Exists(basePath) && Directory.Exists(diffPath)) { - // There are files with diffs. Build up a dictionary mapping base file name to net text diff count. - - var rawLines = result.StdOut.Split(new[] { "\0", Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries); - if (rawLines.Length % 3 != 0) - { - Console.WriteLine($"Error parsing output: {result.StdOut}"); - return fileToTextDiffCount; - } - - for (int i = 0; i < rawLines.Length; i += 3) - { - string rawStats = rawLines[i]; - string rawBasePath = rawLines[i + 1]; - string rawDiffPath = rawLines[i + 2]; - - string[] fields = rawStats.Split(new char[] { ' ', '\t', '"' }, StringSplitOptions.RemoveEmptyEntries); - - string parsedFullDiffFilePath = Path.GetFullPath(rawDiffPath); - string parsedFullBaseFilePath = Path.GetFullPath(rawBasePath); + pairs = Directory.EnumerateFiles(basePath, "*", SearchOption.AllDirectories) + .Select(path => (Base: path, Diff: Path.Combine(diffPath, Path.GetRelativePath(basePath, path)))) + .Where(pair => File.Exists(pair.Diff)); + } + else + { + if (Directory.Exists(basePath)) + basePath = Path.Combine(basePath, Path.GetFileName(diffPath)); + if (Directory.Exists(diffPath)) + diffPath = Path.Combine(diffPath, Path.GetFileName(basePath)); + pairs = new[] { (basePath, diffPath) }; + } - if (!File.Exists(parsedFullBaseFilePath)) - { - Console.WriteLine($"Error parsing path '{rawBasePath}'. `{parsedFullBaseFilePath}` doesn't exist."); - continue; - } + // Initialize the process manager on the caller thread before starting parallel workers. + ProcessManager manager = ProcessManager.Instance; + var counts = pairs.AsParallel().WithDegreeOfParallelism(Math.Min(Environment.ProcessorCount, 8)) + .Where(pair => !FilesEqual(pair.Base, pair.Diff)) + .Select(pair => (pair.Base, Count: TextDiffCount(pair.Base, pair.Diff, manager))) + .Where(pair => pair.Count.HasValue) + .ToDictionary(pair => pair.Base, pair => pair.Count.Value, StringComparer.Ordinal); + + if (counts.Count != 0) + Console.WriteLine($"Found {counts.Count} files with textual diffs."); + return counts; + } + private static bool FilesEqual(string basePath, string diffPath) + { + // Git compares symbolic links themselves, not the contents of their targets. + if (new System.IO.FileInfo(basePath).LinkTarget != null || new System.IO.FileInfo(diffPath).LinkTarget != null) + return false; - if (!File.Exists(parsedFullDiffFilePath)) - { - Console.WriteLine($"Error parsing path '{rawDiffPath}'. `{parsedFullDiffFilePath}` doesn't exist."); - continue; - } + using var baseStream = File.OpenRead(basePath); + using var diffStream = File.OpenRead(diffPath); + if (baseStream.Length != diffStream.Length) + return false; - // Sometimes .dasm is parsed as binary and we don't get numbers, just dashes - int addCount = 0; - int delCount = 0; - Int32.TryParse(fields[0], out addCount); - Int32.TryParse(fields[1], out delCount); - fileToTextDiffCount[parsedFullBaseFilePath] = addCount + delCount; + byte[] baseBuffer = ArrayPool.Shared.Rent(64 * 1024); + byte[] diffBuffer = ArrayPool.Shared.Rent(64 * 1024); + try + { + int read; + while ((read = baseStream.Read(baseBuffer)) != 0) + { + if (diffStream.ReadAtLeast(diffBuffer.AsSpan(0, read), read, throwOnEndOfStream: false) != read || + !baseBuffer.AsSpan(0, read).SequenceEqual(diffBuffer.AsSpan(0, read))) + return false; } - - Console.WriteLine($"Found {fileToTextDiffCount.Count()} files with textual diffs."); + return diffStream.ReadByte() == -1; } + finally + { + ArrayPool.Shared.Return(baseBuffer); + ArrayPool.Shared.Return(diffBuffer); + } + } - return fileToTextDiffCount; + private static int? TextDiffCount(string basePath, string diffPath, ProcessManager manager) + { + var startInfo = new ProcessStartInfo("git") + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + foreach (string argument in new[] { "diff", "--no-index", "--diff-filter=M", "--exit-code", "--numstat", "-z", "--", basePath, diffPath }) + startInfo.ArgumentList.Add(argument); + + using Process process = manager.Start(startInfo); + process.Start(); + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + process.WaitForExit(); + string output = outputTask.GetAwaiter().GetResult(); + string error = errorTask.GetAwaiter().GetResult(); + if (process.ExitCode == 0) + return null; + if (process.ExitCode != 1) + throw new InvalidOperationException($"git diff failed for '{basePath}' and '{diffPath}' (exit {process.ExitCode}): {error}"); + + string[] fields = output.Split('\t', 3); + if (fields.Length != 3) + throw new InvalidOperationException($"Invalid git numstat output for '{basePath}': {output}"); + // Binary files have '-' in both numeric fields. + return ParseCount(fields[0]) + ParseCount(fields[1]); + + static int ParseCount(string value) => value == "-" ? 0 : int.Parse(value, CultureInfo.InvariantCulture); } private T Get(Option option) => _command.Result.GetValue(option); diff --git a/test/jit-analyze/Regression/Tests.cs b/test/jit-analyze/Regression/Tests.cs index 92619256..5cb154fb 100644 --- a/test/jit-analyze/Regression/Tests.cs +++ b/test/jit-analyze/Regression/Tests.cs @@ -43,6 +43,7 @@ private static int Main(string[] args) ExtractMetrics(); LineBoundaries(); CommandLine(); + TextDiffs(); Console.WriteLine($"PASS: {checks} assertions (parser, line boundaries, CLI and TSV)."); return 0; } @@ -235,6 +236,49 @@ private static void Totals(string output, int before, int after, int delta) Contains(output, $"Total bytes of base: {before}\nTotal bytes of diff: {after}\nTotal bytes of delta: {delta} ("); } + private static void TextDiffs() + { + string before = Path.Combine(root, "text base"); + string after = Path.Combine(root, "text diff"); + string textOnly = Write("text base/nested/text only.dasm", Method("Same", 10) + "; old\n"); + Write("text diff/nested/text only.dasm", Method("Same", 10) + "; new\n"); + string binary = Write("text base/binary.dasm", "\0old"); + Write("text diff/binary.dasm", "\0new"); + string longFile = Write("text base/long.dasm", new string('x', 131072) + "a\n"); + Write("text diff/long.dasm", new string('x', 131072) + "b\n"); + Write("text base/identical.dasm", Method("Unchanged", 5)); + Write("text diff/identical.dasm", Method("Unchanged", 5)); + Write("text base/removed.dasm", "removed"); + Write("text diff/added.dasm", "added"); + if (!OperatingSystem.IsWindows()) + { + Write("text base/tab\tand\nnewline.dasm", "old\n"); + Write("text diff/tab\tand\nnewline.dasm", "new\n"); + } + + Dictionary counts = Analyzer.DiffInText(after, before); + Equal(OperatingSystem.IsWindows() ? 3 : 4, counts.Count, "text diff file count"); + Equal(2, counts[textOnly], "text-only diff count"); + Equal(0, counts[binary], "binary diff count"); + Equal(2, counts[longFile], "difference after multiple buffers"); + Equal(0, Analyzer.DiffInText(before, before).Count, "identical trees"); + Equal(2, Analyzer.DiffInText(Path.Combine(after, "nested/text only.dasm"), textOnly)[textOnly], "single file counts"); + + string[] args = { "--base", before, "--diff", after, "--recursive" }; + TextWriter oldOut = Console.Out; + using var stdout = new StringWriter(); + try + { + Console.SetOut(stdout); + Equal(0, new JitAnalyzeRootCommand(args).Parse(args).Invoke(), "text-only exit code"); + } + finally + { + Console.SetOut(oldOut); + } + Contains(stdout.ToString(), $"nested{Path.DirectorySeparatorChar}text only.dasm had 2 diffs"); + } + private static void CheckTsv(string tsv, string[] methods, int headers = 1) { string expectedHeader = "File\tMethod" + string.Concat(MetricNames.Select(n => From a816f7528930a79317dfafeffdcb00ef525cb64d Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Fri, 4 Sep 2026 23:19:55 +0000 Subject: [PATCH 07/18] Reuse parsed methods and aggregate directly for identical file pairs Recognize byte-identical inputs before parsing the second side, and avoid building joins or method deltas for them. Preserve absent-metric relative delta semantics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/jit-analyze/MetricCollection.cs | 8 +++++++ src/jit-analyze/Program.cs | 34 ++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/jit-analyze/MetricCollection.cs b/src/jit-analyze/MetricCollection.cs index 5cc3fb20..b0c26358 100644 --- a/src/jit-analyze/MetricCollection.cs +++ b/src/jit-analyze/MetricCollection.cs @@ -140,6 +140,14 @@ public void Rel(MetricCollection other) } } + public void AddRelativeDifference(MetricCollection diff, MetricCollection baseline) + { + for (int i = 0; i < _values.Length; i++) + { + _values[i] += (diff._values[i] - baseline._values[i]) / baseline._values[i]; + } + } + public void SetValueFrom(MetricCollection other) { other._values.CopyTo(_values, 0); diff --git a/src/jit-analyze/Program.cs b/src/jit-analyze/Program.cs index 1ff2e22d..5110b0b6 100644 --- a/src/jit-analyze/Program.cs +++ b/src/jit-analyze/Program.cs @@ -402,7 +402,9 @@ public FileDelta[][] Comparator(IEnumerable baseInfo, .Select(pair => { var baseMethods = ExtractMethodInfo(pair.Base.paths); - var diffMethods = ExtractMethodInfo(pair.Diff.paths); + bool identical = pair.Base.paths.Length == pair.Diff.paths.Length && + pair.Base.paths.Zip(pair.Diff.paths).All(paths => FilesEqual(paths.First, paths.Second)); + var diffMethods = identical ? baseMethods : ExtractMethodInfo(pair.Diff.paths); return metricNames.Select(metricName => CompareFile(pair.Base.name, pair.Diff.name, baseMethods, diffMethods, metricName)).ToArray(); }).ToArray(); @@ -411,6 +413,36 @@ public FileDelta[][] Comparator(IEnumerable baseInfo, private FileDelta CompareFile(string baseName, string diffName, IEnumerable baseMethods, IEnumerable diffMethods, string metricName) { + if (ReferenceEquals(baseMethods, diffMethods)) + { + var total = new MetricCollection(); + var relative = new MetricCollection(); + int count = 0; + foreach (MethodInfo method in baseMethods) + { + total.Add(method.Metrics); + // Preserve 0/0 (NaN) for metrics absent from an otherwise identical method. + relative.AddRelativeDifference(method.Metrics, method.Metrics); + count++; + } + var unchanged = new FileDelta + { + baseName = baseName, + diffName = diffName, + baseMetrics = total, + diffMetrics = new MetricCollection(total), + deltaMetrics = new MetricCollection(), + relDeltaMetrics = relative, + methodsInBoth = count, + methodsOnlyInBase = Array.Empty(), + methodsOnlyInDiff = Array.Empty(), + methodDeltaList = Array.Empty(), + }; + if (_reconcile) + unchanged.Reconcile(); + return unchanged; + } + MethodInfoComparer methodInfoComparer = new MethodInfoComparer(); var jointList = baseMethods.Join(diffMethods, x => x.name, y => y.name, (x, y) => new MethodDelta From f261f89f191edf377eeca24b15944b397f21cb3b Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Fri, 4 Sep 2026 23:26:26 +0000 Subject: [PATCH 08/18] Preserve symbolic-link semantics in parallel text comparisons Walk matched directories without following directory links, include dangling links, and cover link cycles and quoted paths. Compare JSON and Markdown against the original analyzer as well as stdout and TSV. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/jit-analyze/Program.cs | 44 ++++++++++++++++++++++----- test/jit-analyze/Regression/README.md | 5 ++- test/jit-analyze/Regression/Tests.cs | 21 +++++++++++-- 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/src/jit-analyze/Program.cs b/src/jit-analyze/Program.cs index 5110b0b6..89dfb019 100644 --- a/src/jit-analyze/Program.cs +++ b/src/jit-analyze/Program.cs @@ -898,17 +898,17 @@ public static Dictionary DiffInText(string diffPath, string basePat basePath = Path.GetFullPath(basePath); diffPath = Path.GetFullPath(diffPath); IEnumerable<(string Base, string Diff)> pairs; - if (Directory.Exists(basePath) && Directory.Exists(diffPath)) + bool baseDirectory = IsRealDirectory(basePath); + bool diffDirectory = IsRealDirectory(diffPath); + if (baseDirectory && diffDirectory) { - pairs = Directory.EnumerateFiles(basePath, "*", SearchOption.AllDirectories) - .Select(path => (Base: path, Diff: Path.Combine(diffPath, Path.GetRelativePath(basePath, path)))) - .Where(pair => File.Exists(pair.Diff)); + pairs = EnumerateTextPairs(new DirectoryInfo(basePath), new DirectoryInfo(diffPath)); } else { - if (Directory.Exists(basePath)) + if (baseDirectory) basePath = Path.Combine(basePath, Path.GetFileName(diffPath)); - if (Directory.Exists(diffPath)) + if (diffDirectory) diffPath = Path.Combine(diffPath, Path.GetFileName(basePath)); pairs = new[] { (basePath, diffPath) }; } @@ -926,10 +926,40 @@ public static Dictionary DiffInText(string diffPath, string basePat return counts; } + private static bool IsRealDirectory(string path) => + (File.GetAttributes(path) & (FileAttributes.Directory | FileAttributes.ReparsePoint)) == FileAttributes.Directory; + + private static IEnumerable<(string Base, string Diff)> EnumerateTextPairs(DirectoryInfo baseline, DirectoryInfo diff) + { + var diffEntries = diff.EnumerateFileSystemInfos().ToDictionary(entry => entry.Name, StringComparer.Ordinal); + foreach (FileSystemInfo entry in baseline.EnumerateFileSystemInfos()) + { + if (!diffEntries.TryGetValue(entry.Name, out FileSystemInfo other)) + continue; + + bool baseDirectory = IsRealDirectory(entry.FullName); + bool diffDirectory = IsRealDirectory(other.FullName); + if (baseDirectory && diffDirectory) + { + foreach (var pair in EnumerateTextPairs((DirectoryInfo)entry, (DirectoryInfo)other)) + yield return pair; + } + else if (!baseDirectory && !diffDirectory) + { + yield return (entry.FullName, other.FullName); + } + } + } + private static bool FilesEqual(string basePath, string diffPath) { // Git compares symbolic links themselves, not the contents of their targets. - if (new System.IO.FileInfo(basePath).LinkTarget != null || new System.IO.FileInfo(diffPath).LinkTarget != null) + var baseInfo = new System.IO.FileInfo(basePath); + var diffInfo = new System.IO.FileInfo(diffPath); + if (baseInfo.LinkTarget != null || diffInfo.LinkTarget != null) + return false; + + if (baseInfo.Length != diffInfo.Length) return false; using var baseStream = File.OpenRead(basePath); diff --git a/test/jit-analyze/Regression/README.md b/test/jit-analyze/Regression/README.md index 087ba9ed..ccdd0bf1 100644 --- a/test/jit-analyze/Regression/README.md +++ b/test/jit-analyze/Regression/README.md @@ -19,13 +19,16 @@ dotnet run --project test/jit-analyze/Regression -c Release -- /path/to/baseline Only CRLF output endings are normalized; numeric formatting is invariant. Normal runs use explicit assertions, not a baseline executable or the historical -`../baseline*.out` goldens. Textual git diffs are disabled to isolate analyzer behavior. +`../baseline*.out` goldens. Baseline comparisons disable textual git diffs to isolate +metric analysis; separate tests exercise Git and the text-only report. Coverage includes all 12 metrics; repeated/Unicode method names; absent optional metrics; both perf-score spellings; zero-byte records; debug info; concatenated-file offsets; LF, CRLF and CR; missing final newlines; empty files; long selected and ignored lines; and UTF-8/CRLF around 64 KiB boundaries. CLI tests exercise reconciliation, warnings, filtering, multiple metrics, TSV, concatenation and unequal single filenames. +Text-diff tests cover unchanged files, binary files, long files, nested paths, spaces, +Unix tabs/newlines in paths, dangling links and directory links without traversal. Intentionally preserved behavior: diff --git a/test/jit-analyze/Regression/Tests.cs b/test/jit-analyze/Regression/Tests.cs index 5cb154fb..ca6c2229 100644 --- a/test/jit-analyze/Regression/Tests.cs +++ b/test/jit-analyze/Regression/Tests.cs @@ -254,15 +254,26 @@ private static void TextDiffs() { Write("text base/tab\tand\nnewline.dasm", "old\n"); Write("text diff/tab\tand\nnewline.dasm", "new\n"); + Directory.CreateSymbolicLink(Path.Combine(before, "directory-link"), before); + Directory.CreateSymbolicLink(Path.Combine(after, "directory-link"), after); + File.CreateSymbolicLink(Path.Combine(before, "dangling-link"), "missing-base"); + File.CreateSymbolicLink(Path.Combine(after, "dangling-link"), "missing-diff"); } Dictionary counts = Analyzer.DiffInText(after, before); - Equal(OperatingSystem.IsWindows() ? 3 : 4, counts.Count, "text diff file count"); + Equal(OperatingSystem.IsWindows() ? 3 : 6, counts.Count, "text diff file count"); Equal(2, counts[textOnly], "text-only diff count"); Equal(0, counts[binary], "binary diff count"); Equal(2, counts[longFile], "difference after multiple buffers"); Equal(0, Analyzer.DiffInText(before, before).Count, "identical trees"); Equal(2, Analyzer.DiffInText(Path.Combine(after, "nested/text only.dasm"), textOnly)[textOnly], "single file counts"); + if (!OperatingSystem.IsWindows()) + { + Equal(2, Analyzer.DiffInText(Path.Combine(after, "directory-link"), Path.Combine(before, "directory-link")) + [Path.Combine(before, "directory-link")], "directory links are compared without traversal"); + Directory.Delete(Path.Combine(before, "directory-link")); + Directory.Delete(Path.Combine(after, "directory-link")); + } string[] args = { "--base", before, "--diff", after, "--recursive" }; TextWriter oldOut = Console.Out; @@ -323,8 +334,10 @@ private static void CheckTsv(string tsv, string[] methods, int headers = 1) private static (int Code, string Output, string Tsv) Invoke(string before, string after, params string[] options) { string tsv = Path.Combine(root, "result.tsv"); + string json = Path.Combine(root, "result.json"); + string markdown = Path.Combine(root, "result.md"); File.Delete(tsv); - string[] args = new[] { "--base", before, "--diff", after, "--skip-text-diff", "--tsv", tsv }.Concat(options).ToArray(); + string[] args = new[] { "--base", before, "--diff", after, "--skip-text-diff", "--tsv", tsv, "--json", json, "--md", markdown }.Concat(options).ToArray(); TextWriter oldOut = Console.Out; TextWriter oldError = Console.Error; using var stdout = new StringWriter(); @@ -344,6 +357,8 @@ private static (int Code, string Output, string Tsv) Invoke(string before, strin Equal("", stderr.ToString(), "CLI stderr"); string output = stdout.ToString().Replace("\r\n", "\n"); string table = File.ReadAllText(tsv).Replace("\r\n", "\n"); + string jsonOutput = File.ReadAllText(json); + string markdownOutput = File.ReadAllText(markdown); if (baseline != null) { using var process = new Process(); @@ -361,6 +376,8 @@ private static (int Code, string Output, string Tsv) Invoke(string before, strin Equal("", baselineError.GetAwaiter().GetResult(), "baseline stderr"); Equal(output, baselineOut.GetAwaiter().GetResult().Replace("\r\n", "\n"), "baseline stdout"); Equal(table, File.ReadAllText(tsv).Replace("\r\n", "\n"), "baseline TSV"); + Equal(jsonOutput, File.ReadAllText(json), "baseline JSON"); + Equal(markdownOutput, File.ReadAllText(markdown), "baseline markdown"); } return (code, output, table); } From 3533d57e8e89143aa964e0aa6b49310bd36422f3 Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Fri, 4 Sep 2026 23:33:00 +0000 Subject: [PATCH 09/18] Document large-input analysis and measured optimization results Record the issue 2148 artifact workload, seven incremental optimizations, repeated end-to-end timings, peak RSS, and benchmark commands and caveats. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/jit-analyze/README.md | 63 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/src/jit-analyze/README.md b/src/jit-analyze/README.md index a459e7ce..b6e68054 100644 --- a/src/jit-analyze/README.md +++ b/src/jit-analyze/README.md @@ -14,7 +14,68 @@ To build/setup: for directions how. * Run analyze --base `` --diff `` to produce a summary of the differences. - + +## Large disassembly sets + +Directory analysis parses and compares independent file pairs in parallel, with at +most eight workers (or the available processor count, if lower). Each worker +releases unchanged method data after comparing a pair, and reuses parsed methods +across requested metrics. Byte-identical pairs only need one parse. Instruction +lines are scanned using pooled buffers rather than allocated as individual strings. + +Textual diff analysis remains enabled by default. It uses the same concurrency +bound, skips Git for byte-identical files, and reuses counts across metrics. +Git's added/deleted line counts are retained, including binary-file handling. +The text-only summary lists files whose text changed but whose metrics did not. +Directory symlinks are compared as links rather than followed. + +To measure the complete analysis on Linux, build Release and run, for example: + +```sh +dotnet build src/jit-analyze -c Release +/usr/bin/time -v src/jit-analyze/bin/Release/net10.0/jit-analyze \ + --base /path/to/main --diff /path/to/pr --recursive --count 100 +``` + +The analyzer returns a nonzero exit code when metrics differ; this does not mean +the run failed. Compare repeated runs on the same files and machine, and retain +textual diffs when measuring end-to-end performance. `--skip-text-diff` can isolate +metric analysis, but measures a different workload. GNU time reports peak resident +memory for a process, not the simultaneous sum of all Git worker processes. + +### Reference benchmark + +The assembly artifacts from [MihuBot/runtime-utils#2148](https://github.com/MihuBot/runtime-utils/issues/2148) +contain 760 `.dasm` files per side, totaling 25,501,489,811 bytes. The files were +flattened by filename, matching the runner's combined assembly directories, and +analyzed with `-b main -d pr -r -c 100`, with textual diffs enabled. + +On a 16-logical-processor Linux machine with 31 GiB RAM, using a Release build +with .NET SDK 10.0.111 / runtime 10.0.11: + +| Version | Wall time, three runs | Median wall time | Median peak RSS | +| --- | --- | --- | --- | +| Original (`e718415`) | 229.39, 229.38, 247.92 s | 229.39 s | 13.30 GiB | +| Optimized (`f261f89`) | 35.33, 31.87, 32.28 s | 32.28 s | 1.46 GiB | + +This is a **7.1x median speedup** and **89% lower peak RSS**. The independently +measured optimization steps were: + +| Commit | Change | Full-run wall time | +| --- | --- | --- | +| `27ea778` | Materialize comparisons instead of rebuilding deferred queries | 177.20 s | +| `b973865` | Parse and compare bounded parallel file pairs | 132.67 s | +| `bc47d72` | Scan instruction lines with pooled buffers | 111.59 s | +| `bcb13f8` | Aggregate metrics while parsing with generated regexes | 104.14 s | +| `c882924` | Store compact metric values and eliminate copies | 71.34 s | +| `bb910ad` | Parallelize textual diffs and skip identical inputs | 41.65 s | +| `a816f75` | Reuse parsed methods for identical file pairs | 32.74 s | + +The metric reports agree with the original analyzer and the job's published +totals. The optimized report additionally displays 106 text-only files that the +original omitted because it looked up relative names in an absolute-path dictionary. +The displayed line counts agree with a directory-level Git comparison. + The output of analyze looks like the following: ``` $ jit-analyze --base ~/Work/output/base --diff ~/Work/output/diff From e80f9a4c639f8326714ff674011db9a0402c05cc Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Fri, 4 Sep 2026 23:38:29 +0000 Subject: [PATCH 10/18] Compute text line counts only for files eligible for the text-only report Use git diff --quiet for other files while preserving their contribution to the changed-file count. Avoid unused numstat work, including streaming object hashes for very large files, and retain full counts for callers of DiffInText. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/jit-analyze/Program.cs | 48 ++++++++++++++++++---------- test/jit-analyze/Regression/Tests.cs | 9 ++++-- 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/src/jit-analyze/Program.cs b/src/jit-analyze/Program.cs index 89dfb019..76e5f89e 100644 --- a/src/jit-analyze/Program.cs +++ b/src/jit-analyze/Program.cs @@ -28,7 +28,9 @@ internal sealed partial class Program private readonly int _count; private readonly string _basePath; private readonly string _diffPath; + private readonly string _baseDirectory; private Dictionary _textDiffCounts; + private HashSet _filesNeedingTextDiffCounts; private static string METRIC_SEP = new string('-', 80); @@ -53,6 +55,7 @@ public Program(JitAnalyzeRootCommand command) _count = Get(command.Count); _basePath = Get(command.BasePath); _diffPath = Get(command.DiffPath); + _baseDirectory = Directory.Exists(_basePath) ? Path.GetFullPath(_basePath) : Path.GetDirectoryName(Path.GetFullPath(_basePath)); } public class FileInfo @@ -694,12 +697,11 @@ void DisplayMethodMetric(string headerText, string subtext, int methodCount, dyn { // Show files with text diffs but no metric diffs. - Dictionary diffCounts = _textDiffCounts ??= DiffInText(_diffPath, _basePath); + Dictionary diffCounts = _textDiffCounts ??= DiffInText(_diffPath, _basePath, _filesNeedingTextDiffCounts); // TODO: resolve diffs to particular methods in the files. - string baseDirectory = Directory.Exists(_basePath) ? _basePath : Path.GetDirectoryName(Path.GetFullPath(_basePath)); var zeroDiffFilesWithDiffs = fileDeltaList - .Select(file => (File: file, Path: Path.GetFullPath(Path.Combine(baseDirectory, file.baseName)))) + .Select(file => (File: file, Path: Path.GetFullPath(Path.Combine(_baseDirectory, file.baseName)))) .Where(x => !Get(_command.ConcatFiles) && diffCounts.ContainsKey(x.Path) && x.File.deltaMetrics.IsZero()) .OrderByDescending(x => diffCounts[x.Path]); @@ -893,7 +895,10 @@ public static StringBuilder GenerateTSV(IEnumerable compareList) // For example: // 6\t6\t\0d:\root\dasmset_8\base\Vector3Interop_ro.dasm\0d:\root\dasmset_8\diff\Vector3Interop_ro.dasm\0 // - public static Dictionary DiffInText(string diffPath, string basePath) + public static Dictionary DiffInText(string diffPath, string basePath) => + DiffInText(diffPath, basePath, filesNeedingCounts: null); + + private static Dictionary DiffInText(string diffPath, string basePath, HashSet filesNeedingCounts) { basePath = Path.GetFullPath(basePath); diffPath = Path.GetFullPath(diffPath); @@ -915,15 +920,17 @@ public static Dictionary DiffInText(string diffPath, string basePat // Initialize the process manager on the caller thread before starting parallel workers. ProcessManager manager = ProcessManager.Instance; - var counts = pairs.AsParallel().WithDegreeOfParallelism(Math.Min(Environment.ProcessorCount, 8)) + var changes = pairs.AsParallel().WithDegreeOfParallelism(Math.Min(Environment.ProcessorCount, 8)) .Where(pair => !FilesEqual(pair.Base, pair.Diff)) - .Select(pair => (pair.Base, Count: TextDiffCount(pair.Base, pair.Diff, manager))) - .Where(pair => pair.Count.HasValue) - .ToDictionary(pair => pair.Base, pair => pair.Count.Value, StringComparer.Ordinal); - - if (counts.Count != 0) - Console.WriteLine($"Found {counts.Count} files with textual diffs."); - return counts; + .Select(pair => (pair.Base, Result: CompareText(pair.Base, pair.Diff, manager, + countLines: filesNeedingCounts == null || filesNeedingCounts.Contains(pair.Base)))) + .Where(pair => pair.Result.HasChanges) + .ToArray(); + + if (changes.Length != 0) + Console.WriteLine($"Found {changes.Length} files with textual diffs."); + return changes.Where(pair => pair.Result.LineCount.HasValue) + .ToDictionary(pair => pair.Base, pair => pair.Result.LineCount.Value, StringComparer.Ordinal); } private static bool IsRealDirectory(string path) => @@ -987,7 +994,7 @@ private static bool FilesEqual(string basePath, string diffPath) } } - private static int? TextDiffCount(string basePath, string diffPath, ProcessManager manager) + private static (bool HasChanges, int? LineCount) CompareText(string basePath, string diffPath, ProcessManager manager, bool countLines) { var startInfo = new ProcessStartInfo("git") { @@ -995,7 +1002,7 @@ private static bool FilesEqual(string basePath, string diffPath) RedirectStandardOutput = true, RedirectStandardError = true, }; - foreach (string argument in new[] { "diff", "--no-index", "--diff-filter=M", "--exit-code", "--numstat", "-z", "--", basePath, diffPath }) + foreach (string argument in new[] { "diff", "--no-index", "--diff-filter=M", "--exit-code", countLines ? "--numstat" : "--quiet", "-z", "--", basePath, diffPath }) startInfo.ArgumentList.Add(argument); using Process process = manager.Start(startInfo); @@ -1006,15 +1013,18 @@ private static bool FilesEqual(string basePath, string diffPath) string output = outputTask.GetAwaiter().GetResult(); string error = errorTask.GetAwaiter().GetResult(); if (process.ExitCode == 0) - return null; + return (false, null); if (process.ExitCode != 1) throw new InvalidOperationException($"git diff failed for '{basePath}' and '{diffPath}' (exit {process.ExitCode}): {error}"); + if (!countLines) + return (true, null); + string[] fields = output.Split('\t', 3); if (fields.Length != 3) throw new InvalidOperationException($"Invalid git numstat output for '{basePath}': {output}"); // Binary files have '-' in both numeric fields. - return ParseCount(fields[0]) + ParseCount(fields[1]); + return (true, ParseCount(fields[0]) + ParseCount(fields[1])); static int ParseCount(string value) => value == "-" ? 0 : int.Parse(value, CultureInfo.InvariantCulture); } @@ -1053,6 +1063,12 @@ public int Run() string md = Get(_command.MD); string[] metricNames = Get(_command.Metrics).ToArray(); FileDelta[][] comparisons = Comparator(baseList, diffList, metricNames); + // Detailed text counts are only displayed for files with no metric differences. + // Still ask Git whether every other file changed, without computing unused numstat data. + _filesNeedingTextDiffCounts = comparisons.SelectMany(files => files) + .Where(file => file.deltaMetrics.IsZero() && !Get(_command.ConcatFiles)) + .Select(file => Path.GetFullPath(Path.Combine(_baseDirectory, file.baseName))) + .ToHashSet(StringComparer.Ordinal); for (int metricIndex = 0; metricIndex < metricNames.Length; metricIndex++) { string metricName = metricNames[metricIndex]; diff --git a/test/jit-analyze/Regression/Tests.cs b/test/jit-analyze/Regression/Tests.cs index ca6c2229..2de2488d 100644 --- a/test/jit-analyze/Regression/Tests.cs +++ b/test/jit-analyze/Regression/Tests.cs @@ -248,6 +248,8 @@ private static void TextDiffs() Write("text diff/long.dasm", new string('x', 131072) + "b\n"); Write("text base/identical.dasm", Method("Unchanged", 5)); Write("text diff/identical.dasm", Method("Unchanged", 5)); + string metricChanged = Write("text base/metric-changed.dasm", Method("Changed", 10)); + Write("text diff/metric-changed.dasm", Method("Changed", 11)); Write("text base/removed.dasm", "removed"); Write("text diff/added.dasm", "added"); if (!OperatingSystem.IsWindows()) @@ -261,10 +263,11 @@ private static void TextDiffs() } Dictionary counts = Analyzer.DiffInText(after, before); - Equal(OperatingSystem.IsWindows() ? 3 : 6, counts.Count, "text diff file count"); + Equal(OperatingSystem.IsWindows() ? 4 : 7, counts.Count, "text diff file count"); Equal(2, counts[textOnly], "text-only diff count"); Equal(0, counts[binary], "binary diff count"); Equal(2, counts[longFile], "difference after multiple buffers"); + Equal(2, counts[metricChanged], "full counts remain available for metric changes"); Equal(0, Analyzer.DiffInText(before, before).Count, "identical trees"); Equal(2, Analyzer.DiffInText(Path.Combine(after, "nested/text only.dasm"), textOnly)[textOnly], "single file counts"); if (!OperatingSystem.IsWindows()) @@ -281,13 +284,15 @@ private static void TextDiffs() try { Console.SetOut(stdout); - Equal(0, new JitAnalyzeRootCommand(args).Parse(args).Invoke(), "text-only exit code"); + Equal(-1, new JitAnalyzeRootCommand(args).Parse(args).Invoke(), "mixed text and metric exit code"); } finally { Console.SetOut(oldOut); } Contains(stdout.ToString(), $"nested{Path.DirectorySeparatorChar}text only.dasm had 2 diffs"); + Contains(stdout.ToString(), $"Found {(OperatingSystem.IsWindows() ? 4 : 6)} files with textual diffs."); + Equal(false, stdout.ToString().Contains("metric-changed.dasm had"), "metric changes do not need displayed line counts"); } private static void CheckTsv(string tsv, string[] methods, int headers = 1) From 12fdba843d80c7eb999117d8978bcc77907ee435 Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Fri, 4 Sep 2026 23:39:45 +0000 Subject: [PATCH 11/18] Document demand-driven text analysis and 16.7x benchmark improvement Record per-file profiling, three complete workload timings, and the reduced peak RSS. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6ce50c2b-b096-4210-9633-948434c43ee4 --- src/jit-analyze/README.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/jit-analyze/README.md b/src/jit-analyze/README.md index b6e68054..a9ae6955 100644 --- a/src/jit-analyze/README.md +++ b/src/jit-analyze/README.md @@ -25,6 +25,8 @@ lines are scanned using pooled buffers rather than allocated as individual strin Textual diff analysis remains enabled by default. It uses the same concurrency bound, skips Git for byte-identical files, and reuses counts across metrics. +It requests detailed line counts only for files eligible for the text-only report; +other files use Git's difference-only query and still contribute to the changed-file count. Git's added/deleted line counts are retained, including binary-file handling. The text-only summary lists files whose text changed but whose metrics did not. Directory symlinks are compared as links rather than followed. @@ -56,9 +58,10 @@ with .NET SDK 10.0.111 / runtime 10.0.11: | Version | Wall time, three runs | Median wall time | Median peak RSS | | --- | --- | --- | --- | | Original (`e718415`) | 229.39, 229.38, 247.92 s | 229.39 s | 13.30 GiB | -| Optimized (`f261f89`) | 35.33, 31.87, 32.28 s | 32.28 s | 1.46 GiB | +| First optimization series (`f261f89`) | 35.33, 31.87, 32.28 s | 32.28 s | 1.46 GiB | +| Demand-driven text counts (`e80f9a4`) | 15.16, 13.77, 13.55 s | 13.77 s | 0.79 GiB | -This is a **7.1x median speedup** and **89% lower peak RSS**. The independently +This is a **16.7x median speedup** and **94% lower peak RSS**. The independently measured optimization steps were: | Commit | Change | Full-run wall time | @@ -70,6 +73,12 @@ measured optimization steps were: | `c882924` | Store compact metric values and eliminate copies | 71.34 s | | `bb910ad` | Parallelize textual diffs and skip identical inputs | 41.65 s | | `a816f75` | Reuse parsed methods for identical file pairs | 32.74 s | +| `e80f9a4` | Request numstat only for files eligible for the text-only report | 15.16 s | + +Per-file profiling found that Git spent about 20 seconds producing unused numstat +data for `KubernetesClient.dasm`, while its difference-only query took less than +0.01 seconds. Avoiding unused counts retains Git's change detection and the complete +report, without replacing line counts that are actually displayed. The metric reports agree with the original analyzer and the job's published totals. The optimized report additionally displays 106 text-only files that the From 6160a6754e486aa3ee90f6282ac0a561d3e4b878 Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Sat, 5 Sep 2026 08:47:00 +0000 Subject: [PATCH 12/18] Use default PLINQ parallelism for assembly analysis and text comparisons Remove the application-specific eight-worker cap from both phases. Record fresh interleaved full-workload benchmarks: capped median 13.93s and 0.86 GiB peak RSS, default median 14.34s and 1.12 GiB. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6ce50c2b-b096-4210-9633-948434c43ee4 --- src/jit-analyze/Program.cs | 3 +-- src/jit-analyze/README.md | 25 +++++++++++++++++++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/jit-analyze/Program.cs b/src/jit-analyze/Program.cs index 76e5f89e..5cf16e9b 100644 --- a/src/jit-analyze/Program.cs +++ b/src/jit-analyze/Program.cs @@ -401,7 +401,6 @@ public FileDelta[][] Comparator(IEnumerable baseInfo, return baseInfo.Join(diffInfo, b => b.isExplicitOnlyFile ? "" : b.name, d => d.isExplicitOnlyFile ? "" : d.name, (b, d) => (Base: b, Diff: d)) .AsParallel().AsOrdered() - .WithDegreeOfParallelism(Math.Min(Environment.ProcessorCount, 8)) .Select(pair => { var baseMethods = ExtractMethodInfo(pair.Base.paths); @@ -920,7 +919,7 @@ private static Dictionary DiffInText(string diffPath, string basePa // Initialize the process manager on the caller thread before starting parallel workers. ProcessManager manager = ProcessManager.Instance; - var changes = pairs.AsParallel().WithDegreeOfParallelism(Math.Min(Environment.ProcessorCount, 8)) + var changes = pairs.AsParallel() .Where(pair => !FilesEqual(pair.Base, pair.Diff)) .Select(pair => (pair.Base, Result: CompareText(pair.Base, pair.Diff, manager, countLines: filesNeedingCounts == null || filesNeedingCounts.Contains(pair.Base)))) diff --git a/src/jit-analyze/README.md b/src/jit-analyze/README.md index a9ae6955..9ef1bcb9 100644 --- a/src/jit-analyze/README.md +++ b/src/jit-analyze/README.md @@ -17,14 +17,14 @@ To build/setup: ## Large disassembly sets -Directory analysis parses and compares independent file pairs in parallel, with at -most eight workers (or the available processor count, if lower). Each worker +Directory analysis parses and compares independent file pairs using PLINQ's default +parallelism, based on the available processor count. Each worker releases unchanged method data after comparing a pair, and reuses parsed methods across requested metrics. Byte-identical pairs only need one parse. Instruction lines are scanned using pooled buffers rather than allocated as individual strings. -Textual diff analysis remains enabled by default. It uses the same concurrency -bound, skips Git for byte-identical files, and reuses counts across metrics. +Textual diff analysis remains enabled by default. It uses the same default +parallelism, skips Git for byte-identical files, and reuses counts across metrics. It requests detailed line counts only for files eligible for the text-only report; other files use Git's difference-only query and still contribute to the changed-file count. Git's added/deleted line counts are retained, including binary-file handling. @@ -85,6 +85,23 @@ totals. The optimized report additionally displays 106 text-only files that the original omitted because it looked up relative names in an absolute-path dictionary. The displayed line counts agree with a directory-level Git comparison. +### Default-parallelism comparison + +After removing the eight-worker cap from both analysis phases, fresh runs compared +the capped executable against PLINQ's default parallelism on the same +16-logical-processor machine and full artifact workload. One warm-up run per +variant was excluded; the three measured runs were interleaved with alternating order. + +| Parallelism | Wall time, three runs | Median wall time | Median peak RSS | +| --- | --- | --- | --- | +| Eight-worker cap (`e80f9a4`) | 15.43, 13.93, 13.91 s | 13.93 s | 0.86 GiB | +| PLINQ default | 14.34, 14.01, 16.51 s | 14.34 s | 1.12 GiB | + +Default parallelism did not improve this workload in these runs: the median was +about 3% slower and peak RSS was about 31% higher. The run ranges overlap, so this +small timing difference should not be interpreted as a precisely established cost. +Both phases now use PLINQ's default rather than an application-specific cap. + The output of analyze looks like the following: ``` $ jit-analyze --base ~/Work/output/base --diff ~/Work/output/diff From eda4266d8d5c606678cc0038948f5d386f43a303 Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Sat, 5 Sep 2026 08:58:05 +0000 Subject: [PATCH 13/18] Use an IO FileInfo alias and explain directory-link handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6ce50c2b-b096-4210-9633-948434c43ee4 --- src/jit-analyze/Program.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/jit-analyze/Program.cs b/src/jit-analyze/Program.cs index 5cf16e9b..91198683 100644 --- a/src/jit-analyze/Program.cs +++ b/src/jit-analyze/Program.cs @@ -15,6 +15,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; +using IOFileInfo = System.IO.FileInfo; namespace ManagedCodeGen { @@ -932,6 +933,7 @@ private static Dictionary DiffInText(string diffPath, string basePa .ToDictionary(pair => pair.Base, pair => pair.Result.LineCount.Value, StringComparer.Ordinal); } + // Directory.Exists follows links; Git compares directory links themselves instead. private static bool IsRealDirectory(string path) => (File.GetAttributes(path) & (FileAttributes.Directory | FileAttributes.ReparsePoint)) == FileAttributes.Directory; @@ -960,8 +962,8 @@ private static bool IsRealDirectory(string path) => private static bool FilesEqual(string basePath, string diffPath) { // Git compares symbolic links themselves, not the contents of their targets. - var baseInfo = new System.IO.FileInfo(basePath); - var diffInfo = new System.IO.FileInfo(diffPath); + var baseInfo = new IOFileInfo(basePath); + var diffInfo = new IOFileInfo(diffPath); if (baseInfo.LinkTarget != null || diffInfo.LinkTarget != null) return false; From 93f7a099de3cdcd20287295240751829dee3735d Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Sat, 5 Sep 2026 09:00:08 +0000 Subject: [PATCH 14/18] Use an owned buffer and Array.Resize in DisassemblyReader Replace the pooled buffer with a 32K-character array and grow it with Array.Resize for long lines. Remove pool cleanup and update the reader documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6ce50c2b-b096-4210-9633-948434c43ee4 --- src/jit-analyze/DisassemblyReader.cs | 14 +++----------- src/jit-analyze/README.md | 4 +++- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/jit-analyze/DisassemblyReader.cs b/src/jit-analyze/DisassemblyReader.cs index bebc8010..953c9ad1 100644 --- a/src/jit-analyze/DisassemblyReader.cs +++ b/src/jit-analyze/DisassemblyReader.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Buffers; using System.IO; namespace ManagedCodeGen @@ -11,7 +10,7 @@ namespace ManagedCodeGen internal sealed class DisassemblyReader : IDisposable { private readonly StreamReader _reader; - private char[] _buffer = ArrayPool.Shared.Rent(64 * 1024); + private char[] _buffer = new char[32 * 1024]; private int _start; private int _end; private bool _skipLF; @@ -58,10 +57,7 @@ public bool ReadLine(out ReadOnlySpan line) scanned = remaining.Length; if (remaining.Length == _buffer.Length) { - char[] larger = ArrayPool.Shared.Rent(checked(_buffer.Length * 2)); - remaining.CopyTo(larger); - ArrayPool.Shared.Return(_buffer); - _buffer = larger; + Array.Resize(ref _buffer, checked(_buffer.Length * 2)); } else { @@ -76,10 +72,6 @@ public bool ReadLine(out ReadOnlySpan line) } } - public void Dispose() - { - _reader.Dispose(); - ArrayPool.Shared.Return(_buffer); - } + public void Dispose() => _reader.Dispose(); } } diff --git a/src/jit-analyze/README.md b/src/jit-analyze/README.md index 9ef1bcb9..43faf8fe 100644 --- a/src/jit-analyze/README.md +++ b/src/jit-analyze/README.md @@ -21,7 +21,9 @@ Directory analysis parses and compares independent file pairs using PLINQ's defa parallelism, based on the available processor count. Each worker releases unchanged method data after comparing a pair, and reuses parsed methods across requested metrics. Byte-identical pairs only need one parse. Instruction -lines are scanned using pooled buffers rather than allocated as individual strings. +lines are scanned using a reusable, reader-owned buffer rather than allocated as +individual strings. The buffer starts at 32K characters and grows with `Array.Resize` +when needed for longer lines. Textual diff analysis remains enabled by default. It uses the same default parallelism, skips Git for byte-identical files, and reuses counts across metrics. From 88fe6f47764f9e38eb633c9e557730f75c1fcd63 Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Sat, 5 Sep 2026 09:05:16 +0000 Subject: [PATCH 15/18] Reuse Utility.ExecuteProcess for analyzer Git commands Add a ProcessStartInfo overload so ArgumentList preserves literal paths, while retaining the existing overload's pre-quoted argument behavior. Share output capture and process disposal instead of duplicating them in jit-analyze. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6ce50c2b-b096-4210-9633-948434c43ee4 --- src/jit-analyze/Program.cs | 31 +++++++++------------------ src/util/util.cs | 19 ++++++++++------ test/jit-analyze/Regression/README.md | 2 ++ test/jit-analyze/Regression/Tests.cs | 30 ++++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 27 deletions(-) diff --git a/src/jit-analyze/Program.cs b/src/jit-analyze/Program.cs index 91198683..9a4585bf 100644 --- a/src/jit-analyze/Program.cs +++ b/src/jit-analyze/Program.cs @@ -919,10 +919,10 @@ private static Dictionary DiffInText(string diffPath, string basePa } // Initialize the process manager on the caller thread before starting parallel workers. - ProcessManager manager = ProcessManager.Instance; + _ = ProcessManager.Instance; var changes = pairs.AsParallel() .Where(pair => !FilesEqual(pair.Base, pair.Diff)) - .Select(pair => (pair.Base, Result: CompareText(pair.Base, pair.Diff, manager, + .Select(pair => (pair.Base, Result: CompareText(pair.Base, pair.Diff, countLines: filesNeedingCounts == null || filesNeedingCounts.Contains(pair.Base)))) .Where(pair => pair.Result.HasChanges) .ToArray(); @@ -995,35 +995,24 @@ private static bool FilesEqual(string basePath, string diffPath) } } - private static (bool HasChanges, int? LineCount) CompareText(string basePath, string diffPath, ProcessManager manager, bool countLines) + private static (bool HasChanges, int? LineCount) CompareText(string basePath, string diffPath, bool countLines) { - var startInfo = new ProcessStartInfo("git") - { - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - }; + var startInfo = new ProcessStartInfo("git"); foreach (string argument in new[] { "diff", "--no-index", "--diff-filter=M", "--exit-code", countLines ? "--numstat" : "--quiet", "-z", "--", basePath, diffPath }) startInfo.ArgumentList.Add(argument); - using Process process = manager.Start(startInfo); - process.Start(); - var outputTask = process.StandardOutput.ReadToEndAsync(); - var errorTask = process.StandardError.ReadToEndAsync(); - process.WaitForExit(); - string output = outputTask.GetAwaiter().GetResult(); - string error = errorTask.GetAwaiter().GetResult(); - if (process.ExitCode == 0) + ProcessResult result = Utility.ExecuteProcess(startInfo, capture: true); + if (result.ExitCode == 0) return (false, null); - if (process.ExitCode != 1) - throw new InvalidOperationException($"git diff failed for '{basePath}' and '{diffPath}' (exit {process.ExitCode}): {error}"); + if (result.ExitCode != 1) + throw new InvalidOperationException($"git diff failed for '{basePath}' and '{diffPath}' (exit {result.ExitCode}): {result.StdErr}"); if (!countLines) return (true, null); - string[] fields = output.Split('\t', 3); + string[] fields = result.StdOut.Split('\t', 3); if (fields.Length != 3) - throw new InvalidOperationException($"Invalid git numstat output for '{basePath}': {output}"); + throw new InvalidOperationException($"Invalid git numstat output for '{basePath}': {result.StdOut}"); // Binary files have '-' in both numeric fields. return (true, ParseCount(fields[0]) + ParseCount(fields[1])); diff --git a/src/util/util.cs b/src/util/util.cs index b0eee54e..1aa3307e 100644 --- a/src/util/util.cs +++ b/src/util/util.cs @@ -175,10 +175,6 @@ public static ProcessResult ExecuteProcess(string name, IEnumerable comm { var startInfo = new ProcessStartInfo { - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardError = true, - RedirectStandardOutput = true, WorkingDirectory = workingDirectory, FileName = name, Arguments = string.Join(" ", commandArgs) @@ -192,11 +188,22 @@ public static ProcessResult ExecuteProcess(string name, IEnumerable comm } } + return ExecuteProcess(startInfo, capture); + } + + // Supports ArgumentList without changing the legacy overload's pre-quoted argument handling. + public static ProcessResult ExecuteProcess(ProcessStartInfo startInfo, bool capture = false) + { + startInfo.UseShellExecute = false; + startInfo.CreateNoWindow = true; + startInfo.RedirectStandardError = true; + startInfo.RedirectStandardOutput = true; + // set up the pipe for the stdout and builder for stderr StringBuilder _errorDataStringBuilder = new StringBuilder(); StringBuilder _outputDataStringBuilder = new StringBuilder(); - Process process = ProcessManager.Instance.Start(startInfo); + using Process process = ProcessManager.Instance.Start(startInfo); if (capture) { @@ -233,7 +240,7 @@ public static ProcessResult ExecuteProcess(string name, IEnumerable comm catch (System.Exception e) { // Maybe the program we're spawning wasn't found (ERROR_FILE_NOT_FOUND == 2). - Console.Error.WriteLine($"Error: failed to start '{name} {startInfo.Arguments}': {e.Message}"); + Console.Error.WriteLine($"Error: failed to start '{startInfo.FileName} {startInfo.Arguments}': {e.Message}"); return new ProcessResult() { diff --git a/test/jit-analyze/Regression/README.md b/test/jit-analyze/Regression/README.md index ccdd0bf1..865ef11d 100644 --- a/test/jit-analyze/Regression/README.md +++ b/test/jit-analyze/Regression/README.md @@ -29,6 +29,8 @@ ignored lines; and UTF-8/CRLF around 64 KiB boundaries. CLI tests exercise recon warnings, filtering, multiple metrics, TSV, concatenation and unequal single filenames. Text-diff tests cover unchanged files, binary files, long files, nested paths, spaces, Unix tabs/newlines in paths, dangling links and directory links without traversal. +Process-helper tests cover legacy pre-quoted arguments, literal `ArgumentList` +boundaries, and capture of child failures and stderr. Intentionally preserved behavior: diff --git a/test/jit-analyze/Regression/Tests.cs b/test/jit-analyze/Regression/Tests.cs index 2de2488d..66e3e022 100644 --- a/test/jit-analyze/Regression/Tests.cs +++ b/test/jit-analyze/Regression/Tests.cs @@ -44,6 +44,7 @@ private static int Main(string[] args) LineBoundaries(); CommandLine(); TextDiffs(); + ProcessHelpers(); Console.WriteLine($"PASS: {checks} assertions (parser, line boundaries, CLI and TSV)."); return 0; } @@ -236,6 +237,35 @@ private static void Totals(string output, int before, int after, int delta) Contains(output, $"Total bytes of base: {before}\nTotal bytes of diff: {after}\nTotal bytes of delta: {delta} ("); } + private static void ProcessHelpers() + { + const string key = "jit-analyze-test.value"; + ProcessResult legacy = Utility.ExecuteProcess("git", + new[] { "-c", $"\"{key}=two words\"", "config", "--get", key }, + capture: true, workingDirectory: root); + Equal(0, legacy.ExitCode, "legacy process exit code"); + Equal("two words" + Environment.NewLine, legacy.StdOut, "legacy pre-quoted arguments"); + Equal("", legacy.StdErr, "legacy process stderr"); + + const string value = "spaces \"quotes\" backslash\\ and\ttabs"; + var startInfo = new ProcessStartInfo("git") { WorkingDirectory = root }; + foreach (string argument in new[] { "-c", $"{key}={value}", "config", "--get", key }) + startInfo.ArgumentList.Add(argument); + ProcessResult result = Utility.ExecuteProcess(startInfo, capture: true); + Equal(0, result.ExitCode, "argument-list process exit code"); + Equal(value + Environment.NewLine, result.StdOut, "literal argument boundaries"); + Equal("", result.StdErr, "argument-list process stderr"); + + startInfo = new ProcessStartInfo("git"); + startInfo.ArgumentList.Add("-C"); + startInfo.ArgumentList.Add(Path.Combine(root, "missing directory")); + startInfo.ArgumentList.Add("status"); + result = Utility.ExecuteProcess(startInfo, capture: true); + Equal(128, result.ExitCode, "failed child exit code"); + Equal("", result.StdOut, "failed child stdout"); + Equal(true, result.StdErr.Length > 0, "failed child stderr captured"); + } + private static void TextDiffs() { string before = Path.Combine(root, "text base"); From 24d313c60ca416fa244f0bd0d694c45124322eed Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Sat, 5 Sep 2026 09:06:00 +0000 Subject: [PATCH 16/18] Remove redundant stream-length check in FilesEqual Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6ce50c2b-b096-4210-9633-948434c43ee4 --- src/jit-analyze/Program.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/jit-analyze/Program.cs b/src/jit-analyze/Program.cs index 9a4585bf..ed603b9b 100644 --- a/src/jit-analyze/Program.cs +++ b/src/jit-analyze/Program.cs @@ -972,8 +972,6 @@ private static bool FilesEqual(string basePath, string diffPath) using var baseStream = File.OpenRead(basePath); using var diffStream = File.OpenRead(diffPath); - if (baseStream.Length != diffStream.Length) - return false; byte[] baseBuffer = ArrayPool.Shared.Rent(64 * 1024); byte[] diffBuffer = ArrayPool.Shared.Rent(64 * 1024); From 4577d8db4b382ab5099008ca93e0205bb9a3f7c2 Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Sat, 5 Sep 2026 09:16:01 +0000 Subject: [PATCH 17/18] Remove the redundant byte comparison before Git text analysis Retain equality detection for sharing parsed methods, but let Git check text pairs directly. Update the description of the text-analysis pipeline. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6ce50c2b-b096-4210-9633-948434c43ee4 --- src/jit-analyze/Program.cs | 1 - src/jit-analyze/README.md | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/jit-analyze/Program.cs b/src/jit-analyze/Program.cs index ed603b9b..dc667319 100644 --- a/src/jit-analyze/Program.cs +++ b/src/jit-analyze/Program.cs @@ -921,7 +921,6 @@ private static Dictionary DiffInText(string diffPath, string basePa // Initialize the process manager on the caller thread before starting parallel workers. _ = ProcessManager.Instance; var changes = pairs.AsParallel() - .Where(pair => !FilesEqual(pair.Base, pair.Diff)) .Select(pair => (pair.Base, Result: CompareText(pair.Base, pair.Diff, countLines: filesNeedingCounts == null || filesNeedingCounts.Contains(pair.Base)))) .Where(pair => pair.Result.HasChanges) diff --git a/src/jit-analyze/README.md b/src/jit-analyze/README.md index 43faf8fe..055720e1 100644 --- a/src/jit-analyze/README.md +++ b/src/jit-analyze/README.md @@ -26,7 +26,8 @@ individual strings. The buffer starts at 32K characters and grows with `Array.Re when needed for longer lines. Textual diff analysis remains enabled by default. It uses the same default -parallelism, skips Git for byte-identical files, and reuses counts across metrics. +parallelism and reuses counts across metrics. Git checks each matched pair directly, +without a redundant byte comparison before invoking it. It requests detailed line counts only for files eligible for the text-only report; other files use Git's difference-only query and still contribute to the changed-file count. Git's added/deleted line counts are retained, including binary-file handling. From 7f68fc739844f605149f2248e8f3589a74a53f7f Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Sat, 5 Sep 2026 09:27:16 +0000 Subject: [PATCH 18/18] Exclude regression harness and README expansion from the PR diff Keep the cumulative changes focused on the analyzer optimizations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6ce50c2b-b096-4210-9633-948434c43ee4 --- src/jit-analyze/README.md | 92 +--- test/jit-analyze/Regression/README.md | 48 -- test/jit-analyze/Regression/Regression.csproj | 12 - test/jit-analyze/Regression/Tests.cs | 419 ------------------ 4 files changed, 1 insertion(+), 570 deletions(-) delete mode 100644 test/jit-analyze/Regression/README.md delete mode 100644 test/jit-analyze/Regression/Regression.csproj delete mode 100644 test/jit-analyze/Regression/Tests.cs diff --git a/src/jit-analyze/README.md b/src/jit-analyze/README.md index 055720e1..a459e7ce 100644 --- a/src/jit-analyze/README.md +++ b/src/jit-analyze/README.md @@ -14,97 +14,7 @@ To build/setup: for directions how. * Run analyze --base `` --diff `` to produce a summary of the differences. - -## Large disassembly sets - -Directory analysis parses and compares independent file pairs using PLINQ's default -parallelism, based on the available processor count. Each worker -releases unchanged method data after comparing a pair, and reuses parsed methods -across requested metrics. Byte-identical pairs only need one parse. Instruction -lines are scanned using a reusable, reader-owned buffer rather than allocated as -individual strings. The buffer starts at 32K characters and grows with `Array.Resize` -when needed for longer lines. - -Textual diff analysis remains enabled by default. It uses the same default -parallelism and reuses counts across metrics. Git checks each matched pair directly, -without a redundant byte comparison before invoking it. -It requests detailed line counts only for files eligible for the text-only report; -other files use Git's difference-only query and still contribute to the changed-file count. -Git's added/deleted line counts are retained, including binary-file handling. -The text-only summary lists files whose text changed but whose metrics did not. -Directory symlinks are compared as links rather than followed. - -To measure the complete analysis on Linux, build Release and run, for example: - -```sh -dotnet build src/jit-analyze -c Release -/usr/bin/time -v src/jit-analyze/bin/Release/net10.0/jit-analyze \ - --base /path/to/main --diff /path/to/pr --recursive --count 100 -``` - -The analyzer returns a nonzero exit code when metrics differ; this does not mean -the run failed. Compare repeated runs on the same files and machine, and retain -textual diffs when measuring end-to-end performance. `--skip-text-diff` can isolate -metric analysis, but measures a different workload. GNU time reports peak resident -memory for a process, not the simultaneous sum of all Git worker processes. - -### Reference benchmark - -The assembly artifacts from [MihuBot/runtime-utils#2148](https://github.com/MihuBot/runtime-utils/issues/2148) -contain 760 `.dasm` files per side, totaling 25,501,489,811 bytes. The files were -flattened by filename, matching the runner's combined assembly directories, and -analyzed with `-b main -d pr -r -c 100`, with textual diffs enabled. - -On a 16-logical-processor Linux machine with 31 GiB RAM, using a Release build -with .NET SDK 10.0.111 / runtime 10.0.11: - -| Version | Wall time, three runs | Median wall time | Median peak RSS | -| --- | --- | --- | --- | -| Original (`e718415`) | 229.39, 229.38, 247.92 s | 229.39 s | 13.30 GiB | -| First optimization series (`f261f89`) | 35.33, 31.87, 32.28 s | 32.28 s | 1.46 GiB | -| Demand-driven text counts (`e80f9a4`) | 15.16, 13.77, 13.55 s | 13.77 s | 0.79 GiB | - -This is a **16.7x median speedup** and **94% lower peak RSS**. The independently -measured optimization steps were: - -| Commit | Change | Full-run wall time | -| --- | --- | --- | -| `27ea778` | Materialize comparisons instead of rebuilding deferred queries | 177.20 s | -| `b973865` | Parse and compare bounded parallel file pairs | 132.67 s | -| `bc47d72` | Scan instruction lines with pooled buffers | 111.59 s | -| `bcb13f8` | Aggregate metrics while parsing with generated regexes | 104.14 s | -| `c882924` | Store compact metric values and eliminate copies | 71.34 s | -| `bb910ad` | Parallelize textual diffs and skip identical inputs | 41.65 s | -| `a816f75` | Reuse parsed methods for identical file pairs | 32.74 s | -| `e80f9a4` | Request numstat only for files eligible for the text-only report | 15.16 s | - -Per-file profiling found that Git spent about 20 seconds producing unused numstat -data for `KubernetesClient.dasm`, while its difference-only query took less than -0.01 seconds. Avoiding unused counts retains Git's change detection and the complete -report, without replacing line counts that are actually displayed. - -The metric reports agree with the original analyzer and the job's published -totals. The optimized report additionally displays 106 text-only files that the -original omitted because it looked up relative names in an absolute-path dictionary. -The displayed line counts agree with a directory-level Git comparison. - -### Default-parallelism comparison - -After removing the eight-worker cap from both analysis phases, fresh runs compared -the capped executable against PLINQ's default parallelism on the same -16-logical-processor machine and full artifact workload. One warm-up run per -variant was excluded; the three measured runs were interleaved with alternating order. - -| Parallelism | Wall time, three runs | Median wall time | Median peak RSS | -| --- | --- | --- | --- | -| Eight-worker cap (`e80f9a4`) | 15.43, 13.93, 13.91 s | 13.93 s | 0.86 GiB | -| PLINQ default | 14.34, 14.01, 16.51 s | 14.34 s | 1.12 GiB | - -Default parallelism did not improve this workload in these runs: the median was -about 3% slower and peak RSS was about 31% higher. The run ranges overlap, so this -small timing difference should not be interpreted as a precisely established cost. -Both phases now use PLINQ's default rather than an application-specific cap. - + The output of analyze looks like the following: ``` $ jit-analyze --base ~/Work/output/base --diff ~/Work/output/diff diff --git a/test/jit-analyze/Regression/README.md b/test/jit-analyze/Regression/README.md deleted file mode 100644 index 865ef11d..00000000 --- a/test/jit-analyze/Regression/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# jit-analyze regression tests - -Run from the repository root with the .NET SDK required by `src/Directory.Build.props`: - -```sh -dotnet run --project test/jit-analyze/Regression -c Release -``` - -This small console test links the real analyzer sources, including its command-line -parser, and reuses its existing build properties/dependency. It adds no test packages -or production test hooks. Failures return a nonzero exit code. Generated tiny fixtures -live under the test output directory and are removed even on failure. - -Optionally compare all CLI stdout, TSV and exit codes against an older executable: - -```sh -dotnet run --project test/jit-analyze/Regression -c Release -- /path/to/baseline/jit-analyze -``` - -Only CRLF output endings are normalized; numeric formatting is invariant. Normal -runs use explicit assertions, not a baseline executable or the historical -`../baseline*.out` goldens. Baseline comparisons disable textual git diffs to isolate -metric analysis; separate tests exercise Git and the text-only report. - -Coverage includes all 12 metrics; repeated/Unicode method names; absent optional -metrics; both perf-score spellings; zero-byte records; debug info; concatenated-file -offsets; LF, CRLF and CR; missing final newlines; empty files; long selected and -ignored lines; and UTF-8/CRLF around 64 KiB boundaries. CLI tests exercise reconciliation, -warnings, filtering, multiple metrics, TSV, concatenation and unequal single filenames. -Text-diff tests cover unchanged files, binary files, long files, nested paths, spaces, -Unix tabs/newlines in paths, dangling links and directory links without traversal. -Process-helper tests cover legacy pre-quoted arguments, literal `ArgumentList` -boundaries, and capture of child failures and stderr. - -Intentionally preserved behavior: - -* Offsets are zero-based across all input files, but offset zero is omitted. -* Zero-byte summaries count as functions as well as assembly listings. -* Debug records without method names aggregate into the empty-name group and - contribute offsets/function counts. -* Extra allocation bytes are computed after grouping. Missing allocation means zero - extra bytes; integer-only spill/resolution weights do not match. -* Explicit files compare despite different names. Unmatched directory files are - warned about, not reconciled; concatenation instead treats them as one logical file. -* Each selected metric appends a complete TSV header and its own selected method - rows. Reconciled rows appear even when that selected metric is zero. TSV uses the - base filename, fractional percentages (zero for zero bases), and a trailing tab. -* Each metric with a nonzero total delta contributes `-1` to the exit code. diff --git a/test/jit-analyze/Regression/Regression.csproj b/test/jit-analyze/Regression/Regression.csproj deleted file mode 100644 index cde8c3c4..00000000 --- a/test/jit-analyze/Regression/Regression.csproj +++ /dev/null @@ -1,12 +0,0 @@ - - - - - Exe - JitAnalyzeRegression.Tests - - - - - - diff --git a/test/jit-analyze/Regression/Tests.cs b/test/jit-analyze/Regression/Tests.cs deleted file mode 100644 index 66e3e022..00000000 --- a/test/jit-analyze/Regression/Tests.cs +++ /dev/null @@ -1,419 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Text; -using ManagedCodeGen; -using Analyzer = ManagedCodeGen.Program; - -namespace JitAnalyzeRegression; - -internal static class Tests -{ - private static readonly string[] MetricNames = - { - "CodeSize", "PrologSize", "PerfScore", "InstrCount", "AllocSize", "ExtraAllocBytes", - "DebugClauseCount", "DebugVarCount", "SpillCount", "SpillWeight", "ResolutionCount", "ResolutionWeight" - }; - - private static string root; - private static string baseline; - private static int checks; - - private static int Main(string[] args) - { - CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; - CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture; - if (args.Length > 1) - { - Console.Error.WriteLine("Usage: dotnet run --project test/jit-analyze/Regression [-c Release] -- [baseline-executable]"); - return 1; - } - - baseline = args.SingleOrDefault(); - root = Path.Combine(AppContext.BaseDirectory, "fixtures-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); - try - { - ExtractMetrics(); - LineBoundaries(); - CommandLine(); - TextDiffs(); - ProcessHelpers(); - Console.WriteLine($"PASS: {checks} assertions (parser, line boundaries, CLI and TSV)."); - return 0; - } - catch (Exception e) - { - Console.Error.WriteLine(e); - return 1; - } - finally - { - Directory.Delete(root, recursive: true); - } - } - - private static void Equal(T expected, T actual, string context) - { - checks++; - if (!EqualityComparer.Default.Equals(expected, actual)) - throw new Exception($"{context}: expected <{expected}>, actual <{actual}>"); - } - - private static void Contains(string text, string expected) - { - Equal(true, text.Contains(expected, StringComparison.Ordinal), $"output contains {expected}"); - } - - private static string Write(string name, string contents) - { - string path = Path.Combine(root, name); - Directory.CreateDirectory(Path.GetDirectoryName(path)); - File.WriteAllText(path, contents, new UTF8Encoding(false)); - return path; - } - - private static string Listing(string name) => $"; Assembly listing for method {name}"; - - private static string Summary(string name, int size, int prolog = 0, int perf = 0) => - $"; Total bytes of code {size}, prolog size {prolog}, PerfScore {perf} for method {name}"; - - private static string Method(string name, int size, int prolog = 0, int perf = 0) => - Listing(name) + "\n" + Summary(name, size, prolog, perf) + "\n"; - - private static void CheckMethod(Analyzer.MethodInfo method, string name, int count, string offsets, params double[] values) - { - Equal(name, method.name, "method name"); - Equal(count, method.functionCount, $"{name} function count"); - Equal(offsets, string.Join(",", method.functionOffsets), $"{name} offsets"); - for (int i = 0; i < MetricNames.Length; i++) - Equal(values.Length > i ? values[i] : 0, method.Metrics.GetMetric(MetricNames[i]).Value, $"{name} {MetricNames[i]}"); - } - - private static void ExtractMetrics() - { - Equal(string.Join(",", MetricNames), string.Join(",", MetricCollection.AllMetrics.Select(m => m.Name)), "metric schema"); - const string repeated = "Namespace.类型:方法(é, 😀)"; - string first = Write("metrics-first.dasm", string.Join("\r\n", new[] - { - Listing(repeated), - $"; Total bytes of code 10, prolog size 2, PerfScore 1.25, instruction count 3, allocated bytes for code 16, SpillCount 2 SpillCountWt 3.50 ResolutionMovs 4 ResolutionMovsWt 5.25 for method {repeated}", - "; Variable debug info: 4 live range(s), 2 var(s)", - "ignored for method not-a-method", - Listing("Zero"), - "; Total bytes of code 0 for method Zero", - "; Variable debug info: 5 live range(s), 2 var(s)" - })); - string second = Write("metrics-second.dasm", string.Join("\n", new[] - { - Listing(repeated), - $"; Total bytes of code 20, prolog size 3, perf score 2.50, instruction count 6, allocated bytes for code 24, SpillCount 3 SpillCountWt 4.25 ResolutionMovs 5 ResolutionMovsWt 6.50 for method {repeated}", - Listing("Optional"), - "; Total bytes of code 7 for method Optional" - })); - var methods = Analyzer.ExtractMethodInfo(new[] { first, second }).ToArray(); - Equal(4, methods.Length, "group count"); - CheckMethod(methods[0], repeated, 2, "7", 30, 5, 3.75, 9, 40, 10, 0, 0, 5, 7.75, 9, 11.75); - CheckMethod(methods[1], "", 2, "2,6", 0, 0, 0, 0, 0, 0, 9, 4); - CheckMethod(methods[2], "Zero", 2, "4"); - CheckMethod(methods[3], "Optional", 1, "9", 7); - - Equal(0, Analyzer.ExtractMethodInfo(Array.Empty()).Count(), "no input files"); - Equal(0, Analyzer.ExtractMethodInfo(new[] { Write("empty.dasm", "") }).Count(), "empty file"); - - // Extra allocation is computed after grouping, not independently per record. - string mixed = Write("mixed.dasm", Listing("Mixed") + "\n; Total bytes of code 4 for method Mixed\n" + - Listing("Mixed") + "\n; Total bytes of code 6, allocated bytes for code 16 for method Mixed"); - CheckMethod(Analyzer.ExtractMethodInfo(new[] { mixed }).Single(), "Mixed", 2, "2", 10, 0, 0, 0, 16, 6); - - // The weights require a decimal point; malformed selected lines still form groups. - string malformed = Write("malformed.dasm", - "; Total bytes of code invalid for method Odd\n" + - "; Total bytes of code 1, SpillCount 2 SpillCountWt 3 ResolutionMovs 4 ResolutionMovsWt 5 for method Odd"); - CheckMethod(Analyzer.ExtractMethodInfo(new[] { malformed }).Single(), "Odd", 1, "", 1); - } - - private static void LineBoundaries() - { - foreach (string newline in new[] { "\n", "\r\n", "\r" }) - foreach (bool terminalNewline in new[] { false, true }) - foreach (int length in new[] { 0, 1, 65534, 65535, 65536, 65537, 131073 }) - { - string name = "类:é😀" + new string('x', length); - string path = Write("boundary.dasm", new string(' ', length) + newline + - Listing(name) + newline + Summary(name, 17, 3, 2) + - (terminalNewline ? newline : "")); - CheckMethod(Analyzer.ExtractMethodInfo(new[] { path }).Single(), name, 1, "1", 17, 3, 2); - } - - // Place record starts, CRLF pairs and multibyte UTF-8 around a 64 KiB byte boundary. - foreach (int offset in Enumerable.Range(65532, 9)) - { - string path = Write("aligned.dasm", new string(' ', offset) + "\r\n" + - Listing("é😀") + "\r\n" + Summary("é😀", 9)); - CheckMethod(Analyzer.ExtractMethodInfo(new[] { path }).Single(), "é😀", 1, "1", 9); - path = Write("utf8-aligned.dasm", new string(' ', offset) + "é😀\r\n" + - Listing("Unicode") + "\r\n" + Summary("Unicode", 9)); - CheckMethod(Analyzer.ExtractMethodInfo(new[] { path }).Single(), "Unicode", 1, "1", 9); - path = Write("metric-aligned.dasm", "; Total bytes of code 9" + - new string(' ', offset - "; Total bytes of code 9".Length) + - "PerfScore 12.25, instruction count 7 for method Boundary"); - CheckMethod(Analyzer.ExtractMethodInfo(new[] { path }).Single(), "Boundary", 0, "", 9, 0, 12.25, 7); - } - - string mixed = Write("newlines.dasm", "\r\n" + Listing("Mixed") + "\r" + - Summary("Mixed", 2) + "\n\n" + Listing("Mixed") + "\r\n" + Summary("Mixed", 3)); - CheckMethod(Analyzer.ExtractMethodInfo(new[] { mixed }).Single(), "Mixed", 2, "1,4", 5); - - string bom = Path.Combine(root, "bom.dasm"); - File.WriteAllText(bom, Listing("BOM:é😀") + "\n" + Summary("BOM:é😀", 3), new UTF8Encoding(true)); - CheckMethod(Analyzer.ExtractMethodInfo(new[] { bom }).Single(), "BOM:é😀", 1, "", 3); - } - - private static void CommandLine() - { - string baseFile = Write("base/keep.dasm", - Method("Shared", 10, 2) + Method("Removed", 5, 1) + Method("Stable", 7, 1) + Method("PerfOnly", 3, 1, 1)); - string diffFile = Write("diff/keep.dasm", - Method("Shared", 14, 1) + Method("Added", 8, 2) + Method("Stable", 7, 1) + Method("PerfOnly", 3, 1, 2)); - Write("base/baseOnly.dasm", Method("BaseUnique", 100)); - Write("diff/diffOnly.dasm", Method("DiffUnique", 200)); - string baseDir = Path.GetDirectoryName(baseFile); - string diffDir = Path.GetDirectoryName(diffFile); - - var reconciled = Invoke(baseDir, diffDir, "--warn"); - Equal(-1, reconciled.Code, "reconciled exit code"); - Totals(reconciled.Output, 25, 32, 7); - Contains(reconciled.Output, "Total byte diff includes 3 bytes from reconciling methods"); - Contains(reconciled.Output, "Warning: 1 files in base but not in diff."); - Contains(reconciled.Output, "Warning: 1 files in diff but not in base."); - Contains(reconciled.Output, "Mismatched methods in keep.dasm\nBase:\n Removed\nDiff:\n Added"); - CheckTsv(reconciled.Tsv, new[] { "Shared", "Removed", "Added" }); - - var common = Invoke(baseDir, diffDir, "--no-reconcile", "--warn"); - Equal(-1, common.Code, "unreconciled exit code"); - Totals(common.Output, 20, 24, 4); - Equal(false, common.Output.Contains("from reconciling methods"), "reconciliation disabled"); - CheckTsv(common.Tsv, new[] { "Shared" }); - - var filtered = Invoke(baseDir, diffDir, "--filter", "keep", "--warn", "--metrics", "CodeSize", "--metrics", "PerfScore"); - Equal(-2, filtered.Code, "multiple changed metrics exit code"); - Totals(filtered.Output, 25, 32, 7); - Contains(filtered.Output, "Summary of Perf Score diffs: (using filter 'keep')"); - Contains(filtered.Output, "Total PerfScoreUnits of base: 1\nTotal PerfScoreUnits of diff: 2"); - Equal(false, filtered.Output.Contains("files in base but not"), "filter excludes unique files"); - CheckTsv(filtered.Tsv, new[] { "Shared", "Removed", "Added", "PerfOnly", "Removed", "Added" }, headers: 2); - - var concat = Invoke(baseDir, diffDir, "--concat-files", "--warn"); - Equal(-1, concat.Code, "concatenated exit code"); - Totals(concat.Output, 125, 232, 107); - Equal(false, concat.Output.Contains("files in base but not"), "concat suppresses file mismatch warnings"); - Contains(concat.Output, "BaseUnique"); - Contains(concat.Output, "DiffUnique"); - var concatCommon = Invoke(baseDir, diffDir, "--concat-files", "--no-reconcile"); - Totals(concatCommon.Output, 20, 24, 4); - - string renamed = Write("renamed.dasm", File.ReadAllText(diffFile)); - var single = Invoke(baseFile, renamed, "--warn"); - Equal(-1, single.Code, "unequal single filenames exit code"); - Totals(single.Output, 25, 32, 7); - CheckTsv(single.Tsv, new[] { "Shared", "Removed", "Added" }); - Equal(false, single.Output.Contains("files in base but not"), "explicit files match despite unequal names"); - - var identical = Invoke(baseFile, baseFile, "--warn"); - Equal(0, identical.Code, "identical input exit code"); - Totals(identical.Output, 25, 25, 0); - CheckTsv(identical.Tsv, Array.Empty()); - } - - private static void Totals(string output, int before, int after, int delta) - { - Contains(output, $"Total bytes of base: {before}\nTotal bytes of diff: {after}\nTotal bytes of delta: {delta} ("); - } - - private static void ProcessHelpers() - { - const string key = "jit-analyze-test.value"; - ProcessResult legacy = Utility.ExecuteProcess("git", - new[] { "-c", $"\"{key}=two words\"", "config", "--get", key }, - capture: true, workingDirectory: root); - Equal(0, legacy.ExitCode, "legacy process exit code"); - Equal("two words" + Environment.NewLine, legacy.StdOut, "legacy pre-quoted arguments"); - Equal("", legacy.StdErr, "legacy process stderr"); - - const string value = "spaces \"quotes\" backslash\\ and\ttabs"; - var startInfo = new ProcessStartInfo("git") { WorkingDirectory = root }; - foreach (string argument in new[] { "-c", $"{key}={value}", "config", "--get", key }) - startInfo.ArgumentList.Add(argument); - ProcessResult result = Utility.ExecuteProcess(startInfo, capture: true); - Equal(0, result.ExitCode, "argument-list process exit code"); - Equal(value + Environment.NewLine, result.StdOut, "literal argument boundaries"); - Equal("", result.StdErr, "argument-list process stderr"); - - startInfo = new ProcessStartInfo("git"); - startInfo.ArgumentList.Add("-C"); - startInfo.ArgumentList.Add(Path.Combine(root, "missing directory")); - startInfo.ArgumentList.Add("status"); - result = Utility.ExecuteProcess(startInfo, capture: true); - Equal(128, result.ExitCode, "failed child exit code"); - Equal("", result.StdOut, "failed child stdout"); - Equal(true, result.StdErr.Length > 0, "failed child stderr captured"); - } - - private static void TextDiffs() - { - string before = Path.Combine(root, "text base"); - string after = Path.Combine(root, "text diff"); - string textOnly = Write("text base/nested/text only.dasm", Method("Same", 10) + "; old\n"); - Write("text diff/nested/text only.dasm", Method("Same", 10) + "; new\n"); - string binary = Write("text base/binary.dasm", "\0old"); - Write("text diff/binary.dasm", "\0new"); - string longFile = Write("text base/long.dasm", new string('x', 131072) + "a\n"); - Write("text diff/long.dasm", new string('x', 131072) + "b\n"); - Write("text base/identical.dasm", Method("Unchanged", 5)); - Write("text diff/identical.dasm", Method("Unchanged", 5)); - string metricChanged = Write("text base/metric-changed.dasm", Method("Changed", 10)); - Write("text diff/metric-changed.dasm", Method("Changed", 11)); - Write("text base/removed.dasm", "removed"); - Write("text diff/added.dasm", "added"); - if (!OperatingSystem.IsWindows()) - { - Write("text base/tab\tand\nnewline.dasm", "old\n"); - Write("text diff/tab\tand\nnewline.dasm", "new\n"); - Directory.CreateSymbolicLink(Path.Combine(before, "directory-link"), before); - Directory.CreateSymbolicLink(Path.Combine(after, "directory-link"), after); - File.CreateSymbolicLink(Path.Combine(before, "dangling-link"), "missing-base"); - File.CreateSymbolicLink(Path.Combine(after, "dangling-link"), "missing-diff"); - } - - Dictionary counts = Analyzer.DiffInText(after, before); - Equal(OperatingSystem.IsWindows() ? 4 : 7, counts.Count, "text diff file count"); - Equal(2, counts[textOnly], "text-only diff count"); - Equal(0, counts[binary], "binary diff count"); - Equal(2, counts[longFile], "difference after multiple buffers"); - Equal(2, counts[metricChanged], "full counts remain available for metric changes"); - Equal(0, Analyzer.DiffInText(before, before).Count, "identical trees"); - Equal(2, Analyzer.DiffInText(Path.Combine(after, "nested/text only.dasm"), textOnly)[textOnly], "single file counts"); - if (!OperatingSystem.IsWindows()) - { - Equal(2, Analyzer.DiffInText(Path.Combine(after, "directory-link"), Path.Combine(before, "directory-link")) - [Path.Combine(before, "directory-link")], "directory links are compared without traversal"); - Directory.Delete(Path.Combine(before, "directory-link")); - Directory.Delete(Path.Combine(after, "directory-link")); - } - - string[] args = { "--base", before, "--diff", after, "--recursive" }; - TextWriter oldOut = Console.Out; - using var stdout = new StringWriter(); - try - { - Console.SetOut(stdout); - Equal(-1, new JitAnalyzeRootCommand(args).Parse(args).Invoke(), "mixed text and metric exit code"); - } - finally - { - Console.SetOut(oldOut); - } - Contains(stdout.ToString(), $"nested{Path.DirectorySeparatorChar}text only.dasm had 2 diffs"); - Contains(stdout.ToString(), $"Found {(OperatingSystem.IsWindows() ? 4 : 6)} files with textual diffs."); - Equal(false, stdout.ToString().Contains("metric-changed.dasm had"), "metric changes do not need displayed line counts"); - } - - private static void CheckTsv(string tsv, string[] methods, int headers = 1) - { - string expectedHeader = "File\tMethod" + string.Concat(MetricNames.Select(n => - $"\tBase {n}\tDiff {n}\tDelta {n}\tPercentage {n}")); - string[] lines = tsv.Split('\n', StringSplitOptions.RemoveEmptyEntries); - Equal(headers, lines.Count(l => l == expectedHeader), "TSV headers (one per metric)"); - string[] rows = lines.Where(l => l != expectedHeader).ToArray(); - Equal(string.Join(",", methods), string.Join(",", rows.Select(l => l.Split('\t')[1])), "TSV row order"); - foreach (string row in rows) - { - string[] fields = row.Split('\t'); - Equal(51, fields.Length, "TSV field count including trailing tab"); - Equal("keep.dasm", fields[0], "TSV uses base filename"); - double[] before = fields[1] switch - { - "Shared" => new double[] { 10, 2, 0 }, - "Removed" => new double[] { 5, 1, 0 }, - "Added" => new double[] { 0, 0, 0 }, - "PerfOnly" => new double[] { 3, 1, 1 }, - _ => throw new Exception("Unexpected TSV method") - }; - double[] after = fields[1] switch - { - "Shared" => new double[] { 14, 1, 0 }, - "Removed" => new double[] { 0, 0, 0 }, - "Added" => new double[] { 8, 2, 0 }, - "PerfOnly" => new double[] { 3, 1, 2 }, - _ => throw new Exception("Unexpected TSV method") - }; - for (int i = 0; i < MetricNames.Length; i++) - { - double b = i < before.Length ? before[i] : 0; - double d = i < after.Length ? after[i] : 0; - double[] expected = { b, d, d - b, b == 0 ? 0 : (d - b) / b }; - for (int j = 0; j < expected.Length; j++) - Equal(expected[j].ToString(CultureInfo.InvariantCulture), fields[2 + i * 4 + j], $"TSV {fields[1]} {MetricNames[i]} column {j}"); - } - Equal("", fields[^1], "TSV trailing tab"); - } - } - - private static (int Code, string Output, string Tsv) Invoke(string before, string after, params string[] options) - { - string tsv = Path.Combine(root, "result.tsv"); - string json = Path.Combine(root, "result.json"); - string markdown = Path.Combine(root, "result.md"); - File.Delete(tsv); - string[] args = new[] { "--base", before, "--diff", after, "--skip-text-diff", "--tsv", tsv, "--json", json, "--md", markdown }.Concat(options).ToArray(); - TextWriter oldOut = Console.Out; - TextWriter oldError = Console.Error; - using var stdout = new StringWriter(); - using var stderr = new StringWriter(); - int code; - try - { - Console.SetOut(stdout); - Console.SetError(stderr); - code = new JitAnalyzeRootCommand(args).Parse(args).Invoke(); - } - finally - { - Console.SetOut(oldOut); - Console.SetError(oldError); - } - Equal("", stderr.ToString(), "CLI stderr"); - string output = stdout.ToString().Replace("\r\n", "\n"); - string table = File.ReadAllText(tsv).Replace("\r\n", "\n"); - string jsonOutput = File.ReadAllText(json); - string markdownOutput = File.ReadAllText(markdown); - if (baseline != null) - { - using var process = new Process(); - process.StartInfo = new ProcessStartInfo(baseline) { RedirectStandardOutput = true, RedirectStandardError = true }; - foreach (string arg in args) - process.StartInfo.ArgumentList.Add(arg); - process.StartInfo.Environment["LC_ALL"] = "C"; - process.StartInfo.Environment["DOTNET_SYSTEM_GLOBALIZATION_INVARIANT"] = "1"; - File.Delete(tsv); - process.Start(); - var baselineOut = process.StandardOutput.ReadToEndAsync(); - var baselineError = process.StandardError.ReadToEndAsync(); - process.WaitForExit(); - Equal(code & 255, process.ExitCode & 255, "baseline exit code"); - Equal("", baselineError.GetAwaiter().GetResult(), "baseline stderr"); - Equal(output, baselineOut.GetAwaiter().GetResult().Replace("\r\n", "\n"), "baseline stdout"); - Equal(table, File.ReadAllText(tsv).Replace("\r\n", "\n"), "baseline TSV"); - Equal(jsonOutput, File.ReadAllText(json), "baseline JSON"); - Equal(markdownOutput, File.ReadAllText(markdown), "baseline markdown"); - } - return (code, output, table); - } -}