Performance
This guide covers performance characteristics, benchmarks, and optimization techniques.
Table of Contents
- Overview
- Key Metrics
- Benchmark Results
- Real-World Context
- Optimization Tips
- Running Your Own Benchmarks
Overview
Benchmarks on .NET 10 show railway-oriented programming adds only ~11-16 nanoseconds overhead compared to imperative code—less than 0.002% of typical I/O operations.
Test Environment:
- CPU: Intel Core i7-1185G7 @ 3.00GHz
- OS: Windows 11
- .NET: 10.0.1
Key Metrics
| Metric | Finding |
|---|---|
| Overhead | 11-16 nanoseconds (~12-13% vs imperative) |
| Memory | Identical allocations to imperative code |
| Success Path | Highly optimized, minimal allocations |
| Error Path | Efficient with short-circuit optimization |
| Combine Operations | 7-58 ns for 2-5 results |
| Bind Operations | 9-63 ns for 1-5 chains |
| Map Operations | 4.6-44.5 ns for 1-5 transforms |
Benchmark Results
ROP vs Imperative Style
Direct comparison of ROP versus traditional if-style code:
| Method | Mean | Allocated |
|---|---|---|
| ROP Happy Path | 147 ns | 144 B |
| Imperative Happy Path | 131 ns | 144 B |
| ROP Error Path | 99 ns | 184 B |
| Imperative Error Path | 88 ns | 184 B |
Key Takeaways:
- ROP adds ~16 ns on success path (12% overhead)
- ROP adds ~11 ns on error path (13% overhead)
- Identical memory allocations between approaches
- Error paths are faster due to short-circuit optimization
Core Operation Benchmarks
Combine Operations
Aggregating multiple Result objects:
| Results Combined | Mean Time | Allocated |
|---|---|---|
| 2 results | 7 ns | 0 B |
| 3 results | 30 ns | 0 B |
| 5 results | 58 ns | 0 B |
Zero allocations - highly efficient for validation scenarios.
Bind Operations
Chaining operations that return Results:
| Chain Length | Mean Time | Allocated |
|---|---|---|
| 1 bind | 9 ns | 0 B |
| 3 binds | 35 ns | 0 B |
| 5 binds | 63 ns | 0 B |
Linear scaling with excellent performance characteristics.
Map Operations
Transforming successful values:
| Transforms | Mean Time | Allocated |
|---|---|---|
| 1 map | 4.6 ns | 0 B |
| 3 maps | 21 ns | 0 B |
| 5 maps | 44.5 ns | 0 B |
Fastest operation with zero allocations on success path.
Tap Operations
Executing side effects:
| Taps | Mean Time | Allocated |
|---|---|---|
| 1 tap | 3 ns | 0 B |
| 3 taps | 18 ns | 32 B |
| 5 taps | 37.4 ns | 64 B |
Minimal overhead for logging and side effects.
Ensure Operations
Adding validation checks:
| Checks | Mean Time | Allocated |
|---|---|---|
| 1 ensure | 22.5 ns | 152 B |
| 3 ensures | 89 ns | 456 B |
| 5 ensures | 175 ns | 760 B |
Note: Allocations include error object creation for failed validations.
Async Operations
Async operations have similar performance characteristics with additional Task overhead:
// Async overhead is from Task machinery, not ROP
await GetUserAsync(id) // ~1,000,000 ns (database call)
.BindAsync(ProcessUserAsync) // + 50 ns (ROP overhead)
.TapAsync(LogUserAsync); // + 20 ns (ROP overhead)
The ROP overhead is less than 0.01% of typical async I/O operations.
Real-World Context
To put these numbers in perspective:
Database Query: 1,000,000 ns (1 ms)
HTTP Request: 10,000,000 ns (10 ms)
File Read: 5,000,000 ns (5 ms)
ROP Chain (5 ops): 150 ns (0.00015 ms)
↑
0.015% of a database query
The 16ns ROP overhead is 1/62,500th of a single database query!
Performance in Web Applications
In a typical ASP.NET Core request:
// Typical web request processing
app.MapPost("/orders", async (CreateOrderRequest request, CancellationToken ct) =>
{
return await ValidateRequest(request) // ~50 ns
.BindAsync((req, ct) => CreateOrderAsync(req, ct), ct) // ~1-5 ms (DB write)
.TapAsync((order, ct) => PublishEventAsync(order, ct), ct) // ~10-50 ms (message queue)
.MatchAsync(
onSuccess: order => Results.Created($"/orders/{order.Id}", order),
onFailure: error => error.ToHttpResult()
);
});
// Total ROP overhead: ~150 ns
// Total request time: ~15-60 ms
// ROP percentage: 0.0003%
Optimization Tips
1. Prefer Struct-Based Value Objects
// ✅ Good - Struct, no heap allocation
public readonly struct UserId
{
private readonly Guid _value;
public UserId(Guid value) => _value = value;
}
// ❌ Worse - Class, heap allocation
public class UserId
{
public Guid Value { get; }
public UserId(Guid value) => Value = value;
}
2. Use ValueTask for Hot Paths
// ✅ Good - ValueTask for potentially synchronous completions
public ValueTask<Result<User>> GetUserFromCacheAsync(UserId id)
{
if (_cache.TryGetValue(id, out var user))
return ValueTask.FromResult(Result.Success(user));
return new ValueTask<Result<User>>(FetchFromDbAsync(id));
}
// ❌ Allocates Task even when cached
public Task<Result<User>> GetUserFromCacheAsync(UserId id)
{
if (_cache.TryGetValue(id, out var user))
return Task.FromResult(Result.Success(user));
return FetchFromDbAsync(id);
}
3. Combine Before Bind
// ✅ Good - Validate all at once
var result = Email.TryCreate(email)
.Combine(FirstName.TryCreate(firstName))
.Combine(LastName.TryCreate(lastName))
.Bind((e, f, l) => CreateUser(e, f, l));
// ❌ Less efficient - Sequential validation
var result = Email.TryCreate(email)
.Bind(e => FirstName.TryCreate(firstName)
.Bind(f => LastName.TryCreate(lastName)
.Bind(l => CreateUser(e, f, l))));
4. Minimize Allocations in Hot Paths
// ✅ Good - Reuse error instances
private static readonly Error InvalidAgeError =
Error.Validation("Age must be between 0 and 120");
public Result<Age> ValidateAge(int age)
{
return age is >= 0 and <= 120
? Result.Success(new Age(age))
: InvalidAgeError;
}
// ❌ Allocates error on every failure
public Result<Age> ValidateAge(int age)
{
return age is >= 0 and <= 120
? Result.Success(new Age(age))
: Error.Validation("Age must be between 0 and 120"); // New allocation
}
5. Use ConfigureAwait in Libraries
// ✅ Good - For library code
public async Task<Result<User>> GetUserAsync(UserId id)
{
var user = await _repository.GetByIdAsync(id).ConfigureAwait(false);
return user.ToResult(Error.NotFound($"User {id} not found"));
}
// ✅ Also fine - For application code (ASP.NET Core)
public async Task<Result<User>> GetUserAsync(UserId id)
{
var user = await _repository.GetByIdAsync(id);
return user.ToResult(Error.NotFound($"User {id} not found"));
}
6. Avoid Excessive Logging in Hot Paths
// ❌ Bad - Logs on every success
.Tap(user => _logger.LogDebug("Got user {Id}", user.Id))
// ✅ Good - Log only on failures or important events
.TapOnFailure(error => _logger.LogWarning("Failed to get user: {Error}", error))
// ✅ Good - Use structured logging with guards
.Tap(user =>
{
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("Got user {Id}", user.Id);
})
Benefits Without Sacrifice
Despite the minimal overhead, you get significant benefits:
✅ Same Memory Usage - No additional allocations vs imperative code
⚡ Blazing Fast - Single-digit to low double-digit nanosecond overhead
✅ Better Code - Cleaner, more testable, and maintainable
✅ Explicit Errors - Clear error propagation and aggregation
✅ Composable - Chain operations naturally
✅ Type Safe - Compiler-enforced error handling
Running Your Own Benchmarks
Install BenchmarkDotNet
dotnet add package BenchmarkDotNet
Create a Benchmark
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using Trellis;
[MemoryDiagnoser]
[ShortRunJob]
public class MyBenchmarks
{
[Benchmark]
public Result<int> RopStyle()
{
return Result.Success(5)
.Map(x => x * 2)
.Ensure(x => x > 0, Error.Validation("Must be positive"))
.Map(x => x + 10);
}
[Benchmark(Baseline = true)]
public int ImperativeStyle()
{
var x = 5;
x = x * 2;
if (x <= 0) throw new InvalidOperationException();
return x + 10;
}
}
// Run benchmarks
class Program
{
static void Main(string[] args)
{
BenchmarkRunner.Run<MyBenchmarks>();
}
}
Run the Benchmark
dotnet run -c Release --project YourBenchmarkProject
View Full Project Benchmarks
cd Trellis
dotnet run --project Trellis.Benchmark/Trellis.Benchmark.csproj -c Release
Performance FAQs
Q: Is ROP slower than exceptions?
A: For the error path, ROP is typically faster than exceptions:
- Exception throw: ~1,000-10,000 ns
- ROP error return: ~90-150 ns
Q: Should I worry about the 16ns overhead?
A: No, unless you're in a tight CPU-bound loop. For typical web applications with database/HTTP calls, the overhead is 0.002% or less.
Q: What about memory pressure?
A: ROP has identical memory allocations to imperative code. The Result struct is stack-allocated in most cases.
Q: How does async affect performance?
A: Async overhead comes from the Task machinery (~50-100 ns), not ROP. ROP adds the same ~15 ns overhead on top.
Q: Can I use ROP in high-performance scenarios?
A: Yes! The overhead is minimal. Many high-throughput systems use ROP successfully. Profile your specific use case if concerned.