diff --git a/.codex/hooks.json b/.codex/hooks.json index 7d7e4887..5fe8a196 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "bash \"$(git rev-parse --show-toplevel)/.codex/hooks/format-csharp.sh\"", + "command": "bash \"$(jj root)/.codex/hooks/format-csharp.sh\"", "timeout": 600, "statusMessage": "Formatting C# with CSharpier" } diff --git a/.codex/hooks/format-csharp.sh b/.codex/hooks/format-csharp.sh index 669040a1..f5b84fc5 100755 --- a/.codex/hooks/format-csharp.sh +++ b/.codex/hooks/format-csharp.sh @@ -2,6 +2,6 @@ set -euo pipefail -root="$(git rev-parse --show-toplevel)" +root="$(jj root)" dotnet tool restore --tool-manifest "$root/dotnet-tools.json" >/dev/null dotnet csharpier format "$root" diff --git a/.gitignore b/.gitignore index 3b0e5cbf..465831eb 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,7 @@ publish [Oo]bj sql TestResults +BenchmarkDotNet.Artifacts *.Cache ClientBin stylecop.* diff --git a/Directory.Packages.props b/Directory.Packages.props index 469fe75b..6ef7d834 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,9 @@ + + + diff --git a/Prexonite.Benchmarks/BenchmarkStackContext.cs b/Prexonite.Benchmarks/BenchmarkStackContext.cs new file mode 100644 index 00000000..2d0608a3 --- /dev/null +++ b/Prexonite.Benchmarks/BenchmarkStackContext.cs @@ -0,0 +1,33 @@ +using Prexonite.Types; + +namespace Prexonite.Benchmarks; + +internal sealed class BenchmarkStackContext : StackContext +{ + readonly PValue _returnValue = PType.Null.CreatePValue(); + + internal BenchmarkStackContext(Engine engine, Application application) + { + ParentEngine = engine; + ParentApplication = application; + ImportedNamespaces = application.CreateFunction("benchmark-context").ImportedNamespaces; + } + + public override Engine ParentEngine { get; } + + public override Application ParentApplication { get; } + + public override SymbolCollection ImportedNamespaces { get; } + + public override PValue ReturnValue => _returnValue; + + public override bool TryHandleException(Exception exc) + { + return false; + } + + protected override bool PerformNextCycle(StackContext? lastContext) + { + return false; + } +} diff --git a/Prexonite.Benchmarks/CilObjectMemberCallBenchmarks.cs b/Prexonite.Benchmarks/CilObjectMemberCallBenchmarks.cs new file mode 100644 index 00000000..0fb9c766 --- /dev/null +++ b/Prexonite.Benchmarks/CilObjectMemberCallBenchmarks.cs @@ -0,0 +1,500 @@ +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Order; +using Prexonite.Compiler; +using Prexonite.Compiler.Cil; +using Prexonite.Types; +using CilCompiler = Prexonite.Compiler.Cil.Compiler; + +namespace Prexonite.Benchmarks; + +[CategoriesColumn] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[Orderer(SummaryOrderPolicy.FastestToSlowest)] +public class CilObjectMemberCallBenchmarks +{ + const string UncachedPrefix = "uncached"; + + readonly Engine _engine = new(); + readonly Application _application = new("cil-benchmarks"); + readonly CilDispatchProbe _probe = new(); + + BenchmarkStackContext _sctx = null!; + PValue _subject = null!; + PValue[] _subjectArgs = null!; + PValue[][] _polymorphicArgs = null!; + PValue[][] _lateGuardMissArgs = null!; + PValue[][] _megamorphicArgs = null!; + PValue[] _cachedFreshReceiverArgs = null!; + PValue[] _uncachedFreshReceiverArgs = null!; + + CompiledBenchmark _cachedZero; + CompiledBenchmark _uncachedZero; + CompiledBenchmark _cachedPolymorphic; + CompiledBenchmark _uncachedPolymorphic; + CompiledBenchmark _cachedStatic; + CompiledBenchmark _uncachedStatic; + CompiledBenchmark _cachedWide; + CompiledBenchmark _uncachedWide; + CompiledBenchmark _cachedLateGuardMiss; + CompiledBenchmark _uncachedLateGuardMiss; + CompiledBenchmark _cachedFreshReceiver; + CompiledBenchmark _uncachedFreshReceiver; + CompiledBenchmark _cachedMegamorphic; + CompiledBenchmark _uncachedMegamorphic; + int _cachedPolymorphicIndex; + int _uncachedPolymorphicIndex; + int _cachedLateGuardMissIndex; + int _uncachedLateGuardMissIndex; + int _cachedMegamorphicIndex; + int _uncachedMegamorphicIndex; + + [GlobalSetup] + public void Setup() + { + _engine.RegisterAssembly(typeof(CilDispatchProbe).Assembly); + _sctx = new(_engine, _application); + _subject = _engine.CreateNativePValue(_probe); + _subjectArgs = [_subject]; + _polymorphicArgs = + [ + [_subject, PType.Int.CreatePValue(1)], + [_subject, PType.Int.CreatePValue(1).WithTypeLock()], + ]; + _lateGuardMissArgs = + [ + [_subject, PType.Object[typeof(int)].CreatePValue(16)], + [_subject, PType.Object[typeof(string)].CreatePValue("sixteen")], + ]; + _megamorphicArgs = + [ + [_subject, PType.Int.CreatePValue(1)], + [_subject, PType.Object[typeof(int)].CreatePValue(2)], + [_subject, new ObjectPType(typeof(int)).CreatePValue(3)], + [_subject, new ObjectPType(typeof(int)).CreatePValue(4)], + [_subject, new ObjectPType(typeof(int)).CreatePValue(5)], + ]; + _cachedFreshReceiverArgs = new PValue[1]; + _uncachedFreshReceiverArgs = new PValue[1]; + + var loader = new Loader(new(_engine, _application)); + loader.LoadFromString(_source); + if (loader.ErrorCount != 0) + throw new InvalidOperationException( + "Cannot compile the CIL benchmark source: " + + string.Join(Environment.NewLine, loader.Errors) + ); + + foreach ( + var function in _application.Functions.Where(function => + function.Id.StartsWith(UncachedPrefix, StringComparison.Ordinal) + ) + ) + function.Meta[CilCompiler.DisableObjectMemberCallSitesKey] = true; + + CilCompiler.Compile(_application, _engine, FunctionLinking.FullyIsolated); + + _cachedZero = _getCompiled("cachedZero"); + _uncachedZero = _getCompiled("uncachedZero"); + _cachedPolymorphic = _getCompiled("cachedPolymorphic"); + _uncachedPolymorphic = _getCompiled("uncachedPolymorphic"); + _cachedStatic = _getCompiled("cachedStatic"); + _uncachedStatic = _getCompiled("uncachedStatic"); + _cachedWide = _getCompiled("cachedWide"); + _uncachedWide = _getCompiled("uncachedWide"); + _cachedLateGuardMiss = _getCompiled("cachedLateGuardMiss"); + _uncachedLateGuardMiss = _getCompiled("uncachedLateGuardMiss"); + _cachedFreshReceiver = _getCompiled("cachedFreshReceiver"); + _uncachedFreshReceiver = _getCompiled("uncachedFreshReceiver"); + _cachedMegamorphic = _getCompiled("cachedMegamorphic"); + _uncachedMegamorphic = _getCompiled("uncachedMegamorphic"); + + _assertResult(_cachedZero.Invoke(_sctx, _subjectArgs), 42); + _assertResult(_uncachedZero.Invoke(_sctx, _subjectArgs), 42); + _assertResult(_cachedPolymorphic.Invoke(_sctx, _polymorphicArgs[0]), "automatic"); + _assertResult(_cachedPolymorphic.Invoke(_sctx, _polymorphicArgs[1]), "locked"); + _assertResult(_uncachedPolymorphic.Invoke(_sctx, _polymorphicArgs[0]), "automatic"); + _assertResult(_cachedStatic.Invoke(_sctx, []), 42); + _assertResult(_uncachedStatic.Invoke(_sctx, []), 42); + _assertResult(_cachedWide.Invoke(_sctx, _subjectArgs), 136); + _assertResult(_uncachedWide.Invoke(_sctx, _subjectArgs), 136); + _assertResult(_cachedLateGuardMiss.Invoke(_sctx, _lateGuardMissArgs[0]), 16); + _assertResult(_cachedLateGuardMiss.Invoke(_sctx, _lateGuardMissArgs[1]), 16); + _assertResult(_uncachedLateGuardMiss.Invoke(_sctx, _lateGuardMissArgs[0]), 16); + foreach (var args in _megamorphicArgs) + _assertResult(_cachedMegamorphic.Invoke(_sctx, args), args[1].Value!); + _assertResult(_uncachedMegamorphic.Invoke(_sctx, _megamorphicArgs[0]), 1); + + _assertStableBindingCount(_cachedZero, 1); + _assertStableBindingCount(_cachedPolymorphic, 2); + _assertStableBindingCount(_cachedStatic, 1); + _assertStableBindingCount(_cachedWide, 1); + _assertStableBindingCount(_cachedLateGuardMiss, 2); + _assertUncached(_uncachedZero); + _assertUncached(_uncachedPolymorphic); + _assertUncached(_uncachedStatic); + _assertUncached(_uncachedWide); + _assertUncached(_uncachedLateGuardMiss); + _assertUncached(_uncachedMegamorphic); + _assertMegamorphicSite(); + _assertForcedReceiverRebinding(); + } + + [Benchmark(Baseline = true)] + [BenchmarkCategory("CIL zero-argument")] + public PValue UncachedZeroArgument() + { + return _uncachedZero.Invoke(_sctx, _subjectArgs); + } + + [Benchmark] + [BenchmarkCategory("CIL zero-argument")] + public PValue CachedZeroArgument() + { + return _cachedZero.Invoke(_sctx, _subjectArgs); + } + + [Benchmark(Baseline = true)] + [BenchmarkCategory("CIL polymorphic lock")] + public PValue UncachedPolymorphicLock() + { + return _uncachedPolymorphic.Invoke( + _sctx, + _polymorphicArgs[_uncachedPolymorphicIndex++ & 1] + ); + } + + [Benchmark] + [BenchmarkCategory("CIL polymorphic lock")] + public PValue CachedPolymorphicLock() + { + return _cachedPolymorphic.Invoke(_sctx, _polymorphicArgs[_cachedPolymorphicIndex++ & 1]); + } + + [Benchmark(Baseline = true)] + [BenchmarkCategory("CIL static")] + public PValue UncachedStatic() + { + return _uncachedStatic.Invoke(_sctx, []); + } + + [Benchmark] + [BenchmarkCategory("CIL static")] + public PValue CachedStatic() + { + return _cachedStatic.Invoke(_sctx, []); + } + + [Benchmark(Baseline = true)] + [BenchmarkCategory("CIL wide hit")] + public PValue UncachedWide() + { + return _uncachedWide.Invoke(_sctx, _subjectArgs); + } + + [Benchmark] + [BenchmarkCategory("CIL wide hit")] + public PValue CachedWide() + { + return _cachedWide.Invoke(_sctx, _subjectArgs); + } + + [Benchmark(Baseline = true)] + [BenchmarkCategory("CIL late miss")] + public PValue UncachedLateGuardMiss() + { + return _uncachedLateGuardMiss.Invoke( + _sctx, + _lateGuardMissArgs[_uncachedLateGuardMissIndex++ & 1] + ); + } + + [Benchmark] + [BenchmarkCategory("CIL late miss")] + public PValue CachedLateGuardMiss() + { + return _cachedLateGuardMiss.Invoke( + _sctx, + _lateGuardMissArgs[_cachedLateGuardMissIndex++ & 1] + ); + } + + [Benchmark(Baseline = true)] + [BenchmarkCategory("CIL receiver rebind")] + public PValue UncachedFreshReceiver() + { + _uncachedFreshReceiverArgs[0] = new ObjectPType(typeof(CilDispatchProbe)).CreatePValue( + _probe + ); + return _uncachedFreshReceiver.Invoke(_sctx, _uncachedFreshReceiverArgs); + } + + [Benchmark] + [BenchmarkCategory("CIL receiver rebind")] + public PValue CachedFreshReceiver() + { + _cachedFreshReceiverArgs[0] = new ObjectPType(typeof(CilDispatchProbe)).CreatePValue( + _probe + ); + return _cachedFreshReceiver.Invoke(_sctx, _cachedFreshReceiverArgs); + } + + [Benchmark(Baseline = true)] + [BenchmarkCategory("CIL megamorphic retained hit")] + public PValue UncachedMegamorphicRetainedHit() + { + return _uncachedMegamorphic.Invoke(_sctx, _megamorphicArgs[^1]); + } + + [Benchmark] + [BenchmarkCategory("CIL megamorphic retained hit")] + public PValue CachedMegamorphicRetainedHit() + { + return _cachedMegamorphic.Invoke(_sctx, _megamorphicArgs[^1]); + } + + [Benchmark(Baseline = true)] + [BenchmarkCategory("CIL megamorphic five-shape cycle")] + public PValue UncachedMegamorphicFiveShapeCycle() + { + return _uncachedMegamorphic.Invoke( + _sctx, + _megamorphicArgs[_uncachedMegamorphicIndex++ % _megamorphicArgs.Length] + ); + } + + [Benchmark] + [BenchmarkCategory("CIL megamorphic five-shape cycle")] + public PValue CachedMegamorphicFiveShapeCycle() + { + return _cachedMegamorphic.Invoke( + _sctx, + _megamorphicArgs[_cachedMegamorphicIndex++ % _megamorphicArgs.Length] + ); + } + + CompiledBenchmark _getCompiled(string id) + { + var function = + _application.Functions[id] + ?? throw new InvalidOperationException($"Benchmark function {id} is missing."); + var implementation = + function.CilImplementation + ?? throw new InvalidOperationException($"Benchmark function {id} was not compiled."); + return new(function, implementation, _memberCallOffset(function)); + } + + static int _memberCallOffset(PFunction function) + { + var offset = function.Code.FindIndex(instruction => + instruction.OpCode is OpCode.get or OpCode.sget + ); + if (offset < 0) + throw new InvalidOperationException( + $"Benchmark function {function.Id} has no member call." + ); + return offset; + } + + static void _assertResult(PValue value, object expected) + { + if (!Equals(value.Value, expected)) + throw new InvalidOperationException( + $"CIL benchmark setup produced {value.Value ?? "null"}, expected {expected}." + ); + } + + static void _assertStableBindingCount(CompiledBenchmark benchmark, int expected) + { + var site = benchmark.Function.ObjectMemberCallSites.GetExisting(benchmark.CallOffset); + if (site?.BindingCount != expected) + throw new InvalidOperationException( + $"{benchmark.Function.Id} bound {site?.BindingCount ?? 0} rules; expected {expected}." + ); + } + + static void _assertUncached(CompiledBenchmark benchmark) + { + if (benchmark.Function.ObjectMemberCallSites.GetExisting(benchmark.CallOffset) != null) + throw new InvalidOperationException( + $"Diagnostic baseline {benchmark.Function.Id} unexpectedly created a call site." + ); + } + + void _assertForcedReceiverRebinding() + { + var siteBefore = _cachedFreshReceiver.Function.ObjectMemberCallSites.GetExisting( + _cachedFreshReceiver.CallOffset + ); + var bindingsBefore = siteBefore?.BindingCount ?? 0; + for (var i = 0; i < ObjectMemberCallSite.MaxPolymorphicBindingCount + 2; i++) + _assertResult(CachedFreshReceiver(), 42); + _assertResult(UncachedFreshReceiver(), 42); + + var site = _cachedFreshReceiver.Function.ObjectMemberCallSites.GetExisting( + _cachedFreshReceiver.CallOffset + ); + if ( + site?.BindingCount + != bindingsBefore + ObjectMemberCallSite.MaxPolymorphicBindingCount + 2 + || site.IsMegamorphic != true + || site.CachedBindingCount != ObjectMemberCallSite.MaxPolymorphicBindingCount + ) + throw new InvalidOperationException( + "Fresh receiver tags did not saturate the compiled call site and continue rebinding." + ); + _assertUncached(_uncachedFreshReceiver); + } + + void _assertMegamorphicSite() + { + var site = _cachedMegamorphic.Function.ObjectMemberCallSites.GetExisting( + _cachedMegamorphic.CallOffset + ); + if (site?.IsMegamorphic != true) + throw new InvalidOperationException( + "The five-shape compiled benchmark site did not become megamorphic." + ); + if (site.CachedBindingCount != ObjectMemberCallSite.MaxPolymorphicBindingCount) + throw new InvalidOperationException( + $"The megamorphic site retained {site.CachedBindingCount} bindings; expected " + + $"{ObjectMemberCallSite.MaxPolymorphicBindingCount}." + ); + if (site.BindingCount != 5) + throw new InvalidOperationException( + $"The megamorphic site performed {site.BindingCount} initial bindings; expected 5." + ); + } + + readonly record struct CompiledBenchmark( + PFunction Function, + CilFunction Implementation, + int CallOffset + ) + { + internal PValue Invoke(StackContext sctx, PValue[] args) + { + Implementation(Function, sctx, args, null, out var result, out _); + return result; + } + } + + const string _source = """ + function cachedZero(target) = target.Zero(); + function uncachedZero(target) = target.Zero(); + + function cachedPolymorphic(target, value) = target.Overloaded(value, 2, 3); + function uncachedPolymorphic(target, value) = target.Overloaded(value, 2, 3); + + function cachedStatic() = Prexonite::Benchmarks::CilDispatchProbe.StaticExact(17, 25); + function uncachedStatic() = Prexonite::Benchmarks::CilDispatchProbe.StaticExact(17, 25); + + function cachedWide(target) = + target.WideExact(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16); + function uncachedWide(target) = + target.WideExact(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16); + + function cachedLateGuardMiss(target, last) = + target.WideShape(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, last); + function uncachedLateGuardMiss(target, last) = + target.WideShape(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, last); + + function cachedFreshReceiver(target) = target.Exact(17, 25); + function uncachedFreshReceiver(target) = target.Exact(17, 25); + + function cachedMegamorphic(target, value) = target.Shape(value); + function uncachedMegamorphic(target, value) = target.Shape(value); + """; +} + +public sealed class CilDispatchProbe +{ + public int Zero() + { + return 42; + } + + public int Exact(int left, int right) + { + return left + right; + } + + public static int StaticExact(int left, int right) + { + return left + right; + } + + public object Shape(object value) + { + return value; + } + + public string Overloaded(double value, int second, int third) + { + return "automatic"; + } + + public string Overloaded(int value, double second, double third) + { + return "locked"; + } + + public int WideExact( + int value01, + int value02, + int value03, + int value04, + int value05, + int value06, + int value07, + int value08, + int value09, + int value10, + int value11, + int value12, + int value13, + int value14, + int value15, + int value16 + ) + { + return value01 + + value02 + + value03 + + value04 + + value05 + + value06 + + value07 + + value08 + + value09 + + value10 + + value11 + + value12 + + value13 + + value14 + + value15 + + value16; + } + + public int WideShape( + int value01, + int value02, + int value03, + int value04, + int value05, + int value06, + int value07, + int value08, + int value09, + int value10, + int value11, + int value12, + int value13, + int value14, + int value15, + object value16 + ) + { + return 16; + } +} diff --git a/Prexonite.Benchmarks/NoiseResistantConfig.cs b/Prexonite.Benchmarks/NoiseResistantConfig.cs new file mode 100644 index 00000000..c697b911 --- /dev/null +++ b/Prexonite.Benchmarks/NoiseResistantConfig.cs @@ -0,0 +1,35 @@ +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Environments; +using BenchmarkDotNet.Jobs; +using Perfolizer.Mathematics.OutlierDetection; + +namespace Prexonite.Benchmarks; + +public static class NoiseResistantConfig +{ + public static IConfig Create() + { + var job = Job + .Default.WithId("NoiseResistant-.NET10") + .WithRuntime(CoreRuntime.Core10_0) + .WithLaunchCount(3) + .WithMinIterationCount(15) + .WithMaxIterationCount(100) + .WithMaxRelativeError(0.02) + .WithOutlierMode(OutlierMode.RemoveUpper); + + return ManualConfig + .Create(DefaultConfig.Instance) + .AddJob(job) + .AddDiagnoser(MemoryDiagnoser.Default) + .AddColumn( + StatisticColumn.Median, + StatisticColumn.StdDev, + StatisticColumn.P95, + BaselineRatioColumn.RatioMean + ) + .WithOption(ConfigOptions.StopOnFirstError, true); + } +} diff --git a/Prexonite.Benchmarks/ObjectMemberCallBenchmarks.cs b/Prexonite.Benchmarks/ObjectMemberCallBenchmarks.cs new file mode 100644 index 00000000..b5494b92 --- /dev/null +++ b/Prexonite.Benchmarks/ObjectMemberCallBenchmarks.cs @@ -0,0 +1,376 @@ +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Order; +using Prexonite.Types; + +namespace Prexonite.Benchmarks; + +[CategoriesColumn] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[Orderer(SummaryOrderPolicy.FastestToSlowest)] +public class ObjectMemberCallBenchmarks +{ + const string ExactMember = nameof(DispatchProbe.Exact); + const string OverloadedMember = nameof(DispatchProbe.Overloaded); + const string ShapeMember = nameof(DispatchProbe.Shape); + const string StaticExactMember = nameof(DispatchProbe.StaticExact); + const string WideExactMember = nameof(DispatchProbe.WideExact); + const string WideShapeMember = nameof(DispatchProbe.WideShape); + + readonly Engine _engine = new(); + readonly Application _application = new("benchmarks"); + readonly DispatchProbe _probe = new(); + + BenchmarkStackContext _sctx = null!; + PValue _subject = null!; + ObjectPType _staticType = null!; + DynamicObjectMemberCallSite _exactSite = null!; + DynamicObjectMemberCallSite _overloadedSite = null!; + DynamicObjectMemberCallSite _polymorphicSite = null!; + DynamicObjectMemberCallSite _wideExactSite = null!; + DynamicObjectMemberCallSite _lateGuardMissSite = null!; + DynamicObjectMemberCallSite _freshReceiverTagSite = null!; + DynamicObjectMemberCallSite _freshArgumentTagSite = null!; + StaticObjectMemberCallSite _staticSite = null!; + PValue[] _exactArgs = null!; + PValue[] _overloadedArgs = null!; + PValue[][] _polymorphicArgs = null!; + PValue[] _wideExactArgs = null!; + PValue[][] _lateGuardMissArgs = null!; + PValue[] _uncachedFreshArgumentTagArgs = null!; + PValue[] _cachedFreshArgumentTagArgs = null!; + int _cachedPolymorphicIndex; + int _uncachedPolymorphicIndex; + int _cachedLateGuardMissIndex; + int _uncachedLateGuardMissIndex; + + [GlobalSetup] + public void Setup() + { + _sctx = new(_engine, _application); + _subject = _engine.CreateNativePValue(_probe); + _staticType = PType.Object[typeof(DispatchProbe)]; + _exactArgs = [PType.Int.CreatePValue(17), PType.Int.CreatePValue(25)]; + _overloadedArgs = + [ + PType.Int.CreatePValue(1), + PType.Int.CreatePValue(2), + PType.Int.CreatePValue(3), + ]; + _polymorphicArgs = + [ + _overloadedArgs, + [ + PType.Int.CreatePValue(1).WithTypeLock(), + PType.Int.CreatePValue(2), + PType.Int.CreatePValue(3), + ], + ]; + _wideExactArgs = Enumerable.Range(1, 16).Select(PType.Int.CreatePValue).ToArray(); + _lateGuardMissArgs = + [ + _createWideShapeArgs(PType.Object[typeof(int)].CreatePValue(16)), + _createWideShapeArgs(PType.Object[typeof(string)].CreatePValue("sixteen")), + ]; + _uncachedFreshArgumentTagArgs = new PValue[1]; + _cachedFreshArgumentTagArgs = new PValue[1]; + + _exactSite = new(PCall.Get, ExactMember); + _overloadedSite = new(PCall.Get, OverloadedMember); + _polymorphicSite = new(PCall.Get, OverloadedMember); + _wideExactSite = new(PCall.Get, WideExactMember); + _lateGuardMissSite = new(PCall.Get, WideShapeMember); + _freshReceiverTagSite = new(PCall.Get, ExactMember); + _freshArgumentTagSite = new(PCall.Get, ShapeMember); + _staticSite = new(PCall.Get, StaticExactMember); + + _assertResult(_exactSite.Invoke(_sctx, _subject, _exactArgs), 42); + _assertResult(_overloadedSite.Invoke(_sctx, _subject, _overloadedArgs), "automatic"); + _assertResult(_polymorphicSite.Invoke(_sctx, _subject, _polymorphicArgs[0]), "automatic"); + _assertResult(_polymorphicSite.Invoke(_sctx, _subject, _polymorphicArgs[1]), "locked"); + _assertResult(_wideExactSite.Invoke(_sctx, _subject, _wideExactArgs), 136); + _assertResult(_lateGuardMissSite.Invoke(_sctx, _subject, _lateGuardMissArgs[0]), 16); + _assertResult(_lateGuardMissSite.Invoke(_sctx, _subject, _lateGuardMissArgs[1]), 16); + _assertResult(_staticSite.Invoke(_sctx, _staticType, _exactArgs), 42); + + if (_polymorphicSite.BindingCount != 2) + throw new InvalidOperationException( + "The polymorphic site did not bind exactly two rules." + ); + + _assertForcedRebinding(); + } + + [Benchmark(Baseline = true)] + [BenchmarkCategory("Instance exact")] + public PValue UncachedInstanceExact() + { + return _subject.DynamicCall(_sctx, _exactArgs.AsSpan(), PCall.Get, ExactMember); + } + + [Benchmark] + [BenchmarkCategory("Instance exact")] + public PValue CachedInstanceExact() + { + return _exactSite.Invoke(_sctx, _subject, _exactArgs); + } + + [Benchmark(Baseline = true)] + [BenchmarkCategory("Instance overloaded")] + public PValue UncachedInstanceOverloaded() + { + return _subject.DynamicCall(_sctx, _overloadedArgs.AsSpan(), PCall.Get, OverloadedMember); + } + + [Benchmark] + [BenchmarkCategory("Instance overloaded")] + public PValue CachedInstanceOverloaded() + { + return _overloadedSite.Invoke(_sctx, _subject, _overloadedArgs); + } + + [Benchmark(Baseline = true)] + [BenchmarkCategory("Instance polymorphic")] + public PValue UncachedInstancePolymorphic() + { + return _subject.DynamicCall( + _sctx, + _polymorphicArgs[_uncachedPolymorphicIndex++ & 1].AsSpan(), + PCall.Get, + OverloadedMember + ); + } + + [Benchmark] + [BenchmarkCategory("Instance polymorphic")] + public PValue CachedInstancePolymorphic() + { + return _polymorphicSite.Invoke( + _sctx, + _subject, + _polymorphicArgs[_cachedPolymorphicIndex++ & 1] + ); + } + + [Benchmark(Baseline = true)] + [BenchmarkCategory("Wide cache hit")] + public PValue UncachedWideExact() + { + return _subject.DynamicCall(_sctx, _wideExactArgs.AsSpan(), PCall.Get, WideExactMember); + } + + [Benchmark] + [BenchmarkCategory("Wide cache hit")] + public PValue CachedWideExact() + { + return _wideExactSite.Invoke(_sctx, _subject, _wideExactArgs); + } + + [Benchmark(Baseline = true)] + [BenchmarkCategory("Late guard miss")] + public PValue UncachedLateGuardMiss() + { + return _subject.DynamicCall( + _sctx, + _lateGuardMissArgs[_uncachedLateGuardMissIndex++ & 1].AsSpan(), + PCall.Get, + WideShapeMember + ); + } + + [Benchmark] + [BenchmarkCategory("Late guard miss")] + public PValue CachedLateGuardMiss() + { + return _lateGuardMissSite.Invoke( + _sctx, + _subject, + _lateGuardMissArgs[_cachedLateGuardMissIndex++ & 1] + ); + } + + [Benchmark(Baseline = true)] + [BenchmarkCategory("Rebind: receiver tag")] + public PValue UncachedFreshReceiverTag() + { + var subject = new ObjectPType(typeof(DispatchProbe)).CreatePValue(_probe); + return subject.DynamicCall(_sctx, _exactArgs.AsSpan(), PCall.Get, ExactMember); + } + + [Benchmark] + [BenchmarkCategory("Rebind: receiver tag")] + public PValue CachedFreshReceiverTag() + { + var subject = new ObjectPType(typeof(DispatchProbe)).CreatePValue(_probe); + return _freshReceiverTagSite.Invoke(_sctx, subject, _exactArgs); + } + + [Benchmark(Baseline = true)] + [BenchmarkCategory("Rebind: argument tag")] + public PValue UncachedFreshArgumentTag() + { + _uncachedFreshArgumentTagArgs[0] = new ObjectPType(typeof(int)).CreatePValue(1); + return _subject.DynamicCall( + _sctx, + _uncachedFreshArgumentTagArgs.AsSpan(), + PCall.Get, + ShapeMember + ); + } + + [Benchmark] + [BenchmarkCategory("Rebind: argument tag")] + public PValue CachedFreshArgumentTag() + { + _cachedFreshArgumentTagArgs[0] = new ObjectPType(typeof(int)).CreatePValue(1); + return _freshArgumentTagSite.Invoke(_sctx, _subject, _cachedFreshArgumentTagArgs); + } + + [Benchmark(Baseline = true)] + [BenchmarkCategory("Static exact")] + public PValue UncachedStaticExact() + { + return _staticType.StaticCall(_sctx, _exactArgs, PCall.Get, StaticExactMember); + } + + [Benchmark] + [BenchmarkCategory("Static exact")] + public PValue CachedStaticExact() + { + return _staticSite.Invoke(_sctx, _staticType, _exactArgs); + } + + static void _assertResult(PValue value, object expected) + { + if (!Equals(value.Value, expected)) + throw new InvalidOperationException( + $"Benchmark setup resolved an unexpected overload: {value.Value ?? "null"}." + ); + } + + PValue[] _createWideShapeArgs(PValue last) + { + var args = Enumerable.Range(1, 15).Select(PType.Int.CreatePValue).ToArray(); + Array.Resize(ref args, 16); + args[^1] = last; + return args; + } + + void _assertForcedRebinding() + { + var receiverBindings = _freshReceiverTagSite.BindingCount; + for (var i = 0; i < ObjectMemberCallSite.MaxPolymorphicBindingCount + 2; i++) + _assertResult(CachedFreshReceiverTag(), 42); + if ( + _freshReceiverTagSite.BindingCount + != receiverBindings + ObjectMemberCallSite.MaxPolymorphicBindingCount + 2 + || !_freshReceiverTagSite.IsMegamorphic + || _freshReceiverTagSite.CachedBindingCount + != ObjectMemberCallSite.MaxPolymorphicBindingCount + ) + throw new InvalidOperationException( + "Fresh receiver tags did not saturate the call site and continue rebinding." + ); + + var argumentBindings = _freshArgumentTagSite.BindingCount; + for (var i = 0; i < ObjectMemberCallSite.MaxPolymorphicBindingCount + 2; i++) + _assertResult(CachedFreshArgumentTag(), 1); + if ( + _freshArgumentTagSite.BindingCount + != argumentBindings + ObjectMemberCallSite.MaxPolymorphicBindingCount + 2 + || !_freshArgumentTagSite.IsMegamorphic + || _freshArgumentTagSite.CachedBindingCount + != ObjectMemberCallSite.MaxPolymorphicBindingCount + ) + throw new InvalidOperationException( + "Fresh argument tags did not saturate the call site and continue rebinding." + ); + } + + public sealed class DispatchProbe + { + public int Exact(int left, int right) + { + return left + right; + } + + public static int StaticExact(int left, int right) + { + return left + right; + } + + public string Overloaded(double value, int second, int third) + { + return "automatic"; + } + + public string Overloaded(int value, double second, double third) + { + return "locked"; + } + + public object Shape(object value) + { + return value; + } + + public int WideExact( + int value01, + int value02, + int value03, + int value04, + int value05, + int value06, + int value07, + int value08, + int value09, + int value10, + int value11, + int value12, + int value13, + int value14, + int value15, + int value16 + ) + { + return value01 + + value02 + + value03 + + value04 + + value05 + + value06 + + value07 + + value08 + + value09 + + value10 + + value11 + + value12 + + value13 + + value14 + + value15 + + value16; + } + + public int WideShape( + int value01, + int value02, + int value03, + int value04, + int value05, + int value06, + int value07, + int value08, + int value09, + int value10, + int value11, + int value12, + int value13, + int value14, + int value15, + object value16 + ) + { + return 16; + } + } +} diff --git a/Prexonite.Benchmarks/Prexonite.Benchmarks.csproj b/Prexonite.Benchmarks/Prexonite.Benchmarks.csproj new file mode 100644 index 00000000..e7e520cc --- /dev/null +++ b/Prexonite.Benchmarks/Prexonite.Benchmarks.csproj @@ -0,0 +1,11 @@ + + + Exe + false + + + + + + + diff --git a/Prexonite.Benchmarks/Program.cs b/Prexonite.Benchmarks/Program.cs new file mode 100644 index 00000000..57a8ae08 --- /dev/null +++ b/Prexonite.Benchmarks/Program.cs @@ -0,0 +1,13 @@ +using BenchmarkDotNet.Running; + +namespace Prexonite.Benchmarks; + +public static class Program +{ + public static void Main(string[] args) + { + BenchmarkSwitcher + .FromAssembly(typeof(Program).Assembly) + .Run(args, NoiseResistantConfig.Create()); + } +} diff --git a/Prexonite.Benchmarks/README.md b/Prexonite.Benchmarks/README.md new file mode 100644 index 00000000..10e7a6d5 --- /dev/null +++ b/Prexonite.Benchmarks/README.md @@ -0,0 +1,53 @@ +# Object member dispatch benchmarks + +This project compares the original uncached `ObjectPType` resolver with the cached `CallSite` +paths used by the interpreter and CIL compiler. Both implementations run side by side against the +same Prexonite build: + +- `Uncached*` calls `PValue.DynamicCall` or `ObjectPType.StaticCall`, which still perform member + discovery, filtering, and overload ranking on every invocation. This is the path CIL-compiled + member calls used before callsite emission was added. +- `Cached*` invokes a pre-warmed `DynamicObjectMemberCallSite` or + `StaticObjectMemberCallSite`. It reuses the resolved candidate plan while preserving Prexonite's + conversions and reflection invocation. +- `CilObjectMemberCallBenchmarks` invokes paired generated functions directly. Its `Uncached*` + functions are compiled with the diagnostic callsite opt-out, so the generated-function frame, + argument handling, and return handling are identical on both sides. + +The suite deliberately separates the easy steady-state case from costly cache behavior: + +- `Wide cache hit` validates all 16 argument guards on every successful hit. +- `Instance polymorphic` alternates the explicit-conversion lock, exercising two stable rules. +- `Late guard miss` changes only argument 16, so a failed rule pays for 15 successful guard checks + before the call site updates to its other pre-warmed rule. +- `Rebind: receiver tag` and `Rebind: argument tag` supply a fresh `ObjectPType` reference on every + invocation. The sites are saturated during setup, so these measure the sticky megamorphic + fallback rather than an ever-growing polymorphic rule chain. +- `CIL megamorphic retained hit` saturates a site and then repeatedly uses its newest retained + binding. `CIL megamorphic five-shape cycle` rotates through five shapes, so four hit retained + bindings while the evicted shape is resolved afresh on every cycle. + +Run the benchmarks from the repository root in Release mode: + +```bash +dotnet build -c Release -m:1 Prexonite.Benchmarks/Prexonite.Benchmarks.csproj +dotnet run -c Release --no-build --project Prexonite.Benchmarks -- --filter '*Prexonite.Benchmarks.ObjectMemberCallBenchmarks.*' +``` + +Use `--filter '*CilObjectMemberCallBenchmarks*'` to run only the CIL-generated function suite. + +The default configuration is intended for a desktop that may experience unrelated load: + +- three separate benchmark-process launches; +- BenchmarkDotNet's adaptive pilot and warmup stages; +- 15–100 measurement iterations, continuing until the 99.9% confidence-interval error is at most + 2% of the mean (or the maximum iteration count is reached); +- upper-outlier removal for interruptions caused by other processes; +- mean, median, standard deviation, 95th percentile, and baseline ratio (BenchmarkDotNet adds ratio + variability when it is statistically meaningful); +- `MemoryDiagnoser` allocation and GC statistics. + +Results and raw measurements are written to `BenchmarkDotNet.Artifacts`. Check BenchmarkDotNet's +warnings, the error and `RatioSD` columns, and the generated distribution data before trusting a +small difference. Avoid comparing results from different power modes or while the machine is under +sustained load. diff --git a/Prexonite/Compiler/AST/AstTypeCast.cs b/Prexonite/Compiler/AST/AstTypeCast.cs index 1106db83..79c631f6 100644 --- a/Prexonite/Compiler/AST/AstTypeCast.cs +++ b/Prexonite/Compiler/AST/AstTypeCast.cs @@ -60,9 +60,8 @@ public override bool TryOptimize(CompilerTarget target, [NotNullWhen(true)] out if (Type is not AstConstantTypeExpression constType) return false; - //Constant cast - if (Subject is AstConstant constSubject) - return _tryOptimizeConstCast(target, constSubject, constType, out expr); + // An explicit conversion carries a runtime type lock used by CLR overload resolution. + // Folding it to an ordinary constant would lose that marker. //Redundant cast AstTypecast? castSubject; @@ -83,31 +82,6 @@ public override bool TryOptimize(CompilerTarget target, [NotNullWhen(true)] out return false; } - bool _tryOptimizeConstCast( - CompilerTarget target, - AstConstant constSubject, - AstConstantTypeExpression constType, - [NotNullWhen(true)] out AstExpr? expr - ) - { - expr = null; - PType type; - try - { - type = target.Loader.ConstructPType(constType.TypeExpression); - } - catch (PrexoniteException) - { - //ignore, cast failed. cannot be optimized - return false; - } - - if (constSubject.ToPValue(target).TryConvertTo(target.Loader, type, out var result)) - return AstConstant.TryCreateConstant(target, Position, result, out expr); - else - return false; - } - public NodeApplicationState CheckNodeApplicationState() { return new( diff --git a/Prexonite/Compiler/Cil/Compiler.cs b/Prexonite/Compiler/Cil/Compiler.cs index 9b4e25f4..3170633b 100644 --- a/Prexonite/Compiler/Cil/Compiler.cs +++ b/Prexonite/Compiler/Cil/Compiler.cs @@ -17,6 +17,10 @@ namespace Prexonite.Compiler.Cil; [SuppressMessage("Microsoft.Naming", "CA1724:TypeNamesShouldNotMatchNamespaces")] public static class Compiler { + // Retains the former generated IL for correctness/performance comparisons. + internal const string DisableObjectMemberCallSitesKey = + @"\cil_disable_object_member_call_sites"; + #region Public interface and LCG Setup public static void Compile(Application app, Engine targetEngine) @@ -1490,22 +1494,48 @@ out var sharedNamesEntry case OpCode.get: state.FillArgv(argc); - state.EmitLoadLocal(state.SctxLocal); - state.ReadArgv(argc); - state.EmitLdcI4((int)PCall.Get); - state.Il.Emit(OpCodes.Ldstr, id!); - state.Il.EmitCall(OpCodes.Call, PVDynamicCallMethod, null); + if (state.Source.Meta[DisableObjectMemberCallSitesKey].Switch) + { + state.EmitLoadLocal(state.SctxLocal); + state.ReadArgv(argc); + state.EmitLdcI4((int)PCall.Get); + state.Il.Emit(OpCodes.Ldstr, id!); + state.Il.EmitCall(OpCodes.Call, PVDynamicCallMethod, null); + } + else + { + state.EmitLoadArg(CompilerState.ParamSourceIndex); + state.EmitLdcI4(instructionIndex); + state.EmitLoadLocal(state.SctxLocal); + state.ReadArgv(argc); + state.EmitLdcI4((int)PCall.Get); + state.Il.Emit(OpCodes.Ldstr, id!); + state.EmitCall(Runtime.CallDynamicObjectMemberMethod); + } if (justEffect) state.Il.Emit(OpCodes.Pop); break; case OpCode.set: state.FillArgv(argc); - state.EmitLoadLocal(state.SctxLocal); - state.ReadArgv(argc); - state.EmitLdcI4((int)PCall.Set); - state.Il.Emit(OpCodes.Ldstr, id!); - state.Il.EmitCall(OpCodes.Call, PVDynamicCallMethod, null); + if (state.Source.Meta[DisableObjectMemberCallSitesKey].Switch) + { + state.EmitLoadLocal(state.SctxLocal); + state.ReadArgv(argc); + state.EmitLdcI4((int)PCall.Set); + state.Il.Emit(OpCodes.Ldstr, id!); + state.Il.EmitCall(OpCodes.Call, PVDynamicCallMethod, null); + } + else + { + state.EmitLoadArg(CompilerState.ParamSourceIndex); + state.EmitLdcI4(instructionIndex); + state.EmitLoadLocal(state.SctxLocal); + state.ReadArgv(argc); + state.EmitLdcI4((int)PCall.Set); + state.Il.Emit(OpCodes.Ldstr, id!); + state.EmitCall(Runtime.CallDynamicObjectMemberMethod); + } state.Il.Emit(OpCodes.Pop); break; @@ -1528,11 +1558,24 @@ out var sharedNamesEntry methodId = id[(idx + 2)..]; typeExpr = id[..idx]; state.EmitLoadType(typeExpr); - state.EmitLoadLocal(state.SctxLocal); - state.ReadArgv(argc); - state.EmitLdcI4((int)PCall.Get); - state.Il.Emit(OpCodes.Ldstr, methodId); - state.EmitVirtualCall(Runtime.StaticCallMethod); + if (state.Source.Meta[DisableObjectMemberCallSitesKey].Switch) + { + state.EmitLoadLocal(state.SctxLocal); + state.ReadArgv(argc); + state.EmitLdcI4((int)PCall.Get); + state.Il.Emit(OpCodes.Ldstr, methodId); + state.EmitVirtualCall(Runtime.StaticCallMethod); + } + else + { + state.EmitLoadArg(CompilerState.ParamSourceIndex); + state.EmitLdcI4(instructionIndex); + state.EmitLoadLocal(state.SctxLocal); + state.ReadArgv(argc); + state.EmitLdcI4((int)PCall.Get); + state.Il.Emit(OpCodes.Ldstr, methodId); + state.EmitCall(Runtime.CallStaticObjectMemberMethod); + } if (justEffect) state.Il.Emit(OpCodes.Pop); break; @@ -1547,11 +1590,24 @@ out var sharedNamesEntry methodId = id[(idx + 2)..]; typeExpr = id[..idx]; state.EmitLoadType(typeExpr); - state.EmitLoadLocal(state.SctxLocal); - state.ReadArgv(argc); - state.EmitLdcI4((int)PCall.Set); - state.Il.Emit(OpCodes.Ldstr, methodId); - state.EmitVirtualCall(Runtime.StaticCallMethod); + if (state.Source.Meta[DisableObjectMemberCallSitesKey].Switch) + { + state.EmitLoadLocal(state.SctxLocal); + state.ReadArgv(argc); + state.EmitLdcI4((int)PCall.Set); + state.Il.Emit(OpCodes.Ldstr, methodId); + state.EmitVirtualCall(Runtime.StaticCallMethod); + } + else + { + state.EmitLoadArg(CompilerState.ParamSourceIndex); + state.EmitLdcI4(instructionIndex); + state.EmitLoadLocal(state.SctxLocal); + state.ReadArgv(argc); + state.EmitLdcI4((int)PCall.Set); + state.Il.Emit(OpCodes.Ldstr, methodId); + state.EmitCall(Runtime.CallStaticObjectMemberMethod); + } state.Il.Emit(OpCodes.Pop); break; diff --git a/Prexonite/Compiler/Cil/Runtime.cs b/Prexonite/Compiler/Cil/Runtime.cs index 23d1ac6f..ecbeb3ae 100644 --- a/Prexonite/Compiler/Cil/Runtime.cs +++ b/Prexonite/Compiler/Cil/Runtime.cs @@ -97,6 +97,34 @@ public static class Runtime [typeof(StackContext), typeof(PValue[]), typeof(PCall), typeof(string)] )!; + public static MethodInfo CallDynamicObjectMemberMethod { get; } = + typeof(Runtime).GetMethod( + nameof(CallDynamicObjectMember), + [ + typeof(PValue), + typeof(PFunction), + typeof(int), + typeof(StackContext), + typeof(PValue[]), + typeof(PCall), + typeof(string), + ] + )!; + + public static MethodInfo CallStaticObjectMemberMethod { get; } = + typeof(Runtime).GetMethod( + nameof(CallStaticObjectMember), + [ + typeof(PType), + typeof(PFunction), + typeof(int), + typeof(StackContext), + typeof(PValue[]), + typeof(PCall), + typeof(string), + ] + )!; + public static MethodInfo CallInternalFunctionMethod { get; } = typeof(Runtime).GetMethod(nameof(CallInternalFunction))!; @@ -136,6 +164,44 @@ public static class Runtime // ReSharper restore InconsistentNaming // ReSharper disable UnusedMember.Global + public static PValue CallDynamicObjectMember( + PValue subject, + PFunction source, + int instructionOffset, + StackContext sctx, + PValue[] args, + PCall call, + string id + ) + { + if (subject.Type is not ObjectPType objectType) + return subject.DynamicCall(sctx, args, call, id); + + if (objectType.TryDynamicCallOverride(sctx, subject, args, call, id, false, out var result)) + return result; + + return source + .ObjectMemberCallSites.GetDynamic(instructionOffset, call, id) + .Invoke(sctx, subject, args); + } + + public static PValue CallStaticObjectMember( + PType type, + PFunction source, + int instructionOffset, + StackContext sctx, + PValue[] args, + PCall call, + string id + ) + { + return type is ObjectPType objectType + ? source + .ObjectMemberCallSites.GetStatic(instructionOffset, call, id) + .Invoke(sctx, objectType, args) + : type.StaticCall(sctx, args, call, id); + } + public static PValue LoadGlobalVariableReferenceAsPValue( StackContext sctx, string id, @@ -330,16 +396,15 @@ public static PValue CheckType(PValue obj, PValue type) public static PValue CastConst(PValue obj, PType type, StackContext sctx) { - return obj.ConvertTo(sctx, type, true); + return obj.ConvertTo(sctx, type, true).WithTypeLock(); } public static PValue Cast(PValue obj, PValue type, StackContext sctx) { - return obj.ConvertTo( - sctx, - (PType)(type.Value ?? throw new PrexoniteException("Cast requires a type value.")), - true + var target = (PType)( + type.Value ?? throw new PrexoniteException("Cast requires a type value.") ); + return obj.ConvertTo(sctx, target, true).WithTypeLock(); } //[System.Diagnostics.DebuggerHidden] diff --git a/Prexonite/FunctionContext.cs b/Prexonite/FunctionContext.cs index 1fc86269..77bfb6ca 100644 --- a/Prexonite/FunctionContext.cs +++ b/Prexonite/FunctionContext.cs @@ -1,7 +1,6 @@ #region Namespace Imports using System.Diagnostics; -using System.Reflection; using JetBrains.Annotations; using Prexonite.Commands; using Prexonite.Commands.Core; @@ -301,10 +300,8 @@ bool _performNextCycle(StackContext? lastContext, bool needToReturn) var t = ins.GenericArgument as PType; PVariable? pvar; PFunction? func; - //used by static calls int idx; string methodId; - MemberInfo? member; #region OPCODE HANDLING @@ -656,7 +653,7 @@ out var sharedNamesEntry case OpCode.cast_arg: t = (PType)(Pop().Value ?? PType.Null); cast_type: - Push(Pop().ConvertTo(this, t, true)); + Push(Pop().ConvertTo(this, t, true).WithTypeLock()); break; #endregion @@ -670,7 +667,27 @@ out var sharedNamesEntry case OpCode.get: _fillArgs(argc, out argv); left = Pop(); - right = left.DynamicCall(this, argv, PCall.Get, id!); + if (left.Type is ObjectPType getObjectType) + { + if ( + !getObjectType.TryDynamicCallOverride( + this, + left, + argv, + PCall.Get, + id!, + false, + out var overrideResult + ) + ) + right = Implementation + .ObjectMemberCallSites.GetDynamic(Pointer - 1, PCall.Get, id!) + .Invoke(this, left, argv); + else + right = overrideResult; + } + else + right = left.DynamicCall(this, argv, PCall.Get, id!); if (!justEffect) Push(right); needToReturn = true; @@ -678,7 +695,25 @@ out var sharedNamesEntry case OpCode.set: _fillArgs(argc, out argv); left = Pop(); - left.DynamicCall(this, argv, PCall.Set, id!); + if (left.Type is ObjectPType setObjectType) + { + if ( + !setObjectType.TryDynamicCallOverride( + this, + left, + argv, + PCall.Set, + id!, + false, + out _ + ) + ) + Implementation + .ObjectMemberCallSites.GetDynamic(Pointer - 1, PCall.Set, id!) + .Invoke(this, left, argv); + } + else + left.DynamicCall(this, argv, PCall.Set, id!); needToReturn = true; break; @@ -695,10 +730,7 @@ out var sharedNamesEntry ); needToReturn = true; methodId = id[(idx + 2)..]; - member = ins.GenericArgument as MemberInfo; - if (member != null) - goto callByMemberGet; - else if ((object?)t != null) + if ((object?)t != null) { // nothing } @@ -708,24 +740,13 @@ out var sharedNamesEntry var typeExpr = id[..idx]; t = ConstructPType(typeExpr); ins.GenericArgument = t; - if (t is ObjectPType objT) - { - //Try to get a member info - right = objT.StaticCall(this, argv, PCall.Get, methodId, out member); - if (!justEffect) - Push(right); - if (member != null) - ins.GenericArgument = member; - break; - } } - right = t.StaticCall(this, argv, PCall.Get, methodId); - if (!justEffect) - Push(right); - break; - callByMemberGet: - right = ObjectPType._execute(this, member, argv, PCall.Get, methodId, null); + right = t is ObjectPType staticGetObjectType + ? Implementation + .ObjectMemberCallSites.GetStatic(Pointer - 1, PCall.Get, methodId) + .Invoke(this, staticGetObjectType, argv) + : t.StaticCall(this, argv, PCall.Get, methodId); if (!justEffect) Push(right); break; @@ -739,10 +760,7 @@ out var sharedNamesEntry ); needToReturn = true; methodId = id[(idx + 2)..]; - member = ins.GenericArgument as MemberInfo; - if (member != null) - goto callByMemberSet; - else if ((object?)t != null) + if ((object?)t != null) { // nothing } @@ -752,21 +770,14 @@ out var sharedNamesEntry var typeExpr = id[..idx]; t = ConstructPType(typeExpr); ins.GenericArgument = t; - if (t is ObjectPType) - { - //Try to get a member info - var objT = (ObjectPType)t; - objT.StaticCall(this, argv, PCall.Set, methodId, out member); - if (member != null) - ins.GenericArgument = member; - break; - } } - t.StaticCall(this, argv, PCall.Set, methodId); - break; - callByMemberSet: - ObjectPType._execute(this, member, argv, PCall.Set, methodId, null); + if (t is ObjectPType staticSetObjectType) + Implementation + .ObjectMemberCallSites.GetStatic(Pointer - 1, PCall.Set, methodId) + .Invoke(this, staticSetObjectType, argv); + else + t.StaticCall(this, argv, PCall.Set, methodId); break; #endregion diff --git a/Prexonite/ObjectMemberCallSite.cs b/Prexonite/ObjectMemberCallSite.cs new file mode 100644 index 00000000..59368411 --- /dev/null +++ b/Prexonite/ObjectMemberCallSite.cs @@ -0,0 +1,346 @@ +using System.Collections.ObjectModel; +using System.Linq.Expressions; +using System.Runtime.CompilerServices; +using Prexonite.Types; + +namespace Prexonite; + +internal delegate PValue DynamicObjectMemberTarget( + CallSite site, + StackContext sctx, + PValue subject, + PValue[] args +); + +internal delegate PValue StaticObjectMemberTarget( + CallSite site, + StackContext sctx, + ObjectPType objectType, + PValue[] args +); + +internal abstract class ObjectMemberCallSite(PCall call, string id) +{ + internal const int MaxPolymorphicBindingCount = 4; + + protected PCall Call { get; } = call; + protected string Id { get; } = id; + + internal abstract bool Describes(PCall call, string id, bool isStatic); + internal abstract int BindingCount { get; } + internal abstract int CachedBindingCount { get; } + internal abstract bool IsMegamorphic { get; } +} + +internal sealed class DynamicObjectMemberCallSite : ObjectMemberCallSite +{ + readonly DynamicObjectMemberBinder _binder; + readonly CallSite _site; + + internal DynamicObjectMemberCallSite(PCall call, string id) + : base(call, id) + { + _binder = new(call, id); + _site = CallSite.Create(_binder); + } + + internal PValue Invoke(StackContext sctx, PValue subject, PValue[] args) + { + return _site.Target(_site, sctx, subject, args); + } + + internal override bool Describes(PCall call, string id, bool isStatic) + { + return !isStatic && call == Call && string.Equals(id, Id, StringComparison.Ordinal); + } + + internal override int BindingCount => _binder.BindingCount; + internal override int CachedBindingCount => _binder.CachedBindingCount; + internal override bool IsMegamorphic => _binder.IsMegamorphic; +} + +internal sealed class StaticObjectMemberCallSite : ObjectMemberCallSite +{ + readonly StaticObjectMemberBinder _binder; + readonly CallSite _site; + + internal StaticObjectMemberCallSite(PCall call, string id) + : base(call, id) + { + _binder = new(call, id); + _site = CallSite.Create(_binder); + } + + internal PValue Invoke(StackContext sctx, ObjectPType objectType, PValue[] args) + { + return _site.Target(_site, sctx, objectType, args); + } + + internal override bool Describes(PCall call, string id, bool isStatic) + { + return isStatic && call == Call && string.Equals(id, Id, StringComparison.Ordinal); + } + + internal override int BindingCount => _binder.BindingCount; + internal override int CachedBindingCount => _binder.CachedBindingCount; + internal override bool IsMegamorphic => _binder.IsMegamorphic; +} + +internal sealed class ObjectMemberCallSiteTable(int instructionCount) +{ + readonly object _resizeLock = new(); + ObjectMemberCallSite?[] _sites = new ObjectMemberCallSite?[instructionCount]; + + internal DynamicObjectMemberCallSite GetDynamic(int instructionOffset, PCall call, string id) + { + return (DynamicObjectMemberCallSite)_get( + instructionOffset, + call, + id, + false, + static (call, id) => new DynamicObjectMemberCallSite(call, id) + ); + } + + internal StaticObjectMemberCallSite GetStatic(int instructionOffset, PCall call, string id) + { + return (StaticObjectMemberCallSite)_get( + instructionOffset, + call, + id, + true, + static (call, id) => new StaticObjectMemberCallSite(call, id) + ); + } + + ObjectMemberCallSite _get( + int instructionOffset, + PCall call, + string id, + bool isStatic, + Func create + ) + { + var sites = _getSites(instructionOffset); + var site = Volatile.Read(ref sites[instructionOffset]); + if (site == null) + { + var newSite = create(call, id); + site = + Interlocked.CompareExchange(ref sites[instructionOffset], newSite, null) ?? newSite; + } + + if (!site.Describes(call, id, isStatic)) + throw new PrexoniteException( + $"Instruction {instructionOffset} attempted to use two incompatible object member call sites." + ); + + return site; + } + + ObjectMemberCallSite?[] _getSites(int instructionOffset) + { + var sites = Volatile.Read(ref _sites); + if (instructionOffset < sites.Length) + return sites; + + lock (_resizeLock) + { + sites = _sites; + if (instructionOffset >= sites.Length) + { + var newLength = Math.Max(instructionOffset + 1, Math.Max(4, sites.Length * 2)); + Array.Resize(ref sites, newLength); + Volatile.Write(ref _sites, sites); + } + } + + return sites; + } + + internal ObjectMemberCallSite? GetExisting(int instructionOffset) + { + var sites = Volatile.Read(ref _sites); + return instructionOffset < sites.Length + ? Volatile.Read(ref sites[instructionOffset]) + : null; + } +} + +internal abstract class ObjectMemberBinder(PCall call, string id) : CallSiteBinder +{ + readonly object _bindingLock = new(); + ObjectPType.MemberBinding[] _cachedBindings = []; + int _bindingCount; + int _isMegamorphic; + + protected PCall Call { get; } = call; + protected string Id { get; } = id; + internal int BindingCount => Volatile.Read(ref _bindingCount); + internal int CachedBindingCount => Volatile.Read(ref _cachedBindings).Length; + internal bool IsMegamorphic => Volatile.Read(ref _isMegamorphic) != 0; + + protected T BindRule( + Func matchesCurrentCall, + Func bind, + Func createGuardedTarget, + Func createMegamorphicTarget + ) + where T : class + { + lock (_bindingLock) + { + var cachedBindings = _cachedBindings; + if (_isMegamorphic != 0) + return createMegamorphicTarget(cachedBindings); + + // Two callers can miss the DLR rule cache concurrently. Avoid retaining the + // same Prexonite binding twice when the first caller wins that race. + foreach (var cachedBinding in cachedBindings) + if (matchesCurrentCall(cachedBinding)) + return createGuardedTarget(cachedBinding); + + Interlocked.Increment(ref _bindingCount); + var newBinding = bind(); + if (cachedBindings.Length < ObjectMemberCallSite.MaxPolymorphicBindingCount) + { + var newBindings = new ObjectPType.MemberBinding[cachedBindings.Length + 1]; + newBindings[0] = newBinding; + Array.Copy(cachedBindings, 0, newBindings, 1, cachedBindings.Length); + Volatile.Write(ref _cachedBindings, newBindings); + return createGuardedTarget(newBinding); + } + + // Keep the four most recent shapes. The returned target handles all future + // calls itself, so the DLR will install it as a sticky megamorphic rule. + var retainedBindings = new ObjectPType.MemberBinding[ + ObjectMemberCallSite.MaxPolymorphicBindingCount + ]; + retainedBindings[0] = newBinding; + Array.Copy(cachedBindings, 0, retainedBindings, 1, retainedBindings.Length - 1); + Volatile.Write(ref _cachedBindings, retainedBindings); + Volatile.Write(ref _isMegamorphic, 1); + return createMegamorphicTarget(retainedBindings); + } + } + + protected ObjectPType.MemberBinding BindUncached(Func bind) + { + Interlocked.Increment(ref _bindingCount); + return bind(); + } + + public override Expression Bind( + object[] args, + ReadOnlyCollection parameters, + LabelTarget returnLabel + ) + { + throw new NotSupportedException("This binder supplies call-site delegates directly."); + } +} + +internal sealed class DynamicObjectMemberBinder(PCall call, string id) + : ObjectMemberBinder(call, id) +{ + public override T BindDelegate(CallSite site, object[] args) + { + if (typeof(T) != typeof(DynamicObjectMemberTarget)) + return null!; + + var sctx = (StackContext)args[0]; + var subject = (PValue)args[1]; + var arguments = (PValue[])args[2]; + var objectType = (ObjectPType)subject.Type; + var target = BindRule( + binding => binding.MatchesDynamic(subject, arguments), + () => objectType.BindDynamicMember(sctx, subject, arguments, Call, Id), + _createGuardedTarget, + _createMegamorphicTarget + ); + return (T)(object)target; + } + + static DynamicObjectMemberTarget _createGuardedTarget(ObjectPType.MemberBinding binding) + { + return (callSite, context, receiver, actualArguments) => + { + if (binding.MatchesDynamic(receiver, actualArguments)) + return binding.Invoke(context, receiver, actualArguments); + + return ((CallSite)callSite).Update( + callSite, + context, + receiver, + actualArguments + ); + }; + } + + DynamicObjectMemberTarget _createMegamorphicTarget(ObjectPType.MemberBinding[] retainedBindings) + { + return (_, context, receiver, actualArguments) => + { + foreach (var binding in retainedBindings) + if (binding.MatchesDynamic(receiver, actualArguments)) + return binding.Invoke(context, receiver, actualArguments); + + var objectType = (ObjectPType)receiver.Type; + var rebound = BindUncached(() => + objectType.BindDynamicMember(context, receiver, actualArguments, Call, Id) + ); + return rebound.Invoke(context, receiver, actualArguments); + }; + } +} + +internal sealed class StaticObjectMemberBinder(PCall call, string id) : ObjectMemberBinder(call, id) +{ + public override T BindDelegate(CallSite site, object[] args) + { + if (typeof(T) != typeof(StaticObjectMemberTarget)) + return null!; + + var sctx = (StackContext)args[0]; + var objectType = (ObjectPType)args[1]; + var arguments = (PValue[])args[2]; + var target = BindRule( + binding => binding.MatchesStatic(objectType, arguments), + () => objectType.BindStaticMember(sctx, arguments, Call, Id), + _createGuardedTarget, + _createMegamorphicTarget + ); + return (T)(object)target; + } + + static StaticObjectMemberTarget _createGuardedTarget(ObjectPType.MemberBinding binding) + { + return (callSite, context, actualType, actualArguments) => + { + if (binding.MatchesStatic(actualType, actualArguments)) + return binding.Invoke(context, null, actualArguments); + + return ((CallSite)callSite).Update( + callSite, + context, + actualType, + actualArguments + ); + }; + } + + StaticObjectMemberTarget _createMegamorphicTarget(ObjectPType.MemberBinding[] retainedBindings) + { + return (_, context, actualType, actualArguments) => + { + foreach (var binding in retainedBindings) + if (binding.MatchesStatic(actualType, actualArguments)) + return binding.Invoke(context, null, actualArguments); + + var rebound = BindUncached(() => + actualType.BindStaticMember(context, actualArguments, Call, Id) + ); + return rebound.Invoke(context, null, actualArguments); + }; + } +} diff --git a/Prexonite/PFunction.cs b/Prexonite/PFunction.cs index 3d324f6b..82605789 100644 --- a/Prexonite/PFunction.cs +++ b/Prexonite/PFunction.cs @@ -16,6 +16,8 @@ namespace Prexonite; /// public class PFunction : IHasMetaTable, IIndirectCall, IStackAware, IDependent { + ObjectMemberCallSiteTable? _objectMemberCallSites; + /// /// The meta key under which the function's id is stored. /// @@ -159,6 +161,12 @@ public SymbolTable LocalVariableMapping public bool IsMacro => Declaration.IsMacro; + internal ObjectMemberCallSiteTable ObjectMemberCallSites => + LazyInitializer.EnsureInitialized( + ref _objectMemberCallSites, + () => new ObjectMemberCallSiteTable(Code.Count) + ); + #endregion #region Storage diff --git a/Prexonite/PValue.cs b/Prexonite/PValue.cs index 7466a978..f0c5f4cd 100644 --- a/Prexonite/PValue.cs +++ b/Prexonite/PValue.cs @@ -64,6 +64,10 @@ public sealed class PValue : DynamicObject, IIndirectCall, IObject /// The type of the value inside the Prexonite VM. [DebuggerStepThrough] public PValue(object? value, PType type) + : this(value, type, false) { } + + [DebuggerStepThrough] + PValue(object? value, PType type, bool isTypeLocked) { if (value == null) type = NullPType.Instance; @@ -72,6 +76,7 @@ public PValue(object? value, PType type) Value = value; Type = type; + IsTypeLocked = isTypeLocked; } /// @@ -87,6 +92,20 @@ public PValue(object? value, PType type) /// An instance of . public PType Type { get; } + /// + /// Indicates that this value is the immediate result of an explicit Prexonite conversion. + /// CLR member resolution must not implicitly convert such a value again. + /// + public bool IsTypeLocked { get; } + + /// + /// Returns a copy whose type is locked for CLR overload resolution. + /// + internal PValue WithTypeLock() + { + return IsTypeLocked ? this : new(Value, Type, true); + } + #endregion #region PType proxy methods diff --git a/Prexonite/Properties/AssemblyInfo.cs b/Prexonite/Properties/AssemblyInfo.cs index 315e7037..a9ed1635 100644 --- a/Prexonite/Properties/AssemblyInfo.cs +++ b/Prexonite/Properties/AssemblyInfo.cs @@ -22,4 +22,5 @@ // Makes internal members of Prexonite visible to the unit testing project // PrexoniteTests. [assembly: InternalsVisibleTo("PrexoniteTests")] +[assembly: InternalsVisibleTo("Prexonite.Benchmarks")] [assembly: InternalsVisibleTo("Prx")] diff --git a/Prexonite/Types/ObjectPType.cs b/Prexonite/Types/ObjectPType.cs index 89008362..5f858078 100644 --- a/Prexonite/Types/ObjectPType.cs +++ b/Prexonite/Types/ObjectPType.cs @@ -167,6 +167,29 @@ bool suppressIObject id ??= ""; + if (TryDynamicCallOverride(sctx, subject, args, call, id, suppressIObject, out result)) + return true; + + var resolution = _resolveDynamicMembers(sctx, subject, args, call, id); + if (resolution.Candidates.Length == 1) + resolvedMember = resolution.Candidates[0]; + var ret = _try_execute(resolution.Candidates, resolution.Conditions, subject, out result); + if (!ret) + resolvedMember = null; + return ret; + } + + internal bool TryDynamicCallOverride( + StackContext sctx, + PValue subject, + ReadOnlySpan args, + PCall call, + string id, + bool suppressIObject, + [NotNullWhen(true)] out PValue? result + ) + { + result = null; if ( !suppressIObject && subject.Value is IObject iobj @@ -174,7 +197,7 @@ bool suppressIObject ) return true; - //Special interop members + // Special interop members do not use CLR reflection and remain outside call sites. switch (id.ToLowerInvariant()) { case @"\implements": @@ -202,6 +225,29 @@ arg.Type is ObjectPType objTy return true; } + return false; + } + + internal MemberBinding BindDynamicMember( + StackContext sctx, + PValue subject, + ReadOnlySpan args, + PCall call, + string id + ) + { + var resolution = _resolveDynamicMembers(sctx, subject, args, call, id); + return new(this, args, call, id, false, subject.Value is Array, resolution.Candidates); + } + + (CallConditions Conditions, ImmutableArray Candidates) _resolveDynamicMembers( + StackContext sctx, + PValue subject, + ReadOnlySpan args, + PCall call, + string id + ) + { var cond = new CallConditions(sctx, args.ToImmutableArray().AsMemory(), call, id); MemberTypes mtypes; MemberFilter filter; @@ -210,7 +256,7 @@ arg.Type is ObjectPType objTy filter = _member_filter; if (id.LastIndexOf('\\') == 0) - return false; //Default index accessors do not accept calling directives + return (cond, []); mtypes = MemberTypes.Event | MemberTypes.Field | MemberTypes.Method | MemberTypes.Property; } @@ -255,14 +301,7 @@ arg.Type is ObjectPType objTy ) .ToImmutableArray(); - if (candidates.Length == 1) - resolvedMember = candidates[0]; - - var ret = _try_execute(candidates, cond, subject, out result); - if (!ret) //Call did not succeed -> member invalid - resolvedMember = null; - - return ret; + return (cond, candidates); } public override bool TryStaticCall( @@ -291,6 +330,33 @@ out MemberInfo? resolvedMember result = null; resolvedMember = null; + var resolution = _resolveStaticMembers(sctx, args, call, id); + if (resolution.Candidates.Length == 1) + resolvedMember = resolution.Candidates[0]; + var ret = _try_execute(resolution.Candidates, resolution.Conditions, null, out result); + if (!ret) + resolvedMember = null; + return ret; + } + + internal MemberBinding BindStaticMember( + StackContext sctx, + ReadOnlySpan args, + PCall call, + string id + ) + { + var resolution = _resolveStaticMembers(sctx, args, call, id); + return new(this, args, call, id, true, false, resolution.Candidates); + } + + (CallConditions Conditions, ImmutableArray Candidates) _resolveStaticMembers( + StackContext sctx, + ReadOnlySpan args, + PCall call, + string id + ) + { var cond = new CallConditions(sctx, args.ToImmutableArray().AsMemory(), call, id); MemberTypes mtypes; MemberFilter filter; @@ -298,7 +364,7 @@ out MemberInfo? resolvedMember { filter = _member_filter; if (id.LastIndexOf('\\') == 0) - return false; //Default index accessors do not accept calling directives + return (cond, []); mtypes = MemberTypes.Event | MemberTypes.Field | MemberTypes.Method | MemberTypes.Property; } @@ -324,13 +390,7 @@ out MemberInfo? resolvedMember ) .ToImmutableArray(); //Filter - if (candidates.Length == 1) - resolvedMember = candidates[0]; - - var ret = _try_execute(candidates, cond, null, out result); - if (!ret) //Call did not succeed -> member invalid - resolvedMember = null; - return ret; + return (cond, candidates); } bool _try_call_conversion_operator( @@ -572,7 +632,7 @@ static bool _try_execute_single( for (var i = 0; i < cargs.Length; i++) { var arg = sargs.Span[i]; - if (!arg.IsNull) //Neither Type-locked nor null + if (!(arg.IsTypeLocked || arg.IsNull)) { var pTy = parameters[i].ParameterType; var aTy = arg.ClrType; @@ -621,7 +681,7 @@ exc.InnerException is PrexoniteRuntimeException else { var arg = cond.Args.Span[0]; - if (!(arg.IsNull)) //Neither Type-locked nor null + if (!(arg.IsTypeLocked || arg.IsNull)) { var paramTy = field.FieldType; var argTy = arg.ClrType; @@ -719,6 +779,86 @@ internal static PValue _execute( throw new InvalidCallException(sb.ToString()); } + internal sealed class MemberBinding + { + readonly ObjectPType _owner; + readonly Type?[] _argumentTypes; + readonly PType[] _argumentPTypes; + readonly bool[] _argumentTypeLocks; + readonly PCall _call; + readonly string _id; + readonly bool _isStatic; + readonly bool _subjectIsArray; + readonly ImmutableArray _candidates; + + internal MemberBinding( + ObjectPType owner, + ReadOnlySpan args, + PCall call, + string id, + bool isStatic, + bool subjectIsArray, + ImmutableArray candidates + ) + { + _owner = owner; + _call = call; + _id = id; + _isStatic = isStatic; + _subjectIsArray = subjectIsArray; + _candidates = candidates; + _argumentTypes = new Type?[args.Length]; + _argumentPTypes = new PType[args.Length]; + _argumentTypeLocks = new bool[args.Length]; + for (var i = 0; i < args.Length; i++) + { + _argumentTypes[i] = args[i].ClrType; + _argumentPTypes[i] = args[i].Type; + _argumentTypeLocks[i] = args[i].IsTypeLocked; + } + } + + internal bool MatchesDynamic(PValue subject, ReadOnlySpan args) + { + return !_isStatic + && ReferenceEquals(subject.Type, _owner) + && (subject.Value is Array) == _subjectIsArray + && _argumentsMatch(args); + } + + internal bool MatchesStatic(ObjectPType objectType, ReadOnlySpan args) + { + return _isStatic && objectType.ClrType == _owner.ClrType && _argumentsMatch(args); + } + + bool _argumentsMatch(ReadOnlySpan args) + { + if (args.Length != _argumentTypes.Length) + return false; + + for (var i = 0; i < args.Length; i++) + if ( + args[i].ClrType != _argumentTypes[i] + || !ReferenceEquals(args[i].Type, _argumentPTypes[i]) + || args[i].IsTypeLocked != _argumentTypeLocks[i] + ) + return false; + + return true; + } + + internal PValue Invoke(StackContext sctx, PValue? subject, PValue[] args) + { + var cond = new CallConditions(sctx, args.AsMemory(), _call, _id); + if (_try_execute(_candidates, cond, subject, out var result)) + return result; + + throw _isStatic + ? _owner._createStaticCallException(args, _id) + : _createDynamicCallException(subject!, args, _id); + } + } + [DebuggerStepThrough] class CallConditions { @@ -818,7 +958,8 @@ static bool _member_filter(MemberInfo candidate, object? arg) { //Get+Field = 0 Parameters, Set+Field = 1 Parameter FieldInfo when cond.Call == PCall.Get => cond.Args.Length == 0, - FieldInfo => cond.Args.Length == 1, + FieldInfo field => cond.Args.Length == 1 + && _type_lock_allows(cond.Args.Span[0], field.FieldType), PropertyInfo property when cond.Call == PCall.Get => property.CanRead && _method_filter(property.GetGetMethod()!, cond), //cond.Call == PCall.Set @@ -877,6 +1018,12 @@ static bool _method_filter(MethodBase method, CallConditions cond) if (cond.Args.Length != parameters.Length) return false; + // An explicit Prexonite conversion locks the resulting CLR type. This lets callers + // rule out overloads which would require another automatic conversion. + for (var i = 0; i < parameters.Length; i++) + if (!_type_lock_allows(cond.Args.Span[i], parameters[i].ParameterType)) + return false; + //optional Criteria No.3: Return types must match if (cond.ReturnType != null && method is MethodInfo methodInfo) { @@ -895,6 +1042,15 @@ static bool _method_filter(MethodBase method, CallConditions cond) return true; } + static bool _type_lock_allows(PValue arg, Type parameterType) + { + if (!arg.IsTypeLocked || arg.IsNull) + return true; + + var argumentType = arg.ClrType; + return parameterType == argumentType || parameterType.IsAssignableFrom(argumentType); + } + public override bool IndirectCall( StackContext sctx, PValue subject, @@ -923,24 +1079,31 @@ out MemberInfo? resolvedMember ) { if (!tryDynamicCall(sctx, subject, args, call, id, out var result, out resolvedMember)) + throw _createDynamicCallException(subject, args, id); + return result; + } + + static InvalidCallException _createDynamicCallException( + PValue subject, + ReadOnlySpan args, + string id + ) + { + var sb = new StringBuilder(); + sb.Append("Cannot resolve call '"); + sb.Append(id); + sb.Append("' on object of type "); + sb.Append(subject.IsNull ? "null" : subject.ClrType!.FullName); + sb.Append(" with ("); + foreach (var arg in args) { - var sb = new StringBuilder(); - sb.Append("Cannot resolve call '"); - sb.Append(id); - sb.Append("' on object of type "); - sb.Append(subject.IsNull ? "null" : subject.ClrType!.FullName); - sb.Append(" with ("); - foreach (var arg in args) - { - sb.Append(arg); - sb.Append(", "); - } - if (args.Length > 0) - sb.Length -= 2; - sb.Append(")."); - throw new InvalidCallException(sb.ToString()); + sb.Append(arg); + sb.Append(", "); } - return result; + if (args.Length > 0) + sb.Length -= 2; + sb.Append(")."); + return new(sb.ToString()); } public override PValue DynamicCall( @@ -963,24 +1126,27 @@ out MemberInfo? resolvedMember ) { if (!tryStaticCall(sctx, args, call, id, out var result, out resolvedMember)) + throw _createStaticCallException(args, id); + return result; + } + + InvalidCallException _createStaticCallException(ReadOnlySpan args, string id) + { + var sb = new StringBuilder(); + sb.Append("Cannot resolve static call '"); + sb.Append(id); + sb.Append("' on type "); + sb.Append(ClrType.FullName); + sb.Append(" with ("); + foreach (var arg in args) { - var sb = new StringBuilder(); - sb.Append("Cannot resolve static call '"); - sb.Append(id); - sb.Append("' on type "); - sb.Append(ClrType.FullName); - sb.Append(" with ("); - foreach (var arg in args) - { - sb.Append(arg); - sb.Append(", "); - } - if (args.Length > 0) - sb.Length -= 2; - sb.Append(")."); - throw new InvalidCallException(sb.ToString()); + sb.Append(arg); + sb.Append(", "); } - return result; + if (args.Length > 0) + sb.Length -= 2; + sb.Append(")."); + return new(sb.ToString()); } public override PValue StaticCall(StackContext sctx, PValue[] args, PCall call, string id) diff --git a/PrexoniteTests/Tests/Compiler.Parser.cs b/PrexoniteTests/Tests/Compiler.Parser.cs index a51601bc..2836fe83 100644 --- a/PrexoniteTests/Tests/Compiler.Parser.cs +++ b/PrexoniteTests/Tests/Compiler.Parser.cs @@ -569,7 +569,14 @@ @func.0 action2 label endif2 //Constant + ldc.bool true + cast.const Bool + jump.f elseConstant @func.0 action1 + jump endifConstant + label elseConstant + @func.0 action2 + label endifConstant //Complex ldc.string "===========COMPLEX============" diff --git a/PrexoniteTests/Tests/ObjectMemberCallSiteTests.cs b/PrexoniteTests/Tests/ObjectMemberCallSiteTests.cs new file mode 100644 index 00000000..d872d509 --- /dev/null +++ b/PrexoniteTests/Tests/ObjectMemberCallSiteTests.cs @@ -0,0 +1,448 @@ +using NUnit.Framework; +using Prexonite; +using Prexonite.Compiler; +using Prexonite.Types; +using CilCompiler = Prexonite.Compiler.Cil.Compiler; + +namespace PrexoniteTests.Tests; + +[TestFixture] +[Parallelizable(ParallelScope.Fixtures)] +public class ObjectMemberCallSiteTests : VMTestsBase +{ + public ObjectMemberCallSiteTests() + { + CompileToCil = false; + } + + [Test] + public void InterpreterCallSiteDistinguishesExplicitlyConvertedArguments() + { + Compile( + """ + function invokeProbe(target, value) = target.Select(value, 2, 3); + + function main(target, value) = + [invokeProbe(target, value), + invokeProbe(target, value~Int), + invokeProbe(target, value)]; + """ + ); + + Expect( + result => + { + var values = (List)result.Value!; + Assert.That( + values.Select(value => value.Value), + Is.EqualTo( + new[] { "automatic conversion", "type locked", "automatic conversion" } + ) + ); + }, + engine.CreateNativePValue(new OverloadProbe()), + PType.Int.CreatePValue(1) + ); + + var function = target.Functions["invokeProbe"]!; + var callOffset = function.Code.FindIndex(instruction => + instruction.OpCode == OpCode.get && instruction.Id == "Select" + ); + Assert.That(callOffset, Is.GreaterThanOrEqualTo(0)); + Assert.That( + function.ObjectMemberCallSites.GetExisting(callOffset), + Is.InstanceOf() + ); + Assert.That( + function.ObjectMemberCallSites.GetExisting(callOffset)!.BindingCount, + Is.EqualTo(2), + "The polymorphic site should bind once for unlocked Int and once for locked Int." + ); + } + + [Test] + public void ExplicitConversionCreatesALockedCopy() + { + Compile("function main(value) = value~Int;"); + var original = PType.Int.CreatePValue(1); + + PValue? result = null; + Expect(value => result = value, original); + + Assert.That(original.IsTypeLocked, Is.False); + Assert.That(result, Is.Not.Null); + Assert.That(result!.IsTypeLocked, Is.True); + Assert.That(result.Type, Is.SameAs(original.Type)); + Assert.That(result.Value, Is.EqualTo(original.Value)); + Assert.That(result, Is.Not.SameAs(original)); + } + + [Test] + public void ExplicitConversionOfAConstantIsNotFoldedAway() + { + Compile("function main(target) = target.Select(1~Int, 2, 3);"); + + Assert.That( + target.Functions["main"]!.Code.Any(instruction => + instruction.OpCode == OpCode.cast_const + ), + Is.True + ); + Expect("type locked", engine.CreateNativePValue(new OverloadProbe())); + } + + [Test] + public void InterpreterCallSiteDistinguishesPTypeTagsForTheSameClrType() + { + Compile("function main(target, value) = target.Accept(value);"); + var probe = engine.CreateNativePValue(new OverloadProbe()); + + Expect("accepted", probe, PType.Int.CreatePValue(1)); + Expect("accepted", probe, PType.Object[typeof(int)].CreatePValue(1)); + Expect("accepted", probe, PType.Int.CreatePValue(2)); + + var function = target.Functions["main"]!; + var callOffset = function.Code.FindIndex(instruction => + instruction.OpCode == OpCode.get && instruction.Id == "Accept" + ); + Assert.That( + function.ObjectMemberCallSites.GetExisting(callOffset)!.BindingCount, + Is.EqualTo(2), + "Int and Object must produce separate rules despite wrapping the same CLR type." + ); + } + + [Test] + public void InterpreterStaticCallSiteDistinguishesExplicitConversions() + { + engine.RegisterAssembly(typeof(ObjectMemberCallSiteStaticProbe).Assembly); + Compile( + """ + function invokeProbe(value) = + PrexoniteTests::Tests::ObjectMemberCallSiteStaticProbe.Select(value, 2, 3); + + function main(value) = + [invokeProbe(value), invokeProbe(value~Int), invokeProbe(value)]; + """ + ); + + Expect( + result => + { + var values = (List)result.Value!; + Assert.That( + values.Select(value => value.Value), + Is.EqualTo( + new[] { "automatic conversion", "type locked", "automatic conversion" } + ) + ); + }, + PType.Int.CreatePValue(1) + ); + + var function = target.Functions["invokeProbe"]!; + var callOffset = function.Code.FindIndex(instruction => + instruction.OpCode == OpCode.sget && instruction.Id!.EndsWith("::Select") + ); + Assert.That( + function.ObjectMemberCallSites.GetExisting(callOffset)!.BindingCount, + Is.EqualTo(2) + ); + } + + [Test] + public void InterpreterCallSiteRetainsFourBindingsAfterBecomingMegamorphic() + { + Compile("function main(target, value) = target.Accept(value);"); + var probe = engine.CreateNativePValue(new OverloadProbe()); + var shapes = CreateDistinctIntShapes(); + + foreach (var shape in shapes) + Expect("accepted", probe, shape); + + var function = target.Functions["main"]!; + var callOffset = function.Code.FindIndex(instruction => + instruction.OpCode == OpCode.get && instruction.Id == "Accept" + ); + var site = function.ObjectMemberCallSites.GetExisting(callOffset)!; + Assert.That(site.IsMegamorphic, Is.True); + Assert.That(site.CachedBindingCount, Is.EqualTo(4)); + Assert.That(site.BindingCount, Is.EqualTo(5)); + + Expect("accepted", probe, shapes[^1]); + Assert.That(site.BindingCount, Is.EqualTo(5), "The newest shape should remain cached."); + + Expect("accepted", probe, shapes[0]); + Expect("accepted", probe, shapes[0]); + Assert.That( + site.BindingCount, + Is.EqualTo(7), + "An evicted shape should be resolved afresh on every megamorphic miss." + ); + + Expect("accepted", probe, shapes[1]); + Assert.That(site.BindingCount, Is.EqualTo(7), "Retained shapes should still hit."); + } + + [Test] + public void InterpreterStaticCallSiteUsesTheSameMegamorphicPolicy() + { + engine.RegisterAssembly(typeof(ObjectMemberCallSiteStaticProbe).Assembly); + Compile( + "function main(value) = " + + "PrexoniteTests::Tests::ObjectMemberCallSiteStaticProbe.Accept(value);" + ); + var shapes = CreateDistinctIntShapes(); + + for (var i = 0; i < shapes.Length; i++) + Expect(i + 1, shapes[i]); + + var function = target.Functions["main"]!; + var callOffset = function.Code.FindIndex(instruction => + instruction.OpCode == OpCode.sget && instruction.Id!.EndsWith("::Accept") + ); + var site = function.ObjectMemberCallSites.GetExisting(callOffset)!; + Assert.That(site.IsMegamorphic, Is.True); + Assert.That(site.CachedBindingCount, Is.EqualTo(4)); + Assert.That(site.BindingCount, Is.EqualTo(5)); + + Expect(5, shapes[^1]); + Assert.That(site.BindingCount, Is.EqualTo(5)); + Expect(1, shapes[0]); + Assert.That(site.BindingCount, Is.EqualTo(6)); + } + + internal static PValue[] CreateDistinctIntShapes() + { + return + [ + PType.Int.CreatePValue(1), + PType.Object[typeof(int)].CreatePValue(2), + new ObjectPType(typeof(int)).CreatePValue(3), + new ObjectPType(typeof(int)).CreatePValue(4), + new ObjectPType(typeof(int)).CreatePValue(5), + ]; + } + + public sealed class OverloadProbe + { + public string Select(double value, int second, int third) + { + return "automatic conversion"; + } + + public string Select(int value, double second, double third) + { + return "type locked"; + } + + public string Accept(int value) + { + return "accepted"; + } + } +} + +[TestFixture] +[Parallelizable(ParallelScope.Fixtures)] +public class ObjectMemberCallSiteCilCompatibilityTests : VMTestsBase +{ + [Test] + public void CilExplicitConversionStillLocksOverloadResolution() + { + Compile("function main(target) = target.Select(1~Int, 2, 3);"); + + Expect( + "type locked", + engine.CreateNativePValue(new ObjectMemberCallSiteTests.OverloadProbe()) + ); + } + + [Test] + public void CilCallSiteDistinguishesExplicitlyConvertedArguments() + { + Compile( + """ + function invokeProbe(target, value) = target.Select(value, 2, 3); + + function main(target, value) = + [invokeProbe(target, value), + invokeProbe(target, value~Int), + invokeProbe(target, value)]; + """ + ); + + Expect( + result => + { + var values = (List)result.Value!; + Assert.That( + values.Select(value => value.Value), + Is.EqualTo( + new[] { "automatic conversion", "type locked", "automatic conversion" } + ) + ); + }, + engine.CreateNativePValue(new ObjectMemberCallSiteTests.OverloadProbe()), + PType.Int.CreatePValue(1) + ); + + var function = target.Functions["invokeProbe"]!; + Assert.That(function.HasCilImplementation, Is.True); + Assert.That( + function + .ObjectMemberCallSites.GetExisting(_callOffset(function, OpCode.get, "Select"))! + .BindingCount, + Is.EqualTo(2) + ); + } + + [Test] + public void CilCallSiteDistinguishesPTypeTagsForTheSameClrType() + { + Compile("function main(target, value) = target.Accept(value);"); + var probe = engine.CreateNativePValue(new ObjectMemberCallSiteTests.OverloadProbe()); + + Expect("accepted", probe, PType.Int.CreatePValue(1)); + Expect("accepted", probe, PType.Object[typeof(int)].CreatePValue(1)); + Expect("accepted", probe, PType.Int.CreatePValue(2)); + + var function = target.Functions["main"]!; + Assert.That( + function + .ObjectMemberCallSites.GetExisting(_callOffset(function, OpCode.get, "Accept"))! + .BindingCount, + Is.EqualTo(2) + ); + } + + [Test] + public void CilStaticCallSiteDistinguishesExplicitConversions() + { + engine.RegisterAssembly(typeof(ObjectMemberCallSiteStaticProbe).Assembly); + Compile( + """ + function invokeProbe(value) = + PrexoniteTests::Tests::ObjectMemberCallSiteStaticProbe.Select(value, 2, 3); + + function main(value) = + [invokeProbe(value), invokeProbe(value~Int), invokeProbe(value)]; + """ + ); + + Expect( + result => + { + var values = (List)result.Value!; + Assert.That( + values.Select(value => value.Value), + Is.EqualTo( + new[] { "automatic conversion", "type locked", "automatic conversion" } + ) + ); + }, + PType.Int.CreatePValue(1) + ); + + var function = target.Functions["invokeProbe"]!; + var callOffset = function.Code.FindIndex(instruction => + instruction.OpCode == OpCode.sget && instruction.Id!.EndsWith("::Select") + ); + Assert.That( + function.ObjectMemberCallSites.GetExisting(callOffset)!.BindingCount, + Is.EqualTo(2) + ); + } + + [Test] + public void CilObjectOverrideDoesNotCreateAReflectionCallSite() + { + Compile("function main(target) = target.answer();"); + var callable = new MemberCallable { Name = "callsite override" }; + callable.Expect("answer", [], returns: PType.Int.CreatePValue(42)); + + Expect(42, engine.CreateNativePValue(callable)); + + var function = target.Functions["main"]!; + Assert.That( + function.ObjectMemberCallSites.GetExisting(_callOffset(function, OpCode.get, "answer")), + Is.Null + ); + callable.AssertCalledAll(); + } + + [Test] + public void CilCallSitesCanBeDisabledForDiagnosticComparison() + { + var loader = new Loader(options); + loader.LoadFromString("function main(target) = target.Accept(1);"); + Assert.That(loader.ErrorCount, Is.Zero); + var function = target.Functions["main"]!; + function.Meta[CilCompiler.DisableObjectMemberCallSitesKey] = true; + CilCompiler.Compile(target, engine); + + Expect( + "accepted", + engine.CreateNativePValue(new ObjectMemberCallSiteTests.OverloadProbe()) + ); + + Assert.That( + function.ObjectMemberCallSites.GetExisting(_callOffset(function, OpCode.get, "Accept")), + Is.Null + ); + } + + [Test] + public void CilCallSiteRetainsFourBindingsAfterBecomingMegamorphic() + { + Compile("function main(target, value) = target.Accept(value);"); + var probe = engine.CreateNativePValue(new ObjectMemberCallSiteTests.OverloadProbe()); + var shapes = ObjectMemberCallSiteTests.CreateDistinctIntShapes(); + + foreach (var shape in shapes) + Expect("accepted", probe, shape); + + var function = target.Functions["main"]!; + var site = function.ObjectMemberCallSites.GetExisting( + _callOffset(function, OpCode.get, "Accept") + )!; + Assert.That(site.IsMegamorphic, Is.True); + Assert.That(site.CachedBindingCount, Is.EqualTo(4)); + Assert.That(site.BindingCount, Is.EqualTo(5)); + + Expect("accepted", probe, shapes[^1]); + Assert.That(site.BindingCount, Is.EqualTo(5)); + Expect("accepted", probe, shapes[0]); + Expect("accepted", probe, shapes[0]); + Assert.That(site.BindingCount, Is.EqualTo(7)); + Expect("accepted", probe, shapes[1]); + Assert.That(site.BindingCount, Is.EqualTo(7)); + } + + static int _callOffset(PFunction function, OpCode opCode, string id) + { + var callOffset = function.Code.FindIndex(instruction => + instruction.OpCode == opCode && instruction.Id == id + ); + Assert.That(callOffset, Is.GreaterThanOrEqualTo(0)); + return callOffset; + } +} + +public static class ObjectMemberCallSiteStaticProbe +{ + public static string Select(double value, int second, int third) + { + return "automatic conversion"; + } + + public static string Select(int value, double second, double third) + { + return "type locked"; + } + + public static int Accept(object value) + { + return (int)value; + } +} diff --git a/README.md b/README.md index 4589b7cd..cf2779f8 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ somewhat object-aware3, interpreted Technically speaking, Prexonite Script is actually being compiled. Three times. A significant portion of Prexonite code runs as x86, directly on your CPU. But wait until you hear the best part: It doesn't matter since most performance is wasted on insisting that every method invocation must do a full overload resolution. You know, in case those pesky methods swapped places since the last loop iteration. +  Technically speaking, Prexonite Script is actually being compiled. Three times. A significant portion of Prexonite code runs as native machine code. And, after a mere couple of decades, CLR member calls even remember their overload resolution. We still check the answer—this is a dynamically typed abomination—but the pesky methods no longer need to prove that they have not swapped places since the last loop iteration. - \ No newline at end of file + diff --git a/prx.slnx b/prx.slnx index 414b890a..27a6b190 100644 --- a/prx.slnx +++ b/prx.slnx @@ -1,6 +1,7 @@ +