diff --git a/eng/Subsets.props b/eng/Subsets.props index e16c5fb861462b..874f666820ee5b 100644 --- a/eng/Subsets.props +++ b/eng/Subsets.props @@ -525,6 +525,8 @@ Test="true" Category="clr" Condition="'$(DotNetBuildSourceOnly)' != 'true'"/> + diff --git a/src/coreclr/tools/Common/TypeSystem/Common/Utilities/CustomAttributeTypeNameParser.cs b/src/coreclr/tools/Common/TypeSystem/Common/Utilities/CustomAttributeTypeNameParser.cs index 931a95bb4b177f..128a30753aab39 100644 --- a/src/coreclr/tools/Common/TypeSystem/Common/Utilities/CustomAttributeTypeNameParser.cs +++ b/src/coreclr/tools/Common/TypeSystem/Common/Utilities/CustomAttributeTypeNameParser.cs @@ -36,6 +36,16 @@ public static TypeDesc GetTypeByCustomAttributeTypeName(this ModuleDesc module, }.Resolve(parsed); } + /// + /// Determines whether is a valid assembly-qualified type name, + /// including assembly qualification for constructed generic arguments. + /// + public static bool IsAssemblyQualifiedTypeName(string name) + { + return TypeName.TryParse(name.AsSpan(), out TypeName parsed, s_typeNameParseOptions) + && IsFullyQualified(parsed); + } + public static TypeDesc GetTypeByCustomAttributeTypeNameForDataFlow(string name, ModuleDesc callingModule, TypeSystemContext context, List referencedModules, bool needsAssemblyName, bool fallbackToCoreLib, out bool failedBecauseNotFullyQualified) { @@ -65,32 +75,32 @@ public static TypeDesc GetTypeByCustomAttributeTypeNameForDataFlow(string name, TypeDesc type = resolver.Resolve(parsed); return type; + } - static bool IsFullyQualified(TypeName typeName) + private static bool IsFullyQualified(TypeName typeName) + { + if (typeName.AssemblyName is null) { - if (typeName.AssemblyName is null) - { - return false; - } + return false; + } - if (typeName.IsArray || typeName.IsPointer || typeName.IsByRef) - { - return IsFullyQualified(typeName.GetElementType()); - } + if (typeName.IsArray || typeName.IsPointer || typeName.IsByRef) + { + return IsFullyQualified(typeName.GetElementType()); + } - if (typeName.IsConstructedGenericType) + if (typeName.IsConstructedGenericType) + { + foreach (TypeName typeArgument in typeName.GetGenericArguments()) { - foreach (var typeArgument in typeName.GetGenericArguments()) + if (!IsFullyQualified(typeArgument)) { - if (!IsFullyQualified(typeArgument)) - { - return false; - } + return false; } } - - return true; } + + return true; } private struct TypeNameResolver diff --git a/src/coreclr/tools/dotnet-pgo/MethodListMibcWriter.cs b/src/coreclr/tools/dotnet-pgo/MethodListMibcWriter.cs new file mode 100644 index 00000000000000..1892f84a2334d7 --- /dev/null +++ b/src/coreclr/tools/dotnet-pgo/MethodListMibcWriter.cs @@ -0,0 +1,335 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Reflection.Metadata; +using System.Text.Json; +using System.Text.Json.Serialization; + +using ILCompiler; +using ILCompiler.IBC; + +using Internal.TypeSystem; + +namespace Microsoft.Diagnostics.Tools.Pgo +{ + internal static class MethodListMibcWriter + { + private sealed class MethodListDocument + { + public string Runtime { get; set; } + + public string Os { get; set; } + + public string Architecture { get; set; } + + public MethodListEntry[] Methods { get; set; } + } + + private sealed class MethodListEntry + { + public string Type { get; set; } + + public string Name { get; set; } + + public string[] ParameterTypes { get; set; } + + public string[] GenericArguments { get; set; } + } + + public static int Run(string methodListPath, string outputPath, IReadOnlyList references, bool compressed, bool validate) + { + try + { + ValidateInputPaths(methodListPath, outputPath, references); + MethodListDocument document = ReadDocument(methodListPath); + + using var context = new MethodListTypeSystemContext(references); + List methods = ResolveMethods(context, document); + methods.Sort(static (left, right) => StringComparer.Ordinal.Compare(GetSortKey(left), GetSortKey(right))); + + var config = new MibcConfig + { + Runtime = document.Runtime, + Os = document.Os, + Arch = document.Architecture, + }; + + var profileData = new MethodProfileData[methods.Count]; + for (int i = 0; i < methods.Count; i++) + { + profileData[i] = new MethodProfileData( + methods[i], + MethodProfilingDataFlags.ReadMethodCode, + exclusiveWeight: 0, + callWeights: null, + scenarioMask: 0xFFFFFFFF, + schemaData: null); + } + + return MibcEmitter.GenerateMibcFile( + config, + context, + new FileInfo(outputPath), + profileData, + validate, + uncompressed: !compressed); + } + catch (Exception ex) when (ex is IOException or JsonException or InvalidDataException or TypeSystemException or BadImageFormatException or FormatException) + { + Program.PrintError(ex.Message); + return -6; + } + } + + private static string GetSortKey(MethodDesc method) + { + return $"{((MetadataType)method.OwningType.GetTypeDefinition()).Module.Assembly.GetName().Name}:{method}"; + } + + private static MethodListDocument ReadDocument(string methodListPath) + { + MethodListDocument document = JsonSerializer.Deserialize( + File.ReadAllText(methodListPath), + new JsonSerializerOptions + { + AllowTrailingCommas = true, + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + }); + + if (document is null) + { + throw new InvalidDataException($"Method list '{methodListPath}' is empty."); + } + + if (string.IsNullOrWhiteSpace(document.Runtime) || + string.IsNullOrWhiteSpace(document.Os) || + string.IsNullOrWhiteSpace(document.Architecture)) + { + throw new InvalidDataException("The method list must specify non-empty runtime, os, and architecture values."); + } + + if (document.Methods is null || document.Methods.Length == 0) + { + throw new InvalidDataException($"Method list '{methodListPath}' contains no methods."); + } + + return document; + } + + private static List ResolveMethods(MethodListTypeSystemContext context, MethodListDocument document) + { + var methods = new List(document.Methods.Length); + var seenMethods = new HashSet(); + + foreach (MethodListEntry entry in document.Methods) + { + MethodDesc method = ResolveMethod(context, entry); + if (!seenMethods.Add(method)) + { + throw new InvalidDataException($"Method list contains duplicate entry '{GetSortKey(method)}'."); + } + + methods.Add(method); + } + + return methods; + } + + private static MethodDesc ResolveMethod(MethodListTypeSystemContext context, MethodListEntry entry) + { + if (entry is null || string.IsNullOrWhiteSpace(entry.Type) || string.IsNullOrWhiteSpace(entry.Name)) + { + throw new InvalidDataException("Each method entry must specify non-empty type and name values."); + } + + TypeDesc owningType = ResolveClosedType(context, entry.Type, $"declaring type for method '{entry.Name}'"); + TypeDesc[] genericArguments = ResolveTypes(context, entry.GenericArguments, $"generic arguments for '{entry.Type}.{entry.Name}'"); + TypeDesc[] parameterTypes = ResolveTypes(context, entry.ParameterTypes, $"parameter types for '{entry.Type}.{entry.Name}'"); + + MethodDesc match = null; + bool foundName = false; + bool foundArity = false; + foreach (MethodDesc candidate in owningType.GetMethods()) + { + if (!candidate.Name.StringEquals(entry.Name)) + { + continue; + } + + foundName = true; + if (candidate.Instantiation.Length != genericArguments.Length) + { + continue; + } + + foundArity = true; + MethodDesc instantiatedCandidate = genericArguments.Length == 0 + ? candidate + : candidate.MakeInstantiatedMethod(genericArguments); + + if (entry.ParameterTypes is not null && !ParametersMatch(instantiatedCandidate.Signature, parameterTypes)) + { + continue; + } + + if (match is not null) + { + throw new InvalidDataException( + $"Method '{entry.Type}.{entry.Name}' is ambiguous; specify parameterTypes to select an overload."); + } + + match = instantiatedCandidate; + } + + if (match is not null) + { + return match; + } + + if (foundName && !foundArity) + { + throw new InvalidDataException( + $"Method '{entry.Type}.{entry.Name}' has no overload with generic arity {genericArguments.Length}."); + } + + throw new InvalidDataException($"Unable to resolve method '{entry.Type}.{entry.Name}'."); + } + + private static TypeDesc ResolveClosedType(MethodListTypeSystemContext context, string typeName, string description) + { + if (!CustomAttributeTypeNameParser.IsAssemblyQualifiedTypeName(typeName)) + { + throw new InvalidDataException($"Type '{typeName}' in {description} must be assembly-qualified."); + } + + int assemblySeparator = GetAssemblySeparator(typeName); + Debug.Assert(assemblySeparator >= 0); + AssemblyNameInfo assemblyName = AssemblyNameInfo.Parse(typeName.AsSpan(assemblySeparator + 1).Trim()); + if (!context.HasAssembly(assemblyName)) + { + throw new FileNotFoundException( + $"No reference was supplied for assembly '{assemblyName.Name}' required by type '{typeName}'."); + } + + TypeDesc type = context.SystemModule.GetTypeByCustomAttributeTypeName(typeName, throwIfNotFound: false); + if (type is null) + { + throw new InvalidDataException($"Unable to resolve type '{typeName}' in {description}."); + } + + if (type.ContainsSignatureVariables(treatGenericParameterLikeSignatureVariable: true)) + { + throw new InvalidDataException($"Open generic type '{typeName}' in {description} is not supported."); + } + + return type; + } + + private static TypeDesc[] ResolveTypes(MethodListTypeSystemContext context, string[] typeNames, string description) + { + if (typeNames is null) + { + return Array.Empty(); + } + + var types = new TypeDesc[typeNames.Length]; + for (int i = 0; i < typeNames.Length; i++) + { + if (string.IsNullOrWhiteSpace(typeNames[i])) + { + throw new InvalidDataException($"Type names in {description} must be non-empty."); + } + + types[i] = ResolveClosedType(context, typeNames[i], description); + } + + return types; + } + + private static int GetAssemblySeparator(string typeName) + { + int bracketDepth = 0; + bool escaped = false; + for (int i = 0; i < typeName.Length; i++) + { + char character = typeName[i]; + if (escaped) + { + escaped = false; + continue; + } + + if (character == '\\') + { + escaped = true; + } + else if (character == '[') + { + bracketDepth++; + } + else if (character == ']') + { + bracketDepth--; + } + else if (character == ',' && bracketDepth == 0) + { + return i; + } + } + + return -1; + } + + private static bool ParametersMatch(MethodSignature signature, TypeDesc[] parameterTypes) + { + if (signature.Length != parameterTypes.Length) + { + return false; + } + + for (int i = 0; i < parameterTypes.Length; i++) + { + if (signature[i] != parameterTypes[i]) + { + return false; + } + } + + return true; + } + + private static void ValidateInputPaths(string methodListPath, string outputPath, IReadOnlyList references) + { + if (string.IsNullOrWhiteSpace(methodListPath)) + { + throw new InvalidDataException("A method list filename must be specified."); + } + + if (!File.Exists(methodListPath)) + { + throw new FileNotFoundException($"Unable to find method list '{methodListPath}'.", methodListPath); + } + + if (string.IsNullOrWhiteSpace(outputPath)) + { + throw new InvalidDataException("An output filename must be specified."); + } + + if (!outputPath.EndsWith(".mibc", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException("The Mibc output filename must end with '.mibc'."); + } + + if (references is null || references.Count == 0) + { + throw new InvalidDataException("At least one reference assembly must be specified."); + } + } + } +} diff --git a/src/coreclr/tools/dotnet-pgo/MethodListTypeSystemContext.cs b/src/coreclr/tools/dotnet-pgo/MethodListTypeSystemContext.cs new file mode 100644 index 00000000000000..bfdc9a61a79528 --- /dev/null +++ b/src/coreclr/tools/dotnet-pgo/MethodListTypeSystemContext.cs @@ -0,0 +1,108 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; + +using Internal.TypeSystem; +using Internal.TypeSystem.Ecma; + +namespace Microsoft.Diagnostics.Tools.Pgo +{ + internal sealed class MethodListTypeSystemContext : MetadataTypeSystemContext, IMetadataStringDecoderProvider, IDisposable + { + private readonly Dictionary _modules = new(StringComparer.OrdinalIgnoreCase); + private readonly List _streams = new(); + private readonly List _readers = new(); + private MetadataStringDecoder _metadataStringDecoder; + + public MethodListTypeSystemContext(IReadOnlyList references) + { + try + { + foreach (string reference in references) + { + if (!File.Exists(reference)) + { + throw new FileNotFoundException($"Unable to find reference '{reference}'.", reference); + } + + FileStream stream = File.OpenRead(reference); + _streams.Add(stream); + + var reader = new PEReader(stream); + _readers.Add(reader); + + if (!reader.HasMetadata) + { + throw new BadImageFormatException($"Reference '{reference}' does not contain managed metadata.", reference); + } + + EcmaModule module = EcmaModule.Create(this, reader, containingAssembly: null); + string simpleName = module.Assembly.GetName().Name; + if (!_modules.TryAdd(simpleName, module)) + { + throw new InvalidDataException($"Multiple references define assembly '{simpleName}'."); + } + } + + if (!_modules.TryGetValue("System.Private.CoreLib", out ModuleDesc systemModule)) + { + throw new InvalidDataException("A reference to System.Private.CoreLib is required."); + } + + SetSystemModule(systemModule); + } + catch + { + Dispose(); + throw; + } + } + + public override bool SupportsCanon => true; + + public override bool SupportsUniversalCanon => false; + + public bool HasAssembly(AssemblyNameInfo name) + { + return _modules.ContainsKey(name.Name); + } + + public void Dispose() + { + foreach (PEReader reader in _readers) + { + reader.Dispose(); + } + + foreach (FileStream stream in _streams) + { + stream.Dispose(); + } + } + + public MetadataStringDecoder GetMetadataStringDecoder() + { + return _metadataStringDecoder ??= new CachingMetadataStringDecoder(0x10000); + } + + public override ModuleDesc ResolveAssembly(AssemblyNameInfo name, bool throwIfNotFound) + { + if (_modules.TryGetValue(name.Name, out ModuleDesc module)) + { + return module; + } + + if (throwIfNotFound) + { + throw new FileNotFoundException($"Unable to resolve assembly '{name.Name}'."); + } + + return null; + } + } +} diff --git a/src/coreclr/tools/dotnet-pgo/MibcEmitter.cs b/src/coreclr/tools/dotnet-pgo/MibcEmitter.cs index 2379a45e31f31f..6980a4c649c0b0 100644 --- a/src/coreclr/tools/dotnet-pgo/MibcEmitter.cs +++ b/src/coreclr/tools/dotnet-pgo/MibcEmitter.cs @@ -32,6 +32,8 @@ namespace Microsoft.Diagnostics.Tools.Pgo { static class MibcEmitter { + private static readonly DateTimeOffset s_deterministicZipTimestamp = new(1980, 1, 1, 0, 0, 0, TimeSpan.Zero); + class MIbcGroup : IPgoEncodedValueEmitter { private static int s_emitCount = 0; @@ -323,6 +325,7 @@ public static int GenerateMibcFile(MibcConfig config, TypeSystemContext tsc, Fil using (ZipArchive file = ZipFile.Open(outputFileName.FullName, ZipArchiveMode.Create)) { var entry = file.CreateEntry(outputFileName.Name + ".dll", CompressionLevel.Optimal); + entry.LastWriteTime = s_deterministicZipTimestamp; using (Stream archiveStream = entry.Open()) { peFile.CopyTo(archiveStream); diff --git a/src/coreclr/tools/dotnet-pgo/PgoRootCommand.cs b/src/coreclr/tools/dotnet-pgo/PgoRootCommand.cs index 179aa957b336dd..0f492fe45088c7 100644 --- a/src/coreclr/tools/dotnet-pgo/PgoRootCommand.cs +++ b/src/coreclr/tools/dotnet-pgo/PgoRootCommand.cs @@ -23,6 +23,8 @@ internal sealed class PgoRootCommand : RootCommand new("--input", "-i") { Description = "Name of the input mibc file to dump", Required = true, Arity = ArgumentArity.ExactlyOne }; public Option TraceFilePath { get; } = new("--trace", "-t") { Description = "Specify the trace file to be parsed" }; + public Option MethodListFilePath { get; } = + new("--method-list") { Description = "Specify a JSON file containing methods to place in a Mibc file", Required = true }; public Option OutputFilePath { get; } = new("--output", "-o") { Description = "Specify the output filename to be created" }; public Option PreciseDebugInfoFile { get; } = @@ -82,6 +84,7 @@ internal sealed class PgoRootCommand : RootCommand public JitTraceOptions JitTraceOptions; public bool Warnings; public bool BasicProgressMessages; + public bool CreateMibcFromMethodList; public bool DetailedProgressMessages; public bool DumpMibc; public ParseResult Result; @@ -137,6 +140,25 @@ public PgoRootCommand(string[] args) : base(".NET PGO Tool") Subcommands.Add(createMbicCommand); + Command createMibcFromMethodListCommand = new("create-mibc-from-method-list", "Create a Mibc profile data file from a JSON method list") + { + MethodListFilePath, + OutputFilePath, + Reference, + _verbosity, + Compressed, + }; + + createMibcFromMethodListCommand.SetAction(result => + { + CreateMibcFromMethodList = true; + ValidateOutputFile = true; + + return ExecuteWithContext(result, true); + }); + + Subcommands.Add(createMibcFromMethodListCommand); + JitTraceOptions = JitTraceOptions.none; #if DEBUG Command createJitTraceCommand = new("create-jittrace","Transform a trace file into a jittrace runtime file") diff --git a/src/coreclr/tools/dotnet-pgo/Program.cs b/src/coreclr/tools/dotnet-pgo/Program.cs index 5a0e2d1fe14111..96d9d7fdbcc1f4 100644 --- a/src/coreclr/tools/dotnet-pgo/Program.cs +++ b/src/coreclr/tools/dotnet-pgo/Program.cs @@ -261,6 +261,15 @@ public int Run() { return InnerDumpMain(); } + if (_command.CreateMibcFromMethodList) + { + return MethodListMibcWriter.Run( + Get(_command.MethodListFilePath), + Get(_command.OutputFilePath), + Get(_command.Reference), + Get(_command.Compressed), + _command.ValidateOutputFile); + } if (_inputFilesToMerge.Count > 0) { return InnerMergeMain(); diff --git a/src/coreclr/tools/dotnet-pgo/README.md b/src/coreclr/tools/dotnet-pgo/README.md index a377bf1e4e23e8..0058421bfa4bbe 100644 --- a/src/coreclr/tools/dotnet-pgo/README.md +++ b/src/coreclr/tools/dotnet-pgo/README.md @@ -25,6 +25,62 @@ static void FunctionCalledAtStartup() ## Consuming a .mibc file Invoke the `crossgen2` tool with the --mibc option, passing the .mibc file created by dotnet-pgo. +## Creating a .mibc file from a method list + +The `create-mibc-from-method-list` command creates a deterministic profile root set without collecting +a trace. This is an exploratory, general-purpose way to construct profiles; it is not required for +automatic hardware-intrinsic rooting in partial no-JIT compilations. + +The input is a JSON object with the following properties: + +- `runtime`, `os`, and `architecture`: non-empty strings written to the MIBC configuration. +- `methods`: a non-empty array of method entries. +- `methods[].type`: the assembly-qualified declaring type name. Constructed generic types must also + use assembly-qualified generic arguments. +- `methods[].name`: the metadata method name. +- `methods[].parameterTypes`: optional assembly-qualified parameter type names used to select an + overload. Omit only when the name and generic arity identify exactly one method. +- `methods[].genericArguments`: optional assembly-qualified type names for a closed generic method + instantiation. + +All referenced assemblies, including `System.Private.CoreLib`, must be passed with `--reference`. +Open generic types and methods are not supported. Duplicate resolved methods are rejected. + +```json +{ + "runtime": "CoreCLR", + "os": "linux", + "architecture": "x64", + "methods": [ + { + "type": "Example.ProfileRoots, Example", + "name": "Parse", + "parameterTypes": [ + "System.String, System.Private.CoreLib" + ] + }, + { + "type": "Example.ProfileRoots, Example", + "name": "Create", + "genericArguments": [ + "System.Int32, System.Private.CoreLib" + ], + "parameterTypes": [ + "System.Int32, System.Private.CoreLib" + ] + } + ] +} +``` + +```console +dotnet-pgo create-mibc-from-method-list \ + --method-list methods.json \ + --reference System.Private.CoreLib.dll \ + --reference Example.dll \ + --output methods.mibc +``` + ## Example tracing commands used to generate the input to this tool: Note, this tool requires MethodDetails events which are produced by the .NET 5.0 runtime, or by modifying the .NET Core 3 runtime to produce the event. diff --git a/src/coreclr/tools/dotnet-pgo/dotnet-pgo.csproj b/src/coreclr/tools/dotnet-pgo/dotnet-pgo.csproj index 40a919e7b9ba90..577452100b9a81 100644 --- a/src/coreclr/tools/dotnet-pgo/dotnet-pgo.csproj +++ b/src/coreclr/tools/dotnet-pgo/dotnet-pgo.csproj @@ -24,6 +24,7 @@ + diff --git a/src/coreclr/tools/dotnet-pgo/tests/MethodListMibcTests.cs b/src/coreclr/tools/dotnet-pgo/tests/MethodListMibcTests.cs new file mode 100644 index 00000000000000..44153869a244dc --- /dev/null +++ b/src/coreclr/tools/dotnet-pgo/tests/MethodListMibcTests.cs @@ -0,0 +1,330 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Text.Json; + +using Microsoft.Diagnostics.Tools.Pgo; + +using Xunit; + +namespace DotNetPgo.Tests +{ + public sealed class MethodListMibcTests + { + private static readonly string s_dotnetPgoPath = typeof(PgoFileType).Assembly.Location; + private static readonly string[] s_platformReferences = + ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")).Split(Path.PathSeparator); + private static readonly string s_testAssemblyPath = typeof(MethodListMibcTests).Assembly.Location; + private static readonly string s_testAssemblyName = typeof(MethodListMibcTests).Assembly.GetName().Name; + + [Fact] + public void CreatesDeterministicMibcAndRoundTripsThroughDump() + { + using var directory = new TemporaryDirectory(); + string firstMethodList = directory.WriteJson( + "first.json", + CreateDocument( + Method(nameof(MethodListTargets.Generic), [QualifiedName(typeof(int))], [QualifiedName(typeof(int))]), + Method("Hidden"), + Method(nameof(MethodListTargets.Overloaded), parameterTypes: [QualifiedName(typeof(string))]))); + string secondMethodList = directory.WriteJson( + "second.json", + CreateDocument( + Method(nameof(MethodListTargets.Overloaded), parameterTypes: [QualifiedName(typeof(string))]), + Method("Hidden"), + Method(nameof(MethodListTargets.Generic), [QualifiedName(typeof(int))], [QualifiedName(typeof(int))]))); + + string mibcPath = Path.Combine(directory.Path, "methods.mibc"); + + CommandResult firstCreate = RunCreate(firstMethodList, mibcPath); + AssertSuccess(firstCreate); + Assert.Contains($"Validated {mibcPath}", firstCreate.StandardOutput, StringComparison.Ordinal); + byte[] firstMibc = File.ReadAllBytes(mibcPath); + AssertSuccess(RunCreate(secondMethodList, mibcPath)); + Assert.Equal(firstMibc, File.ReadAllBytes(mibcPath)); + + string dumpPath = Path.Combine(directory.Path, "dump.json"); + AssertSuccess(Run("dump", "--input", mibcPath, "--output", dumpPath)); + + using JsonDocument dump = JsonDocument.Parse(File.ReadAllText(dumpPath)); + JsonElement methods = dump.RootElement.GetProperty("Methods"); + Assert.Equal(3, methods.GetArrayLength()); + Assert.All(methods.EnumerateArray(), method => + { + Assert.False(method.TryGetProperty("CallWeights", out _)); + Assert.False(method.TryGetProperty("ExclusiveWeight", out _)); + Assert.False(method.TryGetProperty("InstrumentationData", out _)); + }); + } + + [Fact] + public void RejectsAmbiguousMethod() + { + using var directory = new TemporaryDirectory(); + string methodList = directory.WriteJson( + "ambiguous.json", + CreateDocument(Method(nameof(MethodListTargets.Overloaded)))); + + CommandResult result = RunCreate(methodList, Path.Combine(directory.Path, "ambiguous.mibc")); + + AssertFailure(result, "is ambiguous"); + } + + [Fact] + public void RejectsDuplicateMethod() + { + using var directory = new TemporaryDirectory(); + Dictionary method = Method("Hidden"); + string methodList = directory.WriteJson("duplicate.json", CreateDocument(method, method)); + + CommandResult result = RunCreate(methodList, Path.Combine(directory.Path, "duplicate.mibc")); + + AssertFailure(result, "duplicate entry"); + } + + [Fact] + public void RejectsInvalidMethodAndGenericArity() + { + using var directory = new TemporaryDirectory(); + string missingMethodList = directory.WriteJson( + "missing-method.json", + CreateDocument(Method("Missing"))); + string wrongArityMethodList = directory.WriteJson( + "wrong-arity.json", + CreateDocument(Method(nameof(MethodListTargets.Generic)))); + + AssertFailure( + RunCreate(missingMethodList, Path.Combine(directory.Path, "missing-method.mibc")), + "Unable to resolve method"); + AssertFailure( + RunCreate(wrongArityMethodList, Path.Combine(directory.Path, "wrong-arity.mibc")), + "generic arity 0"); + } + + [Fact] + public void RejectsInvalidJsonMissingTypeAndReference() + { + using var directory = new TemporaryDirectory(); + string invalidJson = Path.Combine(directory.Path, "invalid.json"); + File.WriteAllText(invalidJson, "{"); + string missingType = directory.WriteJson( + "missing-type.json", + CreateDocument( + new Dictionary + { + ["type"] = $"Missing.Type, {s_testAssemblyName}", + ["name"] = "Method", + })); + + AssertFailure(RunCreate(invalidJson, Path.Combine(directory.Path, "invalid.mibc")), "JSON"); + AssertFailure(RunCreate(missingType, Path.Combine(directory.Path, "missing-type.mibc")), "Unable to resolve type"); + + string validMethod = directory.WriteJson( + "missing-reference.json", + CreateDocument(Method("Hidden"))); + var arguments = new List + { + "create-mibc-from-method-list", + "--method-list", validMethod, + }; + AddReferences(arguments, includeTestAssembly: false); + arguments.AddRange( + [ + "--output", Path.Combine(directory.Path, "missing-reference.mibc"), + "--compressed", "false", + ]); + AssertFailure(Run(arguments.ToArray()), $"No reference was supplied for assembly '{s_testAssemblyName}'"); + } + + [Fact] + public void RejectsNonMibcOutput() + { + using var directory = new TemporaryDirectory(); + string methodList = directory.WriteJson( + "method.json", + CreateDocument(Method("Hidden"))); + + AssertFailure(RunCreate(methodList, Path.Combine(directory.Path, "output.bin")), "must end with '.mibc'"); + } + + [Fact] + public void RejectsUnqualifiedConstructedGenericArgument() + { + using var directory = new TemporaryDirectory(); + string methodList = directory.WriteJson( + "unqualified-generic-argument.json", + CreateDocument( + new Dictionary + { + ["type"] = "System.Collections.Generic.List`1[[System.String]], System.Private.CoreLib", + ["name"] = "Add", + ["parameterTypes"] = new[] { QualifiedName(typeof(string)) }, + })); + + AssertFailure( + RunCreate(methodList, Path.Combine(directory.Path, "unqualified-generic-argument.mibc")), + "must be assembly-qualified"); + } + + private static void AssertFailure(CommandResult result, string expectedError) + { + Assert.NotEqual(0, result.ExitCode); + Assert.Contains(expectedError, result.StandardError, StringComparison.OrdinalIgnoreCase); + } + + private static void AssertSuccess(CommandResult result) + { + Assert.True( + result.ExitCode == 0, + $"Command failed with exit code {result.ExitCode}.{Environment.NewLine}stdout:{Environment.NewLine}{result.StandardOutput}{Environment.NewLine}stderr:{Environment.NewLine}{result.StandardError}"); + } + + private static Dictionary CreateDocument(params Dictionary[] methods) + { + return new Dictionary + { + ["runtime"] = "CoreCLR", + ["os"] = "test", + ["architecture"] = "test", + ["methods"] = methods, + }; + } + + private static Dictionary Method( + string name, + string[] genericArguments = null, + string[] parameterTypes = null) + { + var method = new Dictionary + { + ["type"] = QualifiedName(typeof(MethodListTargets)), + ["name"] = name, + }; + + if (genericArguments is not null) + { + method["genericArguments"] = genericArguments; + } + + if (parameterTypes is not null) + { + method["parameterTypes"] = parameterTypes; + } + + return method; + } + + private static string QualifiedName(Type type) + { + return type.AssemblyQualifiedName; + } + + private static CommandResult RunCreate(string methodList, string output) + { + var arguments = new List + { + "create-mibc-from-method-list", + "--method-list", methodList, + }; + AddReferences(arguments, includeTestAssembly: true); + arguments.AddRange( + [ + "--output", output, + ]); + + return Run(arguments.ToArray()); + } + + private static void AddReferences(List arguments, bool includeTestAssembly) + { + var assemblyNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (string reference in s_platformReferences) + { + string assemblyName = AssemblyName.GetAssemblyName(reference).Name; + if ((!includeTestAssembly && assemblyName == s_testAssemblyName) || !assemblyNames.Add(assemblyName)) + { + continue; + } + + arguments.Add("--reference"); + arguments.Add(reference); + } + + if (includeTestAssembly && assemblyNames.Add(s_testAssemblyName)) + { + arguments.Add("--reference"); + arguments.Add(s_testAssemblyPath); + } + } + + private static CommandResult Run(params string[] arguments) + { + var startInfo = new ProcessStartInfo + { + FileName = Environment.ProcessPath, + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + }; + startInfo.ArgumentList.Add(s_dotnetPgoPath); + foreach (string argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using Process process = Process.Start(startInfo); + string standardOutput = process.StandardOutput.ReadToEnd(); + string standardError = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + return new CommandResult(process.ExitCode, standardOutput, standardError); + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), System.IO.Path.GetRandomFileName()); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + Directory.Delete(Path, recursive: true); + } + + public string WriteJson(string fileName, object value) + { + string path = System.IO.Path.Combine(Path, fileName); + File.WriteAllText(path, JsonSerializer.Serialize(value)); + return path; + } + } + + private sealed record CommandResult(int ExitCode, string StandardOutput, string StandardError); + } + + internal static class MethodListTargets + { + internal static T Generic(T value) => value; + + private static void Hidden() + { + } + + internal static void Overloaded(int value) + { + } + + internal static void Overloaded(string value) + { + } + } +} diff --git a/src/coreclr/tools/dotnet-pgo/tests/dotnet-pgo.Tests.csproj b/src/coreclr/tools/dotnet-pgo/tests/dotnet-pgo.Tests.csproj new file mode 100644 index 00000000000000..3cdf044eeec2cc --- /dev/null +++ b/src/coreclr/tools/dotnet-pgo/tests/dotnet-pgo.Tests.csproj @@ -0,0 +1,16 @@ + + + + $(NetCoreAppToolCurrent) + AnyCPU + + + + + + + + + + +