diff --git a/.github/workflows/build-tools.yml b/.github/workflows/build-tools.yml index 8038f514a80f..4369e74a07b4 100644 --- a/.github/workflows/build-tools.yml +++ b/.github/workflows/build-tools.yml @@ -21,6 +21,7 @@ jobs: - Evm/Evm.slnx - HiveCompare/HiveCompare.slnx - HiveConsensusWorkflowGenerator/HiveConsensusWorkflowGenerator.slnx + - JitAsm/JitAsm.slnx - Kute/Kute.slnx # - SchemaGenerator/SchemaGenerator.slnx - SendBlobs/SendBlobs.slnx diff --git a/.gitignore b/.gitignore index 6f14b7b2d897..5f3e510becd3 100644 --- a/.gitignore +++ b/.gitignore @@ -358,6 +358,10 @@ paket-files/ #tools/** #!tools/packages.config +# JitAsm uops.info database (110MB, download separately) +tools/JitAsm/instructions.xml +tools/JitAsm/instructions.db + # Tabs Studio *.tss diff --git a/AGENTS.md b/AGENTS.md index 350f984894d6..cb191a70b802 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ This guide helps to get started with the Nethermind Ethereum execution client re - Trust null annotations, do not add redundant null checks - Add tests to existing test files rather than creating new ones - Code comments must explain _why_, not _what_ -- Do not suggest using LINQ when a simple loop would suffice +- **NEVER suggest using LINQ (`.Select()`, `.Where()`, `.Any()`, etc.) when a simple `foreach` or `for` loop would work.** LINQ has overhead and is less readable for simple iterations. Use LINQ only for complex queries where the declarative syntax significantly improves clarity. - Do not use the `#region` and `#endregion` pragmas - Do not alter anything in the [src/bench_precompiles](./src/bench_precompiles/) and [src/tests](./src/tests/) directories diff --git a/cspell.json b/cspell.json index 313bcb6496c2..cc912e7165df 100644 --- a/cspell.json +++ b/cspell.json @@ -35,11 +35,13 @@ "adab", "addmod", "affinitize", + "agen", "akhunov", "alethia", "alexey", "analysed", "apikey", + "archs", "argjson", "asmv", "aspnet", @@ -105,6 +107,7 @@ "buildtransitive", "bulkset", "bursty", + "bword", "buterin", "bylica", "bytecodes", @@ -115,17 +118,41 @@ "callf", "callme", "callvalue", + "callvirt", "cand", "canonicality", "castagnoli", + "cctor", + "cctors", "chainid", "chainspec", "chiado", "cipherparams", "ciphertext", "ckzg", + "classinit", "cloneable", "cmix", + "cmov", + "cmova", + "cmovae", + "cmovb", + "cmovbe", + "cmove", + "cmovg", + "cmovge", + "cmovl", + "cmovle", + "cmovna", + "cmovnb", + "cmovnbe", + "cmovne", + "cmovng", + "cmovnge", + "cmovnl", + "cmovnle", + "cmovnz", + "cmovz", "codecopy", "codehash", "codesection", @@ -142,6 +169,7 @@ "containersection", "contentfiles", "corechart", + "corinfo", "cpufrequency", "crummey", "cryptosuite", @@ -151,6 +179,7 @@ "dataloadn", "datasection", "datasize", + "dasm", "dbdir", "dbsize", "deadlined", @@ -158,6 +187,7 @@ "debhelper", "decommit", "decompiled", + "dedup", "decompiler", "deconfigure", "deconfigured's", @@ -170,12 +200,15 @@ "deserialised", "dests", "devirtualize", + "devirtualized", "devnet", "devnets", "devp2p", "diagnoser", "diagnosers", + "diffable", "disappearer's", + "disasm", "discontiguous", "discport", "discv", @@ -190,11 +223,13 @@ "dpapi", "dpkg", "dupn", + "dynamicclass", "ecies", "ecrec", "edgecase", "efbbbf", "eips", + "eliminable", "emojize", "emptish", "emptystep", @@ -228,6 +263,7 @@ "ethxx", "evmdis", "ewasm", + "evex", "extcall", "extcode", "extcodecopy", @@ -254,17 +290,21 @@ "forkhash", "forkid", "fusaka", + "fullopts", "gasrefund", "gbps", + "gcstatic", "gcdump", "geoff", "getblobs", "getnull", "getpayloadv", "getrlimit", + "getshared", "gettrie", "gopherium", "gwat", + "gword", "halfpath", "hardfork", "hardforks", @@ -292,6 +332,7 @@ "hostnames", "hotstuff", "hyperthreading", + "idiv", "idxs", "iface", "ikvp", @@ -307,6 +348,7 @@ "instart", "internaltype", "interp", + "interruptible", "invalidblockhash", "isnull", "iszero", @@ -314,9 +356,13 @@ "ivle", "jemalloc", "jimbojones", + "jitasm", "jitted", "jitting", "jmps", + "jnbe", + "jnge", + "jnle", "jsonrpcconfig", "jumpdest", "jumpdestpush", @@ -396,13 +442,18 @@ "merkleize", "merkleizer", "mgas", + "microarchitecture", + "microbenchmark", + "microbenchmarks", "microsecs", "midnib", "millis", "mingas", "minlevel", + "minopt", "mintable", "misbehaviour", + "mispredictions", "mklink", "mload", "mmap", @@ -450,6 +501,7 @@ "networkconfigmaxcandidatepeercount", "networkid", "newtonsoft", + "nint", "nito", "nlog", "nodedata", @@ -458,6 +510,7 @@ "nodetype", "nofile", "nonposdao", + "nongcstatic", "nonstring", "nops", "nostack", @@ -466,6 +519,7 @@ "npushes", "nsubstitute", "nugettest", + "nuint", "numfiles", "oand", "offchain", @@ -551,6 +605,8 @@ "redownloading", "reencoding", "refint", + "regs", + "relbr", "refstruct", "regenesis", "reitwiessner", @@ -586,6 +642,7 @@ "samplenewpayload", "sankey", "sbrk", + "sbyte", "scopable", "sdiv", "secp", @@ -596,7 +653,23 @@ "seqlock", "serialised", "setcode", + "setb", + "setbe", "sete", + "setg", + "setge", + "setl", + "setle", + "setna", + "setnb", + "setnbe", + "setne", + "setnge", + "setnl", + "setnle", + "setng", + "setnz", + "setz", "shamir", "shlibs", "shouldly", @@ -607,6 +680,7 @@ "signextend", "sizeinbase", "skiplastn", + "skylake", "slnx", "sload", "smod", @@ -719,20 +793,24 @@ "unsubscription", "unsynchronized", "unvote", + "uops", "upnp", "upto", "upvoting", "vbmi", "vitalik", + "vmovdqu", "vmovups", "vmtrace", "vote₁", "vote₂", "vote₃", "voteₙ", + "vpaddd", "vpcbr", "vpor", "vptest", + "vpxor", "vzeroupper", "wamp", "warmcoinbase", @@ -751,6 +829,7 @@ "wycheproof", "xdai", "xdcx", + "xmmword", "xmlstarlet", "xnpool", "yellowpaper", @@ -759,6 +838,7 @@ "zcompressor", "zdecompressor", "zhizhu", + "zmmword", "zstandard", "zstd", "zwcm" diff --git a/tools/JitAsm/DisassemblyParser.cs b/tools/JitAsm/DisassemblyParser.cs new file mode 100644 index 000000000000..dcbb9687af8f --- /dev/null +++ b/tools/JitAsm/DisassemblyParser.cs @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Text; +using System.Text.RegularExpressions; + +namespace JitAsm; + +internal static partial class DisassemblyParser +{ + // Pattern to detect the start of a method's disassembly + // Example: ; Assembly listing for method Namespace.Type:Method(args) + [GeneratedRegex(@"^; Assembly listing for method (?.+)$", RegexOptions.Compiled | RegexOptions.Multiline)] + private static partial Regex MethodHeaderPattern(); + + // Pattern to detect end of method disassembly (next method or end) + [GeneratedRegex(@"^; Total bytes of code", RegexOptions.Compiled | RegexOptions.Multiline)] + private static partial Regex MethodEndPattern(); + + public static string Parse(string jitOutput, bool lastOnly = false) + { + if (string.IsNullOrWhiteSpace(jitOutput)) + { + return string.Empty; + } + + var result = new StringBuilder(); + var matches = MethodHeaderPattern().Matches(jitOutput); + + if (matches.Count == 0) + { + // No method headers found, return raw output if it looks like assembly + if (jitOutput.Contains("mov") || jitOutput.Contains("call") || jitOutput.Contains("ret")) + { + return jitOutput.Trim(); + } + return string.Empty; + } + + // In tier1 mode, JitDisasm captures both Tier-0 and Tier-1 compilations. + // We want the LAST compilation (Tier-1 with full optimizations). + int startIdx = lastOnly ? matches.Count - 1 : 0; + + for (int i = startIdx; i < matches.Count; i++) + { + var match = matches[i]; + var startIndex = match.Index; + + // Find the end of this method's disassembly + int endIndex = (i + 1 < matches.Count) ? matches[i + 1].Index : jitOutput.Length; + + // Extract this method's disassembly + var methodAsm = jitOutput[startIndex..endIndex].TrimEnd(); + + // Find "Total bytes of code" line and include it + var totalBytesMatch = MethodEndPattern().Match(methodAsm); + if (totalBytesMatch.Success) + { + // Find end of line after "Total bytes of code" + var lineEnd = methodAsm.IndexOf('\n', totalBytesMatch.Index); + if (lineEnd > 0) + { + methodAsm = methodAsm[..(lineEnd + 1)].TrimEnd(); + } + } + + if (result.Length > 0) + { + result.AppendLine(); + result.AppendLine(new string('-', 80)); + result.AppendLine(); + } + + result.AppendLine(methodAsm); + } + + return result.ToString().Trim(); + } + + public static IEnumerable ParseMethods(string jitOutput) + { + if (string.IsNullOrWhiteSpace(jitOutput)) + { + yield break; + } + + var matches = MethodHeaderPattern().Matches(jitOutput); + + for (int i = 0; i < matches.Count; i++) + { + var match = matches[i]; + var methodName = match.Groups["method"].Value; + var startIndex = match.Index; + + int endIndex = (i + 1 < matches.Count) ? matches[i + 1].Index : jitOutput.Length; + + var methodAsm = jitOutput[startIndex..endIndex].TrimEnd(); + + yield return new MethodDisassembly + { + MethodName = methodName, + Assembly = methodAsm + }; + } + } +} + +internal sealed class MethodDisassembly +{ + public required string MethodName { get; init; } + public required string Assembly { get; init; } +} diff --git a/tools/JitAsm/InstructionAnnotator.cs b/tools/JitAsm/InstructionAnnotator.cs new file mode 100644 index 000000000000..068d5ab34853 --- /dev/null +++ b/tools/JitAsm/InstructionAnnotator.cs @@ -0,0 +1,441 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Text; +using System.Text.RegularExpressions; + +namespace JitAsm; + +internal static partial class InstructionAnnotator +{ + // Matches JIT assembly instruction lines: + // " add rax, rcx" + // " mov dword ptr [rbp+0x10], eax" + [GeneratedRegex(@"^\s+(?[a-z]\w*)\s+(?.+)$", RegexOptions.IgnoreCase)] + private static partial Regex InstructionLineRegex(); + + // Matches zero-operand instructions: " ret" or " nop" + [GeneratedRegex(@"^\s+(?[a-z]\w*)\s*$", RegexOptions.IgnoreCase)] + private static partial Regex ZeroOperandRegex(); + + // 64-bit registers + private static readonly HashSet Regs64 = new(StringComparer.OrdinalIgnoreCase) + { + "rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rsp", "rbp", + "r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15" + }; + + // 32-bit registers + private static readonly HashSet Regs32 = new(StringComparer.OrdinalIgnoreCase) + { + "eax", "ebx", "ecx", "edx", "esi", "edi", "esp", "ebp", + "r8d", "r9d", "r10d", "r11d", "r12d", "r13d", "r14d", "r15d" + }; + + // 16-bit registers + private static readonly HashSet Regs16 = new(StringComparer.OrdinalIgnoreCase) + { + "ax", "bx", "cx", "dx", "si", "di", "sp", "bp", + "r8w", "r9w", "r10w", "r11w", "r12w", "r13w", "r14w", "r15w" + }; + + // 8-bit registers + private static readonly HashSet Regs8 = new(StringComparer.OrdinalIgnoreCase) + { + "al", "bl", "cl", "dl", "sil", "dil", "spl", "bpl", "ah", "bh", "ch", "dh", + "r8b", "r9b", "r10b", "r11b", "r12b", "r13b", "r14b", "r15b" + }; + + // JIT mnemonic → uops.info mnemonic mapping for conditional jumps + // JIT uses Intel-style aliases (je, jne, ja, etc.) but uops.info uses the + // canonical forms (jz, jnz, jnbe, etc.) + private static readonly Dictionary MnemonicAliases = new(StringComparer.OrdinalIgnoreCase) + { + ["je"] = "jz", + ["jne"] = "jnz", + ["ja"] = "jnbe", + ["jae"] = "jnb", + ["jb"] = "jb", // canonical + ["jbe"] = "jna", + ["jg"] = "jnle", + ["jge"] = "jnl", + ["jl"] = "jnge", + ["jle"] = "jng", + ["jc"] = "jb", + ["jnc"] = "jnb", + ["jp"] = "jp", // canonical + ["jnp"] = "jnp", // canonical + ["js"] = "js", // canonical + ["jns"] = "jns", // canonical + ["jo"] = "jo", // canonical + ["jno"] = "jno", // canonical + ["cmove"] = "cmovz", + ["cmovne"] = "cmovnz", + ["cmova"] = "cmovnbe", + ["cmovae"] = "cmovnb", + ["cmovb"] = "cmovb", // canonical + ["cmovbe"] = "cmovna", + ["cmovg"] = "cmovnle", + ["cmovge"] = "cmovnl", + ["cmovl"] = "cmovnge", + ["cmovle"] = "cmovng", + ["sete"] = "setz", + ["setne"] = "setnz", + ["seta"] = "setnbe", + ["setae"] = "setnb", + ["setb"] = "setb", // canonical + ["setbe"] = "setna", + ["setg"] = "setnle", + ["setge"] = "setnl", + ["setl"] = "setnge", + ["setle"] = "setng", + }; + + public static string Annotate(string disassembly, InstructionDb db) + { + var sb = new StringBuilder(); + var lines = JoinContinuationLines(disassembly.Split('\n')); + + foreach (string rawLine in lines) + { + string line = rawLine.TrimEnd('\r'); + + // Skip comment lines, labels, directives + if (IsNonInstructionLine(line)) + { + sb.AppendLine(line); + continue; + } + + var match = InstructionLineRegex().Match(line); + if (match.Success) + { + string mnemonic = match.Groups["mnemonic"].Value.ToLowerInvariant(); + string operandsRaw = match.Groups["operands"].Value.Trim(); + + // Skip annotations for calls and jumps to labels + if (ShouldSkipAnnotation(mnemonic, operandsRaw)) + { + sb.AppendLine(line); + continue; + } + + string pattern = ClassifyOperands(operandsRaw, mnemonic); + + // Try the original mnemonic first, then any alias + string lookupMnemonic = MnemonicAliases.TryGetValue(mnemonic, out var alias) + ? alias : mnemonic; + var info = db.Lookup(mnemonic, pattern) + ?? (lookupMnemonic != mnemonic ? db.Lookup(lookupMnemonic, pattern) : null); + + if (info is not null) + { + string annotation = FormatAnnotation(info); + // Pad the line to align annotations + int padTo = Math.Max(line.Length + 1, 55); + sb.Append(line.PadRight(padTo)); + sb.AppendLine(annotation); + } + else + { + sb.AppendLine(line); + } + continue; + } + + // Try zero-operand match + var zeroMatch = ZeroOperandRegex().Match(line); + if (zeroMatch.Success) + { + string mnemonic = zeroMatch.Groups["mnemonic"].Value.ToLowerInvariant(); + if (!ShouldSkipAnnotation(mnemonic, "")) + { + string zeroLookup = MnemonicAliases.TryGetValue(mnemonic, out var zAlias) + ? zAlias : mnemonic; + var info = db.Lookup(mnemonic, "") + ?? (zeroLookup != mnemonic ? db.Lookup(zeroLookup, "") : null); + if (info is not null) + { + string annotation = FormatAnnotation(info); + int padTo = Math.Max(line.Length + 1, 55); + sb.Append(line.PadRight(padTo)); + sb.AppendLine(annotation); + continue; + } + } + } + + sb.AppendLine(line); + } + + return sb.ToString().TrimEnd(); + } + + /// + /// Joins JIT output continuation lines. The JIT wraps long lines at ~80 chars: + /// " call \n[System.Threading.ThreadLocal`1[...]:get_Value():...]\n" + /// "; Assembly listing for method \nNamespace.Type:Method(...)\n" + /// This joins them so each logical line is a single string. + /// + private static List JoinContinuationLines(string[] rawLines) + { + var result = new List(rawLines.Length); + for (int i = 0; i < rawLines.Length; i++) + { + string line = rawLines[i].TrimEnd('\r'); + + // Keep joining while the next line looks like a continuation + while (i + 1 < rawLines.Length) + { + string next = rawLines[i + 1].TrimEnd('\r'); + if (IsContinuationLine(next)) + { + // Preserve a single space between joined parts so "call \n[Type:Method]" + // becomes "call [Type:Method]" rather than "call[Type:Method]" + line = line.TrimEnd() + " " + next.TrimStart(); + i++; + } + else + { + break; + } + } + + result.Add(line); + } + return result; + } + + /// + /// A line is a continuation if it doesn't match any known "primary" line type: + /// blank, comment (;), label (G_M...:), instruction (leading whitespace), data (RWD), or alignment. + /// + private static bool IsContinuationLine(string line) + { + if (line.Length == 0) return false; + + ReadOnlySpan trimmed = line.AsSpan().TrimEnd('\r'); + if (trimmed.Length == 0) return false; + + // Instructions start with whitespace + if (char.IsWhiteSpace(trimmed[0])) return false; + + // Comments start with ';' + if (trimmed[0] == ';') return false; + + // Labels: "G_M000_IG01:" or similar identifiers ending with ':' + // Check if line contains ':' and starts with a label-like pattern + if (trimmed.StartsWith("G_M", StringComparison.Ordinal) && trimmed.Contains(":", StringComparison.Ordinal)) + return false; + + // Read-only data table entries: "RWD00 dd ..." + if (trimmed.StartsWith("RWD", StringComparison.Ordinal)) return false; + + // Alignment directives: "align [N bytes for IG...]" + if (trimmed.StartsWith("align", StringComparison.OrdinalIgnoreCase)) return false; + + // Everything else is a continuation of the previous line + return true; + } + + private static bool IsNonInstructionLine(string line) + { + if (line.Length == 0) return true; + + // Instructions always start with whitespace (indented). + // Labels, data tables, directives, and other non-instruction lines start at column 0. + if (line[0] == ';') return true; // Comment at column 0 + if (!char.IsWhiteSpace(line[0])) return true; // Labels (G_M000_IG01:), data (RWD00), directives, etc. + + // Indented comments: " ; comment" + ReadOnlySpan trimmed = line.AsSpan().TrimStart(); + if (trimmed.Length > 0 && trimmed[0] == ';') return true; + + return false; + } + + private static bool ShouldSkipAnnotation(string mnemonic, string operands) + { + // Skip calls (to runtime helpers, methods, etc.) + if (mnemonic == "call") return true; + + // Skip ret - uops.info TP_unrolled is a microbenchmark artifact (return stack buffer + // mispredictions make the measurement meaningless for real code) + if (mnemonic == "ret") return true; + + // Skip int3/nop - not meaningful for performance analysis + if (mnemonic is "int3" or "nop" or "int") return true; + + return false; + } + + internal static string ClassifyOperands(string operandsRaw, string? mnemonic = null) + { + // Handle trailing comments after operands: "rax, rcx ; some comment" + int commentIdx = operandsRaw.IndexOf(';'); + if (commentIdx >= 0) + operandsRaw = operandsRaw[..commentIdx].TrimEnd(); + + if (string.IsNullOrWhiteSpace(operandsRaw)) + return ""; + + // Split operands by comma, but respect brackets for memory operands + var operands = SplitOperands(operandsRaw); + var parts = new List(); + + for (int i = 0; i < operands.Count; i++) + { + string op = operands[i].Trim(); + + // LEA's second operand is an address expression, not a memory load + // uops.info classifies it as "agen" (address generation) + if (mnemonic is "lea" && i == 1 && op.Contains('[')) + { + parts.Add("agen"); + continue; + } + + string classified = ClassifySingleOperand(op); + if (classified.Length > 0) + parts.Add(classified); + } + + return string.Join(",", parts); + } + + private static List SplitOperands(string operands) + { + var result = new List(); + int depth = 0; + int start = 0; + + for (int i = 0; i < operands.Length; i++) + { + char c = operands[i]; + if (c == '[') depth++; + else if (c == ']') depth--; + else if (c == ',' && depth == 0) + { + result.Add(operands[start..i]); + start = i + 1; + } + } + + result.Add(operands[start..]); + return result; + } + + private static string ClassifySingleOperand(string op) + { + // Memory operand: "dword ptr [rbp+10h]", "qword ptr [rsp+20h]", "[rax]" + if (op.Contains('[')) + { + if (op.Contains("zmmword ptr", StringComparison.OrdinalIgnoreCase)) return "m512"; + if (op.Contains("ymmword ptr", StringComparison.OrdinalIgnoreCase)) return "m256"; + if (op.Contains("xmmword ptr", StringComparison.OrdinalIgnoreCase)) return "m128"; + if (op.Contains("qword ptr", StringComparison.OrdinalIgnoreCase)) return "m64"; + // gword ptr = GC-tracked pointer-width memory (.NET JIT specific, equivalent to qword on x64) + if (op.Contains("gword ptr", StringComparison.OrdinalIgnoreCase)) return "m64"; + // bword ptr = pointer-width memory without GC tracking (.NET JIT specific, equivalent to qword on x64) + if (op.Contains("bword ptr", StringComparison.OrdinalIgnoreCase)) return "m64"; + if (op.Contains("dword ptr", StringComparison.OrdinalIgnoreCase)) return "m32"; + if (op.Contains("word ptr", StringComparison.OrdinalIgnoreCase) && + !op.Contains("dword", StringComparison.OrdinalIgnoreCase) && + !op.Contains("qword", StringComparison.OrdinalIgnoreCase) && + !op.Contains("gword", StringComparison.OrdinalIgnoreCase) && + !op.Contains("bword", StringComparison.OrdinalIgnoreCase) && + !op.Contains("xmmword", StringComparison.OrdinalIgnoreCase) && + !op.Contains("ymmword", StringComparison.OrdinalIgnoreCase) && + !op.Contains("zmmword", StringComparison.OrdinalIgnoreCase)) + return "m16"; + if (op.Contains("byte ptr", StringComparison.OrdinalIgnoreCase)) return "m8"; + return "m"; + } + + // Register operands + string regName = op.Trim(); + + // ZMM registers + if (regName.StartsWith("zmm", StringComparison.OrdinalIgnoreCase)) return "zmm"; + // YMM registers + if (regName.StartsWith("ymm", StringComparison.OrdinalIgnoreCase)) return "ymm"; + // XMM registers + if (regName.StartsWith("xmm", StringComparison.OrdinalIgnoreCase)) return "xmm"; + // K mask registers + if (regName.StartsWith("k", StringComparison.OrdinalIgnoreCase) && regName.Length <= 2 && + regName.Length > 1 && char.IsDigit(regName[1])) return "k"; + + if (Regs64.Contains(regName)) return "r64"; + if (Regs32.Contains(regName)) return "r32"; + if (Regs16.Contains(regName)) return "r16"; + if (Regs8.Contains(regName)) return "r8"; + + // Immediate: hex (0x1A, 1Ah), decimal, or negative + if (IsImmediate(regName)) + { + // Try to determine imm8 vs imm32 from value range + if (TryParseImmediate(regName, out long value)) + { + return value is >= -128 and <= 255 ? "imm8" : "imm32"; + } + return "imm"; + } + + // Label reference (for jumps) - strip SHORT/NEAR prefix added by JIT + if (regName.StartsWith("SHORT ", StringComparison.OrdinalIgnoreCase)) + regName = regName[6..].TrimStart(); + if (regName.StartsWith("NEAR ", StringComparison.OrdinalIgnoreCase)) + regName = regName[5..].TrimStart(); + + if (regName.StartsWith("G_M", StringComparison.OrdinalIgnoreCase)) + return "rel"; + + // Unknown + return ""; + } + + private static bool IsImmediate(string op) + { + if (op.Length == 0) return false; + + // Hex: 0x prefix or trailing h + if (op.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) return true; + if (op.EndsWith('h') || op.EndsWith('H')) + { + return op[..^1].All(c => char.IsAsciiHexDigit(c) || c == '-'); + } + + // Decimal (possibly negative) + return op.All(c => char.IsDigit(c) || c == '-'); + } + + private static bool TryParseImmediate(string op, out long value) + { + value = 0; + + if (op.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + return long.TryParse(op.AsSpan(2), System.Globalization.NumberStyles.HexNumber, + System.Globalization.CultureInfo.InvariantCulture, out value); + + if (op.EndsWith('h') || op.EndsWith('H')) + return long.TryParse(op.AsSpan(0, op.Length - 1), System.Globalization.NumberStyles.HexNumber, + System.Globalization.CultureInfo.InvariantCulture, out value); + + return long.TryParse(op, out value); + } + + private static string FormatAnnotation(InstructionInfo info) + { + var sb = new StringBuilder(); + sb.Append("; ["); + sb.Append($"TP:{info.Throughput:F2}"); + sb.Append($" | Lat:{info.Latency,2}"); + sb.Append($" | Uops:{info.Uops}"); + if (info.Ports is not null) + { + sb.Append($" | {info.Ports}"); + } + sb.Append(']'); + return sb.ToString(); + } +} diff --git a/tools/JitAsm/InstructionDb.cs b/tools/JitAsm/InstructionDb.cs new file mode 100644 index 000000000000..df39ca110944 --- /dev/null +++ b/tools/JitAsm/InstructionDb.cs @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +namespace JitAsm; + +internal sealed class InstructionInfo +{ + public required string Mnemonic { get; init; } + public required string OperandPattern { get; init; } + public float Throughput { get; init; } + public int Latency { get; init; } + public int Uops { get; init; } + public string? Ports { get; init; } +} + +internal sealed class InstructionDb +{ + private const string Magic = "UOPS"; + private const ushort Version = 2; + + private readonly Dictionary> _instructions = new(StringComparer.OrdinalIgnoreCase); + + public string ArchName { get; } + + public InstructionDb(string archName) + { + ArchName = archName; + } + + public void Add(InstructionInfo info) + { + string key = info.Mnemonic.ToLowerInvariant(); + if (!_instructions.TryGetValue(key, out var list)) + { + list = []; + _instructions[key] = list; + } + list.Add(info); + } + + public int Count => _instructions.Sum(kv => kv.Value.Count); + + public InstructionInfo? Lookup(string mnemonic, string operandPattern) + { + if (!_instructions.TryGetValue(mnemonic, out var forms)) + return null; + + // Exact match + foreach (var form in forms) + { + if (string.Equals(form.OperandPattern, operandPattern, StringComparison.OrdinalIgnoreCase)) + return form; + } + + // Relaxed match: ignore register width differences (r32 ≈ r64 for same instruction class) + string relaxed = RelaxPattern(operandPattern); + foreach (var form in forms) + { + if (string.Equals(RelaxPattern(form.OperandPattern), relaxed, StringComparison.OrdinalIgnoreCase)) + return form; + } + + // Mnemonic-only match for zero-operand instructions (ret, nop, etc.) + if (operandPattern.Length == 0) + { + foreach (var form in forms) + { + if (form.OperandPattern.Length == 0) + return form; + } + } + + return null; + } + + private static string RelaxPattern(string pattern) + { + // Normalize register widths: r8/r16/r32/r64 → r, m8/m16/m32/m64/m128/m256/m512 → m, imm8/imm32 → imm + return pattern + .Replace("r64", "r").Replace("r32", "r").Replace("r16", "r").Replace("r8", "r") + .Replace("m512", "m").Replace("m256", "m").Replace("m128", "m") + .Replace("m64", "m").Replace("m32", "m").Replace("m16", "m").Replace("m8", "m") + .Replace("imm32", "imm").Replace("imm8", "imm"); + } + + public void Save(string path) + { + using var stream = File.Create(path); + using var writer = new BinaryWriter(stream); + + // Header + writer.Write(Magic.ToCharArray()); + writer.Write(Version); + writer.Write(ArchName); + + // Count all entries + int entryCount = Count; + writer.Write(entryCount); + + // Entries + foreach (var (_, forms) in _instructions) + { + foreach (var info in forms) + { + writer.Write(info.Mnemonic); + writer.Write(info.OperandPattern); + writer.Write(info.Throughput); + writer.Write((short)info.Latency); + writer.Write((short)info.Uops); + writer.Write(info.Ports ?? string.Empty); + } + } + } + + public static InstructionDb Load(string path) + { + using var stream = File.OpenRead(path); + using var reader = new BinaryReader(stream); + + // Header + char[] magic = reader.ReadChars(4); + if (new string(magic) != Magic) + throw new InvalidDataException($"Invalid instruction database file: bad magic"); + + ushort version = reader.ReadUInt16(); + if (version != Version) + throw new InvalidDataException($"Unsupported instruction database version: {version}"); + + string archName = reader.ReadString(); + int entryCount = reader.ReadInt32(); + + var db = new InstructionDb(archName); + + for (int i = 0; i < entryCount; i++) + { + string mnemonic = reader.ReadString(); + string operandPattern = reader.ReadString(); + float throughput = reader.ReadSingle(); + short latency = reader.ReadInt16(); + short uops = reader.ReadInt16(); + string ports = reader.ReadString(); + + db.Add(new InstructionInfo + { + Mnemonic = mnemonic, + OperandPattern = operandPattern, + Throughput = throughput, + Latency = latency, + Uops = uops, + Ports = ports.Length > 0 ? ports : null + }); + } + + return db; + } +} diff --git a/tools/JitAsm/InstructionDbBuilder.cs b/tools/JitAsm/InstructionDbBuilder.cs new file mode 100644 index 000000000000..7daf9cfa517b --- /dev/null +++ b/tools/JitAsm/InstructionDbBuilder.cs @@ -0,0 +1,265 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Globalization; +using System.Xml; + +namespace JitAsm; + +internal static class InstructionDbBuilder +{ + // Map from CLI flag value to uops.info architecture name + private static readonly Dictionary ArchMap = new(StringComparer.OrdinalIgnoreCase) + { + ["alder-lake"] = "ADL-P", + ["rocket-lake"] = "RKL", + ["ice-lake"] = "ICL", + ["tiger-lake"] = "TGL", + ["skylake"] = "SKL", + ["zen4"] = "ZEN4", + ["zen3"] = "ZEN3", + ["zen2"] = "ZEN2", + }; + + // Fallback chain: if the target arch has no data, try these in order + private static readonly Dictionary FallbackChain = new(StringComparer.OrdinalIgnoreCase) + { + ["ADL-P"] = ["RKL", "TGL", "ICL", "SKL"], + ["RKL"] = ["TGL", "ICL", "SKL"], + ["TGL"] = ["ICL", "SKL"], + ["ICL"] = ["SKL", "SKX", "HSW"], + ["SKL"] = ["SKX", "HSW"], + ["ZEN4"] = ["ZEN3", "ZEN2", "ZEN+"], + ["ZEN3"] = ["ZEN2", "ZEN+"], + ["ZEN2"] = ["ZEN+"], + }; + + public static string ResolveArchName(string cliValue) + { + return ArchMap.TryGetValue(cliValue, out var name) ? name : cliValue.ToUpperInvariant(); + } + + public static IReadOnlyCollection SupportedArchitectures => ArchMap.Keys; + + public static InstructionDb Build(string xmlPath, string archCliValue) + { + string targetArch = ResolveArchName(archCliValue); + string[] fallbacks = FallbackChain.TryGetValue(targetArch, out var fb) ? fb : []; + + var db = new InstructionDb(targetArch); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + using var stream = File.OpenRead(xmlPath); + using var reader = XmlReader.Create(stream, new XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore }); + + while (reader.Read()) + { + if (reader.NodeType != XmlNodeType.Element || reader.Name != "instruction") + continue; + + string? asm = reader.GetAttribute("asm"); + if (asm is null) + continue; + + // Clean the asm mnemonic: remove prefixes like "{load} " or "{store} " + string mnemonic = CleanMnemonic(asm); + if (mnemonic.Length == 0) + continue; + + // Read the instruction subtree + string instructionXml = reader.ReadOuterXml(); + var instrDoc = new XmlDocument(); + instrDoc.LoadXml(instructionXml); + var instrNode = instrDoc.DocumentElement!; + + // Parse operands + string operandPattern = BuildOperandPattern(instrNode); + + // Dedup key + string key = $"{mnemonic.ToLowerInvariant()}|{operandPattern}"; + if (seen.Contains(key)) + continue; + + // Find measurement for target architecture (with fallback) + var measurement = FindMeasurement(instrNode, targetArch, fallbacks); + if (measurement is null) + continue; + + float throughput = ParseFloat(measurement.GetAttribute("TP_unrolled")) + ?? ParseFloat(measurement.GetAttribute("TP_loop")) + ?? 0; + + int uops = ParseInt(measurement.GetAttribute("uops")) ?? 0; + string? ports = measurement.GetAttribute("ports"); + if (string.IsNullOrEmpty(ports)) ports = null; + + // Get max latency from child elements + int latency = 0; + foreach (XmlNode child in measurement.ChildNodes) + { + if (child is XmlElement latencyEl && latencyEl.Name == "latency") + { + int? cycles = ParseInt(latencyEl.GetAttribute("cycles")) + ?? ParseInt(latencyEl.GetAttribute("cycles_mem")) + ?? ParseInt(latencyEl.GetAttribute("cycles_addr")); + if (cycles.HasValue && cycles.Value > latency) + latency = cycles.Value; + } + } + + seen.Add(key); + db.Add(new InstructionInfo + { + Mnemonic = mnemonic.ToLowerInvariant(), + OperandPattern = operandPattern, + Throughput = throughput, + Latency = latency, + Uops = uops, + Ports = ports + }); + } + + return db; + } + + private static string CleanMnemonic(string asm) + { + // Remove assembler hints like "{load} ", "{store} ", "{vex} ", "{evex} " + ReadOnlySpan span = asm.AsSpan().Trim(); + while (span.Length > 0 && span[0] == '{') + { + int end = span.IndexOf('}'); + if (end < 0) break; + span = span[(end + 1)..].TrimStart(); + } + + // Take only the first word (mnemonic), skip any operand hints + int space = span.IndexOf(' '); + if (space > 0) + span = span[..space]; + + return span.ToString(); + } + + private static string BuildOperandPattern(XmlElement instrNode) + { + var parts = new List(); + foreach (XmlNode child in instrNode.ChildNodes) + { + if (child is not XmlElement operandEl || operandEl.Name != "operand") + continue; + + // Skip suppressed operands (flags, implicit registers) + if (operandEl.GetAttribute("suppressed") == "1") + continue; + + string? type = operandEl.GetAttribute("type"); + string? width = operandEl.GetAttribute("width"); + + string part = type switch + { + "reg" => ClassifyReg(width, operandEl.InnerText), + "mem" => ClassifyMem(width), + "agen" => "agen", + "imm" => ClassifyImm(width), + "relbr" => "rel", + _ => "" + }; + + if (part.Length > 0) + parts.Add(part); + } + + return string.Join(",", parts); + } + + private static string ClassifyReg(string? width, string? regNames) + { + // Check register names for xmm/ymm/zmm/mm/k + if (regNames is not null) + { + string firstReg = regNames.Split(',')[0].Trim().ToUpperInvariant(); + if (firstReg.StartsWith("ZMM")) return "zmm"; + if (firstReg.StartsWith("YMM")) return "ymm"; + if (firstReg.StartsWith("XMM")) return "xmm"; + if (firstReg.StartsWith("MM")) return "mm"; + if (firstReg.StartsWith("K")) return "k"; + } + + return width switch + { + "8" => "r8", + "16" => "r16", + "32" => "r32", + "64" => "r64", + "128" => "xmm", + "256" => "ymm", + "512" => "zmm", + _ => "r" + }; + } + + private static string ClassifyMem(string? width) + { + return width switch + { + "8" => "m8", + "16" => "m16", + "32" => "m32", + "64" => "m64", + "128" => "m128", + "256" => "m256", + "512" => "m512", + _ => "m" + }; + } + + private static string ClassifyImm(string? width) + { + return width switch + { + "8" => "imm8", + "16" => "imm16", + "32" => "imm32", + _ => "imm" + }; + } + + private static XmlElement? FindMeasurement(XmlElement instrNode, string targetArch, string[] fallbacks) + { + // Try target arch first, then fallbacks + var archsToTry = new List { targetArch }; + archsToTry.AddRange(fallbacks); + + foreach (string archName in archsToTry) + { + foreach (XmlNode child in instrNode.ChildNodes) + { + if (child is not XmlElement archEl || archEl.Name != "architecture") + continue; + if (archEl.GetAttribute("name") != archName) + continue; + + foreach (XmlNode archChild in archEl.ChildNodes) + { + if (archChild is XmlElement measureEl && measureEl.Name == "measurement") + return measureEl; + } + } + } + + return null; + } + + private static float? ParseFloat(string? value) + { + if (string.IsNullOrEmpty(value)) return null; + return float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out float result) ? result : null; + } + + private static int? ParseInt(string? value) + { + if (string.IsNullOrEmpty(value)) return null; + return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int result) ? result : null; + } +} diff --git a/tools/JitAsm/JitAsm.csproj b/tools/JitAsm/JitAsm.csproj new file mode 100644 index 000000000000..a505e193dda2 --- /dev/null +++ b/tools/JitAsm/JitAsm.csproj @@ -0,0 +1,12 @@ + + + Exe + + + + + + + + + diff --git a/tools/JitAsm/JitAsm.slnx b/tools/JitAsm/JitAsm.slnx new file mode 100644 index 000000000000..b30012a5e817 --- /dev/null +++ b/tools/JitAsm/JitAsm.slnx @@ -0,0 +1,3 @@ + + + diff --git a/tools/JitAsm/JitRunner.cs b/tools/JitAsm/JitRunner.cs new file mode 100644 index 000000000000..b348446161f9 --- /dev/null +++ b/tools/JitAsm/JitRunner.cs @@ -0,0 +1,269 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Diagnostics; +using System.Text; + +namespace JitAsm; + +internal sealed class JitRunner(string assemblyPath, string? typeName, string methodName, string? typeParams, string? classTypeParams, bool verbose, bool tier1 = false) +{ + public async Task RunSinglePassAsync(IReadOnlyList? cctorsToInit = null) + { + var result = await RunJitProcessAsync(cctorsToInit); + return result; + } + + public async Task RunTwoPassAsync() + { + // Pass 1: Run without cctor initialization to detect static constructor calls + var pass1Result = await RunJitProcessAsync(null); + + if (!pass1Result.Success) + { + return pass1Result; + } + + // Detect static constructors in the output + var detectedCctors = StaticCtorDetector.DetectStaticCtors(pass1Result.Output ?? string.Empty); + + if (detectedCctors.Count == 0) + { + // No cctors detected, return pass 1 result + return pass1Result; + } + + // Pass 2: Run with cctor initialization + var pass2Result = await RunJitProcessAsync(detectedCctors); + + return new JitResult + { + Success = pass2Result.Success, + Output = pass2Result.Output, + Error = pass2Result.Error, + Pass1Output = verbose ? pass1Result.Output : null, + DetectedCctors = detectedCctors + }; + } + + private async Task RunJitProcessAsync(IReadOnlyList? cctorsToInit) + { + // Get the path to the JitAsm executable + var (executablePath, argumentPrefix) = GetExecutablePath(); + + // Build the method pattern for JitDisasm + var methodPattern = BuildMethodPattern(); + + var startInfo = new ProcessStartInfo + { + FileName = executablePath, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + // Set JIT environment variables - these must be set before the process starts + if (tier1) + { + // Tier-1 simulation: enable tiered compilation so the method compiles at Tier-0 first, + // then gets recompiled at Tier-1 with full optimizations after the cctors have run. + startInfo.EnvironmentVariables["DOTNET_TieredCompilation"] = "1"; + startInfo.EnvironmentVariables["DOTNET_TieredPGO"] = "1"; + startInfo.EnvironmentVariables["DOTNET_TC_CallCountThreshold"] = "1"; + startInfo.EnvironmentVariables["DOTNET_TC_CallCountingDelayMs"] = "0"; + } + else + { + startInfo.EnvironmentVariables["DOTNET_TieredCompilation"] = "0"; + startInfo.EnvironmentVariables["DOTNET_TC_QuickJit"] = "0"; + } + startInfo.EnvironmentVariables["DOTNET_JitDisasm"] = methodPattern; + startInfo.EnvironmentVariables["DOTNET_JitDiffableDasm"] = "1"; + if (verbose) + startInfo.EnvironmentVariables["JITASM_VERBOSE"] = "1"; + + // Build arguments for internal runner + var args = new StringBuilder(); + if (argumentPrefix is not null) + { + args.Append(EscapeArg(argumentPrefix)); + args.Append(' '); + } + args.Append("--internal-runner "); + args.Append(EscapeArg(assemblyPath)); + args.Append(' '); + args.Append(EscapeArg(methodName)); + + if (typeName is not null) + { + args.Append(" --type "); + args.Append(EscapeArg(typeName)); + } + + if (typeParams is not null) + { + args.Append(" --type-params "); + args.Append(EscapeArg(typeParams)); + } + + if (classTypeParams is not null) + { + args.Append(" --class-type-params "); + args.Append(EscapeArg(classTypeParams)); + } + + if (cctorsToInit is not null && cctorsToInit.Count > 0) + { + args.Append(" --init-cctors "); + args.Append(EscapeArg(string.Join(";", cctorsToInit))); + } + + if (tier1) + { + args.Append(" --tier1"); + } + + startInfo.Arguments = args.ToString(); + + if (verbose) + { + Spectre.Console.AnsiConsole.MarkupLine($"[grey]Running: {Spectre.Console.Markup.Escape(executablePath)} {Spectre.Console.Markup.Escape(startInfo.Arguments)}[/]"); + Spectre.Console.AnsiConsole.MarkupLine($"[grey]DOTNET_JitDisasm={Spectre.Console.Markup.Escape(methodPattern)}[/]"); + Spectre.Console.AnsiConsole.WriteLine(); + } + + try + { + using var process = Process.Start(startInfo); + if (process is null) + { + return new JitResult { Success = false, Error = "Failed to start process" }; + } + + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + + await process.WaitForExitAsync(); + + var stdout = await stdoutTask; + var stderr = await stderrTask; + + // Parse the JIT output from stdout (JIT diagnostics can go to either stream) + // In tier1 mode, multiple compilations are captured; take only the last (Tier-1) + var disassembly = DisassemblyParser.Parse(stdout, lastOnly: tier1); + + if (string.IsNullOrWhiteSpace(disassembly)) + { + // Try stderr + disassembly = DisassemblyParser.Parse(stderr, lastOnly: tier1); + } + + if (string.IsNullOrWhiteSpace(disassembly)) + { + // Check if there's an error + if (!string.IsNullOrWhiteSpace(stderr) && stderr.Contains("Error")) + { + return new JitResult { Success = false, Error = stderr.Trim() }; + } + + // No disassembly found + return new JitResult + { + Success = false, + Error = "No disassembly output found. Method may not exist or JIT output was not captured.", + Output = $"stdout:\n{stdout}\n\nstderr:\n{stderr}" + }; + } + + return new JitResult + { + Success = true, + Output = disassembly + }; + } + catch (Exception ex) + { + return new JitResult { Success = false, Error = ex.Message }; + } + } + + private string BuildMethodPattern() + { + // Build pattern for DOTNET_JitDisasm + // Just use the method name - the JIT will match any method with this name + // Type filtering is done post-hoc by parsing the output + return methodName; + } + + /// + /// Returns the executable path and any prefix arguments needed (e.g., DLL path for dotnet host). + /// + private static (string FileName, string? ArgumentPrefix) GetExecutablePath() + { + // Get the path to the current executable + var currentExe = Environment.ProcessPath; + if (currentExe is not null && File.Exists(currentExe)) + { + // When running via "dotnet run", ProcessPath is the dotnet host, not our tool. + // Detect this by checking if it ends with "dotnet" (or "dotnet.exe"). + var exeName = Path.GetFileNameWithoutExtension(currentExe); + if (exeName.Equals("dotnet", StringComparison.OrdinalIgnoreCase)) + { + var assemblyLocation = typeof(JitRunner).Assembly.Location; + if (!string.IsNullOrEmpty(assemblyLocation)) + { + return ("dotnet", assemblyLocation); + } + } + + return (currentExe, null); + } + + // Fallback to assembly location + var location = typeof(JitRunner).Assembly.Location; + if (!string.IsNullOrEmpty(location)) + { + // For .dll, try to find the corresponding .exe + var directory = Path.GetDirectoryName(location)!; + var baseName = Path.GetFileNameWithoutExtension(location); + + // Try .exe first (Windows) + var exePath = Path.Combine(directory, baseName + ".exe"); + if (File.Exists(exePath)) + { + return (exePath, null); + } + + // Try without extension (Linux/macOS) + exePath = Path.Combine(directory, baseName); + if (File.Exists(exePath)) + { + return (exePath, null); + } + + // Use dotnet to run the dll + return ("dotnet", location); + } + + throw new InvalidOperationException("Could not determine executable path"); + } + + private static string EscapeArg(string arg) + { + if (arg.Contains(' ') || arg.Contains('"')) + { + return $"\"{arg.Replace("\"", "\\\"")}\""; + } + return arg; + } +} + +internal sealed class JitResult +{ + public bool Success { get; init; } + public string? Output { get; init; } + public string? Error { get; init; } + public string? Pass1Output { get; init; } + public IReadOnlyList DetectedCctors { get; init; } = []; +} diff --git a/tools/JitAsm/MethodResolver.cs b/tools/JitAsm/MethodResolver.cs new file mode 100644 index 000000000000..334d7fb14b5e --- /dev/null +++ b/tools/JitAsm/MethodResolver.cs @@ -0,0 +1,393 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Reflection; + +namespace JitAsm; + +internal sealed class MethodResolver(Assembly assembly) +{ + private static readonly Dictionary TypeAliases = new(StringComparer.OrdinalIgnoreCase) + { + ["bool"] = typeof(bool), + ["byte"] = typeof(byte), + ["sbyte"] = typeof(sbyte), + ["char"] = typeof(char), + ["short"] = typeof(short), + ["ushort"] = typeof(ushort), + ["int"] = typeof(int), + ["uint"] = typeof(uint), + ["long"] = typeof(long), + ["ulong"] = typeof(ulong), + ["float"] = typeof(float), + ["double"] = typeof(double), + ["decimal"] = typeof(decimal), + ["string"] = typeof(string), + ["object"] = typeof(object), + ["void"] = typeof(void), + ["nint"] = typeof(nint), + ["nuint"] = typeof(nuint), + }; + + public MethodInfo? ResolveMethod(string? typeName, string methodName, string? typeParams, string? classTypeParams = null) + { + var candidates = new List<(Type Type, MethodInfo Method)>(); + + if (typeName is not null) + { + // Search specific type + var type = ResolveType(typeName); + if (type is null) + { + return null; + } + + // If the type is a generic type definition and we have class type params, construct the concrete type + if (type.IsGenericTypeDefinition && classTypeParams is not null) + { + type = MakeGenericType(type, classTypeParams); + if (type is null) + { + return null; + } + } + + var methods = FindMethods(type, methodName); + candidates.AddRange(methods.Select(m => (type, m))); + + // Also search base types if no methods found (for inherited methods) + if (candidates.Count == 0) + { + var baseType = type.BaseType; + while (baseType is not null && candidates.Count == 0) + { + methods = FindMethods(baseType, methodName, includeInherited: true); + candidates.AddRange(methods.Select(m => (baseType, m))); + baseType = baseType.BaseType; + } + } + } + else + { + // Search all types + foreach (var type in assembly.GetTypes()) + { + var searchType = type; + + // If the type is a generic type definition and we have class type params, try to construct it + if (type.IsGenericTypeDefinition && classTypeParams is not null) + { + var typeParamCount = classTypeParams.Split(',').Length; + if (type.GetGenericArguments().Length == typeParamCount) + { + var constructed = MakeGenericType(type, classTypeParams); + if (constructed is not null) + { + searchType = constructed; + } + } + } + + var methods = FindMethods(searchType, methodName); + candidates.AddRange(methods.Select(m => (searchType, m))); + } + } + + if (candidates.Count == 0) + { + return null; + } + + bool verbose = Environment.GetEnvironmentVariable("JITASM_VERBOSE") == "1"; + if (verbose) + { + Console.Error.WriteLine($"[DEBUG] Candidates before filtering: {candidates.Count}"); + foreach (var c in candidates) + { + Console.Error.WriteLine($"[DEBUG] Method: {c.Method.Name}, IsGenericMethodDefinition: {c.Method.IsGenericMethodDefinition}, GenericArgCount: {c.Method.GetGenericArguments().Length}"); + } + } + + // If type params are specified, filter to only methods with matching generic param count + if (typeParams is not null) + { + var genericParamCount = typeParams.Split(',').Length; + if (verbose) + Console.Error.WriteLine($"[DEBUG] Looking for generic methods with {genericParamCount} type params"); + + var genericMatches = candidates.Where(c => c.Method.IsGenericMethodDefinition && + c.Method.GetGenericArguments().Length == genericParamCount).ToList(); + + if (verbose) + Console.Error.WriteLine($"[DEBUG] Generic matches: {genericMatches.Count}"); + + if (genericMatches.Count > 0) + { + candidates = genericMatches; + } + } + else + { + // Prefer non-generic methods if no type params specified + var nonGeneric = candidates.Where(c => !c.Method.IsGenericMethodDefinition).ToList(); + if (nonGeneric.Count > 0) + { + candidates = nonGeneric; + } + } + + if (candidates.Count == 0) + { + if (verbose) + Console.Error.WriteLine($"[DEBUG] No candidates after filtering"); + return null; + } + + if (verbose) + Console.Error.WriteLine($"[DEBUG] Final candidates: {candidates.Count}, calling MakeGenericIfNeeded"); + + // If there's only one candidate, use it + if (candidates.Count == 1) + { + return MakeGenericIfNeeded(candidates[0].Method, typeParams); + } + + // Return first match + return MakeGenericIfNeeded(candidates[0].Method, typeParams); + } + + private Type? MakeGenericType(Type genericTypeDefinition, string classTypeParams) + { + var typeNames = classTypeParams.Split(',', StringSplitOptions.TrimEntries); + var types = new Type[typeNames.Length]; + + bool verbose = Environment.GetEnvironmentVariable("JITASM_VERBOSE") == "1"; + + for (int i = 0; i < typeNames.Length; i++) + { + var resolved = ResolveTypeParam(typeNames[i]); + if (resolved is null) + { + if (verbose) + Console.Error.WriteLine($"[DEBUG] Failed to resolve type param: {typeNames[i]}"); + return null; + } + types[i] = resolved; + if (verbose) + Console.Error.WriteLine($"[DEBUG] Resolved type param {typeNames[i]} to {resolved.FullName}"); + } + + try + { + var result = genericTypeDefinition.MakeGenericType(types); + if (verbose) + Console.Error.WriteLine($"[DEBUG] Constructed generic type: {result.FullName}"); + return result; + } + catch (Exception ex) + { + if (verbose) + Console.Error.WriteLine($"[DEBUG] MakeGenericType failed: {ex.Message}"); + return null; + } + } + + private Type? ResolveType(string typeName) + { + // Try direct lookup + var type = assembly.GetType(typeName); + if (type is not null) return type; + + // Try with assembly name prefix removed if present + var types = assembly.GetTypes(); + + // Try exact match on FullName + type = types.FirstOrDefault(t => t.FullName == typeName); + if (type is not null) return type; + + // Try match on Name only + type = types.FirstOrDefault(t => t.Name == typeName); + if (type is not null) return type; + + // Try matching generic types by base name (without the `N suffix) + // e.g., "TransactionProcessorBase" should match "TransactionProcessorBase`1" + type = types.FirstOrDefault(t => t.IsGenericTypeDefinition && + (t.FullName?.StartsWith(typeName + "`") == true || + t.Name.StartsWith(typeName + "`"))); + if (type is not null) return type; + + // Also try matching with the ` syntax - the type might be specified as TypeName`1 + if (typeName.Contains('`')) + { + type = types.FirstOrDefault(t => t.FullName == typeName || t.Name == typeName); + if (type is not null) return type; + } + + // Try nested types + foreach (var t in types) + { + if (t.FullName is not null && typeName.StartsWith(t.FullName + "+")) + { + var nestedName = typeName[(t.FullName.Length + 1)..]; + var nested = t.GetNestedType(nestedName, BindingFlags.Public | BindingFlags.NonPublic); + if (nested is not null) return nested; + } + } + + // Search referenced assemblies for cross-assembly types + foreach (var refName in assembly.GetReferencedAssemblies()) + { + try + { + var refAssembly = Assembly.Load(refName); + type = refAssembly.GetType(typeName); + if (type is not null) return type; + + // Try matching generic types by base name (e.g., "ClockCache" matches "ClockCache`2") + Type[] refTypes; + try { refTypes = refAssembly.GetTypes(); } + catch (ReflectionTypeLoadException ex) { refTypes = ex.Types.Where(t => t is not null).ToArray()!; } + + type = refTypes.FirstOrDefault(t => + t.FullName == typeName || t.Name == typeName || + (t.IsGenericTypeDefinition && + (t.FullName?.StartsWith(typeName + "`") == true || + t.Name.StartsWith(typeName + "`")))); + if (type is not null) return type; + } + catch + { + // Skip assemblies that can't be loaded + } + } + + return null; + } + + private static IEnumerable FindMethods(Type type, string methodName, bool includeInherited = false) + { + var flags = BindingFlags.Public | BindingFlags.NonPublic | + BindingFlags.Instance | BindingFlags.Static; + + if (!includeInherited) + { + flags |= BindingFlags.DeclaredOnly; + } + + bool verbose = Environment.GetEnvironmentVariable("JITASM_VERBOSE") == "1"; + var methods = type.GetMethods(flags).Where(m => m.Name == methodName).ToList(); + if (verbose) + { + Console.Error.WriteLine($"[DEBUG] FindMethods on {type.FullName} for '{methodName}': found {methods.Count} methods"); + if (methods.Count == 0) + { + // List some methods to help debug + var allMethods = type.GetMethods(flags).Where(m => m.Name.Contains("Evm") || m.Name.Contains("Execute")).Take(10); + Console.Error.WriteLine($"[DEBUG] Sample methods containing 'Evm' or 'Execute': {string.Join(", ", allMethods.Select(m => m.Name))}"); + } + } + + return methods; + } + + private MethodInfo? MakeGenericIfNeeded(MethodInfo method, string? typeParams) + { + bool verbose = Environment.GetEnvironmentVariable("JITASM_VERBOSE") == "1"; + + if (typeParams is null || !method.IsGenericMethodDefinition) + { + if (verbose) + Console.Error.WriteLine($"[DEBUG] MakeGenericIfNeeded: returning method as-is (typeParams={typeParams}, IsGenericMethodDefinition={method.IsGenericMethodDefinition})"); + return method; + } + + var typeNames = typeParams.Split(',', StringSplitOptions.TrimEntries); + var types = new Type[typeNames.Length]; + + if (verbose) + Console.Error.WriteLine($"[DEBUG] MakeGenericIfNeeded: resolving {typeNames.Length} type params: {string.Join(", ", typeNames)}"); + + for (int i = 0; i < typeNames.Length; i++) + { + var resolved = ResolveTypeParam(typeNames[i]); + if (resolved is null) + { + if (verbose) + Console.Error.WriteLine($"[DEBUG] MakeGenericIfNeeded: failed to resolve type param '{typeNames[i]}'"); + return null; + } + types[i] = resolved; + if (verbose) + Console.Error.WriteLine($"[DEBUG] MakeGenericIfNeeded: resolved '{typeNames[i]}' to {resolved.FullName}"); + } + + try + { + var result = method.MakeGenericMethod(types); + if (verbose) + Console.Error.WriteLine($"[DEBUG] MakeGenericIfNeeded: success, created {result}"); + return result; + } + catch (Exception ex) + { + if (verbose) + Console.Error.WriteLine($"[DEBUG] MakeGenericIfNeeded: MakeGenericMethod failed: {ex.Message}"); + return null; + } + } + + private Type? ResolveTypeParam(string typeName) + { + // Check aliases first + if (TypeAliases.TryGetValue(typeName, out var aliasType)) + { + return aliasType; + } + + // Try the target assembly + var type = ResolveType(typeName); + if (type is not null) return type; + + // Try referenced assemblies + foreach (var refName in assembly.GetReferencedAssemblies()) + { + try + { + var refAssembly = Assembly.Load(refName); + type = refAssembly.GetType(typeName); + if (type is not null) return type; + + // Try by short name + type = refAssembly.GetTypes().FirstOrDefault(t => t.Name == typeName || t.FullName == typeName); + if (type is not null) return type; + } + catch + { + // Skip assemblies that can't be loaded + } + } + + // Try Type.GetType as last resort + return Type.GetType(typeName); + } + + public IEnumerable FindAllMethods(string? typeName, string methodName) + { + if (typeName is not null) + { + var type = ResolveType(typeName); + if (type is not null) + { + return FindMethods(type, methodName); + } + return []; + } + + var results = new List(); + foreach (var type in assembly.GetTypes()) + { + results.AddRange(FindMethods(type, methodName)); + } + return results; + } +} diff --git a/tools/JitAsm/Program.cs b/tools/JitAsm/Program.cs new file mode 100644 index 000000000000..deb928163f70 --- /dev/null +++ b/tools/JitAsm/Program.cs @@ -0,0 +1,581 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.CommandLine; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.Loader; +using System.Text.Json; +using Spectre.Console; + +namespace JitAsm; + +internal static class Program +{ + private static readonly Option AssemblyOption = new("-a", "--assembly") + { + Description = "Path to the assembly containing the method", + Required = true + }; + + private static readonly Option TypeOption = new("-t", "--type") + { + Description = "Fully qualified type name (optional, will search all types if not specified)" + }; + + private static readonly Option MethodOption = new("-m", "--method") + { + Description = "Method name to disassemble", + Required = true + }; + + private static readonly Option TypeParamsOption = new("--type-params") + { + Description = "Method generic type parameters (comma-separated type names)" + }; + + private static readonly Option ClassTypeParamsOption = new("--class-type-params") + { + Description = "Class generic type parameters (comma-separated type names, e.g., for TransactionProcessorBase`1)" + }; + + private static readonly Option SkipCctorOption = new("--skip-cctor-detection") + { + Description = "Skip automatic static constructor detection (single pass only)" + }; + + private static readonly Option FullOptsOption = new("--fullopts") + { + Description = "Use single-pass FullOpts compilation (DOTNET_TieredCompilation=0) instead of the default Tier-1 + PGO simulation" + }; + + private static readonly Option NoAnnotateOption = new("--no-annotate") + { + Description = "Disable per-instruction annotations (throughput, latency, uops, ports from uops.info)" + }; + + private static readonly Option ArchOption = new("--arch") + { + Description = "Target microarchitecture for annotations (default: zen4). Options: zen4, zen3, zen2, alder-lake, rocket-lake, ice-lake, tiger-lake, skylake", + DefaultValueFactory = _ => "zen4" + }; + + private static readonly Option VerboseOption = new("-v", "--verbose") + { + Description = "Show resolution details and both passes" + }; + + private static int Main(string[] args) + { + // Check if running in internal runner mode + if (args.Length > 0 && args[0] == "--internal-runner") + { + return RunInternalRunner(args.Skip(1).ToArray()); + } + + return RunCli(args); + } + + private static int RunCli(string[] args) + { + var rootCommand = new RootCommand("JIT Assembly Disassembler - Generate JIT assembly output for .NET methods") + { + AssemblyOption, + TypeOption, + MethodOption, + TypeParamsOption, + ClassTypeParamsOption, + SkipCctorOption, + FullOptsOption, + NoAnnotateOption, + ArchOption, + VerboseOption + }; + + int exitCode = 0; + rootCommand.SetAction(parseResult => + { + var assembly = parseResult.GetValue(AssemblyOption)!; + var typeName = parseResult.GetValue(TypeOption); + var methodName = parseResult.GetValue(MethodOption)!; + var typeParams = parseResult.GetValue(TypeParamsOption); + var classTypeParams = parseResult.GetValue(ClassTypeParamsOption); + var skipCctor = parseResult.GetValue(SkipCctorOption); + var fullOpts = parseResult.GetValue(FullOptsOption); + var annotate = !parseResult.GetValue(NoAnnotateOption); + var arch = parseResult.GetValue(ArchOption)!; + var verbose = parseResult.GetValue(VerboseOption); + + exitCode = Execute(assembly, typeName, methodName, typeParams, classTypeParams, skipCctor, fullOpts, annotate, arch, verbose); + }); + + int parseExitCode = rootCommand.Parse(args).Invoke(); + return parseExitCode != 0 ? parseExitCode : exitCode; + } + + private static int Execute(FileInfo assembly, string? typeName, string methodName, string? typeParams, string? classTypeParams, bool skipCctor, bool fullOpts, bool annotate, string arch, bool verbose) + { + if (!assembly.Exists) + { + AnsiConsole.MarkupLine($"[red]Error:[/] Assembly not found: {assembly.FullName}"); + return 1; + } + + bool tier1 = !fullOpts; + + if (verbose) + { + AnsiConsole.MarkupLine($"[blue]Assembly:[/] {assembly.FullName}"); + AnsiConsole.MarkupLine($"[blue]Type:[/] {typeName ?? "(search all)"}"); + AnsiConsole.MarkupLine($"[blue]Method:[/] {methodName}"); + if (classTypeParams is not null) + { + AnsiConsole.MarkupLine($"[blue]Class Type Parameters:[/] {classTypeParams}"); + } + if (typeParams is not null) + { + AnsiConsole.MarkupLine($"[blue]Method Type Parameters:[/] {typeParams}"); + } + AnsiConsole.MarkupLine(tier1 + ? "[blue]Mode:[/] Tier-1 + Dynamic PGO (default)" + : "[blue]Mode:[/] FullOpts (TieredCompilation=0)"); + AnsiConsole.WriteLine(); + } + + var runner = new JitRunner(assembly.FullName, typeName, methodName, typeParams, classTypeParams, verbose, tier1); + + InstructionDb? instructionDb = annotate ? LoadOrBuildInstructionDb(arch, verbose) : null; + + JitResult result = (skipCctor ? + runner.RunSinglePassAsync() : + runner.RunTwoPassAsync()) + .GetAwaiter().GetResult(); + + return OutputResult(result, verbose, instructionDb); + } + + private static int OutputResult(JitResult result, bool verbose, InstructionDb? instructionDb = null) + { + if (!result.Success) + { + AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(result.Error ?? "Unknown error")}"); + if (!string.IsNullOrEmpty(result.Output)) + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[yellow]Output:[/]"); + AnsiConsole.WriteLine(result.Output); + } + return 1; + } + + if (verbose && result.DetectedCctors.Count > 0) + { + AnsiConsole.MarkupLine("[blue]Detected static constructors:[/]"); + foreach (var cctor in result.DetectedCctors) + { + AnsiConsole.MarkupLine($" [grey]- {Markup.Escape(cctor)}[/]"); + } + AnsiConsole.WriteLine(); + } + + if (verbose && result.Pass1Output is not null && result.DetectedCctors.Count > 0) + { + AnsiConsole.MarkupLine("[blue]Pass 1 Output (before cctor initialization):[/]"); + AnsiConsole.WriteLine(result.Pass1Output); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[blue]Pass 2 Output (after cctor initialization):[/]"); + } + + string output = result.Output ?? string.Empty; + if (instructionDb is not null) + { + output = InstructionAnnotator.Annotate(output, instructionDb); + } + + Console.WriteLine(output); + return 0; + } + + private static InstructionDb? LoadOrBuildInstructionDb(string arch, bool verbose) + { + string toolDir = AppContext.BaseDirectory; + // Look for files relative to the project source directory first, then the binary directory + string projectDir = Path.GetFullPath(Path.Combine(toolDir, "..", "..", "..", "..")); + if (!File.Exists(Path.Combine(projectDir, "JitAsm.csproj"))) + { + // Fallback: try to find the project directory from the current working directory + string cwd = Directory.GetCurrentDirectory(); + if (File.Exists(Path.Combine(cwd, "tools", "JitAsm", "JitAsm.csproj"))) + projectDir = Path.Combine(cwd, "tools", "JitAsm"); + else if (File.Exists(Path.Combine(cwd, "JitAsm.csproj"))) + projectDir = cwd; + else + projectDir = toolDir; + } + + string dbPath = Path.Combine(projectDir, "instructions.db"); + string xmlPath = Path.Combine(projectDir, "instructions.xml"); + + // Check if we have a cached .db for this architecture + if (File.Exists(dbPath)) + { + try + { + var db = InstructionDb.Load(dbPath); + string targetArch = InstructionDbBuilder.ResolveArchName(arch); + if (db.ArchName == targetArch) + { + if (verbose) + AnsiConsole.MarkupLine($"[blue]Loaded instruction database:[/] {dbPath} ({db.Count} entries for {db.ArchName})"); + return db; + } + + if (verbose) + AnsiConsole.MarkupLine($"[yellow]Cached DB is for {db.ArchName}, need {targetArch}. Rebuilding...[/]"); + } + catch (Exception ex) + { + if (verbose) + AnsiConsole.MarkupLine($"[yellow]Failed to load cached DB: {Markup.Escape(ex.Message)}. Rebuilding...[/]"); + } + } + + // Build from XML + if (!File.Exists(xmlPath)) + { + AnsiConsole.MarkupLine("[yellow]Instruction database not found. Annotations disabled.[/]"); + AnsiConsole.MarkupLine("[yellow]Download it with:[/]"); + AnsiConsole.MarkupLine($" curl -o {Markup.Escape(xmlPath)} https://uops.info/instructions.xml"); + AnsiConsole.MarkupLine("[yellow]Or use --no-annotate to suppress this warning.[/]"); + return null; + } + + AnsiConsole.MarkupLine($"[blue]Building instruction database for {arch}...[/]"); + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var builtDb = InstructionDbBuilder.Build(xmlPath, arch); + stopwatch.Stop(); + + AnsiConsole.MarkupLine($"[blue]Built {builtDb.Count} instruction entries in {stopwatch.Elapsed.TotalSeconds:F1}s[/]"); + + try + { + builtDb.Save(dbPath); + if (verbose) + AnsiConsole.MarkupLine($"[blue]Saved to:[/] {dbPath}"); + } + catch (Exception ex) + { + if (verbose) + AnsiConsole.MarkupLine($"[yellow]Warning: Could not save DB cache: {Markup.Escape(ex.Message)}[/]"); + } + + return builtDb; + } + + private static int RunInternalRunner(string[] args) + { + // Parse internal runner arguments + // Format: [--type ] [--type-params ] [--class-type-params ] [--init-cctors ] + if (args.Length < 2) + { + Console.Error.WriteLine("Internal runner requires assembly and method arguments"); + return 1; + } + + var assemblyPath = args[0]; + var methodName = args[1]; + string? typeName = null; + string? typeParams = null; + string? classTypeParams = null; + List cctorsToInit = []; + bool tier1 = false; + + for (int i = 2; i < args.Length; i++) + { + switch (args[i]) + { + case "--type" when i + 1 < args.Length: + typeName = args[++i]; + break; + case "--type-params" when i + 1 < args.Length: + typeParams = args[++i]; + break; + case "--class-type-params" when i + 1 < args.Length: + classTypeParams = args[++i]; + break; + case "--init-cctors" when i + 1 < args.Length: + cctorsToInit = args[++i].Split(';', StringSplitOptions.RemoveEmptyEntries).ToList(); + break; + case "--tier1": + tier1 = true; + break; + } + } + + // Set up dependency resolution for the target assembly. + // Without this, RuntimeHelpers.PrepareMethod silently fails when the + // JIT can't resolve types from transitive NuGet packages (e.g. Nethermind.Int256). + bool verbose = Environment.GetEnvironmentVariable("JITASM_VERBOSE") == "1"; + + // Build assembly name → file path mapping from the deps.json file. + // This resolves both project references (in the same directory) and + // NuGet packages (from the global packages cache). + var assemblyDir = Path.GetDirectoryName(Path.GetFullPath(assemblyPath))!; + var assemblyMap = BuildAssemblyMap(assemblyPath, assemblyDir, verbose); + + AssemblyLoadContext.Default.Resolving += (context, name) => + { + // First check the target assembly's directory + var localPath = Path.Combine(assemblyDir, name.Name + ".dll"); + if (File.Exists(localPath)) + { + if (verbose) + Console.Error.WriteLine($"[DEBUG] AssemblyResolve: {name.Name} → {localPath} (local)"); + return context.LoadFromAssemblyPath(localPath); + } + + // Then check deps.json mapped paths + if (assemblyMap.TryGetValue(name.Name!, out var mappedPath) && File.Exists(mappedPath)) + { + if (verbose) + Console.Error.WriteLine($"[DEBUG] AssemblyResolve: {name.Name} → {mappedPath} (deps.json)"); + return context.LoadFromAssemblyPath(mappedPath); + } + + if (verbose) + Console.Error.WriteLine($"[DEBUG] AssemblyResolve: {name.Name} → NOT FOUND"); + return null; + }; + + try + { + var assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(Path.GetFullPath(assemblyPath)); + + if (verbose) + { + Console.Error.WriteLine($"[DEBUG] Loaded assembly: {assembly.FullName}"); + Console.Error.WriteLine($"[DEBUG] Type name: {typeName}"); + Console.Error.WriteLine($"[DEBUG] Method name: {methodName}"); + Console.Error.WriteLine($"[DEBUG] Type params: {typeParams}"); + Console.Error.WriteLine($"[DEBUG] Class type params: {classTypeParams}"); + } + + // Initialize static constructors if requested + foreach (var cctorTypeName in cctorsToInit) + { + var cctorType = ResolveType(assembly, cctorTypeName, verbose); + if (cctorType is not null) + { + if (verbose) + Console.Error.WriteLine($"[DEBUG] Running cctor for: {cctorType.FullName}"); + RuntimeHelpers.RunClassConstructor(cctorType.TypeHandle); + } + else if (verbose) + { + Console.Error.WriteLine($"[DEBUG] WARNING: Could not resolve cctor type: {cctorTypeName}"); + } + } + + // Resolve the method + var resolver = new MethodResolver(assembly); + var method = resolver.ResolveMethod(typeName, methodName, typeParams, classTypeParams); + + if (method is null) + { + if (verbose) + { + var types = assembly.GetTypes().Where(t => t.Name.Contains(typeName ?? "")).Take(5); + Console.Error.WriteLine($"[DEBUG] Possible types: {string.Join(", ", types.Select(t => t.FullName))}"); + } + Console.Error.WriteLine($"Could not resolve method: {methodName}"); + return 1; + } + + if (tier1) + { + var parameters = method.GetParameters(); + var invokeArgs = new object?[parameters.Length]; + object? target = method.IsStatic ? null : TryCreateInstance(method.DeclaringType!); + + void InvokeN(int count) + { + for (int i = 0; i < count; i++) + { + try { method.Invoke(target, invokeArgs); } + catch { /* Expected - args are null/default */ } + } + } + + if (verbose) + Console.Error.WriteLine("[DEBUG] Phase 1: Invoking to trigger Tier-0 → Instrumented Tier-0..."); + InvokeN(50); + Thread.Sleep(1000); + + if (verbose) + Console.Error.WriteLine("[DEBUG] Phase 2: Invoking to trigger Instrumented Tier-0 → Tier-1..."); + InvokeN(50); + Thread.Sleep(2000); + + if (verbose) + Console.Error.WriteLine("[DEBUG] Phase 3: Final invocations to ensure Tier-1 is installed..."); + InvokeN(50); + Thread.Sleep(1000); + } + else + { + RuntimeHelpers.PrepareMethod(method.MethodHandle); + if (verbose) + Console.Error.WriteLine($"[DEBUG] PrepareMethod completed for {method.DeclaringType?.FullName}:{method.Name}"); + + // Also invoke the method to ensure all code paths are JIT-compiled. + // PrepareMethod alone may not trigger DOTNET_JitDisasm output for + // methods from dynamically loaded assemblies. + var parameters = method.GetParameters(); + var invokeArgs = new object?[parameters.Length]; + object? target = method.IsStatic ? null : TryCreateInstance(method.DeclaringType!); + try { method.Invoke(target, invokeArgs); } + catch { /* Expected - args are null/default */ } + if (verbose) + Console.Error.WriteLine("[DEBUG] Method invocation completed"); + } + + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + return 1; + } + } + + private static object? TryCreateInstance(Type type) + { + try + { + return RuntimeHelpers.GetUninitializedObject(type); + } + catch + { + return null; + } + } + + private static Type? ResolveType(Assembly assembly, string typeName, bool verbose = false) + { + // Try direct lookup in target assembly first + var type = assembly.GetType(typeName); + if (type is not null) return type; + + // Try searching all types in target assembly + foreach (var t in assembly.GetTypes()) + { + if (t.FullName == typeName || t.Name == typeName) + { + return t; + } + } + + // Search referenced assemblies (types often come from other assemblies, + // e.g., Nethermind.Core types referenced from Nethermind.Evm) + foreach (var refName in assembly.GetReferencedAssemblies()) + { + try + { + var refAssembly = Assembly.Load(refName); + type = refAssembly.GetType(typeName); + if (type is not null) + { + if (verbose) + Console.Error.WriteLine($"[DEBUG] Resolved cctor type '{typeName}' from referenced assembly {refName.Name}"); + return type; + } + + // Try by short name + foreach (var t in refAssembly.GetTypes()) + { + if (t.FullName == typeName || t.Name == typeName) + { + if (verbose) + Console.Error.WriteLine($"[DEBUG] Resolved cctor type '{typeName}' from referenced assembly {refName.Name} (by scan)"); + return t; + } + } + } + catch + { + // Skip assemblies that can't be loaded + } + } + + // Try Type.GetType as last resort (requires assembly-qualified names for cross-assembly) + return Type.GetType(typeName); + } + + /// + /// Build a mapping from assembly name to file path using the deps.json file. + /// Resolves NuGet package references via the global packages cache. + /// + private static Dictionary BuildAssemblyMap(string assemblyPath, string assemblyDir, bool verbose) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + string depsPath = Path.ChangeExtension(assemblyPath, ".deps.json"); + if (!File.Exists(depsPath)) + return map; + + string nugetPackages = Environment.GetEnvironmentVariable("NUGET_PACKAGES") + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "packages"); + + try + { + using var stream = File.OpenRead(depsPath); + using var doc = JsonDocument.Parse(stream); + + var root = doc.RootElement; + if (!root.TryGetProperty("targets", out var targets)) + return map; + + // Get the first (and usually only) target + foreach (var target in targets.EnumerateObject()) + { + foreach (var package in target.Value.EnumerateObject()) + { + if (!package.Value.TryGetProperty("runtime", out var runtime)) + continue; + + foreach (var dll in runtime.EnumerateObject()) + { + string dllRelativePath = dll.Name; // e.g. "lib/net10.0/Nethermind.Int256.dll" + string asmName = Path.GetFileNameWithoutExtension(dllRelativePath); + + // Skip if already in the local directory + if (File.Exists(Path.Combine(assemblyDir, asmName + ".dll"))) + continue; + + // Resolve from NuGet cache: packages/{id}/{version}/{path} + string packageId = package.Name; // e.g. "Nethermind.Numerics.Int256/1.4.0" + string[] parts = packageId.Split('/'); + if (parts.Length == 2) + { + string fullPath = Path.Combine(nugetPackages, parts[0].ToLowerInvariant(), parts[1], dllRelativePath); + if (File.Exists(fullPath)) + { + map[asmName] = fullPath; + if (verbose) + Console.Error.WriteLine($"[DEBUG] Deps map: {asmName} → {fullPath}"); + } + } + } + } + break; // Only process the first target + } + } + catch (Exception ex) + { + if (verbose) + Console.Error.WriteLine($"[DEBUG] Failed to parse deps.json: {ex.Message}"); + } + + return map; + } +} + diff --git a/tools/JitAsm/README.md b/tools/JitAsm/README.md new file mode 100644 index 000000000000..705821f64be3 --- /dev/null +++ b/tools/JitAsm/README.md @@ -0,0 +1,494 @@ +# JitAsm + +A tool for viewing JIT-compiled assembly output for .NET methods. Useful for analyzing code generation, verifying optimizations, and comparing different implementations. + +## How It Works + +JitAsm spawns a child process with JIT diagnostic environment variables (`DOTNET_JitDisasm`) to capture disassembly output. + +By default, the tool simulates **Tier-1 recompilation with Dynamic PGO** — the same compilation tier that runs in production after warm-up. This produces the most representative assembly: smaller code, eliminated static base helpers, and PGO-guided branch layout. + +### Compilation Tiers + +The .NET runtime compiles methods through multiple stages: + +| Stage | Description | Typical Size | +|-------|-------------|-------------| +| **Tier-0** | Quick JIT, minimal optimization (`minopt`) | Largest | +| **Instrumented Tier-0** | Tier-0 + PGO probes for profiling | Larger still | +| **Tier-1 + PGO** (default) | Full optimization with profile data | **Smallest** | +| **FullOpts** (`--fullopts`) | Full optimization, no PGO, no tiering | Middle | + +Key difference between Tier-1 and FullOpts: +- **FullOpts** (`DOTNET_TieredCompilation=0`) compiles in a single pass. Cross-module static base helpers (`CORINFO_HELP_GET_NONGCSTATIC_BASE`) persist because the JIT hasn't resolved the static base address yet. +- **Tier-1 + PGO** compiles after Tier-0 has executed. By then, static bases are resolved, and the JIT can embed addresses directly — eliminating the helper entirely. PGO data also improves branch layout and inlining decisions. + +### Two-Pass Static Constructor Handling + +When a method references static fields, the JIT may include static constructor initialization checks (`CORINFO_HELP_GET_NONGCSTATIC_BASE`, `CORINFO_HELP_GETSHARED_NONGCSTATIC_BASE`). JitAsm automatically detects these and runs a second pass with static constructors pre-initialized, showing the steady-state optimized code path. + +In Tier-1 mode (default), many of these helpers are eliminated naturally since the static bases are already resolved by Tier-0 execution. + +### Tier-1 Simulation + +The tool drives through all PGO compilation stages: + +1. **Invoke** method via reflection (triggers Tier-0 JIT + installs call counting stubs) +2. **Wait** for Instrumented Tier-0 recompilation (PGO profiling version) +3. **Invoke** again (triggers call counter through instrumented code) +4. **Wait** for Tier-1 recompilation (fully optimized with PGO data) +5. **Capture** the final Tier-1 assembly output + +Critical env var: `DOTNET_TC_CallCountingDelayMs=0` — without this, counting stubs aren't installed in time and Tier-1 never triggers. + +## Usage + +```bash +dotnet run --project tools/JitAsm -c Release -- [options] +``` + +### Options + +| Option | Description | +|--------|-------------| +| `-a, --assembly ` | Path to the assembly containing the method (required) | +| `-t, --type ` | Fully qualified type name (optional, searches all types if not specified) | +| `-m, --method ` | Method name to disassemble (required) | +| `--type-params ` | Method generic type parameters (comma-separated) | +| `--class-type-params ` | Class generic type parameters (comma-separated, for generic containing types) | +| `--fullopts` | Use single-pass FullOpts compilation (`TieredCompilation=0`) instead of Tier-1 + PGO | +| `--no-annotate` | Disable per-instruction annotations (throughput, latency, uops, ports). Annotations are **on by default** | +| `--arch ` | Target microarchitecture for annotations (default: `zen4`). See [Supported Architectures](#supported-architectures) | +| `--skip-cctor-detection` | Skip automatic static constructor detection (single pass only) | +| `-v, --verbose` | Show resolution details and both passes | + +### Default Mode: Tier-1 + PGO + +By default, the tool simulates Tier-1 recompilation with Dynamic PGO. This is the compilation tier code runs at in production and produces the most optimized output. + +```bash +# Default: Tier-1 + PGO (production-representative) +dotnet run --project tools/JitAsm -c Release -- -a path/to/assembly.dll -m MethodName +``` + +### FullOpts Mode + +Use `--fullopts` when you need the older single-pass compilation. This is faster (no invocation/sleep cycle) but may show static base helpers and lack PGO-guided optimizations. + +```bash +# FullOpts: single compilation, no PGO +dotnet run --project tools/JitAsm -c Release -- -a path/to/assembly.dll -m MethodName --fullopts +``` + +## Prerequisites + +Build the target project in Release configuration before disassembling: + +```bash +dotnet build src/Nethermind/Nethermind.Evm -c Release +``` + +Assemblies are output to `src/Nethermind/artifacts/bin/{ProjectName}/release/{ProjectName}.dll`. For example: +- `src/Nethermind/artifacts/bin/Nethermind.Evm/release/Nethermind.Evm.dll` +- `src/Nethermind/artifacts/bin/Nethermind.Core/release/Nethermind.Core.dll` + +## Platform Notes (Windows) + +On Windows, follow these rules to avoid argument parsing errors: + +- **Avoid quoted strings** around paths and type parameters — they can cause `'' was not matched` errors +- **Use forward slashes** in paths (works on both Windows and Linux) +- **Put the entire command on a single line** — avoid line continuations (`\`) +- Use absolute paths when possible for clarity + +```bash +# Good (Windows) +dotnet run --project tools/JitAsm -c Release -- -a D:/GitHub/nethermind/src/Nethermind/artifacts/bin/Nethermind.Evm/release/Nethermind.Evm.dll -t Nethermind.Evm.EvmStack -m PushBytesRef + +# Bad (may fail on Windows) +dotnet run --project tools/JitAsm -c Release -- \ + -a "src/Nethermind/artifacts/bin/Nethermind.Evm/release/Nethermind.Evm.dll" \ + -t "Nethermind.Evm.EvmStack" \ + -m PushBytesRef +``` + +On Linux/macOS, both styles work fine. + +## Examples + +### Basic Usage + +```bash +# Disassemble a simple method (searches all types, default Tier-1 + PGO) +dotnet run --project tools/JitAsm -c Release -- -a src/Nethermind/artifacts/bin/Nethermind.Evm/release/Nethermind.Evm.dll -m PushBytesRef +``` + +### With Type Specification + +```bash +# Specify the containing type to narrow the search +dotnet run --project tools/JitAsm -c Release -- -a src/Nethermind/artifacts/bin/Nethermind.Evm/release/Nethermind.Evm.dll -t Nethermind.Evm.EvmStack -m PushBytesRef +``` + +### Generic Methods (Method-Level Type Parameters) + +Use `--type-params` for type parameters on the method itself: + +```bash +# PushBytes(...) where TTracingInst = OffFlag +dotnet run --project tools/JitAsm -c Release -- -a src/Nethermind/artifacts/bin/Nethermind.Evm/release/Nethermind.Evm.dll -t Nethermind.Evm.EvmStack -m PushBytes --type-params Nethermind.Core.OffFlag +``` + +### Generic Classes (Class-Level Type Parameters) + +Use `--class-type-params` when the containing class is generic. This is separate from `--type-params` which is for method-level generics. Generic type names can omit the backtick arity suffix (e.g., `TransactionProcessorBase` matches `TransactionProcessorBase`1`). + +```bash +# TransactionProcessorBase.Execute(...) +dotnet run --project tools/JitAsm -c Release -- -a src/Nethermind/artifacts/bin/Nethermind.Evm/release/Nethermind.Evm.dll -t Nethermind.Evm.TransactionProcessing.TransactionProcessorBase -m Execute --class-type-params Nethermind.Evm.GasPolicy.EthereumGasPolicy --type-params Nethermind.Core.OffFlag +``` + +### Multiple Generic Parameters + +Use comma-separated type names (no spaces after commas): + +```bash +dotnet run --project tools/JitAsm -c Release -- -a src/Nethermind/artifacts/bin/Nethermind.Evm/release/Nethermind.Evm.dll -m SomeGenericMethod --type-params System.Int32,System.String +``` + +### EVM Instruction Example + +Disassemble the MUL opcode implementation with specific gas policy and tracing flags: + +```bash +dotnet run --project tools/JitAsm -c Release -- -a src/Nethermind/artifacts/bin/Nethermind.Evm/release/Nethermind.Evm.dll -t Nethermind.Evm.EvmInstructions -m InstructionMath2Param --type-params Nethermind.Evm.GasPolicy.EthereumGasPolicy,Nethermind.Evm.EvmInstructions+OpMul,Nethermind.Core.OffFlag +``` + +This shows the JIT output for `InstructionMath2Param`: +- `EthereumGasPolicy` - Standard Ethereum gas accounting +- `OpMul` - The multiplication operation (nested type, use `+` syntax) +- `OffFlag` - Tracing disabled (allows dead code elimination of tracing paths) + +Other math operations can be viewed by replacing `OpMul` with: `OpAdd`, `OpSub`, `OpDiv`, `OpMod`, `OpSDiv`, `OpSMod`, `OpLt`, `OpGt`, `OpSLt`, `OpSGt`, `OpEq`, `OpAnd`, `OpOr`, `OpXor`. + +### Type Aliases + +Common C# type aliases are supported: + +```bash +# These are equivalent +--type-params int +--type-params System.Int32 +``` + +Supported aliases: `bool`, `byte`, `sbyte`, `char`, `short`, `ushort`, `int`, `uint`, `long`, `ulong`, `float`, `double`, `decimal`, `string`, `object`, `nint`, `nuint` + +### Verbose Mode + +```bash +# Show detailed output including cctor detection and compilation tier info +dotnet run --project tools/JitAsm -c Release -- -a src/Nethermind/artifacts/bin/Nethermind.Evm/release/Nethermind.Evm.dll -m PushBytesRef -v +``` + +### Skip Static Constructor Detection + +```bash +# Single pass only (faster, but may show cctor overhead) +dotnet run --project tools/JitAsm -c Release -- -a src/Nethermind/artifacts/bin/Nethermind.Evm/release/Nethermind.Evm.dll -m SomeMethod --skip-cctor-detection +``` + +### FullOpts vs Tier-1 Comparison + +Compare the same method under different compilation modes to see what Tier-1 + PGO eliminates: + +```bash +# Tier-1 + PGO (default) — production-representative +dotnet run --project tools/JitAsm -c Release -- -a src/Nethermind/artifacts/bin/Nethermind.Evm/release/Nethermind.Evm.dll -t Nethermind.Evm.BlockExecutionContext -m GetBlobBaseFee > tier1.asm 2>&1 + +# FullOpts — single-pass, no PGO +dotnet run --project tools/JitAsm -c Release -- -a src/Nethermind/artifacts/bin/Nethermind.Evm/release/Nethermind.Evm.dll -t Nethermind.Evm.BlockExecutionContext -m GetBlobBaseFee --fullopts > fullopts.asm 2>&1 + +# Compare +diff tier1.asm fullopts.asm +``` + +Example result for `GetBlobBaseFee`: +- **FullOpts**: 356 bytes, has `CORINFO_HELP_GET_NONGCSTATIC_BASE` +- **Tier-1 + PGO**: 236 bytes (34% smaller), helper eliminated entirely + +## Example Output + +Default Tier-1 + PGO output: + +``` +; Assembly listing for method Namespace.Type:Method() (Tier1) +; Emitting BLENDED_CODE for generic X64 + VEX + EVEX on Windows +; Tier1 code ← Tier-1 recompilation (production tier) +; optimized code +; optimized using Dynamic PGO +; rsp based frame +; partially interruptible +; with Dynamic PGO: fgCalledCount is 50 + +G_M000_IG01: ;; offset=0x0000 + sub rsp, 40 + ... + +; Total bytes of code 236 +``` + +FullOpts output (with `--fullopts`): + +``` +; Assembly listing for method Namespace.Type:Method() (FullOpts) +; FullOpts code ← Single-pass FullOpts +; optimized code +; No PGO data ← No profile data available + ... + +; Total bytes of code 356 +``` + +## Instruction Annotations + +Per-instruction performance data from [uops.info](https://uops.info) is included **by default**, showing throughput, latency, micro-op count, and execution port usage inline with the assembly output. Use `--no-annotate` to disable. + +### Setup + +Download the uops.info instruction database (110MB, one-time): + +```bash +curl -o tools/JitAsm/instructions.xml https://uops.info/instructions.xml +``` + +On first use, the XML is preprocessed into a compact binary cache (`instructions.db`, ~1-2MB). Subsequent runs load from the cache. + +### Usage + +```bash +# Annotations are on by default (zen4) +dotnet run --project tools/JitAsm -c Release -- -a path/to/assembly.dll -m MethodName + +# Use a different architecture +dotnet run --project tools/JitAsm -c Release -- -a path/to/assembly.dll -m MethodName --arch alder-lake + +# Disable annotations +dotnet run --project tools/JitAsm -c Release -- -a path/to/assembly.dll -m MethodName --no-annotate +``` + +### Annotation Format + +Each instruction line gets an end-of-line annotation: + +``` + add rax, rcx ; [TP:0.25 | Lat: 1 | Uops:1] + mov qword ptr [rbp+10h], rax ; [TP:0.50 | Lat:11 | Uops:1] + vmovdqu ymm0, ymmword ptr [rsi] ; [TP:0.50 | Lat: 8 | Uops:1 | 1*FP_LD] +``` + +| Field | Meaning | +|-------|---------| +| `TP` | Reciprocal throughput (cycles per instruction). Lower = faster. | +| `Lat` | Latency (cycles from input ready to output ready). Matters for dependency chains. | +| `Uops` | Micro-op count. Fewer = less pressure on the execution engine. | +| Ports | Execution port usage (e.g., `1*p0156`). Shows which functional units are used. | + +### Supported Architectures + +| `--arch` value | CPU | Description | +|----------------|-----|-------------| +| `zen4` (default) | AMD Zen 4 | Ryzen 7000 / EPYC 9004 | +| `zen3` | AMD Zen 3 | Ryzen 5000 / EPYC 7003 | +| `zen2` | AMD Zen 2 | Ryzen 3000 / EPYC 7002 | +| `alder-lake` | Intel ADL-P | 12th Gen Core (P-cores) | +| `rocket-lake` | Intel RKL | 11th Gen Core | +| `ice-lake` | Intel ICL | 10th Gen Core | +| `tiger-lake` | Intel TGL | 11th Gen Mobile | +| `skylake` | Intel SKL | 6th Gen Core | + +When data for the selected architecture is unavailable for a specific instruction, the tool falls back to a nearby architecture in the same family. + +### Annotation Details + +**Mnemonic mapping:** The .NET JIT uses Intel-style mnemonic aliases (e.g., `je`, `jne`, `ja`) while uops.info uses canonical forms (`jz`, `jnz`, `jnbe`). The annotator automatically maps between them for conditional jumps, conditional moves (`cmove`→`cmovz`), and set-byte instructions (`sete`→`setz`). + +**LEA handling:** The `lea` instruction computes an address but doesn't access memory. Its second operand is classified as `agen` (address generation) rather than a memory load, matching the uops.info encoding. + +**.NET JIT-specific prefixes:** +- `gword ptr` — GC-tracked pointer-width memory reference. Treated as `m64` on x64. +- `bword ptr` — Pointer-width memory reference without GC tracking. Treated as `m64` on x64. +- `SHORT` / `NEAR` — Jump distance hints from the JIT. Stripped before operand classification. + +**Skipped instructions:** `call`, `ret`, `int3`, `nop`, and `int` are not annotated. `ret` is skipped because uops.info's `TP_unrolled` for `ret` reflects return stack buffer mispredictions in microbenchmarks, not real-world performance. + +## Iterative Workflow + +JitAsm is designed for rapid iteration when optimizing code: + +1. **Modify source code** +2. **Build the target assembly:** + ```bash + dotnet build src/Nethermind/Nethermind.Evm -c Release + ``` +3. **View the generated assembly:** + ```bash + dotnet run --project tools/JitAsm -c Release -- -a src/Nethermind/artifacts/bin/Nethermind.Evm/release/Nethermind.Evm.dll -m YourMethod + ``` +4. **Compare output between iterations** + +For fast iteration during optimization, `--fullopts` skips the invocation/sleep cycle (~4s faster) at the cost of less representative output. Use default Tier-1 for final verification. + +## Environment Variables + +The tool sets these JIT diagnostic variables for the child process: + +### Default Mode (Tier-1 + PGO) + +| Variable | Value | Purpose | +|----------|-------|---------| +| `DOTNET_TieredCompilation` | `1` | Enable tiered compilation | +| `DOTNET_TieredPGO` | `1` | Enable Dynamic PGO | +| `DOTNET_TC_CallCountThreshold` | `1` | Minimal call count before recompilation | +| `DOTNET_TC_CallCountingDelayMs` | `0` | Install counting stubs immediately (critical) | +| `DOTNET_JitDisasm` | `` | Output disassembly for matching methods | +| `DOTNET_JitDiffableDasm` | `1` | Consistent, diffable output format | + +### FullOpts Mode (`--fullopts`) + +| Variable | Value | Purpose | +|----------|-------|---------| +| `DOTNET_TieredCompilation` | `0` | Disable tiered compilation | +| `DOTNET_TC_QuickJit` | `0` | Disable quick JIT | +| `DOTNET_JitDisasm` | `` | Output disassembly for matching methods | +| `DOTNET_JitDiffableDasm` | `1` | Consistent, diffable output format | + +## Comparing Generic Specializations (OffFlag vs OnFlag) + +A common workflow is comparing two generic instantiations to verify dead code elimination. Nethermind uses the `IFlag` pattern (`OnFlag`/`OffFlag`) for compile-time branch elimination. + +```bash +# Generate ASM for tracing disabled (common case) +dotnet run --project tools/JitAsm -c Release -- -a src/Nethermind/artifacts/bin/Nethermind.Evm/release/Nethermind.Evm.dll -t Nethermind.Evm.EvmInstructions -m InstructionMath2Param --type-params Nethermind.Evm.GasPolicy.EthereumGasPolicy,Nethermind.Evm.EvmInstructions+OpAdd,Nethermind.Core.OffFlag > off.asm 2>&1 + +# Generate ASM for tracing enabled +dotnet run --project tools/JitAsm -c Release -- -a src/Nethermind/artifacts/bin/Nethermind.Evm/release/Nethermind.Evm.dll -t Nethermind.Evm.EvmInstructions -m InstructionMath2Param --type-params Nethermind.Evm.GasPolicy.EthereumGasPolicy,Nethermind.Evm.EvmInstructions+OpAdd,Nethermind.Core.OnFlag > on.asm 2>&1 + +# Compare: the OnFlag version should have tracing code that is absent from OffFlag +diff off.asm on.asm +``` + +**Red flag:** If both versions have identical code size, the JIT may not be eliminating dead code as expected. + +## Extracting Metrics from ASM Output + +Use these patterns to extract key metrics from the disassembly output for tracking optimization progress: + +```bash +# Code size (from the last line, e.g., "; Total bytes of code 40") +tail -1 output.asm + +# Basic block count (fewer = simpler control flow = better branch prediction) +grep -c "G_M000_IG[0-9]*:" output.asm + +# Branch count (conditional jumps) +grep -cE "\bj(e|ne|g|l|ge|le|a|b|ae|be|nz|z)\b" output.asm + +# Call count (should be minimal in hot paths) +grep -c "call" output.asm + +# Register saves in prologue (>4 push = register pressure issue) +grep -c "push" output.asm + +# Stack frame size (from prologue, e.g., "sub rsp, 40") +grep "sub.*rsp" output.asm +``` + +## Reading Assembly Output + +### Output Structure + +The disassembly header contains useful metadata: + +``` +; Assembly listing for method Namespace.Type:Method() (Tier1) +; Emitting BLENDED_CODE for generic X64 + VEX + EVEX on Windows +; Tier1 code ← Tier-1 optimized (production tier) +; optimized code +; optimized using Dynamic PGO ← PGO-guided optimizations applied +; rsp based frame +; partially interruptible +; with Dynamic PGO: fgCalledCount is 50 ← How many times method was called during profiling +``` + +The code is organized into **basic blocks** labeled `G_M000_IG01`, `G_M000_IG02`, etc. Each block is a straight-line sequence of instructions ending with a branch or fall-through. + +### Common Red Flags + +| Pattern in ASM | Meaning | Severity | +|----------------|---------|----------| +| `call CORINFO_HELP_BOX` | Boxing allocation in hot path | High | +| `call CORINFO_HELP_VIRTUAL_FUNC_PTR` | Virtual dispatch not devirtualized (~25-50 cycles) | High | +| `call CORINFO_HELP_GETSHARED_NONGCSTATIC_BASE` | Static constructor check | High | +| `call CORINFO_HELP_GET_NONGCSTATIC_BASE` | Cross-module static base resolution (eliminated in Tier-1) | Medium* | +| `call CORINFO_HELP_ASSIGN_REF` | GC write barrier (bad in loops) | High | +| `callvirt [Interface:Method()]` | Interface dispatch not devirtualized | High | +| `call [SmallType:SmallMethod()]` | Failed inlining of small method | Medium | +| `cmp ...; jae THROW_LABEL` | Bounds check (may be eliminable) | Medium | +| `idiv` | Division by non-constant (~20-80 cycles) | Medium | +| `>4 push` instructions in prologue | Register pressure | Medium | +| `vmovdqu32 zmmword ptr` in prologue | Large stack zeroing (cold locals bloating hot path) | Low | + +*`CORINFO_HELP_GET_NONGCSTATIC_BASE` appears in FullOpts mode for cross-module static field access but is naturally eliminated in Tier-1 + PGO. If you see it in default (Tier-1) output, it indicates the static base couldn't be resolved at runtime — a real issue. + +### What Good ASM Looks Like + +- Few basic blocks (simple control flow) +- No `call` instructions in the hot path (everything inlined) +- Compact code size (better I-cache utilization) +- No redundant loads of the same memory location +- SIMD instructions (`vpaddd`, `vpxor`, etc.) for bulk data operations +- `cmov*` (conditional moves) instead of branches where appropriate +- Fall-through on the hot path, forward jumps to cold/error paths +- `; optimized using Dynamic PGO` in the header (confirms PGO applied) + +## Troubleshooting + +### No disassembly output + +- Verify the assembly path is correct and the DLL exists +- Check if the method name is spelled correctly (case-sensitive) +- Try without the `-t` option to search all types +- Use `-v` for verbose output to see what's happening +- Ensure you built with `-c Release` (Debug builds produce different code) + +### Tier-1 produces no output / shows Tier-0 instead + +- The Tier-1 simulation invokes the method via reflection with default arguments. Methods taking **ref struct** parameters (`Span`, `ReadOnlySpan`, `Memory`) cannot be invoked this way — the simulation silently fails and produces no output. +- **Workaround:** Use `--fullopts` for methods with ref struct parameters. It compiles in a single pass without invocation. +- For other methods: this means the Tier-1 recompilation didn't fire. The method may not be invocable with null/default arguments. +- Check `-v` output for error messages during invocation phases. + +### Method not found + +- Ensure the assembly is built (Release configuration recommended) +- For generic methods, provide all required type parameters via `--type-params` +- For methods in generic classes, provide class type parameters via `--class-type-params` +- Use fully qualified type names for type parameters from other assemblies +- Generic type names can omit the backtick arity (e.g., `TransactionProcessorBase` matches `TransactionProcessorBase`1`) + +### Multiple methods with same name + +- Specify the type with `-t` to narrow the search +- The tool will use the first matching overload if multiple exist + +### On Windows: `'' was not matched` error + +- Remove all quotes from arguments — pass paths and type names unquoted +- Replace backslashes with forward slashes in paths +- Put the entire command on a single line + +## Building + +```bash +dotnet build tools/JitAsm -c Release +``` diff --git a/tools/JitAsm/StaticCtorDetector.cs b/tools/JitAsm/StaticCtorDetector.cs new file mode 100644 index 000000000000..1b512dceb957 --- /dev/null +++ b/tools/JitAsm/StaticCtorDetector.cs @@ -0,0 +1,175 @@ +// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited +// SPDX-License-Identifier: LGPL-3.0-only + +using System.Text.RegularExpressions; + +namespace JitAsm; + +internal static partial class StaticCtorDetector +{ + // Patterns for detecting static constructor calls in JIT disassembly + // Example: call Namespace.Type:.cctor() + [GeneratedRegex(@"call\s+(?[\w.+`\[\],]+):\.cctor\(\)", RegexOptions.Compiled)] + private static partial Regex CctorCallPattern(); + + // JIT helpers for static field initialization + // Matches both shared and non-shared variants, with optional brackets: + // call CORINFO_HELP_GETSHARED_NONGCSTATIC_BASE + // call [CORINFO_HELP_GET_NONGCSTATIC_BASE] + // call CORINFO_HELP_CLASSINIT_SHARED_DYNAMICCLASS + [GeneratedRegex(@"call\s+\[?CORINFO_HELP_(?:(?:GETSHARED_|GET_)(?:NON)?GCSTATIC_BASE|CLASSINIT_SHARED_DYNAMICCLASS)\]?", RegexOptions.Compiled)] + private static partial Regex StaticHelperPattern(); + + // Pattern for type references in static helper context + // The JIT output often shows the type being initialized nearby + // Example: ; Namespace.Type + [GeneratedRegex(@";\s*(?[\w.+`\[\],]+)\s*$", RegexOptions.Compiled | RegexOptions.Multiline)] + private static partial Regex TypeCommentPattern(); + + // Pattern for detecting lazy initialization checks + // Example: cmp dword ptr [Namespace.Type:initialized], 0 + [GeneratedRegex(@"\[(?[\w.+`\[\],]+):", RegexOptions.Compiled)] + private static partial Regex StaticFieldAccessPattern(); + + // Pattern for extracting types from call targets in JIT output. + // In diffable mode, long call targets wrap to the next line, e.g.: + // call + // [Nethermind.Core.Eip4844Constants:get_MinBlobGasPrice():...] + // In non-diffable mode, they appear on one line: + // call [Nethermind.Core.Eip4844Constants:get_MinBlobGasPrice():...] + // This pattern matches the "[Type:Method" portion (possibly with [r11] prefix for callvirt). + [GeneratedRegex(@"(?:\[(?:r\d+\])?)?\[?(?[\w.+`\[\],]+):(?!:)", RegexOptions.Compiled)] + private static partial Regex CallTargetTypePattern(); + + public static IReadOnlyList DetectStaticCtors(string disassemblyOutput) + { + var detectedTypes = new HashSet(StringComparer.Ordinal); + + // Detect direct .cctor calls + foreach (Match match in CctorCallPattern().Matches(disassemblyOutput)) + { + var typeName = NormalizeTypeName(match.Groups["type"].Value); + if (!string.IsNullOrEmpty(typeName)) + { + detectedTypes.Add(typeName); + } + } + + // Check for static helper calls and try to find associated types + if (StaticHelperPattern().IsMatch(disassemblyOutput)) + { + int typesBeforeHelperScan = detectedTypes.Count; + + // Look for type references near the helper calls (±3 lines) + var lines = disassemblyOutput.Split('\n'); + for (int i = 0; i < lines.Length; i++) + { + if (StaticHelperPattern().IsMatch(lines[i])) + { + // Check surrounding lines for type context + for (int j = Math.Max(0, i - 3); j <= Math.Min(lines.Length - 1, i + 3); j++) + { + var typeMatch = TypeCommentPattern().Match(lines[j]); + if (typeMatch.Success) + { + var typeName = NormalizeTypeName(typeMatch.Groups["type"].Value); + if (!string.IsNullOrEmpty(typeName) && IsValidTypeName(typeName)) + { + detectedTypes.Add(typeName); + } + } + + var fieldMatch = StaticFieldAccessPattern().Match(lines[j]); + if (fieldMatch.Success) + { + var typeName = NormalizeTypeName(fieldMatch.Groups["type"].Value); + if (!string.IsNullOrEmpty(typeName) && IsValidTypeName(typeName)) + { + detectedTypes.Add(typeName); + } + } + } + } + } + + // Fallback: if helpers were found but no types extracted from nearby context + // (common when JitDiffableDasm strips annotations or helper has no nearby type comment), + // scan the entire method for types referenced in call instructions. + // In diffable mode, long call targets wrap to the next line: + // call + // [Nethermind.Core.Eip4844Constants:get_MinBlobGasPrice():...] + // Pre-running extra cctors is cheap; missing the right one means stale output. + if (detectedTypes.Count == typesBeforeHelperScan) + { + for (int i = 0; i < lines.Length; i++) + { + var line = lines[i]; + // Check current line and continuation lines after bare "call" instructions + if (line.TrimStart().StartsWith("call") || (i > 0 && lines[i - 1].TrimEnd().EndsWith("call"))) + { + var callMatch = CallTargetTypePattern().Match(line); + if (callMatch.Success) + { + var typeName = NormalizeTypeName(callMatch.Groups["type"].Value); + if (!string.IsNullOrEmpty(typeName) && IsValidTypeName(typeName)) + { + detectedTypes.Add(typeName); + } + } + } + } + } + } + + var result = new List(detectedTypes); + result.Sort(StringComparer.Ordinal); + return result; + } + + private static string NormalizeTypeName(string typeName) + { + // Remove generic arity suffix if present (e.g., `1, `2) + var tickIndex = typeName.IndexOf('`'); + if (tickIndex > 0) + { + // Keep up to and including the backtick and number for proper type resolution + var endIndex = tickIndex + 1; + while (endIndex < typeName.Length && char.IsDigit(typeName[endIndex])) + { + endIndex++; + } + + // If there's more after the generic arity, check for nested types + if (endIndex < typeName.Length && typeName[endIndex] == '+') + { + // Keep nested type info + return typeName; + } + + return typeName[..endIndex]; + } + + return typeName.Trim(); + } + + private static bool IsValidTypeName(string typeName) + { + // Filter out obvious non-type names + if (string.IsNullOrWhiteSpace(typeName)) + return false; + + // Must contain at least one letter + if (!typeName.Any(char.IsLetter)) + return false; + + // Should not be just a keyword or register name + var lowered = typeName.ToLowerInvariant(); + string[] invalidNames = ["rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rsp", "rbp", + "eax", "ebx", "ecx", "edx", "esi", "edi", "esp", "ebp", + "ptr", "dword", "qword", "byte", "word"]; + if (invalidNames.Contains(lowered)) + return false; + + return true; + } +}