jaunty Architecture › Performance Optimization Guide

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.

C#
// 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.

C#
// 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+.

C#
#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.

C#
// 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.

C#
// 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.

C#
// 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)].

C#
[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+.

C#
#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.

C#
// 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.

C#
// 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.

C#
// 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.

C#
// BAD: Culture-aware (slow)
if (columnName.ToLower() == parameterName.ToLower()) { }

// GOOD: Ordinal (fast)
if (columnName.Equals(parameterName, StringComparison.OrdinalIgnoreCase)) { }

Impact: 5-10x faster string comparison.


Problem: Multiple IndexOf calls for character search.

Solution: Use SearchValues<T> on .NET 8+.

C#
#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

C#
// 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
  • FrozenDictionary on .NET 8+
  • Compiled delegates instead of reflection
  • readonly struct for small value types
  • No LINQ in hot paths
  • Collections iterated through their concrete type, not IEnumerable<T> or IList<T>
  • ConfigureAwait(false) on all async
  • StringComparison.Ordinal for 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

C#
[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

bash
dotnet run -c Release --project benchmarks/Jaunty.Benchmarks

See Also