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