Performance & Benchmarks

Real numbers from this repository's own BenchmarkDotNet suite — including a head-to-head against AutoMapper and Mapster. We report what the numbers actually say, including where AutoMapperLite loses.

Methodology

Benchmarks live in AutoMapperLite.Benchmarks and run with BenchmarkDotNet v0.15.8 in Release configuration. Eight scenarios — simple object, medium object (8 properties), nested object, deeply nested object (4 levels), custom member mapping, a 100-item collection, a 5,000-item collection, and cold start (first-ever mapping call, including configuration) — are each run against hand-written manual mapping, AutoMapperLite 4.4.0, AutoMapper 16.2.0, and Mapster 10.0.12, all configured equivalently for the same source/destination shapes — with one shared mapper instance per library, reused across calls, never rebuilt inside a hot loop — and asserted to produce identical output before any timing number is trusted (AutoMapperLite.Benchmarks/Verify.cs, run via --verify).

dotnet run --project AutoMapperLite.Benchmarks -c Release -- --verify
dotnet run --project AutoMapperLite.Benchmarks -c Release -- --filter "*"

Environment: BenchmarkDotNet v0.15.8, Windows 11, Intel Core Ultra 7 255U (14 logical/12 physical cores), 16 GB RAM, .NET SDK 10.0.400, running on .NET 8.0.30 (X64 RyuJIT). Mean-time figures carry normal machine-to-machine variance and should be read as approximate; allocation figures are deterministic and exact regardless of hardware or system load.

Note on the Nested/DeepNested AutoMapperLite configuration: for the Level1…Level4 chain, the destination property is named Child on every level, matching the source, and each level's type pair has its own registered CreateMap — that's exactly the case AutoMapperLite's own same-name auto-map resolves automatically at plan-compile time (see Why), so no ForMember is registered for it, matching how the AutoMapper and Mapster configurations below rely on their own convention engines for the same chain rather than an explicit per-level mapping. An earlier version of this benchmark routed AutoMapperLite's Child assignments through ForMember calling back into mapper.Map<T>(object) instead — that forced every nested level through the slower untyped dispatch path (entry-point cache lookup, GetType(), boxing) for no reason, and was quietly inflating the DeepNested numbers by roughly 13% versus what AutoMapperLite actually does when configured the way its own docs recommend. The Organization/Country case genuinely needs ForMember in all three libraries' configs, since the destination property (CountryViewModel) is named differently from the source (Country) — same-name auto-map can't apply there regardless of library.

Warm, repeated mapping: manual vs. AutoMapperLite vs. AutoMapper vs. Mapster

AutoMapperLite is not the fastest option on repeated, already-configured calls, but it beats AutoMapper on every scenario in this table — all five object-mapping scenarios, and, since a collection-loop optimization described in Why, both collection scenarios as well. Simple, Medium, Nested, and Deeply-nested object mapping allocate exactly what hand-written manual mapping does; Custom mapping allocates slightly more (104 B vs. 80 B) for a specific, documented reason — see Why. The generic collection API now matches Manual/Mapster's allocation exactly at both sizes shown. Mapster remains faster on raw per-call time everywhere in this table, though the gap has narrowed substantially for collections — see Comparison for when each tool makes sense. Rows marked "object API / generic API" show both IMapper.Map<TDestination>(object) (dispatches by the source's runtime type) and IMapper.Map<TSource,TDestination>(TSource) (dispatches by the compile-time type, skipping runtime type resolution entirely — including, as of this release, for collections: Map<List<TSourceItem>,List<TDestItem>>(sourceList)) — prefer the generic form in any hot loop where the source type is statically known.

ScenarioManualAutoMapperLiteAutoMapperMapster
Simple object — mean time (object API / generic API)6.1 ns32.4 ns / 27.5 ns63.4 ns18.4 ns
Simple object — allocated32 B32 B32 B32 B
Medium object (8 props) — mean time (object API / generic API)11.9 ns39.0 ns / 32.2 ns75.5 ns31.1 ns
Medium object (8 props) — allocated80 B80 B80 B80 B
Nested object — mean time (object API / generic API)17.4 ns63.2 ns / 64.0 ns77.0 ns30.8 ns
Nested object — allocated80 B80 B80 B80 B
Deeply nested object (4 levels) — mean time (object API / generic API)33.4 ns124.9 ns / 98.5 ns123.2 ns68.4 ns
Deeply nested object (4 levels) — allocated248 B248 B248 B248 B
Custom member mapping — mean time (object API / generic API)37.8 ns42.5 ns / 41.2 ns83.3 ns24.0 ns
Custom member mapping — allocated80 B104 B80 B112 B
100-item collection — mean time (object API / generic API)499.5 ns779.9 ns / 666.8 ns922.7 ns553.1 ns
100-item collection — allocated (object API / generic API)3.96 KB4.02 KB / 3.96 KB5.27 KB3.96 KB
5,000-item collection — mean time (object API / generic API)27.32 µs34.90 µs / 34.04 µs43.17 µs29.28 µs
5,000-item collection — allocated (object API / generic API)195.37 KB195.44 KB / 195.37 KB284.53 KB195.37 KB
Mean time and managed allocation per call, lower is better. In mean-time rows, bold marks the faster of AutoMapperLite's two API shapes; in allocation rows, bold marks where AutoMapperLite's allocation matches or nearly matches Manual mapping's.

Using the generic API, AutoMapperLite beats AutoMapper on Simple (~2.3x), Medium (~2.3x), Nested (~1.2x), Deeply-nested (~1.3x), and Custom (~2.0x) object mapping. It now also beats AutoMapper on both collection scenarios — ~1.2–1.4x at 100 items, ~1.3x at 5,000 items — a reversal from earlier measurements, caused by a specific collection-loop fix described in Why, not a change in methodology. Mapster remains faster on raw collection time, though the gap has narrowed considerably (see the scaling table below).

Cold start: first mapping call, including compilation

This measures a single Map call on a brand-new configuration — the cost of building the mapper's internal plan plus the mapping itself, with no warm-up. AutoMapperLite wins here, because it only compiles the one type pair that's actually requested, lazily, the first time it's needed; AutoMapper and Mapster both do more validation/setup work across the whole configuration up front.

LibraryMean timeAllocated
AutoMapperLite214.0 µs7.9 KB
AutoMapper409.2 µs20.02 KB
Mapster460.3 µs95.38 KB
No manual-mapping row — hand-written code has no configuration/compilation step to measure.

Improvement over the previous release (4.0.2)

4.4.0 replaced AutoMapperLite's interpreted-reflection mapping engine with one that compiles each source/destination type pair into a cached delegate via System.Linq.Expressions, resolved once and reused directly on every subsequent call, and later refined the collection-mapping loop itself (see Why) to avoid interface-dispatch overhead that had been quietly limiting collection throughput. Measured on the same scenarios and hardware:

Benchmark4.0.24.4.0Speedup
Map a simple object (generic API)242.0 ns27.5 ns~8.8x
Map a nested object (generic API)424.1 ns64.0 ns~6.6x
Map a 100-item collection26,009.1 ns779.9 ns~33.3x
4.0.2 figures are pre-rewrite, interpreted-reflection measurements; both columns use the same benchmark scenarios and hardware.

Collection scaling: 10 to 100,000 items

The scenarios above use fixed sizes (100 and 5,000 items). This table checks that per-item cost stays flat as the collection grows — i.e. that no per-item type lookup or allocation creeps back into the hot loop at scale. Figures below use the object API (Map<List<TDestination>>(object)); the generic-API equivalent (Map<List<TSourceItem>, List<TDestItem>>(sourceList)) measures within a few percent of these figures at every size shown, since both now share the same specialized per-item loop — see Why.

SizeManualAutoMapperLiteAutoMapperMapster
10 items — mean time66.9 ns160.5 ns163.2 ns81.5 ns
10 items — allocated456 B520 B648 B456 B
1,000 items — mean time4.97 µs6.75 µs7.24 µs5.26 µs
1,000 items — allocated40.06 KB40.12 KB48.60 KB40.06 KB
10,000 items — mean time68.7 µs83.5 µs297.4 µs72.0 µs
10,000 items — allocated400.06 KB400.14 KB582.47 KB400.06 KB
100,000 items — mean time3.02 ms3.27 ms3.97 ms3.04 ms
100,000 items — allocated4,000.09 KB4,000.23 KB5,297.63 KB4,000.09 KB
Bold marks where AutoMapperLite's allocation is within a fraction of a percent of Manual's, or where it beats AutoMapper on time. Time ratio to Manual shrinks as size grows — 2.4x at 10 items, 1.36x at 1,000, 1.22x at 10,000, 1.08x at 100,000 — because the per-item compiled work is essentially identical to Manual code; only a small, fixed per-call cost differs, and it matters less as it's amortized over more items. AutoMapperLite now beats AutoMapper on time and allocation at every size from 1,000 items upward, and by 100,000 items is within 8% of Manual mapping's own time and 0.006% of its allocation.

Why AutoMapperLite performs this way

Every source/destination type pair is compiled once, lazily, into a real Func<TSource, TDestination> via System.Linq.Expressions, cached per mapping configuration. This removes reflection from the compiled path entirely — PropertyInfo.GetValue/SetValue and MethodInfo.Invoke never run per mapped object, and destination construction (top-level and same-name nested objects) is a compiled constructor call (Expression.New), not Activator.CreateInstance. Activator.CreateInstance itself isn't fully gone from every path, though: it still backs List<T> container creation on the untyped collection-dispatch fallback (a non-generic IEnumerable source, or an empty source collection) and intermediate objects along a ForPath walk (see below) — both deliberately-interpreted, less-common paths, not the compiled per-property hot path. Dispatch — finding the right compiled delegate for a given call — is cached per Mapper instance: one dictionary lookup resolves the delegate once, and every subsequent call reuses it directly. Collection mapping resolves the element type and element mapper once per call (not once per item) and runs a strongly-typed loop with no per-item type lookup or boxing. Nested same-name properties resolve their mapping plan at compile time and call directly into it, with no runtime type resolution.

Collection mapping's per-item loop was recently found to be paying for interface dispatch it didn't need. The loop worked by casting the source to IEnumerable<TSourceItem> and using foreach — correct, but for a List<T> source (the overwhelmingly common case) this forces GetEnumerator() to go through the interface, which boxes List<T>'s own struct enumerator once per call. The loop now pattern-matches List<T> and arrays first and indexes directly, falling back to the general enumerator only for other IEnumerable<T> sources; the per-item mapper-resolution check was also moved to run once, immediately before whichever loop starts, instead of once per item (a captured, multi-threaded-shared closure field can't be safely cached in a register across loop iterations the way a true local can). Measured effect: a 100-item collection dropped from roughly 1.3 µs to under 800 ns, and a 5,000-item collection from roughly 60–74 µs to about 35 µs — enough to flip AutoMapperLite from trailing AutoMapper on collections to beating it at every size from 1,000 items upward. The remaining gap to Mapster specifically is a structural one: Mapster's typical usage pattern (source.Adapt<T>(), generic on the caller's own static type) sidesteps the outer object-boundary dispatch cost entirely, which AutoMapperLite's public API still pays once per call (not per item).

Custom mapping's small extra allocation (104 B vs. Manual's 80 B) has a specific, understood cause: ForMember<TMember>'s callback parameter is typed Func<TSource, object?>, not Func<TSource, TMember>. For a value-typed member (an int, in this benchmark), the compiler boxes the result into object on every call. Retyping the callback would remove this cost but is a breaking API change — it would reject ForMember calls that intentionally return a mismatched type to get a descriptive runtime exception, a documented, tested behavior — so it hasn't been made for a ~24 B/call saving.

ForPath nested-path mapping is deliberately left interpreted rather than compiled: it isn't a bottleneck relative to plain auto-mapped properties, and correctly compiling arbitrary-depth, dynamically-walked destination paths is complexity not worth taking on for a cold path.