Skip to content

perf: parameterize filter values and cache parsers and regexes - #110

Merged
pdevito3 merged 4 commits into
mainfrom
fm/qk-performance
Sep 29, 2026
Merged

pdevito3 merged 4 commits into
mainfrom
fm/qk-performance

Conversation

@pdevito3

Copy link
Copy Markdown
Owner

Summary

This PR contains the two performance findings of the QueryKit review. Each finding is one commit. Two more commits correct flaky integration tests.

  1. perf(filter): send filter values to ef core as parameters. Each filter value was an Expression.Constant, so EF Core wrote the value into the SQL as a literal. Each new value made a new SQL text, a new EF Core query compilation, and a new Postgres plan. Now each value is a field read on a small holder object (FilterValue<T>). EF Core sends a field read on a captured object as a parameter, the same as a C# closure variable.
  2. perf(parser): cache sprache parsers and alias regexes. The Sprache parsers were => properties, so each parse built all of them again. A parser in a second from clause was also built again inside a lambda on each parse. The alias replacement built a new Regex for each query name and each of the 24 operators, and for each operator alias, on each parse. Now the parsers are static readonly fields in dependency order, and a static cache keeps one Regex for each pattern.
  3. test(integration): give authors unique names in alias filter tests and test(integration): use a longer guid fragment in the guid contains test. The integration tests share one database. Random author names and random guids sometimes matched a second row, so these tests failed in some runs.

1. Filter values are SQL parameters

The verify-querykit harness ran each filter on the in-memory target and on Postgres, before the fix and after the fix (labels perf-param-* and perf-param-*-fixed). The titles are the same on both targets before and after. The Postgres SQL changed as follows:

Label Before After
equals r."Title" = 'Pancakes' r."Title" = @Value
greater r."Rating" > 3 r."Rating" > @Value
in r."Title" IN ('Pancakes', 'Salt Bread') r."Title" = ANY (@Value)
in-ci lower(r."Title") IN ('pancakes', 'salt bread') lower(r."Title") = ANY (@Value)
contains lower(r."Title") LIKE '%salt%' AND r."Price" < 10.5 lower(r."Title") LIKE @ToLower_contains AND r."Price" < @Value
date r."CreatedAt" > TIMESTAMPTZ '2024-02-01T00:00:00Z' AND r."DateOfOrigin" < DATE '1960-01-01' r."CreatedAt" > @Value AND r."DateOfOrigin" < @Value0
bool-enum r."IsVegetarian" AND r."Visibility" = 2 r."IsVegetarian" = @Value AND r."Visibility" = @Value0
collection i."Name" = 'salt', i0."Stock" > 4 i."Name" = @Value, i0."Stock" > @Value0
count cardinality(r."Tags") > 1 AND 'bread' = ANY (r."Tags") cardinality(r."Tags") > @Value AND @Value0 = ANY (r."Tags")
arithmetic r."Rating" * 2 > 7 r."Rating" * @Value > @Value0

Notes:

  • A null value stays a constant, so the SQL still uses IS NULL.
  • An ^^ list is one array parameter (= ANY (@Value)), so lists of different lengths share one SQL text.
  • The unit tests compare Expression.ToString() output. A field read prints as value(QueryKit.FilterValue1[...]).Value, so the new ToDisplayString()test helper puts the values back inline. One expectation changed fromx.BirthMonth == new Nullable1(January) to x.BirthMonth == January, because the helper prints a nullable enum value directly.

Regression tests:

  • FilterParameterTests (integration): 15 pairs of filters that differ only in the value must give the same SQL text, and the SQL must contain @. One more test makes sure that ^^* uses = ANY (@ and returns the correct rows. Without the fix, all 16 tests fail.
  • FilterValueParameterTests (unit): each value in the expression is a FilterValue<T> read, the value keeps its type, and null stays a constant.

2. Parsers and alias regexes are built once

A static field can only use the fields that are declared above it, so the fields are in dependency order. The recursive arithmetic parser still goes through Parse.Ref. LogicalOperatorParser is public, so it stays a property ({ get; } =) for binary compatibility. RawStringLiteralParser still builds its inner parser on each parse, because that parser depends on the number of opening quotes.

The regex cache is keyed by the full pattern string. The patterns come only from the configuration (query names and operator aliases), never from the filter text, so the number of entries stays small. The static Regex methods have a built-in cache of only 15 patterns, and five aliases with 24 operators need 120 patterns.

Parse time and allocations for each parse, from a Release benchmark on .NET 10 (2000 warm-up parses, then 20000 measured parses):

Filter Before After
Age > 25 73-76 us, 167.5 KB 33-36 us, 54.6 KB
complex (5 comparisons, guid, date, list) 340 us, 1069.7 KB 128-137 us, 398 KB
5 property aliases 287-361 us, 781.3 KB 50-52 us, 102.8 KB
word operators (eq, gt, and) 88-107 us, 318.9 KB 28-30 us, 93.9 KB

The harness ran six filters before and after the change (labels perf-cache-* and perf-cache-*-fixed): a simple filter, nested groups with arithmetic, dates with a list and a guid, property aliases, word operators, and a bad input. The titles and the Postgres SQL are the same before and after on both targets. The bad input Title == gives the same ParsingException message: "Unexpected end of input reached; expected null or numeric character or . or " or [ or letter (Line 1, Column 10)".

Regression test: FilterParserAllocationTests (unit) measures the bytes that one parse allocates. Each budget is about two times the bytes after the fix. Without the fix, all three tests fail:

Expected BytesForEachParse("Age > 25", null) to be less than 102400L, but found 171767L
Expected BytesForEachParse("""name == "lamb" && years > 25""", config) to be less than 256000L, but found 819503L
Expected BytesForEachParse("""Title eq "lamb" and Age gt 25""", config) to be less than 204800L, but found 329007L

3. Flaky integration tests

  • can_filter_on_projections_nested and can_filter_on_child_entity_with_config used a random one-word author name. Another test sometimes made an author with the same name. The tests now use a guid as the author name.
  • can_filter_by_guid_contains filtered on "9edb". A random guid contains these 4 characters in about 1 of 2600 cases, and a run inserts hundreds of people. The test failed in 1 of 5 runs with "Expected people.Count to be 1, but found 2". The test now uses "9edb-a3ec", the same form as can_filter_by_nullable_guid_contains.

Test results

Each commit passes both suites. At the last commit, QueryKit.UnitTests has 227 passed and 2 skipped, and QueryKit.IntegrationTests has 228 passed and 1 skipped.

Related work

The dos-limits, property-resolver, and parser-conversion-bugs PRs also change FilterParser.cs and QueryKitPropertyMappings.cs. When one of them merges first, this branch needs a rebase that keeps both changes.

can_filter_on_projections_nested and can_filter_on_child_entity_with_config
filter on a random one-word author name. The tests share one database, so
another test can insert an author with the same word. Then the filter
returns two recipes and the test fails. Use a GUID as the name, the same
as can_filter_on_projections_nested_complex.
Each filter value was an Expression.Constant, so EF Core wrote it into the
SQL as a literal. Each new value made a new SQL text. EF Core compiled a new
query, and Postgres made a new plan, for each value.

Put each value in a FilterValue<T> holder and read its field. EF Core treats
this read as a captured variable and sends the value as a parameter. The
in-lists become one array parameter (= ANY (@value) on Npgsql). Arithmetic
literals, dates, enums, bools, and guids are parameters too. Null stays a
constant, so the SQL keeps IS NULL.

Nullable enum values are now one value of the nullable type. The expression
prints January, not new Nullable`1(January). The unit tests print holder
reads inline with ToDisplayString().
The tests share one database, so other people with random ids are in the table. A random guid contains "9edb" in about 1 of 2600 cases, so the filter sometimes matched a second person. An 8-character fragment makes a random match improbable.
The Sprache parsers were properties, so each parse built them again. A parser in a second from clause was also built again inside a lambda on each parse. The parsers are now static readonly fields in dependency order. The recursive arithmetic parser still goes through Parse.Ref.

The alias replacement built a new Regex for each query name and operator, and for each operator alias, on each parse. A static cache now keeps one Regex for each pattern. The patterns come only from the configuration.

Age > 25 went from about 75 us and 167 KB to about 34 us and 55 KB for each parse. A filter with five property aliases went from about 300 us and 781 KB to about 50 us and 103 KB.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant