Rest vs gRPC – performance benchmark in .Net Core 3.1

Hey Guys!

The last days in Poland are cold & cloudy. This weather is a great motivator to reflect on… Communication protocols :-)!

This article presents….

  • gRPC CodeFirst approach,
  • The performance benchmark for gRPC vs REST communication in .Net core 3.1,
  • How many does cost to open gRPC channel & why is worth to scope it like HttpClient,
  • Note: All tests were started on my local PC, so all network traffic was occured in localhost & self signed ssl certs.

gRPC CodeFirst approach.

Firstly, I want to show You a little different way of creating gRPC web services than Microsoft official docs. Maybe a long time ago You heard something about gRPC, something about the requirement to have a .proto contract files for generating base, service classes. Maybe, when You look at this, You were scared (I did). What if I tell You, there is a different, unofficial way of creating gRPC services? Does this different approach have this same performance, no .proto files & shorter way of implementation based on C# models, interfaces & attributes? This is protobuf-net.Grpc library created by Marc Gravell. From now, You can forget about ContractFirst and focus on creating contracts by .net coding! (btw. it’s also possible to create .proto files from it :-))!

    [ServiceContract]
    public interface IUserGrpcService
    {
        ValueTask<UserDto> ActivateAsync(ActivateUserCommand command, CallContext context = default);
        ValueTask<CreatedUserDto> CreateUserAsync(CreateUserCommand command, CallContext context = default);
        ValueTask<AuditedUserDto> LoginAsync(LoginCommand command, CallContext context = default);
        ValueTask<ResetPasswordDto> ResetPasswordAsync(ResetPasswordCommand command, CallContext context = default);
        ValueTask<UserDto> SetNewPasswordAsync(SetNewPasswordCommand command, CallContext context = default);
    }

    [ProtoContract]
    public class UserDto
    {
        [ProtoMember(1)]
        public long Id { get; set; }
        [ProtoMember(2, DataFormat = DataFormat.Default)]
        public DateTime CreatedAt { get; set; }
        [ProtoMember(3)]
        public string Email { get; set; }
    }

    // Server
    public class UsersGrpcService : IUserGrpcService
    {
        private readonly IMediator _mediator;

        public UsersGrpcService(IMediator mediator)
        {
            _mediator = mediator;
        }

        public async ValueTask<UserDto> ActivateAsync(ActivateUserCommand command, CallContext context = default)
            => await _mediator.Send(command, context.CancellationToken);

        public async ValueTask<CreatedUserDto> CreateUserAsync(CreateUserCommand command, CallContext context = default)
            => await _mediator.Send(command, context.CancellationToken);
        
        public async ValueTask<AuditedUserDto> LoginAsync(LoginCommand command, CallContext context = default)
            => await _mediator.Send(command, context.CancellationToken);

        public async ValueTask<ResetPasswordDto> ResetPasswordAsync(ResetPasswordCommand command, CallContext context = default)
            => await _mediator.Send(command, context.CancellationToken);

        public async ValueTask<UserDto> SetNewPasswordAsync(SetNewPasswordCommand command, CallContext context = default)
            => await _mediator.Send(command, context.CancellationToken);
    }

    //Startup 
    public void ConfigureServices(IServiceCollection services)
    {
         services.AddCodeFirstGrpc(config =>
         {
             config.ResponseCompressionLevel = System.IO.Compression.CompressionLevel.Optimal;
             config.Interceptors.Add<ExceptionInterceptor>();
         });
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        // ...
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapGrpcService<UsersGrpcService>();
        });
        // ...
    }

    //Client - create of channel and service should be done by DI. 
    //Opening of channel & create service costs. 
    //Remember about it.
    using var grpc = _grpcFactory.CreateChannel(
        _settings.Users.Host,
        _settings.Users.Port);

    var service = grpc.CreateService<IUserGrpcService>();

    //Client - cosume method.
    var dto = await _service.CreateUserAsync(command);
    return dto;

Benchmark – environment


BenchmarkDotNet=v0.12.1, OS=macOS Catalina 10.15.3 (19D76) [Darwin 19.3.0]
Intel Core i7-8850H CPU 2.60GHz (Coffee Lake), 1 CPU, 12 logical and 6 physical cores
.NET Core SDK=3.1.301
  [Host]     : .NET Core 3.1.5 (CoreCLR 4.700.20.26901, CoreFX 4.700.20.27001), X64 RyuJIT  [AttachedDebugger]
  DefaultJob : .NET Core 3.1.5 (CoreCLR 4.700.20.26901, CoreFX 4.700.20.27001), X64 RyuJIT

Benchmark – how web communication should be implemented for the best performance?

MethodCount Mean ErrorStdDev
GrpcWithChannelAndService111,309.2 μs290.53 μs838.25 μs
GrpcWithChannel110,860.3 μs294.60 μs854.69 μs
GrpcFromZero122,423.3 μs1,316.08 μs3,880.51 μs
RestFromZero130,700.7 μs1,962.80 μs5,101.57 μs
RestWithClient1659.0 μs24.50 μs71.08 μs
MethodCount Mean ErrorStdDev
GrpcWithChannelAndService10107,965.4 μs2,136.78 μs2,098.60 μs
GrpcWithChannel10112,519.0 μs3,375.63 μs9,900.13 μs
GrpcFromZero10215,862.6 μs18,452.26 μs54,406.91 μs
RestFromZero10326,166.2 μs18,679.43 μs55,076.72 μs
RestWithClient107,089.6 μs378.08 μs1,102.88 μs
MethodCount Mean ErrorStdDev
GrpcWithChannelAndService20216,042.5 μs6,804.51 μs20,063.25 μs
GrpcWithChannel20203,643.7 μs5,668.72 μs16,081.22 μs
GrpcFromZero20463,342.3 μs22,823.91 μs66,216.31 μs
RestFromZero20652,889.9 μs20,515.53 μs58,862.91 μs
RestWithClient2017,238.0 μs1,385.95 μs4,064.74 μs

Link: https://github.com/Rogaliusz/Grpc.Performance/blob/main/src/Grpc.Performance/Benchmarks/Implementation.cs

Dto Size ~ 2,5kb

As You see The clear winner is traditional method with created HttpClient. I want to focus our attention to cost of creating gRPC channel before every request, is expensive! As You see this is very simillar situation to create of instances HttpClients, is should be created with correct scope, btw. is good material for next article ;-)!

So now, when we know what is the most performant of communicate, every next benchmark will be execute with created gRPC channel & HttpClient ;-).

Benchmark – fetch dto.

MethodCount Mean ErrorStdDev Median
GrpcGetDtoSeriesAsync19,949.7 μs415.48 μs1,225.0 μs9,723.9 μs
RestGetDtoSeriesAsync1737.1 μs52.39 μs152.8 μs700.3 μs
MethodCount Mean ErrorStdDev Median
GrpcGetDtoSeriesAsync10116,574.7 μs4,606.11 μs13,141.5 μs111,470.8 μs
RestGetDtoSeriesAsync107,570.4 μs540.62 μs1,559.8 μs7,274.1 μs
MethodCount Mean ErrorStdDev Median
GrpcGetDtoSeriesAsync25301,079.5 μs14,519.36 μs42,582.8 μs296,800.6 μs
RestGetDtoSeriesAsync2536,390.8 μs5,441.64 μs16,044.8 μs33,363.2 μs
MethodCount Mean ErrorStdDev Median
GrpcGetDtoSeriesAsync1001,106,388.0 μs47,152.74 μs132,995.1 μs1,083,339.7 μs
RestGetDtoSeriesAsync100162,367.5 μs24,483.35 μs71,805.4 μs149,657.5 μs

Link: https://github.com/Rogaliusz/Grpc.Performance/blob/main/src/Grpc.Performance/Benchmarks/Series.cs

Dto Size ~ 2,5kb

Again, for small requests the old, good REST is clear winner :-(..

Benchmark – fetch collections.

MethodMeanErrorStdDevMedian
GrpcGetCollectionBigDtoAsync45.351 ms4.3962 ms12.6841 ms40.895 ms
GrpcGetPaginatedBigDtoAsync11.422 ms0.2475 ms0.7100 ms11.430 ms
RestGetCollectionBigDtoAsync78.303 ms1.5348 ms2.6062 ms77.985 ms
RestGetPaginatedBigDtoAsync3.356 ms0.0657 ms0.0899 ms3.338 ms

Link: https://github.com/Rogaliusz/Grpc.Performance/blob/main/src/Grpc.Performance/Benchmarks/Collection.cs

Collection Size ~ 1,12 mb.
Pagination Size ~ 46 kb.

As we see for bigger payload gRPC is better solution, but probably case of send this kind large payload will be very rare :-(..

Benchmark – send items.

MethodCount MeanErrorStdDevMedian
GrpcSendBigCommand114.393 ms1.4331 ms4.112 ms14.775 ms
GrpcSendBigEventCommand110.697 ms0.3768 ms1.044 ms10.492 ms
RestSendBigCommand12.992 ms0.5211 ms1.520 ms2.676 ms
RestSendBigEventCommand166.549 ms13.4749 ms39.731 ms58.052 ms
MethodCount MeanErrorStdDevMedian
GrpcSendBigCommand1094.743 ms2.1262 ms6.236 ms93.040 ms
GrpcSendBigEventCommand10112.785 ms3.2183 ms9.337 ms112.019 ms
RestSendBigCommand1019.546 ms1.1930 ms3.325 ms19.538 ms
RestSendBigEventCommand10551.404 ms114.6069 ms337.921 ms509.745 ms
MethodCount MeanErrorStdDevMedian
GrpcSendBigCommand20214.305 ms4.7126 ms13.747 ms212.344 ms
GrpcSendBigEventCommand20211.541 ms5.6016 ms16.429 ms208.450 ms
RestSendBigCommand2071.923 ms4.8336 ms14.252 ms71.736 ms
RestSendBigEventCommand201,161.713 ms194.5069 ms573.508 ms1,149.660 ms

Link: https://github.com/Rogaliusz/Grpc.Performance/blob/main/src/Grpc.Performance/Benchmarks/Post.cs

BigCommand – flat model with few properties.
BigEventCommand – model with nested 20 elements collection.

So Again, larger payload = gRPC.

Summary

I’m very surprised of this result. I suspected that gRPC protocol will be clear winner in this battle, but I was wrong. For smaller requests old json format is clear winner, gRPC is good solution only for large payload.

Maybe this result is sponsored by my local network environment. In future I want to do this benchmark in Kubernetes cluster or communication between some endpoints in the web.

One thing is clear, JSON serialization in .Net core 3.1 is really, really fast. Team made great job, respect for them ;-)!

Edit

If You want to see how results are present in Azure Kubernetes Cluster, please check my new post about it 🙂