|
|
|
@@ -9,7 +9,7 @@ regex engines and `jq` on one realistic task:
|
|
|
|
|
|---|---|
|
|
|
|
|
| this repo | `countries.*.technology` |
|
|
|
|
|
| jq | `.countries \| map_values(.technology)` |
|
|
|
|
|
| .NET `Regex` / PCRE.NET | see `Extractors.FlatPattern` / `Extractors.PathAwarePattern` |
|
|
|
|
|
| .NET `Regex` / PCRE.NET | see `Extractors.PathAwarePattern` / `Extractors.CountriesBlockPattern` |
|
|
|
|
|
| `Utf8JsonReader` / `JsonDocument` | hand written navigation |
|
|
|
|
|
|
|
|
|
|
## Corpus
|
|
|
|
@@ -48,64 +48,70 @@ shows immediately when an engine finds something different from the others.
|
|
|
|
|
|
|
|
|
|
## 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.
|
|
|
|
|
AMD Ryzen 7 5700X, 8 physical cores (16 logical), Windows 10 21H2, .NET 10.0.12, jq 1.8.2,
|
|
|
|
|
109 MB save / 92 MB JSON twin, BenchmarkDotNet `RunStrategy.Monitoring`,
|
|
|
|
|
3 warmups and 10 iterations for the in-process engines, 5 for jq.
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
|
|
|
| **SearchExpression (this project)** | **49.6 ms** | **1.00x** | **1.46 MB** | yes |
|
|
|
|
|
| .NET Regex balanced block + inner scan | 114.0 ms | 2.30x | 213 MB | yes |
|
|
|
|
|
| PCRE.NET path aware, JIT compiled | 123.4 ms | 2.49x | 0.90 MB | 1869/1870 |
|
|
|
|
|
| .NET Regex path aware, compiled | 135.1 ms | 2.72x | 1.34 MB | 1869/1870 |
|
|
|
|
|
| Utf8JsonReader over JSON twin | 155.5 ms | 3.13x | 16 KB | yes |
|
|
|
|
|
| JsonDocument over JSON twin | 323.2 ms | 6.51x | 72 B (native) | yes |
|
|
|
|
|
| .NET Regex path aware, NonBacktracking | 440.5 ms | 8.87x | 30.6 MB | 1869/1870 |
|
|
|
|
|
| Full parse, then select | 1204 ms | 24.3x | 962 MB | yes |
|
|
|
|
|
| jq `empty` (parse the file, emit nothing) | 4188 ms | 84.4x | n/a | n/a |
|
|
|
|
|
| jq `.countries \| map_values(.technology)` | 4339 ms | 87.4x | n/a | yes |
|
|
|
|
|
| jq `--stream` | 17497 ms | 352x | n/a | n/a |
|
|
|
|
|
| jq process startup only | 4.1 ms | 0.08x | n/a | n/a |
|
|
|
|
|
|
|
|
|
|
Reading the save from disk instead of a preloaded `byte[]` costs the parser ~9 ms more:
|
|
|
|
|
58.4 ms via `FileStream` (`ParserInputBenchmarks`).
|
|
|
|
|
|
|
|
|
|
### Reading the table
|
|
|
|
|
|
|
|
|
|
* **jq is ~6.7x slower than this parser** and 97% of that time is JSON parsing, not the
|
|
|
|
|
query: `jq empty` on the same file costs 4227 ms of the 4340 ms. Process startup is
|
|
|
|
|
negligible (4 ms). jq's `--stream` mode, 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.
|
|
|
|
|
`Regex` with 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 under `countries`, 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.NonBacktracking`** guarantees 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.RegularExpressions` on 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 `Utf8JsonReader`
|
|
|
|
|
and 2x slower than `JsonDocument`. Those numbers exclude the pdx -> JSON conversion
|
|
|
|
|
(~4.7 s), so they are a ceiling for the format, not a usable alternative.
|
|
|
|
|
* **This parser is the fastest engine measured here.** It is 2.3x faster than the only
|
|
|
|
|
regex approach that enforces the path, 3.1x faster than a hand written `Utf8JsonReader`
|
|
|
|
|
over the equivalent JSON, 6.5x faster than `JsonDocument`, and 87x faster than jq — while
|
|
|
|
|
allocating 1.46 MB for a 109 MB input.
|
|
|
|
|
* **jq is ~87x slower and 97% of that is JSON parsing, not the query**: `jq empty` on the
|
|
|
|
|
same file costs 4188 ms of the 4339 ms. Process startup is negligible (4 ms). jq's
|
|
|
|
|
`--stream` mode, often recommended for large inputs, is 4x *slower* still. jq also cannot
|
|
|
|
|
read the Paradox format, so it first needs the save converted to JSON — a conversion this
|
|
|
|
|
parser has to perform anyway.
|
|
|
|
|
* **Only one regex approach here is actually correct**, and it is the expensive one: cut the
|
|
|
|
|
`countries={...}` block out with a balancing-group pattern (a .NET-only feature; PCRE would
|
|
|
|
|
need recursion), then scan inside it. It allocates 213 MB, because stage one materialises
|
|
|
|
|
the whole block as a string.
|
|
|
|
|
* **The cheaper path-aware pattern pairs a country tag with the next technology block over a
|
|
|
|
|
lazy gap, and silently gets it wrong**: 1869 of 1870 countries, because the tag class
|
|
|
|
|
`[A-Z0-9]{3}` drops EU4's `---` pseudo-country. Even that much only holds 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 interleaved them would report a
|
|
|
|
|
technology block under the wrong country tag, with no error.
|
|
|
|
|
* **`RegexOptions.NonBacktracking`** guarantees linear time but is 3.3x slower than the
|
|
|
|
|
compiled backtracking engine on this pattern and allocates 31 MB.
|
|
|
|
|
* **PCRE.NET** (the most used non-BCL regex engine in .NET, ~460k downloads) is within ~10%
|
|
|
|
|
of `System.Text.RegularExpressions` on the path-aware pattern (123 ms vs 135 ms). Nothing
|
|
|
|
|
here justifies leaving the BCL engine.
|
|
|
|
|
* **The query is what makes this parser fast.** Same parser, same file: 50 ms with
|
|
|
|
|
`countries.*.technology`, 1204 ms and 962 MB allocated without a query — 24x and 660x.
|
|
|
|
|
|
|
|
|
|
### Where this parser's time goes
|
|
|
|
|
### How the parser got here
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
Three measurements on the same benchmark (`ParserInputBenchmarks`, reading a `FileStream`):
|
|
|
|
|
|
|
|
|
|
| lexer | mean | allocated |
|
|
|
|
|
|---|---:|---:|
|
|
|
|
|
| `Stream.ReadByte()` per byte | 834 ms | 1.55 MB |
|
|
|
|
|
| copy the file into a `MemoryStream` first, still `ReadByte()` | 738 ms | 257 MB |
|
|
|
|
|
| 64 KB buffer + `Tokenizer` with byte-level block skipping | 58 ms | 1.46 MB |
|
|
|
|
|
|
|
|
|
|
The first jump came from removing a virtual call per byte. The large one came from making
|
|
|
|
|
the query prune *work* rather than just data: `Tokenizer.SkipBlock` throws away a rejected
|
|
|
|
|
`{...}` block by scanning raw bytes for brace depth with `SearchValues<byte>.IndexOfAny`,
|
|
|
|
|
so blocks the search expression rejects are never turned into tokens or strings at all.
|
|
|
|
|
Retained tokens are spans into the read buffer, decoded only when their value is kept, and
|
|
|
|
|
numbers are parsed straight from UTF8.
|
|
|
|
|