Performance

How EggMapper Achieves High Performance

EggMapper is the fastest .NET runtime object-to-object mapper, achieving near-manual mapping speed through these techniques:

  1. Compile once, run many timesMapperConfiguration compiles expression-tree delegates at construction time. Every subsequent Map() call is a direct delegate invocation with no reflection.
  2. Context-free typed delegates — For flat and nested maps, EggMapper compiles Func<TSource, TDestination> delegates with zero boxing. Nested object mappings are inlined directly into the parent expression tree.
  3. Static generic cachingFastCache<TSource, TDestination> eliminates dictionary lookups after the first call for each type pair.
  4. Inlined collection loopsMapList<>() uses compiled Func<IList<TSource>, List<TDestination>> delegates where the entire loop + element mapping is a single expression tree.
  5. Zero extra allocations — EggMapper matches hand-written code allocation in every scenario.

Why EggMapper is Faster Than AutoMapper

1. Inlined Nested Maps

AutoMapper maps nested objects by calling a separate delegate for each child property. This means every nested object incurs delegate invocation overhead plus ResolutionContext management.

EggMapper inlines the entire child mapping directly into the parent expression tree. The compiled delegate for Order -> OrderDto contains the code for Customer -> CustomerDto and Address -> AddressDto directly — no intermediate calls.

// What AutoMapper compiles (conceptual):
dst.Customer = _customerMapper(src.Customer, ctx);  // delegate call
dst.Address  = _addressMapper(src.Address, ctx);    // delegate call

// What EggMapper compiles (conceptual):
dst.Customer = new CustomerDto {
    Id = src.Customer.Id,
    Name = src.Customer.Name       // inlined directly
};
dst.Address = new AddressDto {
    City = src.Address.City,       // inlined directly
    Street = src.Address.Street
};

2. Inlined Collection Loops

MapList<S,D>() compiles the entire collection loop as a single expression tree:

// Compiled delegate (conceptual):
(IList<Order> src) => {
    var result = new List<OrderDto>(src.Count);
    for (int i = 0; i < src.Count; i++) {
        var item = src[i];
        result.Add(new OrderDto {
            Id = item.Id,
            CustomerName = item.Customer.Name,  // nested mapping inlined
            Total = item.Total
        });
    }
    return result;
}

No enumerator allocation. No per-element delegate call. The JIT sees a tight for loop with direct property access.

3. Static Generic Caching

// EggMapper: zero-cost lookup via static generic class
static class FastCache<TSource, TDestination>
{
    public static volatile CacheEntry? Entry;
}

// The JIT bakes the field address directly into calling code.
// No dictionary, no hash, no key comparison.

4. Context-Free Delegates

For the majority of maps (flat, nested, flattening), EggMapper compiles Func<TSource, TDestination> — a plain function with no context parameter, no boxing, no allocation beyond the destination object.

AutoMapper always passes through ResolutionContext, which adds overhead even when context features are not used.


Allocation Analysis

EggMapper matches hand-written code allocation. The only allocation is the destination object itself:

ScenarioManualEggMapperAutoMapperMapster
Flat (10 props)104 B104 B232 B104 B
Nested (2 objects)248 B248 B504 B248 B
Collection (100 items)10,824 B10,824 B14,424 B10,824 B
Deep collection (100 items, nested)34,424 B34,424 B52,824 B34,424 B

The "extra" allocations in AutoMapper come from ResolutionContext management and intermediate delegate infrastructure. EggMapper eliminates all of this at compile time.

These numbers are representative of .NET 10 x64 builds. Exact values vary by runtime and architecture.


Benchmark Setup

The benchmark suite lives in src/EggMapper.Benchmarks/ and uses BenchmarkDotNet.

Each class compares six mappers against the same manual (hand-written) baseline:

Benchmark classScenario
FlatMappingBenchmark10-property flat object
FlatteningBenchmarkFlattening 2 nested objects into 8 properties
DeepTypeBenchmarkObject with two nested address objects
ComplexTypeBenchmarkNested object + List<T> children
CollectionBenchmarkList<T> with 100 elements
DeepCollectionBenchmark100 elements with 2 nested objects each
LargeCollectionBenchmarkList<T> with 1,000 elements
StartupBenchmarkConfiguration / compilation time

Competitors tested: EggMapper, AutoMapper, Mapster, Mapperly (source-gen), AgileMapper.


Running Benchmarks Locally

cd src/EggMapper.Benchmarks

# All benchmarks on .NET 10 (recommended)
dotnet run -c Release -f net10.0 -- --filter '*'

# Single benchmark class
dotnet run -c Release -f net10.0 -- --filter '*FlatMapping*'

# Export to markdown + JSON
dotnet run -c Release -f net10.0 -- --filter '*' --exporters markdown json

# Faster CI-style run (fewer iterations)
dotnet run -c Release -f net10.0 -- --filter '*' --job short

Results are written to BenchmarkDotNet.Artifacts/results/.


CI Benchmark Results

Benchmarks run automatically on every push to main and on every pull request via the Benchmarks workflow.

  • Pull requests receive a detailed comment with all tables, system info, and column descriptions.
  • Main branch — the README.md Performance section is updated in-place with the latest tables.

Performance Targets

ScenarioTarget
Flat mappingFaster than Mapster
Deep / nested mappingFaster than Mapster
FlatteningFaster than Mapster
Collection (100 items)Within 10% of Mapster
All scenarios1.5-2.5x faster than AutoMapper
All scenariosZero extra allocations vs manual

A lower ratio is better. Ratio = 1.00 equals the hand-written Manual baseline.


Tips for Best Performance in Your Application

Use a Singleton MapperConfiguration

Never construct MapperConfiguration per-request. The constructor compiles expression trees for every registered map. In DI, AddEggMapper() registers it as a singleton automatically.

Prefer Generic Map<TSrc, TDst>() Over Map<TDst>(object)

Always use the two-type-parameter overload in hot paths. It eliminates dictionary lookups entirely.

// Fast: uses static generic cache (zero dict lookup)
var dto = mapper.Map<Order, OrderDto>(order);

// Slower: requires GetType() + dictionary lookup
var dto = mapper.Map<OrderDto>((object)order);

The generic overload uses FastCache<TSource, TDestination> — a static generic class where the JIT bakes the cache field address directly into the calling code. The non-generic overload must resolve the source type at runtime.

Use MapList<TSrc, TDst>() for Collections

// Fast: fully inlined compiled loop
List<OrderDto> dtos = mapper.MapList<Order, OrderDto>(orders);

// Slower: per-element delegate invocation
var dtos = orders.Select(o => mapper.Map<Order, OrderDto>(o)).ToList();

MapList compiles the entire loop as a single expression tree. For 100+ item collections, it is measurably faster.

Use ProjectTo for Read-Only Queries

ProjectTo pushes the mapping into the SQL query. Only the columns needed for the DTO are selected, with no entity tracking overhead. Always prefer it for read-only data.

using EggMapper;

// Best: SQL does the projection, no entity materialization
var dtos = await db.Orders
    .ProjectTo<Order, OrderDto>(config)
    .ToListAsync();

// Worse: loads full entities into memory, then maps
var entities = await db.Orders.ToListAsync();
var dtos = mapper.MapList<Order, OrderDto>(entities);

ProjectTo pushes the mapping into the SQL query. Only the columns needed for the DTO are selected. No entity tracking overhead.

Register All Maps Upfront

Discovered maps compiled lazily still pay a one-time cost on first use. Register everything in profiles or the configuration callback to front-load compilation at startup.

Validate in Tests

// In a unit test
[Fact]
public void MappingConfiguration_IsValid()
{
    var config = new MapperConfiguration(cfg =>
        cfg.AddProfiles(typeof(OrderProfile).Assembly));

    config.AssertConfigurationIsValid();
}

This ensures every map is exercised during the test run. It also catches missing maps and typos in property names before production.