Skip to content

Warmup still ~6x slower than 5.0.3: per-expression AppDomain assembly scan in CustomTypeProvider (root cause of #707) #739

@benluersen

Description

@benluersen

Summary

#707 reported a large slowdown after upgrading past 5.0.x. It was closed via #727 (compiled-delegate cache) and the 6.0.1-preview releases, but the underlying root cause is still present: every expression parse triggers a full AppDomain assembly scan. The cache from #727 only helps when the same expression string recurs; for workflows whose rules are unique expressions (the normal case), compilation is still ~6x slower than 5.0.3.

Root cause

Commit 71d59dc ("[Bug Fix] Handling of Automatic Type Registration", #675) changed CustomTypeProvider.GetCustomTypes() to include base.GetCustomTypes():

public override HashSet<Type> GetCustomTypes()
{
    var all = new HashSet<Type>(base.GetCustomTypes()); // <-- scans all AppDomain assemblies
    all.UnionWith(_types);
    return all;
}

DefaultDynamicLinqCustomTypeProvider.GetCustomTypes() scans every loaded assembly for [DynamicLinqType] types. System.Linq.Dynamic.Core caches that scan per provider instance — but RuleExpressionParser.Parse constructs a new ParsingConfig and CustomTypeProvider for every expression parsed:

public Expression Parse(string expression, ParameterExpression[] parameters, Type returnType)
{
    var config = new ParsingConfig {
        CustomTypeProvider = new CustomTypeProvider(_reSettings.CustomTypes),
        ...

Dynamic LINQ's KeywordsHelper enumerates GetCustomTypes() on every ExpressionParser construction, so the assembly scan runs once per expression — and rules with LocalParams parse several expressions each. Before 71d59dc, GetCustomTypes() returned only the explicitly registered types, so no scan occurred at all; that is why 5.0.x did not exhibit this. (This also explains the "lots of first chance exceptions, dynamic assembly types" observation in #707 — the scan swallows ReflectionTypeLoadException per assembly, per parse.)

Benchmark

20,000 rules, each with 2 LocalParams and a unique main expression, one registered custom type, 174 assemblies loaded in the AppDomain (to approximate a real service). Timing the first ExecuteAllRulesAsync call (which compiles the workflow), net9.0, Release:

Package Warmup
RulesEngine 5.0.3 17.3 s
RulesEngine 5.0.6 16.4 s
RulesEngine 6.0.1-preview.2 113.8 s (6.6x)
6.0.1-preview.2 + fix below 16.3 s

The regression scales with the number of loaded assemblies, so larger services see worse ratios.

Fix

Two small changes restore 5.0.3-level warmup with no behavior change (all 170 unit tests pass):

  1. RuleExpressionParser: build the ParsingConfig/CustomTypeProvider once and reuse it across parses, rebuilding only when ReSettings.CustomTypes is swapped (which AutoRegisterInputType does on workflow registration). This preserves the intent of [Bug Fix] Handling of Automatic Type Registration #675 — the scan results and registered input types are still honored — while running the scan once instead of once per expression.
  2. CustomTypeProvider: memoize the merged type set — it is fixed after construction but was being rebuilt (including the base scan) on every GetCustomTypes() call.

I have this ready as a PR, plus a follow-up PR that adds opt-in parallel rule compilation (ReSettings.EnableParallelRuleCompilation, default false) which brings the 20k-rule warmup down further from 16.3 s to 4.7 s on a 16-thread machine.

https://github.com/microsoft/RulesEngine/pull/740/changes

https://github.com/microsoft/RulesEngine/pull/741/changes

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions