Skip to content

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

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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions