Skip to content

Mock verification slow with It.Is(Expression) #1420

Closed
@peterder72

Description

Mock verification is very slow when used with expressions and large amount of invocations. Repro:

var mock = new Mock<ISimpleObject>();

for (var i = 0; i < 1_000_000; i++)
{
    mock.Object.Do(i);
}

// Takes ~1 minute
mock.Verify(x => x.Do(It.Is<int>(num => num < 10)));

// Takes 0.2s
mock.Verify(x => x.Do(It.IsAny<int>()));

public interface ISimpleObject
{
    void Do(int num);
}

On inspection, most of the time is spent compiling the exact same provided match expression, when predicate is executed for each invocation during verification:

public static TValue Is<TValue>(Expression<Func<TValue, bool>> match)
{
    if (typeof(TValue).IsOrContainsTypeMatcher())
    {
        throw new ArgumentException(Resources.UseItIsOtherOverload, nameof(match));
    }

    var thisMethod = (MethodInfo)MethodBase.GetCurrentMethod();

    return Match.Create<TValue>(
        argument => match.CompileUsingExpressionCompiler().Invoke(argument), // << hot 
        Expression.Lambda<Func<TValue>>(Expression.Call(thisMethod.MakeGenericMethod(typeof(TValue)), match)));
}

This results in huge performance drop on large number of invocations and unnecessary allocations, while not serving any purpose. From what I can see, this issue has not been discovered previously. I was able to trace it back to this commit, but it might've been still present in a different form.

The solution here would be to only execute the compilation once, and then pass the compled delegate into the match factory method:

var thisMethod = (MethodInfo)MethodBase.GetCurrentMethod();
var compiledMethod = match.CompileUsingExpressionCompiler();

return Match.Create<TValue>(
    argument => compiledMethod.Invoke(argument),
    Expression.Lambda<Func<TValue>>(Expression.Call(thisMethod.MakeGenericMethod(typeof(TValue)), match)));

I have implemented a fix on my fork, benchmark results for pre, post, and IsAny (for comparison) is as follows:

BenchmarkDotNet v0.13.8, Arch Linux
AMD Ryzen 9 5900X, 1 CPU, 24 logical and 12 physical cores
.NET SDK 7.0.110
  [Host]   : .NET 7.0.10 (7.0.1023.41001), X64 RyuJIT AVX2
  .NET 7.0 : .NET 7.0.10 (7.0.1023.41001), X64 RyuJIT AVX2

Job=.NET 7.0  Runtime=.NET 7.0  InvocationCount=1  
UnrollFactor=1  

| Method        | Mean         | Error      | StdDev     | Ratio    | RatioSD | Allocated | Alloc Ratio |
|-------------- |-------------:|-----------:|-----------:|---------:|--------:|----------:|------------:|
| RunMoqIs_Pre  | 4,179.471 ms | 81.0558 ms | 83.2384 ms | 1,438.35 |   34.63 | 414.97 MB |      108.38 |
| RunMoqIsAny   |     6.947 ms |  0.1385 ms |  0.1801 ms |     2.40 |    0.07 |  10.87 MB |        2.84 |
| RunMoqIs_Post |     2.899 ms |  0.0411 ms |  0.0364 ms |     1.00 |    0.00 |   3.83 MB |        1.00 |

Benchmark code:

[SimpleJob(RuntimeMoniker.Net70)]
[MemoryDiagnoser(displayGenColumns: false)]
public class CompileBenchmark
{
    private const int NumberOfCalls = 100_000;
    private Mock<ISimpleObject> m_ObjectMock = null!;
    
    [IterationSetup]
    public void Setup()
    {
        m_ObjectMock = new Mock<ISimpleObject>();
        
        for (var i = 0; i < NumberOfCalls; i++)
        {
            m_ObjectMock.Object.Do(i);
        }
        
    }
    
    [Benchmark]
    public void RunMoqIs_Pre()
    {
        m_ObjectMock.Verify(x => x.Do(It.Is_Pre<int>(num => num < 10)));
    }
    
    [Benchmark]
    public void RunMoqIsAny()
    {
        m_ObjectMock.Verify(x => x.Do(It.IsAny<int>()));
    }
    
    [Benchmark(Baseline = true)]
    public void RunMoqIs_Post()
    {
        m_ObjectMock.Verify(x => x.Do(It.Is<int>(num => num < 10)));
    }
}

Since I already have a fix on my fork, I'll be happy to submit a PR for this issue myself

Back this issue
Back this issue

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions