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.
| Scenario | Manual | AutoMapperLite | AutoMapper | Mapster |
|---|---|---|---|---|
| Simple object — mean time (object API / generic API) | 6.1 ns | 32.4 ns / 27.5 ns | 63.4 ns | 18.4 ns |
| Simple object — allocated | 32 B | 32 B | 32 B | 32 B |
| Medium object (8 props) — mean time (object API / generic API) | 11.9 ns | 39.0 ns / 32.2 ns | 75.5 ns | 31.1 ns |
| Medium object (8 props) — allocated | 80 B | 80 B | 80 B | 80 B |
| Nested object — mean time (object API / generic API) | 17.4 ns | 63.2 ns / 64.0 ns | 77.0 ns | 30.8 ns |
| Nested object — allocated | 80 B | 80 B | 80 B | 80 B |
| Deeply nested object (4 levels) — mean time (object API / generic API) | 33.4 ns | 124.9 ns / 98.5 ns | 123.2 ns | 68.4 ns |
| Deeply nested object (4 levels) — allocated | 248 B | 248 B | 248 B | 248 B |
| Custom member mapping — mean time (object API / generic API) | 37.8 ns | 42.5 ns / 41.2 ns | 83.3 ns | 24.0 ns |
| Custom member mapping — allocated | 80 B | 104 B | 80 B | 112 B |
| 100-item collection — mean time (object API / generic API) | 499.5 ns | 779.9 ns / 666.8 ns | 922.7 ns | 553.1 ns |
| 100-item collection — allocated (object API / generic API) | 3.96 KB | 4.02 KB / 3.96 KB | 5.27 KB | 3.96 KB |
| 5,000-item collection — mean time (object API / generic API) | 27.32 µs | 34.90 µs / 34.04 µs | 43.17 µs | 29.28 µs |
| 5,000-item collection — allocated (object API / generic API) | 195.37 KB | 195.44 KB / 195.37 KB | 284.53 KB | 195.37 KB |
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.
| Library | Mean time | Allocated |
|---|---|---|
| AutoMapperLite | 214.0 µs | 7.9 KB |
| AutoMapper | 409.2 µs | 20.02 KB |
| Mapster | 460.3 µs | 95.38 KB |
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:
| Benchmark | 4.0.2 | 4.4.0 | Speedup |
|---|---|---|---|
| Map a simple object (generic API) | 242.0 ns | 27.5 ns | ~8.8x |
| Map a nested object (generic API) | 424.1 ns | 64.0 ns | ~6.6x |
| Map a 100-item collection | 26,009.1 ns | 779.9 ns | ~33.3x |
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.
| Size | Manual | AutoMapperLite | AutoMapper | Mapster |
|---|---|---|---|---|
| 10 items — mean time | 66.9 ns | 160.5 ns | 163.2 ns | 81.5 ns |
| 10 items — allocated | 456 B | 520 B | 648 B | 456 B |
| 1,000 items — mean time | 4.97 µs | 6.75 µs | 7.24 µs | 5.26 µs |
| 1,000 items — allocated | 40.06 KB | 40.12 KB | 48.60 KB | 40.06 KB |
| 10,000 items — mean time | 68.7 µs | 83.5 µs | 297.4 µs | 72.0 µs |
| 10,000 items — allocated | 400.06 KB | 400.14 KB | 582.47 KB | 400.06 KB |
| 100,000 items — mean time | 3.02 ms | 3.27 ms | 3.97 ms | 3.04 ms |
| 100,000 items — allocated | 4,000.09 KB | 4,000.23 KB | 5,297.63 KB | 4,000.09 KB |
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.