diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c9294e4..db34d03 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,6 +21,7 @@ jobs: 7.x 8.x 9.x + 10.0.x - name: Checkout uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index bd6adb1..78130b3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ -#Ignore thumbnails created by windows +# Ignore thumbnails created by windows [Tt]humbs.db -#Ignore files build by Visual Studio +# Ignore files build by Visual Studio *.obj *.exe *.pdb @@ -33,5 +33,8 @@ obj/ *.ReSharper.user [Tt]est[Rr]esult* -#Ignore Rider/Idea files +# Ignore benchmark artifacts +BenchmarkDotNet.Artifacts/ + +# Ignore Rider/Idea files .idea/ diff --git a/Directory.Packages.props b/Directory.Packages.props index c8fe71c..b74cef8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,15 +3,16 @@ true + - + - + - - + + - \ No newline at end of file + diff --git a/README.md b/README.md index 5413317..404ba6f 100644 --- a/README.md +++ b/README.md @@ -3,36 +3,37 @@ [![MIT](https://img.shields.io/github/license/rameel/ramstack.parsing)](https://github.com/rameel/ramstack.parsing/blob/main/LICENSE) A fast, lightweight parser combinator library for .NET. +Build text parsers in C# with typed results and optional error diagnostics. -## Getting Started +## Installation + +Requires .NET 6 or later. -To add the `Ramstack.Parsing` [NuGet package](https://www.nuget.org/packages/Ramstack.Parsing) to your project, run: ```shell dotnet add package Ramstack.Parsing ``` -## Usage +## Example -Here's how to define a simple expression parser that parses and evaluates mathematical expressions in one step: +This parser evaluates arithmetic expressions with parentheses, unary minus, and operator precedence: ```csharp -public static Parser Calc { get; } = CreateParser(); +using System; +using Ramstack.Parsing; +using static Ramstack.Parsing.Parser; -private static Parser CreateParser() -{ - // Grammar: - // ---------------------------------------- - // Start : S Sum $ - // Sum : Product ([+-] S Product)* - // Product : Unary ([*/] S Unary)* - // Unary : '-'? S Primary - // Primary : Parenthesis / Value - // Parenthesis : '(' S Sum ')' S - // Value : Number S - // S : [ \r\n\t]* +var calc = CreateParser(); + +Console.WriteLine(calc.Parse("2 + 3 * (4 - 1)")); // 11 +Console.WriteLine(calc.Parse("1 + (")); // (1:6) Expected '-', '(', or number +static Parser CreateParser() +{ var sum = Deferred(); - var value = Literal.Number().ThenIgnore(S); + + var value = Literal + .Number("number") + .ThenIgnore(S); var parenthesis = sum.Between( Seq(L('('), S), @@ -47,11 +48,11 @@ private static Parser CreateParser() primary ).Do((u, _, d) => u.HasValue ? -d : d); - var product = unary.Fold( + var product = unary.FoldL( OneOf("*/").ThenIgnore(S), (l, r, op) => op == '*' ? l * r : l / r); - sum.Parser = product.Fold( + sum.Parser = product.FoldL( OneOf("+-").ThenIgnore(S), (l, r, op) => op == '+' ? l + r : l - r); @@ -59,203 +60,82 @@ private static Parser CreateParser() } ``` -Use the parser as follows: +Full grammar: [calculator](https://github.com/rameel/ramstack.parsing/blob/main/samples/CalcExpr/README.md). + +### Parsing results + +`Parse` returns a result with `Success`, `Value`, and `ErrorMessage` properties. +Parse errors include the line, column, and expected input: ```csharp -var result = Calc.Parse(expression); +var result = calc.Parse("2 + 3 * (4 - 1)"); if (result.Success) { - var value = result.Value; - ... + Console.WriteLine(result.Value); } else { Console.WriteLine(result.ErrorMessage); - - // - // result.ToString() returns the parsed value as a string, - // or an error message if parsing fails. - // - // Console.WriteLine(result); - // } ``` -The parser provides detailed error messages when the input does not conform to the specified grammar. -For example, when parsing the expression `"1 + ("`, the parser expects `'-'`, `'('`, or a `number` after the opening parenthesis. -The error message clearly indicates this: - -``` -(1:6) Expected '-', '(' or number -``` - -In addition to the main `Parse` method, there is also a lightweight `bool TryParse(ReadOnlySpan source, out T value)` method. -This method suppresses diagnostic messages, returning only the parsing success status and the parsed value if successful. - -If detailed error messages are not required, using `TryParse` is the preferred choice, as it reduces memory allocations -and slightly improves performance by avoiding the overhead associated with collecting diagnostic data. - -## Documentation - -- Documentation can be found in [docs/documentation.md](docs/documentation.md) (Work in Progress). -- Additional usage samples, see the [samples/Samples](samples) directory. - -## Performance - -`Ramstack.Parsing` stands out as an exceptionally fast and efficient parsing library, outperforming many alternatives in both speed and memory usage. - -### Benchmarks: Regex - -Here's a comparison between a regex-based email matcher and an equivalent parser combinator implementation: +Use `TryParse` when you only need the value and success status. +It skips diagnostics, so it allocates less and runs faster: -**Regular Expression:** ```csharp -[GeneratedRegex(@"^[\w.+-]+@[\w-]+\.\w{2,}$")] -private static partial Regex EmailRegexGenerated(); -``` - -**Parser**: -```csharp -var parser = Seq( - Choice(LetterOrDigit, OneOf("_-+.")).OneOrMore(), - L('@'), - Choice(LetterOrDigit, L('-')).OneOrMore(), - L('.'), - LetterOrDigit.AtLeast(2) -).Text(); -``` -The library supports nearly full character class definitions from regular expressions, allowing grammars to be written in a similar way -as it written for regular expression. Optimizations ensure that both definitions are internally normalized to an equivalent representation: -```csharp -var parser = Seq( - Set("\\w+.-").OneOrMore(), - L('@'), - Set("\\w-").OneOrMore(), - L('.'), - Set("\\w").AtLeast(2) -).Text(); -``` - -The benchmark results show that `Ramstack.Parsing` outperforms even highly optimized Regex implementations, -including compiled and source-generated regexes. - -``` -BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.3037) -AMD Ryzen 9 5900X, 1 CPU, 24 logical and 12 physical cores -.NET SDK 9.0.200-preview.0.25057.12 - [Host] : .NET 9.0.1 (9.0.124.61010), X64 RyuJIT AVX2 - DefaultJob : .NET 9.0.1 (9.0.124.61010), X64 RyuJIT AVX2 - - -| Method | Mean | Error | StdDev | Op/s | Gen0 | Allocated | -|---------------- |------------:|---------:|---------:|-------------:|-------:|----------:| -| Ramstack | 34.47 ns | 0.073 ns | 0.065 ns | 29,008,779.8 | - | - | -| Regex | 107.35 ns | 0.125 ns | 0.117 ns | 9,315,658.6 | - | - | -| Regex:Compiled | 44.63 ns | 0.093 ns | 0.083 ns | 22,408,327.6 | - | - | -| Regex:Generated | 41.85 ns | 0.282 ns | 0.250 ns | 23,893,269.4 | - | - | -| Parlot | 295.05 ns | 0.601 ns | 0.533 ns | 3,389,302.6 | 0.0229 | 384 B | -| Parlot:Compiled | 228.98 ns | 0.446 ns | 0.417 ns | 4,367,155.0 | 0.0091 | 152 B | -| Pidgin | 1,602.00 ns | 9.016 ns | 8.434 ns | 624,219.5 | 0.0572 | 976 B | +if (calc.TryParse("2 + 3 * (4 - 1)", out var value)) +{ + Console.WriteLine(value); +} ``` -In this specific scenario, the library simply checks for a match against the email pattern, similar to what `Regex.IsMatch` does, -without extracting the match result itself. Like with regular expressions, this approach avoids unnecessary memory allocations. - -### Benchmarks: Expressions - -This benchmarks tests 2 expressions: -- **Small**: `2.5 * 4.7 - 8.1 / 3` -- **Large**: `((3.14159 * 2.3 * 4) / (1.5 - 0.3) + (2.71828 * 2.5)) * ((8.1 * 3.2 + 4.7) / 2.1 - ((15.7 + 2.3 * 4) / (1.5 - 0.3))) - (((3.5 * 1.41421) * (2.71828 + 3.14159)) / 2)` +### Parsing notes -``` -BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.3037) -AMD Ryzen 9 5900X, 1 CPU, 24 logical and 12 physical cores -.NET SDK 9.0.200-preview.0.25057.12 - [Host] : .NET 9.0.1 (9.0.124.61010), X64 RyuJIT AVX2 - DefaultJob : .NET 9.0.1 (9.0.124.61010), X64 RyuJIT AVX2 - - -| Method | Mean | Error | StdDev | Op/s | Gen0 | Allocated | -|---------------------- |------------:|----------:|----------:|------------:|-------:|----------:| -| Ramstack:Large | 1,600.2 ns | 9.54 ns | 7.96 ns | 624,925.9 | - | - | -| Ramstack:Diag:Large | 1,699.6 ns | 3.53 ns | 3.13 ns | 588,379.8 | 0.0057 | 104 B | -| Parlot:Large | 3,520.1 ns | 11.08 ns | 10.37 ns | 284,081.7 | 0.1984 | 3344 B | -| Parlot:Compiled:Large | 3,356.7 ns | 7.08 ns | 6.27 ns | 297,910.3 | 0.1984 | 3344 B | -| Pidgin:Large | 39,197.3 ns | 150.13 ns | 140.43 ns | 25,511.9 | 0.2441 | 4464 B | -| | | | | | | | -| Ramstack:Small | 209.5 ns | 0.28 ns | 0.25 ns | 4,772,894.8 | - | - | -| Ramstack:Diag:Small | 260.6 ns | 0.57 ns | 0.50 ns | 3,838,032.3 | 0.0062 | 104 B | -| Parlot:Small | 410.7 ns | 1.46 ns | 1.36 ns | 2,434,653.7 | 0.0391 | 656 B | -| Parlot:Compiled:Small | 385.1 ns | 1.22 ns | 0.95 ns | 2,596,891.9 | 0.0391 | 656 B | -| Pidgin:Small | 4,322.2 ns | 18.28 ns | 17.10 ns | 231,363.9 | 0.0381 | 736 B | -``` +- `Parse` and `TryParse` accept strings and `ReadOnlySpan`. +- `L` matches a literal; `S` matches zero or more whitespace characters. Whitespace is handled explicitly. +- Add `Eof` to require the entire input. Here, `Between(S, Eof)` allows leading whitespace and rejects trailing input. +- Build each parser once and reuse it. -- `Ramstack` diagnostic messages disabled. -- `Ramstack:Diag` includes diagnostic messages. - -Even with diagnostics enabled, `Ramstack.Parsing` outperforms other popular libraries. +## Performance -### Benchmarks: JSON +In this email-matching benchmark, `Ramstack.Parsing` performs about as fast as compiled and source-generated regex, +with no measured managed allocations. -This benchmark evaluates JSON parsing performance, including object model construction, across different file sizes. +The benchmark matches `development.team-2021@example.com` against `\A[\w.+-]+@[\w-]+\.\w{2,}\z`. +All implementations use the same Unicode character classes and require the end of input. +This is a simplified pattern, not a full email validator. -- **Small**: 1.16KB -- **Medium**: 11KB -- **Large**: `twitter.json` (617KB) from [serde-rs/json-benchmark](https://github.com/serde-rs/json-benchmark) +Measured with BenchmarkDotNet on .NET 11 RC1 (Linux, AMD Ryzen 9 5900X). -``` -BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.3037) -AMD Ryzen 9 5900X, 1 CPU, 24 logical and 12 physical cores -.NET SDK 9.0.200-preview.0.25057.12 - [Host] : .NET 9.0.1 (9.0.124.61010), X64 RyuJIT AVX2 - DefaultJob : .NET 9.0.1 (9.0.124.61010), X64 RyuJIT AVX2 - - -| Method | Input | Mean | Error | StdDev | Op/s | Gen0 | Gen1 | Gen2 | Allocated | -|----------------- |------- |--------------:|-----------:|-----------:|-----------:|---------:|---------:|---------:|-----------:| -| Ramstack | big | 1,307.929 us | 12.6067 us | 11.7923 us | 764.57 | 152.3438 | 130.8594 | - | 2498.88 KB | -| Parlot | big | 3,102.953 us | 9.3441 us | 8.7405 us | 322.27 | 156.2500 | 132.8125 | - | 2554.06 KB | -| Parlot:Compiled | big | 3,078.931 us | 7.4028 us | 5.7796 us | 324.79 | 156.2500 | 132.8125 | - | 2554.06 KB | -| Pidgin | big | 16,675.788 us | 40.7050 us | 36.0839 us | 59.97 | 156.2500 | 93.7500 | - | 2570.33 KB | -| Newtonsoft.Json | big | 3,878.753 us | 18.3372 us | 16.2554 us | 257.81 | 343.7500 | 320.3125 | - | 5638.63 KB | -| System.Text.Json | big | 1,492.658 us | 10.2347 us | 9.0728 us | 669.95 | 216.7969 | 216.7969 | 216.7969 | 963.67 KB | - -| Ramstack | medium | 20.163 us | 0.0968 us | 0.0906 us | 49,595.14 | 2.6245 | 0.2747 | - | 42.94 KB | -| Parlot | medium | 72.128 us | 0.1193 us | 0.1058 us | 13,864.18 | 2.5635 | 0.2441 | - | 43.09 KB | -| Parlot:Compiled | medium | 70.931 us | 0.2758 us | 0.2445 us | 14,098.14 | 2.5635 | 0.2441 | - | 43.09 KB | -| Pidgin | medium | 266.283 us | 0.4172 us | 0.3698 us | 3,755.41 | 2.4414 | - | - | 44.45 KB | -| Newtonsoft.Json | medium | 52.036 us | 0.4550 us | 0.4257 us | 19,217.47 | 6.0425 | 1.6479 | - | 99.44 KB | -| System.Text.Json | medium | 24.471 us | 0.0968 us | 0.0756 us | 40,865.15 | 1.0376 | - | - | 17.03 KB | - -| Ramstack | small | 3.016 us | 0.0064 us | 0.0053 us | 331,565.28 | 0.3815 | 0.0038 | - | 6.24 KB | -| Parlot | small | 6.723 us | 0.0313 us | 0.0292 us | 148,732.16 | 0.3891 | - | - | 6.39 KB | -| Parlot:Compiled | small | 6.661 us | 0.0210 us | 0.0175 us | 150,120.62 | 0.3891 | - | - | 6.39 KB | -| Pidgin | small | 33.369 us | 0.3909 us | 0.3656 us | 29,968.03 | 0.3662 | - | - | 6.49 KB | -| Newtonsoft.Json | small | 7.940 us | 0.0226 us | 0.0201 us | 125,951.32 | 1.0529 | 0.0458 | - | 17.26 KB | -| System.Text.Json | small | 3.477 us | 0.0133 us | 0.0111 us | 287,598.87 | 0.1373 | - | - | 2.25 KB | -``` +| Method | Mean | Ratio | Allocated | +|-----------------|----------:|------:|----------:| +| Ramstack | 34.81 ns | 1.00 | - | +| Regex | 106.58 ns | 3.06 | - | +| Regex:Compiled | 38.02 ns | 1.09 | - | +| Regex:Generated | 38.42 ns | 1.10 | - | +| Parlot | 160.11 ns | 4.60 | 184 B | +| Parlot:Compiled | 150.57 ns | 4.32 | 184 B | +| Pidgin | 202.13 ns | 5.81 | 0–40 B | -`Ramstack.Parsing` demonstrated superior performance, outperforming other parser libraries by at least 2-3 times, -and even exceeded specialized JSON parsers such as `Newtonsoft.Json` and `System.Text.Json` in terms of both speed and memory efficiency. +Pidgin measured 0–40 B across runs. -### Benchmarked Libraries +Among parser combinator libraries, `Ramstack.Parsing` is the fastest in this comparison - about 1.7–1.9x faster +than the next-fastest implementation on the expression and JSON benchmarks. -1. [x] Parlot 1.3.2 -2. [x] Pidgin 3.3.0 -3. [x] Newtonsoft.Json 13.0.3 -4. [x] System.Text.Json 9.0.0 +See the [full benchmark results](https://github.com/rameel/ramstack.parsing/blob/main/benchmarks/README.md) +for expression evaluation, JSON parsing, library versions, and reproduction commands. +Results depend on the grammar, input, runtime, and hardware. -## Supported Versions +## Examples -| | Version | -|------|------------| -| .NET | 6, 7, 8, 9 | +- [Calculator](https://github.com/rameel/ramstack.parsing/tree/main/samples/CalcExpr) — recursive expressions and operator precedence. +- [JSON](https://github.com/rameel/ramstack.parsing/tree/main/samples/Json) — nested objects and arrays. +- [TinyC](https://github.com/rameel/ramstack.parsing/tree/main/samples/TinyC) — statements, expressions, and comments. -## Contributions +## Contributing -Bug reports and contributions are welcome. +Bug reports and pull requests are welcome. See [Contributing](https://github.com/rameel/ramstack.parsing/blob/main/CONTRIBUTING.md). ## License -This package is released under the **MIT License**. -See the [LICENSE](https://github.com/rameel/ramstack.parsing/blob/main/LICENSE) file for details. +[MIT](https://github.com/rameel/ramstack.parsing/blob/main/LICENSE). diff --git a/benchmarks/EmailBenchmark.cs b/benchmarks/EmailBenchmark.cs index 1936fea..8e69d7d 100644 --- a/benchmarks/EmailBenchmark.cs +++ b/benchmarks/EmailBenchmark.cs @@ -1,6 +1,7 @@ using System.Text.RegularExpressions; using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Columns; using Pidgin; @@ -10,53 +11,81 @@ namespace Ramstack.Parsing.Benchmarks; [MemoryDiagnoser] [OperationsPerSecond] +[HideColumns(Column.Error, Column.StdDev)] public partial class EmailBenchmark { - private static readonly string Email = "development.team-2021@example.com"; + public const string RegexPattern = @"\A[\w.+-]+@[\w-]+\.\w{2,}\z"; + private readonly string _emailValue = "development.team-2021@example.com"; - private static readonly Regex EmailRegex = new Regex(@"^[\w._+-]+@[\w-]+\.\w{2,}$"); - private static readonly Regex EmailRegexCompiled = new Regex((@"^[\w._+-]+@[\w-]+\.\w{2,}$"), RegexOptions.Compiled); + public static readonly Regex EmailRegex = new Regex(RegexPattern); + public static readonly Regex EmailRegexCompiled = new Regex(RegexPattern, RegexOptions.Compiled); - [GeneratedRegex(@"^[\w._+-]+@[\w-]+\.\w{2,}$")] + [GeneratedRegex(RegexPattern)] private static partial Regex EmailRegexGenerated(); [GlobalSetup] public void Setup() { - if (!EmailRegex.IsMatch(Email)) throw new Exception(nameof(EmailRegex)); - if (!EmailRegexCompiled.IsMatch(Email)) throw new Exception(nameof(EmailRegexCompiled)); - if (!EmailRegexGenerated().IsMatch(Email)) throw new Exception(nameof(EmailRegexGenerated)); - if (!RamstackParsers.EmailParser.TryParse(Email, out _)) throw new Exception("RamstackParsers.EmailParser"); - if (!ParlotParsers.EmailParser.TryParse(Email, out _)) throw new Exception("ParlotParsers.EmailParser"); - if (!ParlotParsers.EmailParserCompiled.TryParse(Email, out _)) throw new Exception("ParlotParsers.EmailParserCompiled"); - PidginParsers.EmailParser.ParseOrThrow(Email); + (string Name, Func Match)[] matchers = + [ + ("Ramstack", input => RamstackParsers.EmailParser.TryParse(input, out _)), + ("Regex", EmailRegex.IsMatch), + ("Regex:Compiled", EmailRegexCompiled.IsMatch), + ("Regex:Generated", EmailRegexGenerated().IsMatch), + ("Parlot", input => ParlotParsers.EmailParser.TryParse(input, out _)), + ("Parlot:Compiled", input => ParlotParsers.EmailParserCompiled.TryParse(input, out _)), + ("Pidgin", input => PidginParsers.EmailParser.Parse(input).Success) + ]; + (string Input, bool Expected)[] cases = + [ + (_emailValue, true), + ("a_b+c.d-e@host-name.c0", true), + ("e\u0301@\u203Fhost.\u203F\u0301", true), + ("x@host.12", true), + ("", false), + ("@host.com", false), + ("x@.com", false), + ("x@host.c", false), + ("x@host.co.uk", false), + ("x@host.c-", false), + (_emailValue + "!", false), + (_emailValue + "\n", false), + (_emailValue + "\r\n", false), + (" " + _emailValue, false), + ("x\u200D@host.com", false) + ]; + + foreach (var (name, match) in matchers) + foreach (var (input, expected) in cases) + if (match(input) != expected) + throw new InvalidOperationException($"{name}: unexpected email match for {input}"); } - [Benchmark(Description = "Ramstack")] + [Benchmark(Baseline = true, Description = "Ramstack")] public bool RamstackEmail() => - RamstackParsers.EmailVoidParser.TryParse(Email, out _); + RamstackParsers.EmailParser.TryParse(_emailValue, out _); [Benchmark(Description = "Regex")] public bool RegexEmail() => - EmailRegex.IsMatch(Email); + EmailRegex.IsMatch(_emailValue); [Benchmark(Description = "Regex:Compiled")] public bool RegexEmailCompiled() => - EmailRegexCompiled.IsMatch(Email); + EmailRegexCompiled.IsMatch(_emailValue); [Benchmark(Description = "Regex:Generated")] public bool RegexEmailGenerated() => - EmailRegexGenerated().IsMatch(Email); + EmailRegexGenerated().IsMatch(_emailValue); [Benchmark(Description = "Parlot")] public bool ParlotEmail() => - ParlotParsers.EmailParser.TryParse(Email, out _); + ParlotParsers.EmailParser.TryParse(_emailValue, out _); [Benchmark(Description = "Parlot:Compiled")] public bool ParlotEmailCompiled() => - ParlotParsers.EmailParserCompiled.TryParse(Email, out _); + ParlotParsers.EmailParserCompiled.TryParse(_emailValue, out _); [Benchmark(Description = "Pidgin")] public bool PidginEmail() => - PidginParsers.EmailParser.Parse(Email).Success; + PidginParsers.EmailParser.Parse(_emailValue).Success; } diff --git a/benchmarks/ExpressionBenchmark.cs b/benchmarks/ExpressionBenchmark.cs index 5a9c36b..240ccaa 100644 --- a/benchmarks/ExpressionBenchmark.cs +++ b/benchmarks/ExpressionBenchmark.cs @@ -1,7 +1,7 @@ using System.Diagnostics.CodeAnalysis; using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Columns; using Pidgin; @@ -11,89 +11,100 @@ namespace Ramstack.Parsing.Benchmarks; [MemoryDiagnoser] [OperationsPerSecond] -[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[HideColumns(Column.Error, Column.StdDev)] public class ExpressionBenchmark { [SuppressMessage("ReSharper", "ArrangeRedundantParentheses")] - private const double Large = ((3.14159 * 2.3 * 4) / (1.5 - 0.3) + (2.71828 * 2.5)) * ((8.1 * 3.2 + 4.7) / 2.1 - ((15.7 + 2.3 * 4) / (1.5 - 0.3))) - (((3.5 * 1.41421) * (2.71828 + 3.14159)) / 2); - private const double Small = 2.5 * 4.7 - 8.1 / 3; + public const double LargeResult = ((3.14159 * 2.3 * 4) / (1.5 - 0.3) + (2.71828 * 2.5)) * ((8.1 * 3.2 + 4.7) / 2.1 - ((15.7 + 2.3 * 4) / (1.5 - 0.3))) - (((3.5 * 1.41421) * (2.71828 + 3.14159)) / 2); + public const double SmallResult = 2.5 * 4.7 - 8.1 / 3; - private const string SmallExpression = "2.5 * 4.7 - 8.1 / 3"; - private const string LargeExpression = "(( 3.14159 * 2.3 * 4) / (1.5 - 0.3) + (2.71828 * 2.5) ) * ((8.1 * 3.2 + 4.7) / 2.1 - ((15.7 + 2.3 * 4) / (1.5 - 0.3))) - (((3.5 * 1.41421) * (2.71828 + 3.14159)) / 2)"; + private readonly string _largeExpression = "((3.14159 * 2.3 * 4) / (1.5 - 0.3) + (2.71828 * 2.5)) * ((8.1 * 3.2 + 4.7) / 2.1 - ((15.7 + 2.3 * 4) / (1.5 - 0.3))) - (((3.5 * 1.41421) * (2.71828 + 3.14159)) / 2)"; + private readonly string _smallExpression = "2.5 * 4.7 - 8.1 / 3"; + + public IEnumerable Source + { + get + { + yield return new ExpressionInput(_smallExpression, SmallResult, "small"); + yield return new ExpressionInput(_largeExpression, LargeResult, "large"); + } + } + + [ParamsSource(nameof(Source))] + public ExpressionInput Input { get; set; } [GlobalSetup] [SuppressMessage("ReSharper", "CompareOfFloatsByEqualityOperator")] public void Setup() { - if (RamstackParsers.ExpressionParser.Parse(SmallExpression).Value != Small) throw new Exception("Ramstack: Small"); - if (RamstackParsers.ExpressionParser.Parse(LargeExpression).Value != Large) throw new Exception("Ramstack: Large"); - - if (ParlotParsers.ExpressionParser.Parse(SmallExpression) != Small) throw new Exception("Parlot: Small"); - if (ParlotParsers.ExpressionParser.Parse(LargeExpression) != Large) throw new Exception("Parlot: Large"); - - if (PidginParsers.ExpressionParser.Parse(SmallExpression).Value != Small) throw new Exception("Pidgin: Small"); - if (PidginParsers.ExpressionParser.Parse(LargeExpression).Value != Large) throw new Exception("Pidgin: Large"); + (string Name, double Value)[] results = + [ + (nameof(RamstackExpression), RamstackExpression()), + (nameof(RamstackExpressionDiagnostics), RamstackExpressionDiagnostics()), + (nameof(ParlotExpression), ParlotExpression()), + (nameof(ParlotExpressionCompiled), ParlotExpressionCompiled()), + (nameof(PidginExpression), PidginExpression()) + ]; + + foreach (var (name, value) in results) + if (value != Input.Expected) + throw new InvalidOperationException($"{name}: {value} != {Input.Expected} for {Input}"); + + (string Name, Func Parse)[] parsers = + [ + ("Ramstack", input => RamstackParsers.ExpressionParser.TryParse(input, out var value) ? value : null), + ("Ramstack:Diagnostics", input => RamstackParsers.ExpressionParser.Parse(input) is { Success: true } result ? result.Value : null), + ("Parlot", input => ParlotParsers.ExpressionParser.TryParse(input, out var value) ? value : null), + ("Parlot:Compiled", input => ParlotParsers.ExpressionParserCompiled.TryParse(input, out var value) ? value : null), + ("Pidgin", input => PidginParsers.ExpressionParser.Parse(input) is { Success: true } result ? result.Value : null) + ]; + + foreach (var (name, parse) in parsers) + { + if (parse($" \t{_smallExpression}\r\n") != SmallResult) + throw new InvalidOperationException($"{name}: surrounding whitespace"); + + foreach (var suffix in new[] { " garbage", "+", ")" }) + if (parse(_smallExpression + suffix) is not null) + throw new InvalidOperationException($"{name}: accepted trailing input {suffix}"); + + // Every grammar allows a single unary minus before a primary expression. + foreach (var (input, expected) in new (string, double?)[] { ("-2", -2), ("- 2 * 3", -6), ("-(1 + 2)", -3), ("--2", 2), ("---2", null) }) + if (parse(input) != expected) + throw new InvalidOperationException($"{name}: unexpected result for unary minus '{input}'"); + } } - [BenchmarkCategory("Small")] - [Benchmark(Description = "Ramstack:Small")] - public double RamstackSmall() + [Benchmark(Baseline = true, Description = "Ramstack")] + public double RamstackExpression() { - RamstackParsers.ExpressionParser.TryParse(SmallExpression, out var result); + RamstackParsers.ExpressionParser.TryParse(Input.Value, out var result); return result; } - [BenchmarkCategory("Small")] - [Benchmark(Description = "Ramstack:Diag:Small")] - public double RamstackSmall_Diagnostics() => - RamstackParsers.ExpressionParser.Parse(SmallExpression).Value; + [Benchmark(Description = "Ramstack:Diagnostics")] + public double RamstackExpressionDiagnostics() => + RamstackParsers.ExpressionParser.Parse(Input.Value).Value; - [BenchmarkCategory("Large")] - [Benchmark(Description = "Ramstack:Large")] - public double RamstackLarge() - { - RamstackParsers.ExpressionParser.TryParse(LargeExpression, out var result); - return result; - } + [Benchmark(Description = "Parlot")] + public double ParlotExpression() => + ParlotParsers.ExpressionParser.Parse(Input.Value); - [BenchmarkCategory("Large")] - [Benchmark(Description = "Ramstack:Diag:Large")] - public double RamstackLarge_Diagnostics() => - RamstackParsers.ExpressionParser.Parse(LargeExpression).Value; - - [BenchmarkCategory("Small")] - [Benchmark(Description = "Parlot:Small")] - public double ParlotSmall() => - ParlotParsers.ExpressionParser.Parse(SmallExpression); - - [BenchmarkCategory("Small")] - [Benchmark(Description = "Parlot:Compiled:Small")] - public double ParlotSmallCompiled() => - ParlotParsers.ExpressionParserCompiled.Parse(SmallExpression); - - [BenchmarkCategory("Large")] - [Benchmark(Description = "Parlot:Large")] - public double ParlotLarge() => - ParlotParsers.ExpressionParser.Parse(LargeExpression); - - [BenchmarkCategory("Large")] - [Benchmark(Description = "Parlot:Compiled:Large")] - public double ParlotLargeCompiled() => - ParlotParsers.ExpressionParserCompiled.Parse(LargeExpression); - - [BenchmarkCategory("Small")] - [Benchmark(Description = "Pidgin:Small")] - public double PidginSmall() + [Benchmark(Description = "Parlot:Compiled")] + public double ParlotExpressionCompiled() => + ParlotParsers.ExpressionParserCompiled.Parse(Input.Value); + + [Benchmark(Description = "Pidgin")] + public double PidginExpression() { - var result = PidginParsers.ExpressionParser.Parse(SmallExpression); + var result = PidginParsers.ExpressionParser.Parse(Input.Value); return result.Success ? result.Value : 0; } - [BenchmarkCategory("Large")] - [Benchmark(Description = "Pidgin:Large")] - public double PidginLarge() + public readonly struct ExpressionInput(string value, double expected, string name) { - var result = PidginParsers.ExpressionParser.Parse(LargeExpression); - return result.Success ? result.Value : 0; + public string Value => value; + public double Expected => expected; + public override string ToString() => name; } } diff --git a/benchmarks/JsonBenchmark.cs b/benchmarks/JsonBenchmark.cs index 31910be..cb464b3 100644 --- a/benchmarks/JsonBenchmark.cs +++ b/benchmarks/JsonBenchmark.cs @@ -1,4 +1,7 @@ -using BenchmarkDotNet.Attributes; +using System.Text.Json; + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Columns; using Ramstack.Parsing.Benchmarks.Parsers; @@ -6,26 +9,103 @@ namespace Ramstack.Parsing.Benchmarks; [MemoryDiagnoser] [OperationsPerSecond] +[HideColumns(Column.Error, Column.StdDev)] public class JsonBenchmark { - public static readonly string JsonBig = File.ReadAllText("Data/twitter.json"); - public static readonly string JsonMedium = File.ReadAllText("Data/medium.json"); - public static readonly string JsonSmall = File.ReadAllText("Data/small.json"); + private readonly string _jsonBig = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Data/twitter.json")); + private readonly string _jsonMedium = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Data/medium.json")); + private readonly string _jsonSmall = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Data/small.json")); public IEnumerable Source { get { - yield return new JsonString(JsonSmall.TrimEnd(), "small"); - yield return new JsonString(JsonMedium.TrimEnd(), "medium"); - yield return new JsonString(JsonBig.TrimEnd(), "big"); + yield return new JsonString(_jsonSmall, "small"); + yield return new JsonString(_jsonMedium, "medium"); + yield return new JsonString(_jsonBig, "big"); } } [ParamsSource(nameof(Source))] public JsonString Input { get; set; } - [Benchmark(Description = "Ramstack")] + [GlobalSetup] + public void Setup() + { + var expected = JsonSerializer.Deserialize(Input.Value); + (string Name, object? Value)[] results = + [ + (nameof(RamstackJson), RamstackJson()), + (nameof(ParlotJson), ParlotJson()), + (nameof(ParlotJsonCompiled), ParlotJsonCompiled()), + (nameof(PidginJson), PidginJson()), + (nameof(NewtonsoftJson), NewtonsoftJson()), + (nameof(SystemTextJson), SystemTextJson()) + ]; + + foreach (var (name, value) in results) + { + var actual = value is Newtonsoft.Json.Linq.JToken token + ? JsonSerializer.Deserialize(token.ToString(Newtonsoft.Json.Formatting.None)) + : JsonSerializer.SerializeToElement(value); + + if (!Equivalent(actual, expected)) + throw new InvalidOperationException($"{name}: incorrect JSON model for {Input}"); + } + + (string Name, Func Parse)[] parsers = + [ + ("Ramstack", input => RamstackParsers.JsonParser.TryParse(input, out _)), + ("Parlot", input => ParlotParsers.JsonParser.TryParse(input, out _)), + ("Parlot:Compiled", input => ParlotParsers.JsonParserCompiled.TryParse(input, out _)), + ("Pidgin", input => PidginParsers.JsonParser.TryParse(input, out _)) + ]; + + foreach (var (name, parse) in parsers) + { + if (!parse($" \t{Input.Value}\r\n")) + throw new InvalidOperationException($"{name}: surrounding whitespace"); + + if (parse($"{Input.Value} garbage")) + throw new InvalidOperationException($"{name}: accepted trailing input"); + } + } + + // Combinator parsers store numbers as double; compare numeric values, not JSON formatting. + private static bool Equivalent(JsonElement actual, JsonElement expected) + { + if (actual.ValueKind != expected.ValueKind) + return false; + + switch (expected.ValueKind) + { + case JsonValueKind.Object: + if (actual.EnumerateObject().Count() != expected.EnumerateObject().Count()) + return false; + + foreach (var property in expected.EnumerateObject()) + if (!actual.TryGetProperty(property.Name, out var value) || !Equivalent(value, property.Value)) + return false; + + return true; + + case JsonValueKind.Array: + return actual.GetArrayLength() == expected.GetArrayLength() + && actual.EnumerateArray().Zip(expected.EnumerateArray()).All(pair => Equivalent(pair.First, pair.Second)); + + case JsonValueKind.Number: + // ReSharper disable once CompareOfFloatsByEqualityOperator + return actual.GetDouble() == expected.GetDouble(); + + case JsonValueKind.String: + return actual.GetString() == expected.GetString(); + + default: + return true; + } + } + + [Benchmark(Baseline = true, Description = "Ramstack")] public object? RamstackJson() { RamstackParsers.JsonParser.TryParse(Input.Value, out var result); @@ -50,7 +130,7 @@ public IEnumerable Source [Benchmark(Description = "System.Text.Json")] public object? SystemTextJson() => - System.Text.Json.JsonSerializer.Deserialize(Input.Value); + JsonSerializer.Deserialize(Input.Value); public readonly struct JsonString(string json, string name) { diff --git a/benchmarks/LiteralsBenchmark.cs b/benchmarks/LiteralsBenchmark.cs deleted file mode 100644 index 959ac42..0000000 --- a/benchmarks/LiteralsBenchmark.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Text.RegularExpressions; - -using BenchmarkDotNet.Attributes; - -namespace Ramstack.Parsing.Benchmarks; - -[MemoryDiagnoser] -[OperationsPerSecond] -public partial class LiteralsBenchmark -{ - private const string RegexExpr = "^(abstract|as|base|bool|break|byte|case|catch|char|checked|class|const|continue|decimal|default|delegate|do|double|else|enum|event|explicit|extern|false|finally|fixed|float|for|foreach|goto|if|implicit|in|int|interface|internal|is|lock|long|namespace|new|null|object|operator|out|override|params|private|protected|public|readonly|ref|return|sbyte|sealed|short|sizeof|stackalloc|static|string|struct|switch|this|throw|true|try|typeof|uint|ulong|unchecked|unsafe|ushort|using|virtual|void|volatile|while)$"; - - [GeneratedRegex(RegexExpr)] - private static partial Regex LiteralsRegexGenerated(); - private static readonly Regex LiteralsRegexCompiled = new Regex(RegexExpr, RegexOptions.Compiled); - private static readonly Regex LiteralsRegex = new Regex(RegexExpr); - - private static readonly Parser RamstackLiteralParser = Parser.OneOf(RegexExpr[1..^1].Split('|')).Void(); - - [Params("sbyte", "sealed", "short", "sizeof", "stackalloc", "static", "string", "struct", "switch")] - // [Params( - // "abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char", "checked", "class", "const", "continue", "decimal", "default", - // "delegate", "do", "double", "else", "enum", "event", "explicit", "extern", "false", "finally", "fixed", "float", "for", "foreach", "goto", - // "if", "implicit", "in", "int", "interface", "internal", "is", "lock", "long", "namespace", "new", "null", "object", "operator", "out", - // "override", "params", "private", "protected", "public", "readonly", "ref", "return", "sbyte", "sealed", "short", "sizeof", "stackalloc", - // "static", "string", "struct", "switch", "this", "throw", "true", "try", "typeof", "uint", "ulong", "unchecked", "unsafe", "ushort", "using", - // "virtual", "void", "volatile", "while" - // )] - public string Input { get; set; } = null!; - - [Benchmark(Description = "Regex")] - public bool RegexEmail() => - LiteralsRegex.IsMatch(Input); - - [Benchmark(Description = "RegexCompiled")] - public bool RegexEmailCompiled() => - LiteralsRegexCompiled.IsMatch(Input); - - [Benchmark(Description = "RegexGenerated")] - public bool RegexEmailGenerated() => - LiteralsRegexGenerated().IsMatch(Input); - - [Benchmark(Description = "Ramstack.Parsing")] - public bool RamstackParsing() => - RamstackLiteralParser.TryParse(Input, out _); -} diff --git a/benchmarks/Parsers/ParlotParsers.cs b/benchmarks/Parsers/ParlotParsers.cs index e3164c9..bc67bd3 100644 --- a/benchmarks/Parsers/ParlotParsers.cs +++ b/benchmarks/Parsers/ParlotParsers.cs @@ -16,17 +16,20 @@ public static class ParlotParsers private static Parlot.Fluent.Parser CreateEmailParser() { - var dot = Literals.Char('.'); - var plus = Literals.Char('+'); - var minus = Literals.Char('-'); - var at = Literals.Char('@'); - var wordChar = Literals.Pattern(char.IsLetterOrDigit).Then(_ => 'w'); - var wordDotPlusMinus = OneOrMany(OneOf(wordChar, dot, plus, minus)); - var wordDotMinus = OneOrMany(OneOf(wordChar, dot, minus)); - var wordMinus = OneOrMany(OneOf(wordChar, minus)); - return Capture(wordDotPlusMinus.And(at).And(wordMinus).And(dot).And(wordDotMinus.Eof())); + return Capture( + Literals.Pattern(c => IsWordCharacter(c) || c is '.' or '+' or '-', minSize: 1) + .And(Literals.Char('@')) + .And(Literals.Pattern(c => IsWordCharacter(c) || c == '-', minSize: 1)) + .And(Literals.Char('.')) + .And(Literals.Pattern(IsWordCharacter, minSize: 2)) + .Eof()); } + private static bool IsWordCharacter(char c) => + char.IsLetterOrDigit(c) || char.GetUnicodeCategory(c) is + System.Globalization.UnicodeCategory.NonSpacingMark or + System.Globalization.UnicodeCategory.ConnectorPunctuation; + private static Parlot.Fluent.Parser CreateExpressionParser() { /* @@ -35,8 +38,7 @@ private static Parlot.Fluent.Parser CreateExpressionParser() * * additive => multiplicative ( ( "-" | "+" ) multiplicative )* ; * multiplicative => unary ( ( "/" | "*" ) unary )* ; - * unary => ( "-" ) unary - * | primary ; + * unary => "-"? primary ; * primary => NUMBER * | "(" expression ")" ; */ @@ -58,8 +60,10 @@ private static Parlot.Fluent.Parser CreateExpressionParser() // primary => NUMBER | "(" expression ")"; var primary = number.Or(groupExpression).Named("primary"); - // ( "-" ) unary | primary; - var unary = primary.Unary((minus, x => -x)).Named("unary"); + // unary => "-"? primary; + var unary = minus.Optional().And(primary) + .Then(x => x.Item1.HasValue ? -x.Item2 : x.Item2) + .Named("unary"); // multiplicative => unary ( ( "/" | "*" ) unary )* ; var multiplicative = unary.LeftAssociative( @@ -74,7 +78,8 @@ private static Parlot.Fluent.Parser CreateExpressionParser() ).Named("additive"); expression.Parser = additive; - return expression.Named("expression"); + return expression.Named("expression") + .AndSkip(Literals.Pattern(char.IsWhiteSpace).Optional()).Eof(); } private static Parlot.Fluent.Parser CreateJsonParser() @@ -136,7 +141,7 @@ private static Parlot.Fluent.Parser CreateExpressionParser() Terms.Char('}') ).Then(object? (members) => members.ToDictionary()); - return value.Parser = text + value.Parser = text .Or(number) .Or(boolTrue) .Or(boolFalse) @@ -145,5 +150,7 @@ private static Parlot.Fluent.Parser CreateExpressionParser() .Or(array) .Or(emptyMap) .Or(map); + + return value.AndSkip(Literals.Pattern(char.IsWhiteSpace).Optional()).Eof(); } } diff --git a/benchmarks/Parsers/PidginParsers.cs b/benchmarks/Parsers/PidginParsers.cs index 7b73926..1c356c1 100644 --- a/benchmarks/Parsers/PidginParsers.cs +++ b/benchmarks/Parsers/PidginParsers.cs @@ -10,14 +10,22 @@ namespace Ramstack.Parsing.Benchmarks.Parsers; public static class PidginParsers { - public static readonly Parser ExpressionParser = ExprParserImpl.Instance; - public static readonly Parser EmailParser = - from local in OneOf(LetterOrDigit, Char('_'), Char('.'), Char('+'), Char('-')).AtLeastOnceString() - from at in Char('@') - from domain in OneOf(LetterOrDigit, Char('-')).AtLeastOnceString() - from dot in Char('.') - from tld in Letter.AtLeastOnceString().Where(t => t.Length >= 2) - select $"{local}@{domain}.{tld}"; + public static readonly Parser ExpressionParser = + ExprParserImpl.Instance.Between(SkipWhitespaces).Before(End); + + public static readonly Parser EmailParser = + Token(c => IsWordCharacter(c) || c is '.' or '+' or '-').SkipAtLeastOnce() + .Before(Char('@')) + .Before(Token(c => IsWordCharacter(c) || c == '-').SkipAtLeastOnce()) + .Before(Char('.')) + .Before(Token(IsWordCharacter)) + .Before(Token(IsWordCharacter)) + .Before(Token(IsWordCharacter).SkipMany()) + .Before(End); + + private static bool IsWordCharacter(char c) => + char.IsLetterOrDigit(c) || char.GetUnicodeCategory(c) is + UnicodeCategory.NonSpacingMark or UnicodeCategory.ConnectorPunctuation; private static class ExprParserImpl { @@ -37,22 +45,22 @@ private static Parser> Binary(Parser> Unary(Parser op) => op.Select>(_ => d => -d); private static readonly Parser> Add = Binary(Tok("+").ThenReturn('+')); private static readonly Parser> Sub = Binary(Tok("-").ThenReturn('-')); private static readonly Parser> Mul = Binary(Tok("*").ThenReturn('*')); private static readonly Parser> Div = Binary(Tok("/").ThenReturn('/')); - private static readonly Parser> Neg = Unary(Tok("-").ThenReturn('-')); private static readonly Parser Literal = Tok(Real).Labelled("decimal literal"); public static readonly Parser Instance = Pidgin.Expression.ExpressionParser.Build( expr => ( - OneOf( - Literal, - Parenthesized(expr).Labelled("parenthesized expression") - ), + // unary => "-"? primary + Tok("-").Optional().Then( + OneOf( + Literal, + Parenthesized(expr).Labelled("parenthesized expression") + ), + (minus, x) => minus.HasValue ? -x : x), [ - Operator.Prefix(Neg), Operator.InfixL(Mul).And(Operator.InfixL(Div)), Operator.InfixL(Add).And(Operator.InfixL(Sub)) ] @@ -144,6 +152,16 @@ public static class JsonParser .Between(LBrace, RBrace) .Select(members => members.ToDictionary()); - public static object? Parse(string input) => Json.ParseOrThrow(input); + private static readonly Parser Document = + Json.Between(SkipWhitespaces).Before(End); + + public static object? Parse(string input) => Document.ParseOrThrow(input); + + public static bool TryParse(string input, out object? value) + { + var result = Document.Parse(input); + value = result.Success ? result.Value : null; + return result.Success; + } } } diff --git a/benchmarks/Parsers/RamstackParsers.cs b/benchmarks/Parsers/RamstackParsers.cs index 4e78098..5f581ea 100644 --- a/benchmarks/Parsers/RamstackParsers.cs +++ b/benchmarks/Parsers/RamstackParsers.cs @@ -1,24 +1,22 @@ -using static Ramstack.Parsing.Character; using static Ramstack.Parsing.Parser; namespace Ramstack.Parsing.Benchmarks.Parsers; public static class RamstackParsers { - public static readonly Parser EmailParser = CreateEmailParser(); - public static readonly Parser EmailVoidParser = EmailParser.Void(); + public static readonly Parser EmailParser = CreateEmailParser(); public static readonly Parser ExpressionParser = Samples.CalcExpr.ExpressionParser.Parser; public static readonly Parser JsonParser = Samples.Json.JsonParser.Parser; - private static Parser CreateEmailParser() + private static Parser CreateEmailParser() { var parser = Seq( - Choice(LetterOrDigit, OneOf("_.+-")).OneOrMore(), + Set("\\w.+-").OneOrMore(), L('@'), - Choice(LetterOrDigit, L('-')).OneOrMore(), + Set("\\w-").OneOrMore(), L('.'), - LetterOrDigit.AtLeast(2) - ).Text(); + Set("\\w").AtLeast(2) + ).Void().ThenIgnore(Eof); return parser; } diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..38031fa --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,184 @@ +# Benchmarks + +Full BenchmarkDotNet results for email matching, expression evaluation, and JSON parsing: +`EmailBenchmark`, `ExpressionBenchmark`, and `JsonBenchmark`. + +Results depend on the input, implementation, runtime, and hardware. They are not a general ranking of the libraries. + +## Setup + +Measured on 2026-10-08 with the default BenchmarkDotNet job and concurrent workstation GC. +Each benchmark class ran separately, after a one-minute pause: + +```text +BenchmarkDotNet v0.16.0-preview.2, Linux CachyOS +AMD Ryzen 9 5900X, 1 CPU, 24 logical and 12 physical cores +.NET SDK 11.0.100-rc.1.26425.128 + [Host] : .NET 10.0.12, X64 RyuJIT x86-64-v3 + .NET 11.0 : .NET 11.0.0-rc.1.26425.128, X64 RyuJIT x86-64-v3 +``` + +Library versions: + +- Parlot 1.5.9 +- Pidgin 3.5.1 +- Newtonsoft.Json 13.0.4 +- System.Text.Json from the .NET 11 runtime + +`Ratio` is relative to `Ramstack` for the same input; lower is faster. +`-` means no measured allocation or GC collection in that column. +Parser construction and regex compilation are not timed. +`Parlot:Compiled` uses Parlot's `Compile()`. On these grammars it is about as fast as the interpreted parser. + +## Email matching + +The benchmark matches `development.team-2021@example.com` against this pattern: + +```csharp +[GeneratedRegex(@"\A[\w.+-]+@[\w-]+\.\w{2,}\z")] +private static partial Regex EmailRegexGenerated(); +``` + +The Ramstack parser: + +```csharp +var parser = Seq( + Set("\\w.+-").OneOrMore(), + L('@'), + Set("\\w-").OneOrMore(), + L('.'), + Set("\\w").AtLeast(2) +).Void().ThenIgnore(Eof); +``` + +All implementations use the same Unicode `\w` character set and must match the whole input. +The regex uses `\z` because `$` also matches before a final newline. +This is a simplified pattern, not an RFC-compliant email validator. + +The benchmark only checks whether the input matches. It doesn't create a string containing the match. +Parlot captures a `TextSpan`; Pidgin skips the matched characters. + +| Method | Mean | Op/s | Ratio | Gen0 | Allocated | +|---------------- |----------:|-------------:|------:|---------:|----------:| +| Ramstack | 34.81 ns | 28,723,442.7 | 1.00 | - | - | +| Regex | 106.58 ns | 9,382,454.8 | 3.06 | - | - | +| Regex:Compiled | 38.02 ns | 26,300,462.7 | 1.09 | - | - | +| Regex:Generated | 38.42 ns | 26,029,963.8 | 1.10 | - | - | +| Parlot | 160.11 ns | 6,245,594.6 | 4.60 | 0.0110 | 184 B | +| Parlot:Compiled | 150.57 ns | 6,641,431.5 | 4.32 | 0.0110 | 184 B | +| Pidgin | 202.13 ns | 4,947,403.8 | 5.81 | 0–0.0024 | 0–40 B | + +Pidgin's `Gen0` and `Allocated` ranges come from repeated runs. The timing statistics are from the latest run, in which Pidgin showed no allocation. +Its `Parse(...).Success` call creates a 40 B result object, which the .NET 11 JIT sometimes places on the stack. +Disassembly showed heap allocation in some benchmark processes and stack allocation in others. + +## Expressions + +Each parser evaluates the whole expression and rejects any leftover input. + +- **Small**: `2.5 * 4.7 - 8.1 / 3` +- **Large**: `((3.14159 * 2.3 * 4) / (1.5 - 0.3) + (2.71828 * 2.5)) * ((8.1 * 3.2 + 4.7) / 2.1 - ((15.7 + 2.3 * 4) / (1.5 - 0.3))) - (((3.5 * 1.41421) * (2.71828 + 3.14159)) / 2)` + +`Ramstack` uses `TryParse`, which doesn't collect diagnostics. +`Ramstack:Diagnostics` uses `Parse`, which collects diagnostics and returns a `ParseResult`. + +All three grammars allow at most one unary minus before a primary expression, so `-2` and `-(1 + 2)` are valid, but `---2` isn't. + +| Method | Input | Mean | Op/s | Ratio | Gen0 | Allocated | +|--------------------- |------ |------------:|------------:|------:|-------:|----------:| +| Ramstack | large | 1,542.2 ns | 648,418.1 | 1.00 | - | - | +| Ramstack:Diagnostics | large | 1,760.1 ns | 568,148.6 | 1.14 | 0.0057 | 104 B | +| Parlot | large | 2,805.9 ns | 356,389.2 | 1.82 | 0.0191 | 328 B | +| Parlot:Compiled | large | 2,569.3 ns | 389,213.2 | 1.67 | 0.0191 | 328 B | +| Pidgin | large | 34,104.7 ns | 29,321.5 | 22.11 | 0.2441 | 4112 B | +| | | | | | | | +| Ramstack | small | 210.9 ns | 4,740,834.8 | 1.00 | - | - | +| Ramstack:Diagnostics | small | 248.6 ns | 4,022,355.2 | 1.18 | 0.0062 | 104 B | +| Parlot | small | 371.2 ns | 2,694,200.0 | 1.76 | 0.0196 | 328 B | +| Parlot:Compiled | small | 349.1 ns | 2,864,173.4 | 1.66 | 0.0110 | 184 B | +| Pidgin | small | 3,770.9 ns | 265,190.4 | 17.88 | 0.0381 | 648 B | + +## JSON + +The benchmark parses JSON and builds an object model for each input: + +- **Small**: `small.json`, 1,191 bytes (1.16 KiB) +- **Medium**: `medium.json`, 11,130 bytes (10.87 KiB) +- **Large**: `twitter.json`, 631,514 bytes (616.71 KiB), from [serde-rs/json-benchmark](https://github.com/serde-rs/json-benchmark) + +Sizes are for the UTF-8 files. Each parser reads the same .NET string, loaded before the benchmark. +File reading isn't timed. + +Before timing, the setup code compares each parser's output with the output from `System.Text.Json` +and checks that extra input after the JSON is rejected. + +The object models differ: + +- Combinator parsers build dictionary/list trees with boxed values. +- Newtonsoft.Json builds a `JToken` tree. +- System.Text.Json returns a compact `JsonElement` representation. + +Because the parsers build different objects, the memory allocation figures aren't directly comparable. +The parser combinator examples use `double` for numbers and aren't full JSON validators. +These benchmarks don't test whether the parsers follow every JSON rule or preserve numbers exactly. + +| Method | Input | Mean | Op/s | Ratio | Gen0 | Gen1 | Gen2 | Allocated | +|----------------- |------- |--------------:|-----------:|------:|---------:|---------:|---------:|-----------:| +| Ramstack | big | 1,303.976 μs | 766.89 | 1.00 | 152.3438 | 130.8594 | - | 2498.88 KB | +| Parlot | big | 2,456.401 μs | 407.10 | 1.88 | 152.3438 | 148.4375 | - | 2514.81 KB | +| Parlot:Compiled | big | 2,469.364 μs | 404.96 | 1.89 | 152.3438 | 148.4375 | - | 2514.81 KB | +| Pidgin | big | 16,176.153 μs | 61.82 | 12.41 | 156.2500 | 93.7500 | - | 2570.3 KB | +| Newtonsoft.Json | big | 3,756.486 μs | 266.21 | 2.88 | 343.7500 | 320.3125 | - | 5638.63 KB | +| System.Text.Json | big | 1,312.258 μs | 762.05 | 1.01 | 158.2031 | 158.2031 | 158.2031 | 964.79 KB | +| | | | | | | | | | +| Ramstack | medium | 22.734 μs | 43,986.34 | 1.00 | 2.6245 | 0.2747 | - | 42.94 KB | +| Parlot | medium | 42.917 μs | 23,300.54 | 1.89 | 2.6245 | 0.2441 | - | 43.7 KB | +| Parlot:Compiled | medium | 43.321 μs | 23,083.65 | 1.91 | 2.6245 | 0.2441 | - | 43.7 KB | +| Pidgin | medium | 260.133 μs | 3,844.18 | 11.44 | 2.4414 | - | - | 44.45 KB | +| Newtonsoft.Json | medium | 51.799 μs | 19,305.49 | 2.28 | 6.0425 | 1.6479 | - | 99.44 KB | +| System.Text.Json | medium | 19.981 μs | 50,047.99 | 0.88 | 1.0376 | - | - | 17.03 KB | +| | | | | | | | | | +| Ramstack | small | 3.416 μs | 292,712.86 | 1.00 | 0.3815 | 0.0038 | - | 6.24 KB | +| Parlot | small | 6.237 μs | 160,341.11 | 1.83 | 0.4044 | 0.0076 | - | 6.67 KB | +| Parlot:Compiled | small | 6.131 μs | 163,114.90 | 1.79 | 0.4044 | 0.0076 | - | 6.67 KB | +| Pidgin | small | 35.843 μs | 27,899.82 | 10.49 | 0.3662 | - | - | 6.49 KB | +| Newtonsoft.Json | small | 8.151 μs | 122,680.16 | 2.39 | 1.0529 | 0.0458 | - | 17.26 KB | +| System.Text.Json | small | 3.097 μs | 322,903.49 | 0.91 | 0.1373 | - | - | 2.25 KB | + +## Run the benchmarks + +Use the .NET 10 SDK or later to build the project. Package versions are pinned in +[Directory.Packages.props](../Directory.Packages.props). + +To reproduce the published results, also install the .NET 11 SDK listed above. +The benchmark project targets `net10.0`; the commands below use `--runtimes net11.0` to run the +measured workloads on .NET 11. The library and sample projects keep their current target frameworks. + +From the repository root: + +```shell +dotnet build benchmarks/Ramstack.Parsing.Benchmarks.csproj -c Release + +# Check that the benchmarks build and run correctly. +# Don't use Dry timings to compare performance. +# -------------------------------------------------- +dotnet run --project benchmarks/Ramstack.Parsing.Benchmarks.csproj -c Release --no-build -- \ + --runtimes net11.0 --filter '*EmailBenchmark*' --job Dry --noOverwrite --artifacts ./BenchmarkDotNet.Artifacts/dry-email + +dotnet run --project benchmarks/Ramstack.Parsing.Benchmarks.csproj -c Release --no-build -- \ + --runtimes net11.0 --filter '*ExpressionBenchmark*' --job Dry --noOverwrite --artifacts ./BenchmarkDotNet.Artifacts/dry-expressions + +dotnet run --project benchmarks/Ramstack.Parsing.Benchmarks.csproj -c Release --no-build -- \ + --runtimes net11.0 --filter '*JsonBenchmark*' --job Dry --noOverwrite --artifacts ./BenchmarkDotNet.Artifacts/dry-json + +# Run each benchmark class separately, one at a time, with the default job. +# ------------------------------------------------------------------------- +dotnet run --project benchmarks/Ramstack.Parsing.Benchmarks.csproj -c Release --no-build -- \ + --runtimes net11.0 --filter '*EmailBenchmark*' --noOverwrite --artifacts ./BenchmarkDotNet.Artifacts/email + +dotnet run --project benchmarks/Ramstack.Parsing.Benchmarks.csproj -c Release --no-build -- \ + --runtimes net11.0 --filter '*ExpressionBenchmark*' --noOverwrite --artifacts ./BenchmarkDotNet.Artifacts/expressions + +dotnet run --project benchmarks/Ramstack.Parsing.Benchmarks.csproj -c Release --no-build -- \ + --runtimes net11.0 --filter '*JsonBenchmark*' --noOverwrite --artifacts ./BenchmarkDotNet.Artifacts/json +``` diff --git a/benchmarks/Ramstack.Parsing.Benchmarks.csproj b/benchmarks/Ramstack.Parsing.Benchmarks.csproj index 894df48..8d6e44a 100644 --- a/benchmarks/Ramstack.Parsing.Benchmarks.csproj +++ b/benchmarks/Ramstack.Parsing.Benchmarks.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable latest