Skip to content

Latest commit

 

History

History
674 lines (568 loc) · 30.7 KB

File metadata and controls

674 lines (568 loc) · 30.7 KB

Performance Benchmarks

This document provides detailed performance analysis of the Trellis library using BenchmarkDotNet.

Table of Contents

Overview

The Trellis library is designed with performance in mind. All benchmarks are run using BenchmarkDotNet with memory diagnostics enabled to track both execution time and memory allocations.

Test Environment:

  • CPU: AMD Ryzen 9 9900X 4.40GHz (12 physical / 24 logical cores)
  • OS: Windows 11 (25H2)
  • .NET: 10.0.3 (SDK 10.0.103)
  • BenchmarkDotNet: v0.15.8
  • Job: ShortRun (3 iterations, 1 launch, 3 warmup)
  • Memory Diagnostics: Enabled (Gen0, Gen1, Gen2, Allocations)

Key Findings

✅ Minimal Overhead

Railway-oriented programming adds ~4-5 nanoseconds overhead compared to imperative style, which is negligible in real-world applications.

✅ Consistent Memory Usage

Both ROP and imperative styles allocate the same amount of memory for equivalent operations, showing no additional allocation overhead from the abstraction.

✅ Success Path Optimizations

Success path operations are highly optimized with minimal allocations and fast execution times. Most operations (Map, Tap, Bind) allocate zero bytes on success paths.

✅ Error Path Efficiency

Error paths are also efficient, with proper error aggregation not causing significant performance degradation. Failure paths often have identical or better performance than success paths due to short-circuit optimizations.

✅ Zero-Cost Abstractions

Maybe<T> operations (HasValue, equality, GetHashCode) are effectively free — the JIT inlines them to < 1 ns with zero allocations. Actor.IsOwner is similarly inlined to ~0.01 ns.

Benchmark Results

Railway-Oriented Programming vs Imperative Style

Comparison of ROP style vs traditional if-style code for the same logic.

Method Mean Error StdDev Gen0 Allocated
RopStyleHappy 98.32 ns 105.691 ns 5.793 ns 0.0176 296 B
IfStyleHappy 93.86 ns 58.829 ns 3.225 ns 0.0176 296 B
RopStyleSad 65.63 ns 5.942 ns 0.326 ns 0.0200 336 B
IfStyleSad 75.08 ns 40.925 ns 2.243 ns 0.0200 336 B
RopSample1 635.27 ns 173.344 ns 9.502 ns 0.2298 3848 B
IfSample1 630.80 ns 211.416 ns 11.588 ns 0.2298 3848 B

Analysis:

  • ROP adds ~4 ns overhead on happy path (~5% slower than imperative)
  • Sad path is effectively equal — ROP is actually slightly faster in this run
  • Memory allocations are identical between ROP and imperative styles (296 B happy, 336 B sad)
  • Larger pipelines (RopSample1): ~4 ns overhead on ~635 ns total — effectively zero overhead
  • The overhead is negligible compared to typical I/O operations (database, HTTP, etc.)

Example Code:

// ROP Style
FirstName.TryCreate("Xavier")
    .Combine(EmailAddress.TryCreate("xavier@somewhere.com"))
    .Finally(
        ok => ok.Item1 + " " + ok.Item2,
        error => error.Detail
    );

// Imperative Style (equivalent logic)
var rFirstName = FirstName.TryCreate("Xavier");
var rEmailAddress = EmailAddress.TryCreate("xavier@somewhere.com");
if (rFirstName.IsSuccess && rEmailAddress.IsSuccess)
    return rFirstName.Value + " " + rEmailAddress.Value;

Error? error = null;
if (rFirstName.IsFailure)
    error = rFirstName.Error;
if (rEmailAddress.IsFailure)
    error = error is null ? rEmailAddress.Error : error.Combine(rEmailAddress.Error);
return error!.Detail;

Bind Operations

Testing sequential operations with transformations.

Method Mean Gen0 Allocated
Bind_SingleChain_Success 4.85 ns - -
Bind_SingleChain_Failure 3.75 ns - -
Bind_ThreeChains_AllSuccess 14.79 ns - -
Bind_ThreeChains_FailAtFirst 12.25 ns - -
Bind_ThreeChains_FailAtSecond 34.65 ns 0.0091 152 B
Bind_FiveChains_Success 33.84 ns - -
Bind_TypeTransformation 18.28 ns 0.0024 40 B
Bind_WithComplexOperation_Success 16.47 ns - -
Bind_WithComplexOperation_Failure 12.40 ns - -

Key Insights:

  • Single Bind: Only ~5 ns overhead with zero allocations
  • Linear scaling: ~5-7 ns per additional Bind operation
  • Short-circuit on failure: Failed results propagate immediately without executing downstream functions
  • Chaining efficiency: 5 sequential Binds take only 34 ns total
  • Type transformations: Minimal overhead (18 ns) for int→string conversion
  • Memory allocations only occur when creating error objects or boxing values

Map Operations

Testing value transformations without changing the Result context.

Method Mean Gen0 Allocated
Map_SingleTransformation_Success 3.24 ns - -
Map_SingleTransformation_Failure 3.69 ns - -
Map_ThreeTransformations_Success 12.13 ns - -
Map_ThreeTransformations_Failure 12.01 ns - -
Map_TypeConversion_IntToString 5.00 ns - -
Map_ComplexTransformation 21.48 ns 0.0048 80 B
Map_MathematicalOperations 12.08 ns - -
Map_FiveTransformations_Success 28.74 ns - -
Map_StringManipulation 24.43 ns 0.0048 80 B
Map_WithComplexCalculation 13.59 ns - -
Map_ToComplexObject 27.10 ns 0.0086 144 B

Key Insights:

  • Extremely fast: Single transformation baseline at 3.2 ns with zero allocations
  • Linear scaling: ~4-6 ns per additional map operation (3 maps = 12 ns, 5 maps = 29 ns)
  • Type conversions minimal: Int→String adds only ~2 ns overhead (5.0 ns total)
  • Complex transformations: Object creation adds allocations (80-144 B) but stays under 28 ns
  • Most operations zero-allocation: Success paths typically allocate nothing
  • Ideal for type conversions and simple transformations in hot paths

Tap Operations

Testing side effects without changing the Result.

Method Mean Gen0 Allocated
Tap_SingleAction_Success 2.88 ns - -
Tap_SingleAction_Failure 3.74 ns - -
Tap_ThreeActions_Success 14.84 ns 0.0038 64 B
Tap_ThreeActions_Failure 14.45 ns 0.0038 64 B
Tap_WithLogging_Success 33.03 ns 0.0038 64 B
TapError_OnFailure 10.73 ns - -
TapError_OnSuccess 11.82 ns - -
Tap_MixedWithMap_Success 27.55 ns - -
Tap_ComplexSideEffect_Success 16.15 ns 0.0038 64 B
Tap_FiveActions_Success 23.90 ns 0.0076 128 B
Tap_WithBind_Success 21.81 ns - -

Key Insights:

  • Near-zero overhead: Single action baseline at 2.9 ns with zero allocations
  • Failure path fast: Failure is a no-op at 3.7 ns (slightly slower than success but still negligible)
  • Low allocation: Tap chains allocate only 64 B for list-backed side effects
  • Perfect for logging/auditing: 33 ns for realistic logging scenario is negligible
  • Composes well: Mixed with Map (28 ns) and Bind (22 ns) efficiently
  • Ideal for debugging, logging, caching, and notification scenarios without impacting main flow

Use Case:

await GetUserAsync(userId)
    .TapAsync(user => _logger.LogInformation("Retrieved user {UserId}", user.Id))
    .TapAsync(user => _cache.SetAsync(user.Id, user))
    .TapErrorAsync(error => _logger.LogError("Failed to get user: {Error}", error));

Ensure Operations

Testing validation/guard clauses.

Method Mean Gen0 Allocated
Ensure_SinglePredicate_Pass 12.06 ns 0.0091 152 B
Ensure_SinglePredicate_Fail 11.98 ns 0.0091 152 B
Ensure_SinglePredicate_OnFailureResult 11.62 ns 0.0091 152 B
Ensure_ThreePredicates_AllPass 54.83 ns 0.0272 456 B
Ensure_ThreePredicates_FailAtSecond 53.91 ns 0.0272 456 B
Ensure_ComplexPredicate_Pass 12.02 ns 0.0091 152 B
Ensure_ComplexPredicate_Fail 12.01 ns 0.0091 152 B
Ensure_WithExpensiveValidation_Pass 37.92 ns 0.0091 152 B
Ensure_WithExpensiveValidation_Fail 37.83 ns 0.0091 152 B
Ensure_ComplexObject_MultipleRules 47.71 ns 0.0296 496 B
Ensure_FivePredicates_AllPass 106.16 ns 0.0454 760 B
Ensure_MixedWithMapAndBind 39.66 ns 0.0181 304 B

Key Insights:

  • Single validation: 12 ns with 152 B allocation for error object
  • Pass vs Fail identical: Passing and failing have the same performance (12 ns each)
  • Linear scaling: ~20 ns per additional predicate (3 = 55 ns, 5 = 106 ns)
  • Complex predicates: Only ~0 ns overhead for complex business rules (12 ns vs 12 ns)
  • Composes well: Mixed with Map and Bind at 40 ns (304 B allocation)
  • Short-circuit optimization: Failed validations don't execute subsequent predicates

Use Case:

customer.CanBePromoted()
    .Ensure(c => c.TotalPurchases > 1000, Error.Validation("Minimum purchase requirement"))
    .Ensure(c => c.AccountAge > TimeSpan.FromDays(90), Error.Validation("Account age requirement"))
    .Tap(c => c.Promote());

Match Operations

Testing pattern matching on success/failure paths.

Method Mean Gen0 Allocated
Match_Success 3.32 ns - -
Match_Failure 2.36 ns - -
Match_Success_TypePreserved 2.50 ns - -
Match_Failure_TypePreserved 2.50 ns - -
Switch_Success 2.34 ns - -
Switch_Failure 2.53 ns - -
Match_AfterPipeline_Success 34.32 ns 0.0114 192 B
Match_AfterPipeline_Failure 25.85 ns 0.0124 208 B

Key Insights:

  • Ultra-fast: Match at 2.4-3.3 ns with zero allocations for simple cases
  • Switch even faster: 2.3 ns success path — likely JIT-inlined
  • Pipeline integration: 26-34 ns when used at the end of a multi-step pipeline
  • Zero-allocation: All simple Match/Switch operations allocate nothing
  • Ideal for final result consumption at the end of ROP pipelines

MapOnFailure Operations

Testing error transformation on the failure track.

Method Mean Gen0 Allocated
MapOnFailure_OnSuccess 2.89 ns - -
MapOnFailure_OnFailure 8.39 ns 0.0072 120 B
MapOnFailure_ChangeErrorType 8.03 ns 0.0067 112 B
MapOnFailure_ChainedOnSuccess 7.05 ns - -
MapOnFailure_ChainedOnFailure 21.22 ns 0.0158 264 B
MapOnFailure_InPipeline_Success 20.31 ns 0.0091 152 B
MapOnFailure_InPipeline_Failure 30.62 ns 0.0172 288 B

Key Insights:

  • No-op on success: 2.9 ns with zero allocation when result is successful
  • Error transformation: 8 ns to transform an error with 120 B allocation for the new error
  • Chaining: Two MapOnFailure calls on failure path take 21 ns (264 B)
  • Perfect for translating domain errors to API-layer errors at composition boundaries

Combine Operations

Testing parallel result aggregation for validation scenarios.

Method Mean Gen0 Allocated
Combine_TwoResults_BothSuccess 7.18 ns - -
Combine_TwoResults_FirstFailure 6.08 ns - -
Combine_TwoResults_SecondFailure 6.07 ns - -
Combine_TwoResults_BothFailure 10.57 ns 0.0019 32 B
Combine_ThreeResults_AllSuccess 11.44 ns - -
Combine_ThreeResults_OneFailure 11.16 ns - -
Combine_ThreeResults_TwoFailures 14.09 ns 0.0019 32 B
Combine_DifferentTypes_BothSuccess 10.97 ns - -
Combine_DifferentTypes_OneFailure 7.52 ns - -
Combine_FiveResults_AllSuccess 40.53 ns - -
Combine_FiveResults_OneFailure 53.98 ns 0.0091 152 B
Combine_FiveResults_MultipleFailures 288.52 ns 0.1512 2536 B
Combine_ValueObjects_AllValid 143.04 ns 0.0286 480 B
Combine_ValueObjects_OneInvalid 116.85 ns 0.0219 368 B
Combine_ValueObjects_AllInvalid 436.59 ns 0.2160 3616 B
Combine_WithBind_AllSuccess 27.17 ns 0.0033 56 B
Combine_WithBind_OneFailure 12.93 ns - -
Combine_WithUnit_Success 12.44 ns - -
CombineAsync_TwoResults_BothSuccess 24.63 ns 0.0153 256 B
CombineAsync_ThreeResults_AllSuccess 41.35 ns 0.0206 344 B
Combine_ComplexObject_AllValid 160.00 ns 0.0286 480 B
Combine_ComplexObject_WithValidation 196.16 ns 0.0377 632 B

Key Insights:

  • Extremely fast: Two results combine in ~7 ns (success path)
  • Linear scaling: ~8-10 ns per additional result
  • Error aggregation overhead: ~4-7 ns when combining errors
  • Value object validation: 143 ns for complete user validation (firstName, lastName, email)
  • Async overhead: ~17 ns additional for async operations (dominated by Task machinery)
  • Unit support: Combine with Unit at 12 ns — efficient for void-returning operations
  • Memory allocations only occur on failure paths for error aggregation

Use Case:

// Validate user registration
FirstName.TryCreate(request.FirstName)
    .Combine(LastName.TryCreate(request.LastName))
    .Combine(EmailAddress.TryCreate(request.Email))
    .Combine(Password.TryCreate(request.Password))
    .Bind((first, last, email, pwd) =>
        User.TryCreate(first, last, email, pwd));

Recover Operations

Testing error recovery patterns.

Method Mean Gen0 Allocated
RecoverOnFailure_OnSuccess 3.00 ns - -
RecoverOnFailure_OnFailure 3.75 ns - -
RecoverOnFailure_OnFailure_WithErrorAccess 3.77 ns - -
RecoverOnFailure_WithPredicate_Match 4.10 ns - -
RecoverOnFailure_WithPredicate_NoMatch 3.43 ns - -
RecoverOnFailure_WithPredicate_AndErrorAccess_Match 4.22 ns - -
RecoverOnFailure_Chain_TwoLevels 12.08 ns 0.0024 40 B
RecoverOnFailure_Chain_ThreeLevels 30.82 ns 0.0114 192 B
RecoverOnFailure_WithComplexRecovery 5.01 ns - -
RecoverOnFailure_Multiple_DifferentErrorTypes 17.67 ns - -
RecoverOnFailure_WithExpensiveRecovery 25.13 ns - -
RecoverOnFailure_MixedWithBind_Success 13.45 ns - -
RecoverOnFailure_MixedWithBind_Failure 17.40 ns - -
RecoverOnFailure_WithDefaultValue 3.67 ns - -
RecoverOnFailure_TypeTransformation 6.81 ns 0.0024 40 B
RecoverOnFailure_NestedPredicates 9.34 ns - -

Key Insights:

  • No-op on success: 3.0 ns with zero allocation — skips recovery entirely
  • Fast recovery: Simple recovery at 3.8 ns, even with error access at 3.8 ns
  • Predicate-based recovery: 4.1 ns when predicate matches, 3.4 ns when it doesn't
  • Mostly zero-allocation: Only chained recovery and type transformations allocate
  • Selective recovery: Filter by error type at 18 ns for multi-type dispatch
  • Ideal for graceful degradation and fallback patterns

Async Operations

Testing asynchronous operation performance.

Method Mean Gen0 Allocated
BindAsync_SingleChain_Success 17.49 ns 0.0143 240 B
BindAsync_SingleChain_Failure 14.71 ns 0.0095 160 B
BindAsync_ThreeChains_Success 55.15 ns 0.0430 720 B
BindAsync_ThreeChains_FailAtSecond 68.38 ns 0.0473 792 B
BindAsync_FiveChains_Success 96.04 ns 0.0716 1200 B
BindAsync_TypeTransformation 46.51 ns 0.0339 568 B
MapAsync_SingleTransformation_Success 16.33 ns 0.0139 232 B
MapAsync_ThreeTransformations_Success 51.44 ns 0.0416 696 B
TapAsync_SingleAction_Success 742.52 ns 0.0286 482 B
TapAsync_ThreeActions_Success 2,086.09 ns 0.0839 1456 B
EnsureAsync_SinglePredicate_Pass 24.27 ns 0.0186 312 B
EnsureAsync_ThreePredicates_AllPass 69.22 ns 0.0464 776 B
Mixed_AsyncOperations_Success 679.97 ns 0.0687 1161 B
TaskResult_BindAsync_Success 45.51 ns 0.0382 640 B
RecoverOnFailureAsync_OnFailure 17.08 ns 0.0143 240 B
FinallyAsync_OnSuccess 9.23 ns 0.0086 144 B
BindAsync_WithDelay_Success 15,608,179 ns - 848 B

Key Insights:

  • BindAsync/MapAsync: 16-17 ns for single operations (~12-13 ns overhead from async machinery)
  • Linear scaling: Async chains scale at ~20 ns per operation (5 binds = 96 ns)
  • TapAsync: Higher overhead (743 ns) due to awaitable side-effect patterns
  • FinallyAsync: Very efficient at 9 ns for result consumption
  • Real I/O dominates: The 15.6 ms BindAsync_WithDelay shows async overhead is invisible vs real I/O
  • Overhead is dominated by async/await Task machinery, not by ROP abstractions

Use Case:

await GetStudentAsync(studentId)
    .ParallelAsync(GetGradesAsync(studentId))
    .ParallelAsync(GetAttendanceAsync(studentId))
    .WhenAllAsync()
    .BindAsync((student, grades, attendance) =>
        GenerateReportAsync(student, grades, attendance));

Maybe Operations

Testing optional value handling — Maybe<T> is a readonly struct.

Method Mean Allocated
HasValue_WithValue 0.003 ns -
HasValue_Empty 0.002 ns -
HasNoValue_WithValue 0.003 ns -
HasNoValue_Empty 0.005 ns -
GetValueOrDefault_WithValue 0.006 ns -
GetValueOrDefault_Empty 0.004 ns -
TryGetValue_WithValue 0.004 ns -
TryGetValue_Empty 0.007 ns -
From_Value 0.000 ns -
None_Creation 0.000 ns -
ImplicitConversion_FromValue 0.000 ns -
ImplicitConversion_FromNull 0.000 ns -
Equality_BothWithSameValue 0.001 ns -
Equality_BothEmpty 0.002 ns -
Equality_OneEmptyOneWithValue 0.012 ns -
GetHashCode_WithValue 0.012 ns -
GetHashCode_Empty 0.004 ns -
ToString_WithValue 0.000 ns -
ToString_Empty 0.178 ns -
ToResult_WithValue 2.49 ns 40 B
ToResult_Empty 2.92 ns 40 B
Optional_WithNullValue 0.000 ns -
Optional_WithValue 0.000 ns -
Equals_WithObject 0.010 ns -
Equals_WithSameValue 0.009 ns -
CreateComplexMaybe 19.83 ns 40 B

Key Insights:

  • Effectively free: Most operations are < 1 ns — JIT-inlined to near-zero cost
  • True zero-allocation: All basic operations (HasValue, equality, GetHashCode) allocate nothing
  • readonly struct advantage: No boxing, no heap allocation, pure value-type semantics
  • ToResult bridges: Only allocation point (40 B) when converting Maybe→Result
  • Complex creation: Even CreateComplexMaybe with string parsing is only 20 ns
  • Maybe<T> is a genuine zero-cost abstraction for domain-level optionality

Error Handling

Testing error creation and aggregation.

Method Mean Gen0 Allocated
CreateValidationError_Simple 10.50 ns 0.0091 152 B
CreateNotFoundError 1.72 ns 0.0024 40 B
CreateUnauthorizedError 1.75 ns 0.0024 40 B
CreateConflictError 1.77 ns 0.0024 40 B
CreateUnexpectedError 1.75 ns 0.0024 40 B
CreateValidationError_MultipleFields 319.10 ns 0.1922 3216 B
CreateValidationError_SingleFieldMultipleMessages 239.23 ns 0.1373 2304 B
CombineErrors_TwoValidationErrors 154.82 ns 0.0989 1656 B
CombineErrors_ValidationAndNotFound 25.64 ns 0.0196 328 B
CombineErrors_ThreeValidationErrors 319.82 ns 0.2041 3416 B
CombineErrors_FiveErrors 219.57 ns 0.1347 2256 B
MergeValidationErrors_DifferentFields 146.86 ns 0.0966 1616 B
MergeValidationErrors_SameField 125.93 ns 0.0796 1336 B
MergeValidationErrors_Complex 483.89 ns 0.2794 4688 B
ErrorEquality_SameCode 0.005 ns - -
ErrorEquality_DifferentCode 0.193 ns - -
GetHashCode_ValidationError 21.31 ns - -
GetHashCode_ComplexValidationError 46.62 ns - -
ToString_SimpleError 55.31 ns 0.0396 664 B
ToString_ComplexValidationError 94.33 ns 0.0736 1232 B
ValidationError_WithFieldErrors 30.49 ns 0.0258 432 B
CreateErrorFromException 4.75 ns 0.0096 160 B
IsErrorType_Validation 0.009 ns - -
IsErrorType_NotFound 0.013 ns - -
CreateErrorChain_WithDetails 9.69 ns 0.0091 152 B

Key Insights:

  • Simple errors fast: NotFound/Unauthorized/Conflict at 1.7-1.8 ns (40 B)
  • Validation errors heavier: 11 ns / 152 B for simple, 319 ns / 3216 B for multi-field
  • Error equality free: Identity comparison at ~0.005 ns with zero allocations
  • Error type checking free: IsErrorType at 0.009-0.013 ns — JIT-inlined
  • Error aggregation: Combining 2 errors at 155 ns (1656 B), 5 errors at 220 ns (2256 B)
  • Complex merge: Multi-field validation merge at 484 ns — still fast for validation scenarios
  • Error paths are inherently "cold" — these allocations are acceptable for error reporting

Value Object Operations

Testing DDD ValueObject comparison and sorting.

Method Mean Gen0 Allocated
CompareTo_Equal 30.37 ns 0.0048 80 B
CompareTo_Different 20.84 ns 0.0048 80 B
Sort_100_ValueObjects 18,032 ns 3.1433 53,064 B

Key Insights:

  • ValueObject.CompareTo: 30 ns with 80 B allocation (enumerator-based zip comparison)
  • Early-exit on difference: Different values at 21 ns vs equal values at 30 ns
  • Sorting 100 objects: ~18 µs (180 ns per comparison average)
  • The v3 implementation is 2.1-2.5x faster and uses 2.4x fewer allocations than the previous .ToArray() approach

Money Operations

Testing Money value object arithmetic (multi-currency with ISO 4217).

Method Mean Gen0 Allocated
Create_Valid 32.01 ns 0.0067 112 B
Create_ZeroDecimalCurrency 31.10 ns 0.0067 112 B
Add_SameCurrency 53.09 ns 0.0114 192 B
Subtract_SameCurrency 64.05 ns 0.0114 192 B
Multiply_Decimal 36.67 ns 0.0067 112 B
Multiply_Integer 32.14 ns 0.0067 112 B
Divide_Decimal 58.36 ns 0.0067 112 B
Divide_Integer 34.67 ns 0.0067 112 B
Add_DifferentCurrency_Fails 56.51 ns 0.0181 304 B
Subtract_DifferentCurrency_Fails 57.13 ns 0.0186 312 B
IsGreaterThan 21.64 ns 0.0048 80 B
IsLessThan 21.18 ns 0.0048 80 B
Allocate_ThreeWay 69.72 ns 0.0138 232 B
Allocate_EvenSplit 128.22 ns 0.0167 280 B
ArithmeticPipeline 142.44 ns 0.0248 416 B

Key Insights:

  • Creation: 32 ns / 112 B for creating a Money value (includes currency validation)
  • Arithmetic: 32-64 ns for math operations — same-currency adds are ~53 ns
  • Currency safety: Cross-currency operations fail fast at 57 ns (304 B for error)
  • Integer multiply faster: 32 ns vs 37 ns for decimal multiply
  • Comparison: 21-22 ns / 80 B for greater-than / less-than checks
  • Allocation: Banker's rounding three-way split at 70 ns, even split at 128 ns
  • Full pipeline: Create → multiply → add → subtract at 142 ns (416 B)

Specification Operations

Testing DDD Specification pattern (expression trees, composition).

Method Mean Gen0 Allocated
IsSatisfiedBy_Simple_Pass 328,558 ns - 5,768 B
IsSatisfiedBy_Simple_Fail 340,006 ns - 5,768 B
IsSatisfiedBy_And_Pass 420,440 ns 0.4883 12,976 B
IsSatisfiedBy_And_Fail 414,361 ns 0.4883 12,976 B
IsSatisfiedBy_Or_Pass 434,916 ns 0.4883 12,976 B
IsSatisfiedBy_Or_Fail 413,791 ns 0.4883 12,976 B
IsSatisfiedBy_Not 354,674 ns 0.4883 8,456 B
IsSatisfiedBy_Complex 643,375 ns 0.9766 30,119 B
ToExpression_Simple 268 ns 0.0496 840 B
ToExpression_And 863 ns 0.1488 2,536 B
ToExpression_Complex 2,056 ns 0.3510 6,032 B
Filter_100_Orders 350,095 ns - 6,650 B
Filter_100_Orders_Composed 415,073 ns 0.4883 13,192 B

Key Insights:

  • IsSatisfiedBy is expensive: 329 µs for a simple spec because it calls ToExpression().Compile() every time
  • Composed specs: And/Or compositions at 414-435 µs (1.3x simple due to expression tree merging)
  • ToExpression is fast: 268 ns for simple, 2.1 µs for complex 4-level compositions
  • Filtering: 350 µs for 100 records with simple spec — compilation dominates, not iteration
  • Optimization opportunity: Cache compiled delegates if calling IsSatisfiedBy in hot loops
  • EF Core path is fast: When using ToExpression() directly with LINQ, the expression tree is passed to EF Core without compilation — only the 268 ns–2.1 µs expression build cost applies

Actor / Authorization

Testing Actor permission checking (HashSet-backed).

Method Mean Gen0 Allocated
HasPermission_Found 4.80 ns - -
HasPermission_NotFound 4.01 ns - -
HasPermission_Forbidden 4.59 ns - -
HasPermission_Scoped 15.34 ns 0.0038 64 B
HasAllPermissions_AllPresent 15.29 ns - -
HasAllPermissions_SomeMissing 15.49 ns 0.0043 72 B
HasAnyPermission_OnePresent 10.91 ns 0.0043 72 B
HasAnyPermission_NonePresent 14.44 ns 0.0043 72 B
HasPermission_LargeSet_50_Found 6.30 ns - -
HasPermission_LargeSet_500_Found 6.39 ns - -
HasPermission_LargeSet_500_NotFound 3.34 ns - -
IsOwner_Match 0.009 ns - -
IsOwner_NoMatch 0.019 ns - -
Create_Simple 16.85 ns 0.0134 224 B

Key Insights:

  • O(1) permission lookup: 4.8 ns found, 4.0 ns not found — backed by HashSet
  • Scales perfectly: 500 permissions still 6.4 ns — no degradation with set size
  • IsOwner JIT-inlined: 0.009 ns — effectively a no-op (GUID comparison)
  • Scoped permissions: 15 ns with 64 B allocation for string interpolation
  • HasAll/HasAny: 11-15 ns — efficient for batch permission checking
  • Actor creation: 17 ns / 224 B — lightweight for per-request construction
  • Zero allocations for all simple permission checks

Running Benchmarks

To run the benchmarks yourself:

# Run all benchmarks
dotnet run --project Trellis.Benchmark/Trellis.Benchmark.csproj -c Release

# Run specific benchmark
dotnet run --project Trellis.Benchmark/Trellis.Benchmark.csproj -c Release -- --filter *Combine*

# Run with specific options
dotnet run --project Trellis.Benchmark/Trellis.Benchmark.csproj -c Release -- --filter *ROP* --memory

Benchmark Classes:

  • BenchmarkROP — Core ROP vs imperative comparisons
  • BindBenchmarks — Sequential transformations
  • MapBenchmarks — Value transformations
  • TapBenchmarks — Side effects
  • EnsureBenchmarks — Validation operations
  • MatchBenchmarks — Pattern matching
  • MapOnFailureBenchmarks — Error transformation
  • CombineBenchmarks — Result aggregation
  • RecoverBenchmarks — Error recovery patterns
  • AsyncBenchmarks — Asynchronous operations
  • MaybeBenchmarks — Optional value handling
  • ErrorBenchmarks — Error creation and aggregation
  • ValueObjectBenchmarks — DDD value object comparison
  • MoneyBenchmarks — Multi-currency arithmetic
  • SpecificationBenchmarks — DDD specification pattern
  • ActorBenchmarks — Authorization permission checking

Interpreting Results

What the Numbers Mean

Execution Time:

  • < 100 ns: Excellent — negligible overhead
  • 100-500 ns: Very good — minimal impact
  • 500-1000 ns: Good — reasonable for most scenarios
  • > 1000 ns: Context-dependent — compare to your I/O operations

Memory Allocations:

  • < 100 B: Excellent — minimal heap pressure
  • 100-500 B: Very good — acceptable for most operations
  • 500-1000 B: Good — watch for high-frequency operations
  • > 1000 B: Context-dependent — consider pooling for hot paths

Real-World Context

Typical Operation Costs:

  • Database query: 1,000,000-10,000,000 ns (1-10 ms)
  • HTTP request: 10,000,000-100,000,000 ns (10-100 ms)
  • File I/O: 100,000-1,000,000 ns (0.1-1 ms)
  • ROP overhead: 3-34 ns (0.000003-0.000034 ms)

Conclusion: The overhead from ROP is < 0.001% of typical I/O operations, making it negligible in real-world applications while providing significant benefits in code clarity, testability, and error handling.

Performance Tips

  1. Use Combine for parallel validation — More efficient than sequential checks
  2. Leverage short-circuiting — Failed results don't execute subsequent operations
  3. Prefer Map over Bind — When you don't need to change the Result context
  4. Use ParallelAsync — For independent async operations
  5. Cache compiled specs — If calling IsSatisfiedBy in hot loops, cache the compiled delegate
  6. Don't over-optimize — Focus on I/O and business logic optimization first

Conclusion

The Trellis library provides negligible performance overhead while offering significant improvements in:

  • Code clarity — Railway-oriented style is more readable
  • Error handling — Explicit error propagation and aggregation
  • Testability — Pure functions are easier to test
  • Maintainability — Composable operations reduce complexity

The ~4-5 nanosecond overhead is insignificant compared to typical application operations (database, HTTP, file I/O), making Trellis an excellent choice for building robust, maintainable applications without sacrificing performance.

Performance Summary by Operation:

Category Range Allocations
Map 3-28 ns Zero on success
Match 2-3 ns Zero
Tap 3-33 ns Zero (simple)
Bind 4-34 ns Zero on success
Combine 7-41 ns (success) Zero on success
Ensure 12-106 ns 152 B per predicate
Recover 3-25 ns Zero (simple)
MapOnFailure 3-8 ns Zero on success
Maybe < 1 ns Zero
Actor 4-15 ns Zero (simple lookups)
Money 32-142 ns 112-416 B
Specification 268 ns-643 µs 840 B-30 KB
Errors 2-319 ns 40-3216 B

Last Updated: March 2025 Benchmark Tool: BenchmarkDotNet v0.15.8 Environment: .NET 10.0.3, Release Configuration, AMD Ryzen 9 9900X, Windows 11 (25H2)