Performance Optimization Guide
Version: 2026.02.19
Status: Active
Overview
Jaunty is designed for high performance. This document details the optimization strategies used throughout the codebase.
Memory Optimization
1. Pre-Size Collections
Problem: Dynamic resizing causes allocations and copies.
Solution: Pre-size collections when count is known or estimable.
// BAD: Default capacity (4), will resize multiple times
var results = new List<T>();
// GOOD: Pre-size with expected count
var results = new List<T>(expectedRows);
// GOOD: Use capacity from metadata
var results = new List<T>(metadata.Properties.Length);
Impact: 50-80% reduction in allocations for large result sets.
2. Span for Zero-Allocation Slicing
Problem: Substring allocates new strings.
Solution: Use Span<T> for zero-allocation slicing.
// BAD: Allocates new string
var paramName = sql.Substring(start, length);
// GOOD: Zero allocation
ReadOnlySpan<char> sqlSpan = sql.AsSpan();
ReadOnlySpan<char> paramSpan = sqlSpan.Slice(start, length);
// Convert only when needed
var paramName = paramSpan.ToString();
Impact: Eliminates allocations in SQL parsing hot path.
3. FrozenDictionary for Lookups
Problem: Dictionary<K,V> has locking overhead and isn't optimized for reads.
Solution: Use FrozenDictionary<K,V> on .NET 8+.
#if NET8_0_OR_GREATER
private static readonly FrozenDictionary<string, int> _columnToIndex;
#else
private static readonly Dictionary<string, int> _columnToIndex;
#endif
// Initialization
#if NET8_0_OR_GREATER
_columnToIndex = properties.ToFrozenDictionary(p => p.ColumnName, p => p.Index);
#else
_columnToIndex = properties.ToDictionary(p => p.ColumnName, p => p.Index);
#endif
Impact: 2-3x faster lookups, thread-safe without locking.
4. Compiled Delegates Instead of Reflection
Problem: PropertyInfo.SetValue is slow and allocates.
Solution: Compile expression trees once, reuse forever.
// BAD: Slow reflection per row
property.SetValue(entity, reader.GetValue(columnIndex));
// GOOD: Compiled delegate (one-time cost)
private static Action<T, IDataRecord, int> CreateSetter(PropertyInfo property)
{
// Build expression tree
var targetParam = Expression.Parameter(typeof(T), "target");
var readerParam = Expression.Parameter(typeof(IDataRecord), "reader");
var indexParam = Expression.Parameter(typeof(int), "index");
var getValueCall = Expression.Call(readerParam, nameof(IDataRecord.GetValue), null, indexParam);
var convert = Expression.Convert(getValueCall, property.PropertyType);
var assign = Expression.Assign(Expression.Property(targetParam, property), convert);
// Compile once
return Expression.Lambda<Action<T, IDataRecord, int>>(assign, targetParam, readerParam, indexParam).Compile();
}
// Usage: O(1) with no reflection
setter(entity, reader, columnIndex);
Impact: 10-100x faster property setting.
5. readonly struct for Small Value Types
Problem: Structs can cause hidden defensive copies.
Solution: Use readonly struct to prevent copies.
// BAD: Potential defensive copies
public struct PropertyMetadata
{
public string ColumnName { get; set; }
}
// GOOD: No defensive copies
public readonly struct PropertyMetadata
{
public string ColumnName { get; init; }
}
Impact: Eliminates hidden allocations in tight loops.
Execution Optimization
1. No LINQ in Hot Paths
Problem: LINQ allocates delegates and enumerators.
Solution: Use manual loops.
// BAD: LINQ allocation
var columns = properties.Select(p => p.ColumnName).ToList();
// GOOD: Manual loop
var columns = new List<string>(properties.Length);
foreach (var p in properties)
{
columns.Add(p.ColumnName);
}
Impact: 5-10x fewer allocations in hot paths.
2. Aggressive Inlining
Problem: Method call overhead in tight loops.
Solution: Use [MethodImpl(MethodImplOptions.AggressiveInlining)].
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static string QuoteIdentifier(string identifier)
{
return $"\"{identifier}\"";
}
Impact: Eliminates call overhead for small methods.
Caution: Don't inline large methods (increases code size, hurts I-cache).
3. ValueTask for Async
Problem: Task<T> allocates even for synchronous completion.
Solution: Use ValueTask<T> on .NET 8+.
#if NET8_0_OR_GREATER
public static async ValueTask<T> QuerySingleAsync<T>(...) where T : new()
{
// May complete synchronously
}
#else
public static async Task<T> QuerySingleAsync<T>(...) where T : new()
{
// Fallback for netstandard2.0
}
#endif
Impact: Zero allocation for synchronously-completing async operations.
4. ConfigureAwait(false)
Problem: Capturing SynchronizationContext causes overhead and potential deadlocks.
Solution: Always use ConfigureAwait(false) in library code.
// GOOD: Doesn't capture context
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
Impact: Reduced overhead, deadlock prevention.
5. Iterate Through the Concrete Type, Not an Interface
Problem: a collection typed as IEnumerable<T> or IList<T> reaches GetEnumerator()
through the interface, which returns a boxed enumerator on the heap. The cost is the
interface, not the foreach.
Solution: keep the field, parameter or local typed as the array, List<T> or
Span<T> it already is.
// BAD: the interface type forces a boxed enumerator
private readonly IReadOnlyList<PropertyMetadata> _properties;
foreach (var property in _properties)
{
// ...
}
// GOOD: an array needs no enumerator, and List<T> uses its struct enumerator
private readonly PropertyMetadata[] _properties;
foreach (var property in _properties)
{
// ...
}
Impact: x64, iterating a ten-element PropertyInfo[]. Times are BenchmarkDotNet
means on runtime 10.0.7; allocation is bytes per call from
GC.GetAllocatedBytesForCurrentThread over 1,000,000 direct calls on runtime 10.0.11.
The two columns come from different harnesses, which matters for the last two rows and is
covered below.
| iteration | allocated | mean |
|---|---|---|
foreach over T[] |
0 B | 7.750 ns |
for over a local copy of T[] |
0 B | 8.553 ns |
foreach over Span<T> |
0 B | 8.579 ns |
for over the T[] field |
0 B | 11.092 ns |
foreach over List<T> |
0 B | 11.426 ns |
for over List<T> |
0 B | 12.533 ns |
foreach over IEnumerable<T> |
40 B | 12.496 ns |
foreach over IList<T> |
40 B | 13.007 ns |
Three things follow.
foreach over an array or a List<T> allocates nothing. The compiler lowers
foreach over T[] to an indexed loop with no enumerator at all, and List<T> exposes
a struct enumerator that stays on the stack. The 40 bytes in the last two rows is a boxed
List<T>.Enumerator: 16 bytes of object header, plus a list reference, two int fields
and one T. It appears because the static type is an interface, and IReadOnlyList<T>
behaves the same way.
Replacing foreach with a hand-written for buys nothing. This file previously
advised the opposite, and the advice was never followed: src/ uses foreach in 244
places against 11 indexed for loops. Most of the gap between the first and fourth rows
is not the loop construct at all: the for version indexes the field on every iteration,
while foreach lowering copies the array into a local first. Giving the for loop the
same local copy closes 2.5 ns of the 3.3 ns difference.
One caveat on the 40-byte figure, stated as a limit rather than a finding. The same two
rows measure 40 bytes per call in a standalone harness and zero in BenchmarkDotNet, whose
raw counters report 400 bytes across 67,108,864 operations with no gen-0 collection. Both
readings are real and the cause is not established. Three hypotheses were tested and none
of them explains it: 100,000 warmup iterations give the same 40 bytes; calling the methods
directly rather than through a delegate gives the same 40 bytes; and running with
DOTNET_TieredCompilation=0, DOTNET_TieredPGO=0 or DOTNET_TC_CallCountingDelayMs=0
gives the same 40 bytes, which rules out tiering and dynamic PGO. Treat interface-typed
iteration as may allocate, depending on what the JIT can prove, and prefer the concrete
type rather than relying on an optimization nobody here can predict.
None of this sits on the row path. Jaunty walks a PropertyInfo[] once per entity type
when it builds metadata; the generated mappers read columns by ordinal and iterate no
collection. This section governs cold code, and its value is a rule that is not wrong
rather than nanoseconds saved.
The harness is tmp/claims/foreach-bench/: a bare run reproduces the times, and
foreach-bench alloc-direct reproduces the allocation column.
The harness is tmp/claims/foreach-bench/; foreach-bench alloc-direct reproduces the
allocation column and a bare run reproduces the timings.
String Optimization
1. string.Create for Construction
Problem: String concatenation allocates intermediate strings.
Solution: Use string.Create for direct buffer writing.
// BAD: Multiple allocations
var sql = "INSERT INTO " + tableName + " (" + columns + ") VALUES (" + values + ")";
// GOOD: Single allocation
var sql = string.Create(totalLength, (tableName, columns, values), (span, state) =>
{
span.Slice(0, state.tableName.Length).CopyFrom(state.tableName);
// ... write rest
});
Impact: Single allocation instead of multiple.
2. Ordinal String Comparison
Problem: Culture-aware comparison is slow.
Solution: Use StringComparison.Ordinal.
// BAD: Culture-aware (slow)
if (columnName.ToLower() == parameterName.ToLower()) { }
// GOOD: Ordinal (fast)
if (columnName.Equals(parameterName, StringComparison.OrdinalIgnoreCase)) { }
Impact: 5-10x faster string comparison.
3. SearchValues for Character Search
Problem: Multiple IndexOf calls for character search.
Solution: Use SearchValues<T> on .NET 8+.
#if NET8_0_OR_GREATER
private static readonly SearchValues<char> s_commentStart = SearchValues.Create("--");
private static readonly SearchValues<char> s_stringChar = SearchValues.Create("'");
// Usage
var index = sqlSpan.IndexOfAny(s_commentStart);
#else
var index = sqlSpan.IndexOf("--");
#endif
Impact: 2-3x faster character searching.
Caching Strategy
Cache Hierarchy
| Cache | Scope | Lifetime | Access Pattern |
|---|---|---|---|
MetadataCache<T> |
Per type | Application | Read-only after init |
ParameterCache<T> |
Per type | Application | Read-only after init |
SqlParameterParserCache |
Per SQL | Application | Read-heavy |
CrudSqlCache<T> |
Per type | Application | Read-only after init |
ParameterBinder property getters |
Per type | Application | Read-only after init |
Cache Implementation
// Static generic cache (thread-safe by CLR)
internal static class MetadataCache<T> where T : new()
{
static MetadataCache() { /* One-time init */ }
public static readonly EntityMetadata Metadata;
}
// ConcurrentDictionary for dynamic caching
internal static class SqlParameterParserCache
{
// Size-capped: callers that embed literals or build SQL dynamically would otherwise leak
// memory through an ever-growing set of distinct SQL-text keys.
private static readonly BoundedCache<string, string[]> Cache = new(StringComparer.Ordinal);
// AUD-R34-014: the same SQL text parses differently under MySQL/MariaDB, where a backslash
// escapes the next character inside a string literal. Two caches rather than one composite
// key, because the flag is fixed per engine.
private static readonly BoundedCache<string, string[]> BackslashEscapedCache = new(StringComparer.Ordinal);
public static string[] GetOrAdd(string sql, bool backslashEscapes = false)
{
return backslashEscapes
? BackslashEscapedCache.GetOrAdd(sql, static s => SqlParameterParser.ExtractParameterNames(s, backslashEscapes: true))
: Cache.GetOrAdd(sql, static s => SqlParameterParser.ExtractParameterNames(s));
}
}
Performance Checklist
Code Review Checklist
- Pre-sized collections where count is known
-
Span<T>for string/array slicing -
FrozenDictionaryon .NET 8+ - Compiled delegates instead of reflection
-
readonly structfor small value types - No LINQ in hot paths
- Collections iterated through their concrete type, not
IEnumerable<T>orIList<T> -
ConfigureAwait(false)on all async -
StringComparison.Ordinalfor string comparison -
[MethodImpl(MethodImplOptions.AggressiveInlining)]for small methods -
ValueTask<T>for async methods that may complete synchronously
Profiling Targets
| Metric | Target | Measurement |
|---|---|---|
| First query (cold) | <10ms | Includes metadata build |
| Subsequent queries | <1ms per 1000 rows | Warm cache |
| Allocations per row | <1KB | Depends on entity size |
| GC pressure | Gen 0 <10ms | During query execution |
Benchmarking
BenchmarkDotNet Setup
[MemoryDiagnoser]
public class QueryBenchmarks
{
private IDbConnection _connection;
private const string Sql = "SELECT * FROM products WHERE category_id = @CategoryId";
[GlobalSetup]
public void Setup()
{
_connection = new SQLiteConnection("Data Source=:memory:");
_connection.Open();
}
[Benchmark]
public List<Product> Query()
{
return _connection.Query<Product>(Sql, new { CategoryId = 1 }).ToList();
}
}
Running Benchmarks
dotnet run -c Release --project benchmarks/Jaunty.Benchmarks
See Also
architecture-specification.md- Full architecturemetadata-system-spec.md- Metadata cachingparameter-binding-spec.md- Parameter binding- How Jaunty got fast - the read-path optimizations in order, each with before/after code and measurements