ParadoxSaveParser.Benchmarks
Compares the query engine of this repo (SearchExpression + SaveParserEU4) against
regex engines and jq on one realistic task:
extract the
technologyblock (adm/dip/mil level) of every country from an EU4 save
| engine | query |
|---|---|
| this repo | countries.*.technology |
| jq | .countries | map_values(.technology) |
.NET Regex / PCRE.NET |
see Extractors.FlatPattern / Extractors.PathAwarePattern |
Utf8JsonReader / JsonDocument |
hand written navigation |
Corpus
gamestate— a real 109 MB EU4 1.37 save (text format, 6.0M lines, 2803 country blocks, 1870 of which have atechnologyblock).gamestate.min.json— 92 MB compact JSON twin, produced from the save by this project's own parser. It exists because jq and theSystem.Text.Jsonengines cannot read the Paradox format at all.
The save is not in the repository. Point PSP_BENCH_DATA at the directory holding it,
or drop it in ParadoxSaveParser.CLI/bin/Debug/net10.0/ (the default location).
Usage
dotnet build ParadoxSaveParser.Benchmarks -c Release
cd ParadoxSaveParser.Benchmarks/bin/Release/net10.0
./ParadoxSaveParser.Benchmarks.exe prepare # build the JSON twin (once)
./ParadoxSaveParser.Benchmarks.exe verify # one run of every engine + correctness check
./ParadoxSaveParser.Benchmarks.exe --filter '*' # full BenchmarkDotNet run
Every engine returns the checksum countries * 1_000_000 + sum_of_tech_levels, so verify
shows immediately when an engine finds something different from the others.
Fairness notes
- All in-process engines read from memory; file I/O is excluded. jq is an external process,
so its numbers include process start (measured separately by the
jq startup onlybenchmark) and reading the JSON from the OS page cache. - The regex engines work on the save text, which is 109 MB; jq works on 92 MB of JSON.
- jq is the only engine here that is a general query language: it can do arithmetic,
filtering and reshaping, which neither the regexes nor
SearchExpressioncan.
Results
AMD Ryzen 7 5700X, 16 logical cores, Windows 10 21H2, .NET 10.0.12, jq 1.8.2,
109 MB save / 92 MB JSON twin, BenchmarkDotNet RunStrategy.Monitoring, 5 iterations.
| engine | mean | vs this project | allocated | correct? |
|---|---|---|---|---|
| .NET Regex flat, source generated | 14.8 ms | 0.02x | 1.2 MB | path-blind |
| .NET Regex flat, compiled | 17.0 ms | 0.03x | 1.2 MB | path-blind |
| .NET Regex flat, interpreted | 19.3 ms | 0.03x | 1.2 MB | path-blind |
| PCRE.NET flat, JIT compiled | 97.4 ms | 0.15x | 0.9 MB | path-blind |
| .NET Regex balanced block + flat | 112.3 ms | 0.17x | 224 MB | yes |
| .NET Regex path aware, compiled | 116.2 ms | 0.18x | 1.4 MB | 1869/1870 |
| PCRE.NET path aware, JIT compiled | 122.9 ms | 0.19x | 0.9 MB | 1869/1870 |
| Utf8JsonReader over JSON twin | 155.7 ms | 0.24x | 0 B | yes |
| JsonDocument over JSON twin | 321.4 ms | 0.50x | 0 B (native) | yes |
| .NET Regex path aware, NonBacktracking | 444.5 ms | 0.69x | 32 MB | 1869/1870 |
| SearchExpression (this project) | 645.1 ms | 1.00x | 1.6 MB | yes |
| Full parse, then select | 1831 ms | 2.84x | 1153 MB | yes |
jq .countries | map_values(.technology) |
4340 ms | 6.73x | n/a | yes |
jq empty (parse the file, emit nothing) |
4227 ms | 6.55x | n/a | n/a |
jq --stream |
17490 ms | 27.1x | n/a | n/a |
| jq process startup only | 4.2 ms | 0.01x | n/a | n/a |
Reading the table
- jq is ~6.7x slower than this parser and 97% of that time is JSON parsing, not the
query:
jq emptyon the same file costs 4227 ms of the 4340 ms. Process startup is negligible (4 ms). jq's--streammode, often recommended for large inputs, is 4x slower still. jq also needs the data converted to JSON first, which this parser has to do anyway — so end to end jq is strictly more expensive here. - The regexes are 5-40x faster, but they are not doing the same job. A regex never
parses the structure; it scans bytes for a literal and validates a short window around it.
Regexwith a literal prefix (technology={) is vectorized, so 109 MB is scanned at several GB/s. That speed is real and the cost is real too: nothing verifies that the hit is undercountries, at the right depth, or belongs to the tag matched before it. - Path-aware regexes lose both the speed and the correctness. Forcing the tag into the
pattern costs 7x (17 ms -> 116 ms) and still returns 1869 of 1870 countries: the tag class
[A-Z0-9]{3}silently drops EU4's---pseudo-country. Pairing survives here only by luck of the file layout — all 933 tech-less country blocks happen to be grouped at the end of the save, so the lazy gap never runs across one. A save that interleaves them would make the regex report a technology block under the wrong country tag with no error. RegexOptions.NonBacktrackingguarantees linear time but is 26x slower than the compiled backtracking engine on this pattern and allocates 32 MB.- PCRE.NET (the most used non-BCL regex engine in .NET, ~460k downloads) is 6x slower
than
System.Text.RegularExpressionson the flat pattern, because .NET's vectorized literal prefix search beats PCRE2's JIT here. On the path-aware pattern the two are equal. There is no reason to leave the BCL engine for this workload. - The query is what makes this parser fast, not the parsing. Same parser, same file:
645 ms with
countries.*.technology, 1831 ms and 1.1 GB allocated without a query. The search expression prunes ~65% of the work and 99.9% of the allocations. - Against a JSON reader on equivalent data, the parser is 4x slower than
Utf8JsonReaderand 2x slower thanJsonDocument. Those numbers exclude the pdx -> JSON conversion (~4.7 s), so they are a ceiling for the format, not a usable alternative.
Where this parser's time goes
645 ms for 109 MB is ~170 MB/s, or ~13 cycles per input byte. The lexer reads the save one
byte at a time through Stream.ReadByte() (SaveParserEU4.LexTextSave), which is a virtual
call plus bounds check per byte, and appends char by char into a StringBuilder. Reading
into a byte[] buffer and scanning it with ReadOnlySpan<byte>.IndexOfAny would be the
first thing to try — the regex numbers above show what the same hardware does when it scans
a span instead of a stream.