From f444eedf8c887c2421bd85ea7128d7b3419434a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20L=2E=20Charlier?= Date: Wed, 26 Aug 2026 09:01:34 +0200 Subject: [PATCH 1/5] feat(benchmark): compare supplied Expressif versions --- .gitignore | 3 + benchmark/Directory.Build.props | 9 ++ benchmark/Directory.Build.targets | 1 + benchmark/Expressif.Benchmark.sln | 18 ++++ .../Expressif.Benchmark/BenchmarkConfig.cs | 32 ++++++ .../Expressif.Benchmark.csproj | 8 ++ .../Expressif.Benchmark/ExpressionAdapter.cs | 102 ++++++++++++++++++ .../ExpressionBenchmarks.cs | 60 +++++++++++ benchmark/Expressif.Benchmark/Program.cs | 21 ++++ .../Expressif.Benchmark/VersionDiscovery.cs | 54 ++++++++++ .../Expressif.Benchmark/VersionLoadContext.cs | 35 ++++++ benchmark/README.md | 36 +++++++ 12 files changed, 379 insertions(+) create mode 100644 benchmark/Directory.Build.props create mode 100644 benchmark/Directory.Build.targets create mode 100644 benchmark/Expressif.Benchmark.sln create mode 100644 benchmark/Expressif.Benchmark/BenchmarkConfig.cs create mode 100644 benchmark/Expressif.Benchmark/Expressif.Benchmark.csproj create mode 100644 benchmark/Expressif.Benchmark/ExpressionAdapter.cs create mode 100644 benchmark/Expressif.Benchmark/ExpressionBenchmarks.cs create mode 100644 benchmark/Expressif.Benchmark/Program.cs create mode 100644 benchmark/Expressif.Benchmark/VersionDiscovery.cs create mode 100644 benchmark/Expressif.Benchmark/VersionLoadContext.cs create mode 100644 benchmark/README.md diff --git a/.gitignore b/.gitignore index 2e5c3251..b6e977c4 100644 --- a/.gitignore +++ b/.gitignore @@ -349,6 +349,9 @@ ASALocalRun/ # BeatPulse healthcheck temp database healthchecksdb +# Manually supplied benchmark binaries +/benchmark/bin/ + # Backup folder for Package Reference Convert tool in Visual Studio 2017 MigrationBackup/ diff --git a/benchmark/Directory.Build.props b/benchmark/Directory.Build.props new file mode 100644 index 00000000..f8791d63 --- /dev/null +++ b/benchmark/Directory.Build.props @@ -0,0 +1,9 @@ + + + net10.0 + 14.0 + enable + enable + false + + diff --git a/benchmark/Directory.Build.targets b/benchmark/Directory.Build.targets new file mode 100644 index 00000000..058246e4 --- /dev/null +++ b/benchmark/Directory.Build.targets @@ -0,0 +1 @@ + diff --git a/benchmark/Expressif.Benchmark.sln b/benchmark/Expressif.Benchmark.sln new file mode 100644 index 00000000..46bf94d0 --- /dev/null +++ b/benchmark/Expressif.Benchmark.sln @@ -0,0 +1,18 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Expressif.Benchmark", "Expressif.Benchmark\Expressif.Benchmark.csproj", "{E2C46502-DF6E-4F2B-B2EC-A02CE599202A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {E2C46502-DF6E-4F2B-B2EC-A02CE599202A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E2C46502-DF6E-4F2B-B2EC-A02CE599202A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E2C46502-DF6E-4F2B-B2EC-A02CE599202A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E2C46502-DF6E-4F2B-B2EC-A02CE599202A}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/benchmark/Expressif.Benchmark/BenchmarkConfig.cs b/benchmark/Expressif.Benchmark/BenchmarkConfig.cs new file mode 100644 index 00000000..7cc1aade --- /dev/null +++ b/benchmark/Expressif.Benchmark/BenchmarkConfig.cs @@ -0,0 +1,32 @@ +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Jobs; + +namespace Expressif.Benchmark; + +internal static class BenchmarkConfig +{ + public static IConfig Create(IReadOnlyList versions) + { + var config = ManualConfig.Create(DefaultConfig.Instance) + .AddDiagnoser(MemoryDiagnoser.Default); + + var baselineAssigned = false; + foreach (var version in versions) + { + var job = Job.Default + .WithId(version.Name) + .WithEnvironmentVariable(VersionDiscovery.VersionEnvironmentVariable, version.Directory); + + if (!baselineAssigned && version.AdapterKind == AdapterKind.V1) + { + job = job.AsBaseline(); + baselineAssigned = true; + } + + config.AddJob(job); + } + + return config; + } +} diff --git a/benchmark/Expressif.Benchmark/Expressif.Benchmark.csproj b/benchmark/Expressif.Benchmark/Expressif.Benchmark.csproj new file mode 100644 index 00000000..918fcbf9 --- /dev/null +++ b/benchmark/Expressif.Benchmark/Expressif.Benchmark.csproj @@ -0,0 +1,8 @@ + + + Exe + + + + + diff --git a/benchmark/Expressif.Benchmark/ExpressionAdapter.cs b/benchmark/Expressif.Benchmark/ExpressionAdapter.cs new file mode 100644 index 00000000..1a1719e3 --- /dev/null +++ b/benchmark/Expressif.Benchmark/ExpressionAdapter.cs @@ -0,0 +1,102 @@ +using System.Linq.Expressions; +using System.Reflection; +using System.Runtime.InteropServices; + +namespace Expressif.Benchmark; + +internal sealed class ExpressionAdapter +{ + private readonly Func createExpression; + private readonly Func evaluateExpression; + + private ExpressionAdapter( + Func createExpression, + Func evaluateExpression) + => (this.createExpression, this.evaluateExpression) = (createExpression, evaluateExpression); + + public static ExpressionAdapter Load(string versionDirectory) + { + if (!SetDllDirectory(versionDirectory)) + throw new System.ComponentModel.Win32Exception( + Marshal.GetLastWin32Error(), + $"Could not add '{versionDirectory}' to the native DLL search path."); + + var name = Path.GetFileName(versionDirectory); + var kind = name.StartsWith("v1", StringComparison.OrdinalIgnoreCase) + ? AdapterKind.V1 + : name.StartsWith("v2", StringComparison.OrdinalIgnoreCase) + ? AdapterKind.V2 + : throw new InvalidOperationException($"Unsupported version folder '{name}'."); + var assemblyPath = Path.Combine(versionDirectory, "Expressif.dll"); + var context = new VersionLoadContext(assemblyPath); + var assembly = context.LoadFromAssemblyPath(assemblyPath); + + return kind switch + { + AdapterKind.V1 => CreateV1(assembly), + AdapterKind.V2 => CreateV2(assembly), + _ => throw new ArgumentOutOfRangeException(nameof(kind)), + }; + } + + [DllImport("kernel32", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetDllDirectory(string pathName); + + public object Create(string source) => createExpression(source); + + public Func CreateEvaluator(string source) + { + var expression = createExpression(source); + return value => evaluateExpression(expression, value); + } + + private static ExpressionAdapter CreateV1(Assembly assembly) + { + var expressionType = RequireType(assembly, "Expressif.Expression"); + var constructor = expressionType.GetConstructor([typeof(string)]) + ?? throw new MissingMethodException(expressionType.FullName, ".ctor(string)"); + return new ExpressionAdapter(CompileConstructor(constructor), CompileEvaluate(expressionType)); + } + + private static ExpressionAdapter CreateV2(Assembly assembly) + { + var implementationType = RequireType(assembly, "Expressif.Expression"); + var create = implementationType.GetMethod( + "Create", + BindingFlags.Public | BindingFlags.Static, + [typeof(string)]) + ?? throw new MissingMethodException(implementationType.FullName, "Create(string)"); + var functionType = RequireType(assembly, "Expressif.Functions.IFunction"); + return new ExpressionAdapter(CompileStaticCall(create), CompileEvaluate(functionType)); + } + + private static Type RequireType(Assembly assembly, string typeName) + => assembly.GetType(typeName, throwOnError: true)!; + + private static Func CompileConstructor(ConstructorInfo constructor) + { + var source = Expression.Parameter(typeof(string), "source"); + var body = Expression.Convert(Expression.New(constructor, source), typeof(object)); + return Expression.Lambda>(body, source).Compile(); + } + + private static Func CompileStaticCall(MethodInfo create) + { + var source = Expression.Parameter(typeof(string), "source"); + var body = Expression.Convert(Expression.Call(create, source), typeof(object)); + return Expression.Lambda>(body, source).Compile(); + } + + private static Func CompileEvaluate(Type expressionType) + { + var evaluate = expressionType.GetMethod("Evaluate", [typeof(object)]) + ?? throw new MissingMethodException(expressionType.FullName, "Evaluate(object)"); + var instance = Expression.Parameter(typeof(object), "expression"); + var value = Expression.Parameter(typeof(object), "value"); + var body = Expression.Convert( + Expression.Call(Expression.Convert(instance, expressionType), evaluate, value), + typeof(object)); + return Expression.Lambda>(body, instance, value).Compile(); + } +} diff --git a/benchmark/Expressif.Benchmark/ExpressionBenchmarks.cs b/benchmark/Expressif.Benchmark/ExpressionBenchmarks.cs new file mode 100644 index 00000000..ee22c868 --- /dev/null +++ b/benchmark/Expressif.Benchmark/ExpressionBenchmarks.cs @@ -0,0 +1,60 @@ +using BenchmarkDotNet.Attributes; + +namespace Expressif.Benchmark; + +public class ExpressionBenchmarks +{ + private const string ComplexTextExpression = + "trim | lower | replace(\" \", \"-\") | substring(0, 10) | upper"; + private const string CoercionExpression = + "trim | multiply(1.21) | round(2) | prepend(\"€\")"; + + private ExpressionAdapter adapter = null!; + private Func simpleEvaluator = null!; + private Func textEvaluator = null!; + private Func coercionEvaluator = null!; + + [GlobalSetup(Target = nameof(ParseAndBindComplexPipeline))] + public void SetupConstruction() => SetupAdapter(); + + private void SetupAdapter() + { + var versionDirectory = Environment.GetEnvironmentVariable( + VersionDiscovery.VersionEnvironmentVariable) + ?? throw new InvalidOperationException("The benchmark version environment variable was not set."); + adapter = ExpressionAdapter.Load(versionDirectory); + } + + [GlobalSetup(Target = nameof(EvaluateSimple))] + public void SetupSimple() + { + SetupAdapter(); + simpleEvaluator = adapter.CreateEvaluator("add(5)"); + } + + [GlobalSetup(Target = nameof(EvaluateTextPipeline))] + public void SetupTextPipeline() + { + SetupAdapter(); + textEvaluator = adapter.CreateEvaluator(ComplexTextExpression); + } + + [GlobalSetup(Target = nameof(EvaluateCoercionPipeline))] + public void SetupCoercionPipeline() + { + SetupAdapter(); + coercionEvaluator = adapter.CreateEvaluator(CoercionExpression); + } + + [Benchmark(Description = "Parse and bind complex pipeline")] + public object ParseAndBindComplexPipeline() => adapter.Create(ComplexTextExpression); + + [Benchmark(Description = "Evaluate add(5)")] + public object? EvaluateSimple() => simpleEvaluator(1234.56m); + + [Benchmark(Description = "Evaluate text-only pipeline")] + public object? EvaluateTextPipeline() => textEvaluator(" Benchmark Input Value "); + + [Benchmark(Description = "Evaluate pipeline with implicit coercion")] + public object? EvaluateCoercionPipeline() => coercionEvaluator(" 1234.56 "); +} diff --git a/benchmark/Expressif.Benchmark/Program.cs b/benchmark/Expressif.Benchmark/Program.cs new file mode 100644 index 00000000..3b0a1e7b --- /dev/null +++ b/benchmark/Expressif.Benchmark/Program.cs @@ -0,0 +1,21 @@ +using BenchmarkDotNet.Running; +using Expressif.Benchmark; + +if (!OperatingSystem.IsWindows()) +{ + Console.Error.WriteLine("Expressif benchmarks support Windows only."); + return 1; +} + +try +{ + var versions = VersionDiscovery.Discover(); + BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly) + .Run(args, BenchmarkConfig.Create(versions)); + return 0; +} +catch (Exception exception) +{ + Console.Error.WriteLine(exception.Message); + return 1; +} diff --git a/benchmark/Expressif.Benchmark/VersionDiscovery.cs b/benchmark/Expressif.Benchmark/VersionDiscovery.cs new file mode 100644 index 00000000..ea5de47a --- /dev/null +++ b/benchmark/Expressif.Benchmark/VersionDiscovery.cs @@ -0,0 +1,54 @@ +namespace Expressif.Benchmark; + +internal sealed record VersionUnderTest(string Name, string Directory, AdapterKind AdapterKind); + +internal enum AdapterKind +{ + V1, + V2, +} + +internal static class VersionDiscovery +{ + internal const string VersionEnvironmentVariable = "EXPRESSIF_BENCHMARK_VERSION"; + + public static IReadOnlyList Discover() + { + var root = Path.GetFullPath( + Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "bin", "versions")); + + if (!Directory.Exists(root)) + throw new DirectoryNotFoundException( + $"Version directory not found: '{root}'. See benchmark/README.md for setup instructions."); + + var versions = Directory.EnumerateDirectories(root) + .Select(CreateVersion) + .OrderBy(version => version.AdapterKind) + .ThenBy(version => version.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (versions.Length == 0) + throw new InvalidOperationException($"No version folders were found under '{root}'."); + if (!versions.Any(version => version.AdapterKind == AdapterKind.V1)) + throw new InvalidOperationException("At least one v1... version folder is required as the ratio baseline."); + + return versions; + } + + private static VersionUnderTest CreateVersion(string directory) + { + var name = Path.GetFileName(directory); + var adapterKind = name.StartsWith("v1", StringComparison.OrdinalIgnoreCase) + ? AdapterKind.V1 + : name.StartsWith("v2", StringComparison.OrdinalIgnoreCase) + ? AdapterKind.V2 + : throw new InvalidOperationException( + $"Unsupported version folder '{name}'; folder names must start with v1 or v2."); + + var assemblyPath = Path.Combine(directory, "Expressif.dll"); + if (!File.Exists(assemblyPath)) + throw new FileNotFoundException($"Expressif.dll was not found in '{directory}'.", assemblyPath); + + return new VersionUnderTest(name, Path.GetFullPath(directory), adapterKind); + } +} diff --git a/benchmark/Expressif.Benchmark/VersionLoadContext.cs b/benchmark/Expressif.Benchmark/VersionLoadContext.cs new file mode 100644 index 00000000..5f6b53d5 --- /dev/null +++ b/benchmark/Expressif.Benchmark/VersionLoadContext.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace Expressif.Benchmark; + +internal sealed class VersionLoadContext : AssemblyLoadContext +{ + private readonly AssemblyDependencyResolver resolver; + private readonly string versionDirectory; + + public VersionLoadContext(string mainAssemblyPath) + : base($"Expressif benchmark: {Path.GetFileName(Path.GetDirectoryName(mainAssemblyPath))}", isCollectible: false) + => (resolver, versionDirectory) = ( + new AssemblyDependencyResolver(mainAssemblyPath), + Path.GetDirectoryName(mainAssemblyPath)!); + + protected override Assembly? Load(AssemblyName assemblyName) + { + var path = resolver.ResolveAssemblyToPath(assemblyName) + ?? FindSuppliedAssembly(assemblyName); + return path is null ? null : LoadFromAssemblyPath(path); + } + + private string? FindSuppliedAssembly(AssemblyName assemblyName) + { + var path = Path.Combine(versionDirectory, $"{assemblyName.Name}.dll"); + return File.Exists(path) ? path : null; + } + + protected override nint LoadUnmanagedDll(string unmanagedDllName) + { + var path = resolver.ResolveUnmanagedDllToPath(unmanagedDllName); + return path is null ? nint.Zero : LoadUnmanagedDllFromPath(path); + } +} diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 00000000..303725a6 --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1,36 @@ +# Expressif benchmarks + +This Windows-only .NET 10 harness compares manually supplied Expressif builds. It never +builds, restores, downloads, or checks out an Expressif version. + +## Supply versions + +Build the versions separately, then copy each complete output (including dependencies) to: + +```text +benchmark/bin/versions/ +├── v1.8.0/ +│ ├── Expressif.dll +│ └── ...dependencies... +└── v2-next-major/ + ├── Expressif.dll + └── ...dependencies... +``` + +Folder names beginning with `v1` use the v1 constructor API. Names beginning with `v2` +use the next-major factory API. Every folder is loaded in an isolated +`AssemblyLoadContext`. The first v1 folder is BenchmarkDotNet's baseline; additional v1 +and v2 folders become separate jobs. + +## Run + +From the repository root: + +```powershell +dotnet run --project benchmark/Expressif.Benchmark -c Release +``` + +Pass normal BenchmarkDotNet arguments after `--`, for example `-- --filter *Evaluate*`. +Reflection, assembly loading, expression construction, and delegate creation happen in +global setup for evaluation benchmarks. The construction benchmark measures only the +version's native parse-and-bind call. From 5fb4f52ed38e717cc7e907c7ac0d5c340da20255 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20L=2E=20Charlier?= Date: Wed, 26 Aug 2026 10:03:31 +0200 Subject: [PATCH 2/5] test(benchmark): verify parsing and coercion adapters --- .../Expressif.Benchmark.Tests.csproj | 16 ++++++ .../ExpressionAdapterTests.cs | 55 +++++++++++++++++++ benchmark/Expressif.Benchmark.sln | 6 ++ benchmark/Expressif.Benchmark/AssemblyInfo.cs | 3 + .../Expressif.Benchmark/ExpressionAdapter.cs | 45 ++++++++++++++- benchmark/README.md | 13 +++++ 6 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 benchmark/Expressif.Benchmark.Tests/Expressif.Benchmark.Tests.csproj create mode 100644 benchmark/Expressif.Benchmark.Tests/ExpressionAdapterTests.cs create mode 100644 benchmark/Expressif.Benchmark/AssemblyInfo.cs diff --git a/benchmark/Expressif.Benchmark.Tests/Expressif.Benchmark.Tests.csproj b/benchmark/Expressif.Benchmark.Tests/Expressif.Benchmark.Tests.csproj new file mode 100644 index 00000000..4d448e0b --- /dev/null +++ b/benchmark/Expressif.Benchmark.Tests/Expressif.Benchmark.Tests.csproj @@ -0,0 +1,16 @@ + + + true + + + + + + + + + + + + + diff --git a/benchmark/Expressif.Benchmark.Tests/ExpressionAdapterTests.cs b/benchmark/Expressif.Benchmark.Tests/ExpressionAdapterTests.cs new file mode 100644 index 00000000..448dc18d --- /dev/null +++ b/benchmark/Expressif.Benchmark.Tests/ExpressionAdapterTests.cs @@ -0,0 +1,55 @@ +namespace Expressif.Benchmark.Tests; + +[TestFixture] +public class ExpressionAdapterTests +{ + private const string ComplexTextExpression = + "trim | lower | replace(\" \", \"-\") | substring(0, 10) | upper"; + private const string CoercionExpression = + "trim | multiply(1.21) | round(2) | prepend(\"€\")"; + + [Test] + public void V1ComplexPipeline_ParsingSucceedsButBindingRejectsReplace() + { + var version = GetVersion(AdapterKind.V1); + var adapter = ExpressionAdapter.Load(version.Directory); + + Assert.That(() => adapter.Parse(ComplexTextExpression), Throws.Nothing); + var exception = Assert.Catch(() => adapter.Create(ComplexTextExpression)); + Assert.That(exception!.ToString(), Does.Contain("function named 'replace'")); + } + + [TestCase("V1")] + [TestCase("V2")] + public void CoercionPipeline_NumericStringReturnsFormattedText(string adapterName) + { + var kind = Enum.Parse(adapterName); + var version = GetVersion(kind); + var evaluate = ExpressionAdapter.Load(version.Directory).CreateEvaluator(CoercionExpression); + + var result = evaluate(" 1234.56 "); + + Assert.That(result, Is.TypeOf()); + Assert.That(result, Is.EqualTo("€1493.82")); + } + + private static VersionUnderTest GetVersion(AdapterKind kind) + { + IReadOnlyList versions; + try + { + versions = VersionDiscovery.Discover(); + } + catch (DirectoryNotFoundException exception) + { + Assert.Ignore(exception.Message); + throw; + } + + var version = versions.FirstOrDefault(candidate => candidate.AdapterKind == kind); + if (version is null) + Assert.Ignore($"No {kind.ToString().ToLowerInvariant()} version folder is available."); + + return version!; + } +} diff --git a/benchmark/Expressif.Benchmark.sln b/benchmark/Expressif.Benchmark.sln index 46bf94d0..394b3399 100644 --- a/benchmark/Expressif.Benchmark.sln +++ b/benchmark/Expressif.Benchmark.sln @@ -4,6 +4,8 @@ VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Expressif.Benchmark", "Expressif.Benchmark\Expressif.Benchmark.csproj", "{E2C46502-DF6E-4F2B-B2EC-A02CE599202A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Expressif.Benchmark.Tests", "Expressif.Benchmark.Tests\Expressif.Benchmark.Tests.csproj", "{7027834B-46EF-47CC-9DD4-295539093E43}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -14,5 +16,9 @@ Global {E2C46502-DF6E-4F2B-B2EC-A02CE599202A}.Debug|Any CPU.Build.0 = Debug|Any CPU {E2C46502-DF6E-4F2B-B2EC-A02CE599202A}.Release|Any CPU.ActiveCfg = Release|Any CPU {E2C46502-DF6E-4F2B-B2EC-A02CE599202A}.Release|Any CPU.Build.0 = Release|Any CPU + {7027834B-46EF-47CC-9DD4-295539093E43}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7027834B-46EF-47CC-9DD4-295539093E43}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7027834B-46EF-47CC-9DD4-295539093E43}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7027834B-46EF-47CC-9DD4-295539093E43}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/benchmark/Expressif.Benchmark/AssemblyInfo.cs b/benchmark/Expressif.Benchmark/AssemblyInfo.cs new file mode 100644 index 00000000..7ccb9eb6 --- /dev/null +++ b/benchmark/Expressif.Benchmark/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Expressif.Benchmark.Tests")] diff --git a/benchmark/Expressif.Benchmark/ExpressionAdapter.cs b/benchmark/Expressif.Benchmark/ExpressionAdapter.cs index 1a1719e3..eb6a29a9 100644 --- a/benchmark/Expressif.Benchmark/ExpressionAdapter.cs +++ b/benchmark/Expressif.Benchmark/ExpressionAdapter.cs @@ -6,13 +6,18 @@ namespace Expressif.Benchmark; internal sealed class ExpressionAdapter { + private readonly Func parseExpression; private readonly Func createExpression; private readonly Func evaluateExpression; private ExpressionAdapter( + Func parseExpression, Func createExpression, Func evaluateExpression) - => (this.createExpression, this.evaluateExpression) = (createExpression, evaluateExpression); + => (this.parseExpression, this.createExpression, this.evaluateExpression) = ( + parseExpression, + createExpression, + evaluateExpression); public static ExpressionAdapter Load(string versionDirectory) { @@ -45,6 +50,8 @@ public static ExpressionAdapter Load(string versionDirectory) public object Create(string source) => createExpression(source); + public object Parse(string source) => parseExpression(source); + public Func CreateEvaluator(string source) { var expression = createExpression(source); @@ -56,7 +63,10 @@ private static ExpressionAdapter CreateV1(Assembly assembly) var expressionType = RequireType(assembly, "Expressif.Expression"); var constructor = expressionType.GetConstructor([typeof(string)]) ?? throw new MissingMethodException(expressionType.FullName, ".ctor(string)"); - return new ExpressionAdapter(CompileConstructor(constructor), CompileEvaluate(expressionType)); + return new ExpressionAdapter( + CompileV1Parser(assembly), + CompileConstructor(constructor), + CompileEvaluate(expressionType)); } private static ExpressionAdapter CreateV2(Assembly assembly) @@ -68,7 +78,16 @@ private static ExpressionAdapter CreateV2(Assembly assembly) [typeof(string)]) ?? throw new MissingMethodException(implementationType.FullName, "Create(string)"); var functionType = RequireType(assembly, "Expressif.Functions.IFunction"); - return new ExpressionAdapter(CompileStaticCall(create), CompileEvaluate(functionType)); + var parserType = RequireType(assembly, "Expressif.Syntax.ExpressionParser"); + var parse = parserType.GetMethod( + "Parse", + BindingFlags.Public | BindingFlags.Static, + [typeof(string)]) + ?? throw new MissingMethodException(parserType.FullName, "Parse(string)"); + return new ExpressionAdapter( + CompileStaticCall(parse), + CompileStaticCall(create), + CompileEvaluate(functionType)); } private static Type RequireType(Assembly assembly, string typeName) @@ -81,6 +100,26 @@ private static Func CompileConstructor(ConstructorInfo construct return Expression.Lambda>(body, source).Compile(); } + private static Func CompileV1Parser(Assembly assembly) + { + var rootExpressionType = RequireType(assembly, "Expressif.Parsers.RootExpression"); + var parser = rootExpressionType.GetField("Parser", BindingFlags.Public | BindingFlags.Static)?.GetValue(null) + ?? throw new MissingFieldException(rootExpressionType.FullName, "Parser"); + var parserType = parser.GetType(); + var parserExtensions = RequireType(parserType.Assembly, "Sprache.ParserExtensions"); + var parse = parserExtensions.GetMethods(BindingFlags.Public | BindingFlags.Static) + .Single(method => method.Name == "Parse" + && method.IsGenericMethodDefinition + && method.GetParameters() is [_, { ParameterType: var parameterType }] + && parameterType == typeof(string)) + .MakeGenericMethod(parserType.GetGenericArguments()[0]); + var source = Expression.Parameter(typeof(string), "source"); + var body = Expression.Convert( + Expression.Call(parse, Expression.Constant(parser, parserType), source), + typeof(object)); + return Expression.Lambda>(body, source).Compile(); + } + private static Func CompileStaticCall(MethodInfo create) { var source = Expression.Parameter(typeof(string), "source"); diff --git a/benchmark/README.md b/benchmark/README.md index 303725a6..185af928 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -34,3 +34,16 @@ Pass normal BenchmarkDotNet arguments after `--`, for example `-- --filter *Eval Reflection, assembly loading, expression construction, and delegate creation happen in global setup for evaluation benchmarks. The construction benchmark measures only the version's native parse-and-bind call. + +## Test the supplied binaries + +With version folders populated as above, run: + +```powershell +dotnet test benchmark/Expressif.Benchmark.sln -c Release +``` + +The adapter tests verify the coercion pipeline on each available API generation. They +also characterize the prescribed complex v1 workload specifically: parsing succeeds, +then binding rejects the currently unavailable `replace` function. Tests are skipped +with a setup explanation when their required manually supplied version is absent. From 2b9c30b1e3edf26e9b9975ad518e426673f0e237 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20L=2E=20Charlier?= Date: Wed, 26 Aug 2026 10:16:43 +0200 Subject: [PATCH 3/5] fix(benchmark): use supported text functions --- .../ExpressionAdapterTests.cs | 10 ++++++---- benchmark/Expressif.Benchmark/ExpressionBenchmarks.cs | 2 +- benchmark/README.md | 6 +++--- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/benchmark/Expressif.Benchmark.Tests/ExpressionAdapterTests.cs b/benchmark/Expressif.Benchmark.Tests/ExpressionAdapterTests.cs index 448dc18d..5756286b 100644 --- a/benchmark/Expressif.Benchmark.Tests/ExpressionAdapterTests.cs +++ b/benchmark/Expressif.Benchmark.Tests/ExpressionAdapterTests.cs @@ -4,19 +4,21 @@ namespace Expressif.Benchmark.Tests; public class ExpressionAdapterTests { private const string ComplexTextExpression = - "trim | lower | replace(\" \", \"-\") | substring(0, 10) | upper"; + "trim | lower | replace-chars(\" \", \"-\") | first-chars(10) | upper"; private const string CoercionExpression = "trim | multiply(1.21) | round(2) | prepend(\"€\")"; [Test] - public void V1ComplexPipeline_ParsingSucceedsButBindingRejectsReplace() + public void V1ComplexPipeline_ParsingBindingAndEvaluationSucceed() { var version = GetVersion(AdapterKind.V1); var adapter = ExpressionAdapter.Load(version.Directory); Assert.That(() => adapter.Parse(ComplexTextExpression), Throws.Nothing); - var exception = Assert.Catch(() => adapter.Create(ComplexTextExpression)); - Assert.That(exception!.ToString(), Does.Contain("function named 'replace'")); + Assert.That(() => adapter.Create(ComplexTextExpression), Throws.Nothing); + + var result = adapter.CreateEvaluator(ComplexTextExpression)(" Benchmark Input Value "); + Assert.That(result, Is.EqualTo("BENCHMARK-")); } [TestCase("V1")] diff --git a/benchmark/Expressif.Benchmark/ExpressionBenchmarks.cs b/benchmark/Expressif.Benchmark/ExpressionBenchmarks.cs index ee22c868..329c1323 100644 --- a/benchmark/Expressif.Benchmark/ExpressionBenchmarks.cs +++ b/benchmark/Expressif.Benchmark/ExpressionBenchmarks.cs @@ -5,7 +5,7 @@ namespace Expressif.Benchmark; public class ExpressionBenchmarks { private const string ComplexTextExpression = - "trim | lower | replace(\" \", \"-\") | substring(0, 10) | upper"; + "trim | lower | replace-chars(\" \", \"-\") | first-chars(10) | upper"; private const string CoercionExpression = "trim | multiply(1.21) | round(2) | prepend(\"€\")"; diff --git a/benchmark/README.md b/benchmark/README.md index 185af928..856b16fb 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -44,6 +44,6 @@ dotnet test benchmark/Expressif.Benchmark.sln -c Release ``` The adapter tests verify the coercion pipeline on each available API generation. They -also characterize the prescribed complex v1 workload specifically: parsing succeeds, -then binding rejects the currently unavailable `replace` function. Tests are skipped -with a setup explanation when their required manually supplied version is absent. +also verify that the complex v1 workload parses, binds, and evaluates successfully. +Tests are skipped with a setup explanation when their required manually supplied version +is absent. From 3af09ec74487d97609066bc2ed945054ffde24ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20L=2E=20Charlier?= Date: Wed, 26 Aug 2026 10:24:45 +0200 Subject: [PATCH 4/5] feat(benchmark): isolate parser performance --- .../ExpressionAdapterTests.cs | 14 ++++++++++++++ .../Expressif.Benchmark/ExpressionBenchmarks.cs | 12 ++++++++++++ benchmark/README.md | 4 ++++ 3 files changed, 30 insertions(+) diff --git a/benchmark/Expressif.Benchmark.Tests/ExpressionAdapterTests.cs b/benchmark/Expressif.Benchmark.Tests/ExpressionAdapterTests.cs index 5756286b..30f7b4b3 100644 --- a/benchmark/Expressif.Benchmark.Tests/ExpressionAdapterTests.cs +++ b/benchmark/Expressif.Benchmark.Tests/ExpressionAdapterTests.cs @@ -21,6 +21,20 @@ public void V1ComplexPipeline_ParsingBindingAndEvaluationSucceed() Assert.That(result, Is.EqualTo("BENCHMARK-")); } + [TestCase("V1")] + [TestCase("V2")] + public void WorkloadSources_ParseSuccessfully(string adapterName) + { + var kind = Enum.Parse(adapterName); + var adapter = ExpressionAdapter.Load(GetVersion(kind).Directory); + + Assert.Multiple(() => + { + Assert.That(() => adapter.Parse(ComplexTextExpression), Throws.Nothing); + Assert.That(() => adapter.Parse(CoercionExpression), Throws.Nothing); + }); + } + [TestCase("V1")] [TestCase("V2")] public void CoercionPipeline_NumericStringReturnsFormattedText(string adapterName) diff --git a/benchmark/Expressif.Benchmark/ExpressionBenchmarks.cs b/benchmark/Expressif.Benchmark/ExpressionBenchmarks.cs index 329c1323..0e11c94d 100644 --- a/benchmark/Expressif.Benchmark/ExpressionBenchmarks.cs +++ b/benchmark/Expressif.Benchmark/ExpressionBenchmarks.cs @@ -14,6 +14,12 @@ public class ExpressionBenchmarks private Func textEvaluator = null!; private Func coercionEvaluator = null!; + [GlobalSetup(Target = nameof(ParseComplexPipeline))] + public void SetupComplexParsing() => SetupAdapter(); + + [GlobalSetup(Target = nameof(ParseCoercionPipeline))] + public void SetupCoercionParsing() => SetupAdapter(); + [GlobalSetup(Target = nameof(ParseAndBindComplexPipeline))] public void SetupConstruction() => SetupAdapter(); @@ -46,6 +52,12 @@ public void SetupCoercionPipeline() coercionEvaluator = adapter.CreateEvaluator(CoercionExpression); } + [Benchmark(Description = "Parse complex text pipeline")] + public object ParseComplexPipeline() => adapter.Parse(ComplexTextExpression); + + [Benchmark(Description = "Parse implicit-coercion pipeline")] + public object ParseCoercionPipeline() => adapter.Parse(CoercionExpression); + [Benchmark(Description = "Parse and bind complex pipeline")] public object ParseAndBindComplexPipeline() => adapter.Create(ComplexTextExpression); diff --git a/benchmark/README.md b/benchmark/README.md index 856b16fb..ad1b1b1b 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -47,3 +47,7 @@ The adapter tests verify the coercion pipeline on each available API generation. also verify that the complex v1 workload parses, binds, and evaluates successfully. Tests are skipped with a setup explanation when their required manually supplied version is absent. + +The suite reports native parsing separately for the complex text and implicit-coercion +sources. These parse-only benchmarks reuse parser delegates prepared during setup, so +they exclude binding and expression construction. From 0a841908bd6c2759149ab8685c36b4ccd019d3c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20L=2E=20Charlier?= Date: Wed, 26 Aug 2026 10:30:01 +0200 Subject: [PATCH 5/5] feat(benchmark): compare parse and bind workloads --- .../Expressif.Benchmark.Tests/ExpressionAdapterTests.cs | 2 ++ benchmark/Expressif.Benchmark/ExpressionBenchmarks.cs | 6 ++++++ benchmark/README.md | 3 ++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/benchmark/Expressif.Benchmark.Tests/ExpressionAdapterTests.cs b/benchmark/Expressif.Benchmark.Tests/ExpressionAdapterTests.cs index 30f7b4b3..14d2a22d 100644 --- a/benchmark/Expressif.Benchmark.Tests/ExpressionAdapterTests.cs +++ b/benchmark/Expressif.Benchmark.Tests/ExpressionAdapterTests.cs @@ -32,6 +32,8 @@ public void WorkloadSources_ParseSuccessfully(string adapterName) { Assert.That(() => adapter.Parse(ComplexTextExpression), Throws.Nothing); Assert.That(() => adapter.Parse(CoercionExpression), Throws.Nothing); + Assert.That(() => adapter.Create(ComplexTextExpression), Throws.Nothing); + Assert.That(() => adapter.Create(CoercionExpression), Throws.Nothing); }); } diff --git a/benchmark/Expressif.Benchmark/ExpressionBenchmarks.cs b/benchmark/Expressif.Benchmark/ExpressionBenchmarks.cs index 0e11c94d..101e29df 100644 --- a/benchmark/Expressif.Benchmark/ExpressionBenchmarks.cs +++ b/benchmark/Expressif.Benchmark/ExpressionBenchmarks.cs @@ -23,6 +23,9 @@ public class ExpressionBenchmarks [GlobalSetup(Target = nameof(ParseAndBindComplexPipeline))] public void SetupConstruction() => SetupAdapter(); + [GlobalSetup(Target = nameof(ParseAndBindCoercionPipeline))] + public void SetupCoercionConstruction() => SetupAdapter(); + private void SetupAdapter() { var versionDirectory = Environment.GetEnvironmentVariable( @@ -61,6 +64,9 @@ public void SetupCoercionPipeline() [Benchmark(Description = "Parse and bind complex pipeline")] public object ParseAndBindComplexPipeline() => adapter.Create(ComplexTextExpression); + [Benchmark(Description = "Parse and bind implicit-coercion pipeline")] + public object ParseAndBindCoercionPipeline() => adapter.Create(CoercionExpression); + [Benchmark(Description = "Evaluate add(5)")] public object? EvaluateSimple() => simpleEvaluator(1234.56m); diff --git a/benchmark/README.md b/benchmark/README.md index ad1b1b1b..2dc8539a 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -50,4 +50,5 @@ is absent. The suite reports native parsing separately for the complex text and implicit-coercion sources. These parse-only benchmarks reuse parser delegates prepared during setup, so -they exclude binding and expression construction. +they exclude binding and expression construction. Each source also has a parse-and-bind +benchmark using the version's native public expression-construction path.