Aman Baloch

Software Engineer from Melbourne

View on GitHub
20 October 2024

Performance Optimization Techniques for .NET 8

:clock3: 8 minutes read

.NET 8 brings many performance improvements, but understanding how to optimize your code is essential for getting the most out of these enhancements. This blog will delve into various strategies to optimize .NET 8 applications, complete with code examples demonstrating each technique.

1. Use Asynchronous Programming

Asynchronous programming allows applications to perform non-blocking operations, improving responsiveness and scalability, especially in I/O-bound tasks.

Code Example: Instead of using synchronous I/O operations:

public string ReadFile(string path)
{
    return File.ReadAllText(path); // Blocks the calling thread
}

Use asynchronous methods:

public async Task<string> ReadFileAsync(string path)
{
    return await File.ReadAllTextAsync(path); // Does not block the calling thread
}

Using async and await helps keep threads available for processing other requests.

2. Optimize LINQ Queries

LINQ is powerful but can lead to performance bottlenecks if not used carefully. Avoid unnecessary enumerations and use methods like ToList() only when you need a materialized collection.

Avoid Multiple Enumerations:

var filtered = myCollection.Where(x => x.IsActive);
var count = filtered.Count(); // Enumerates again
var list = filtered.ToList(); // Enumerates once more

Optimize by Materializing Once:

var filteredList = myCollection.Where(x => x.IsActive).ToList();
var count = filteredList.Count; // Uses the same list

Use Parallel LINQ for Large Datasets: For large datasets and independent operations, leverage AsParallel():

var results = myCollection.AsParallel().Where(x => x.IsActive).ToList();

This approach uses multiple threads to process the data in parallel, potentially improving performance for CPU-bound tasks.

3. Minimize Allocations

Reducing memory allocations can significantly improve application performance, especially by using value types (structs) instead of reference types (classes) when appropriate.

Avoid Boxing and Unboxing: Boxing occurs when a value type is converted to a reference type, while unboxing is the reverse process, both of which involve allocations.

int number = 42;
object boxed = number; // Boxing occurs here
int unboxed = (int)boxed; // Unboxing occurs here

To avoid this, use generic methods where possible:

public void ProcessValue<T>(T value) { /* Implementation */ }

4. Use Span<T> and Memory<T>

Span<T> and Memory<T> provide high-performance memory access without allocating on the heap, making them useful for low-level memory manipulation and string processing.

Example:

public static int Sum(int[] numbers)
{
    Span<int> span = numbers;
    int sum = 0;
    for (int i = 0; i < span.Length; i++)
    {
        sum += span[i];
    }
    return sum;
}

Span<T> avoids heap allocations, thus reducing garbage collection (GC) pressure.

5. Pooling and Caching

Reusing objects that are expensive to create, such as database connections or large buffers, can reduce memory usage and improve performance.

Object Pooling Example:

ObjectPool<StringBuilder> stringBuilderPool = new DefaultObjectPool<StringBuilder>(new DefaultPooledObjectPolicy<StringBuilder>());

StringBuilder sb = stringBuilderPool.Get();
try
{
    // Use StringBuilder
}
finally
{
    stringBuilderPool.Return(sb); // Return it to the pool
}

6. Optimize String Operations

String manipulation can be expensive due to immutability, so using StringBuilder for concatenations and ReadOnlySpan<char> for parsing can optimize string handling.

Use StringBuilder:

StringBuilder sb = new StringBuilder();
sb.Append("Hello, ");
sb.Append("World!");
string result = sb.ToString();

Use ReadOnlySpan<char>:

ReadOnlySpan<char> span = "Hello, World!".AsSpan();
ReadOnlySpan<char> helloSpan = span.Slice(0, 5); // Efficient slicing without allocations

7. Efficient Collections

Choosing the right collection type can affect performance. For large arrays, use ArrayPool<T> to reduce GC pressure.

Using ArrayPool<T>:

ArrayPool<int> pool = ArrayPool<int>.Shared;
int[] array = pool.Rent(1024); // Rent an array of at least 1024 elements

try
{
    // Use the array
}
finally
{
    pool.Return(array); // Return the array to the pool
}

8. Profile and Benchmark

Identifying performance bottlenecks is crucial before optimizing. Tools like dotnet-trace, dotnet-counters, and BenchmarkDotNet help profile and benchmark .NET applications.

BenchmarkDotNet Example:

[MemoryDiagnoser]
public class MyBenchmarks
{
    [Benchmark]
    public void TestMethod()
    {
        // Code to benchmark
    }
}

Run the benchmark using:

dotnet run -c Release

9. Parallel Processing

For CPU-bound operations, parallel processing can improve performance. Use Parallel.For and Parallel.ForEach for tasks that can run concurrently.

Example:

Parallel.For(0, 1000, i =>
{
    // Perform some computation
});

10. Reduce Lock Contention

Minimizing lock contention can improve multi-threaded application performance. Use thread-safe collections like ConcurrentDictionary and reduce the scope of locks.

Use ReaderWriterLockSlim for Read-Heavy Scenarios:

ReaderWriterLockSlim rwLock = new ReaderWriterLockSlim();

rwLock.EnterReadLock();
try
{
    // Read operation
}
finally
{
    rwLock.ExitReadLock();
}

11. Optimize I/O Operations

Avoid blocking threads with synchronous I/O operations. Use asynchronous I/O to keep threads available for other tasks.

Example with FileStream:

using FileStream fs = new FileStream("file.txt", FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 4096, useAsync: true);
byte[] buffer = new byte[4096];
await fs.ReadAsync(buffer, 0, buffer.Length);

12. JIT and AOT Compilation

Improving startup time can be crucial in some scenarios. Use ReadyToRun (R2R) images or Native AOT for maximum performance.

Configure ReadyToRun: Add the following to the .csproj file:

<PublishReadyToRun>true</PublishReadyToRun>

13. Use ValueTask

For performance-critical paths, ValueTask can be used to avoid allocating a new Task when the result is available synchronously.

Example:

public ValueTask<int> GetResultAsync(bool cached)
{
    return cached ? new ValueTask<int>(42) : new ValueTask<int>(ComputeAsync());
}

14. Avoid Unnecessary Exceptions

Exceptions are costly, so avoid using them for control flow. Use conditional checks instead.

Avoid:

try
{
    int.Parse("invalid");
}
catch (FormatException)
{
    // Handle error
}

Use:

if (int.TryParse("invalid", out int result))
{
    // Use result
}

15. Optimize JSON Serialization/Deserialization

Use System.Text.Json for efficient JSON processing. Precompute serialization metadata to improve performance.

Example:

JsonSerializerOptions options = new JsonSerializerOptions { DefaultBufferSize = 1024 };
string jsonString = JsonSerializer.Serialize(myObject, options);

By applying these optimization techniques, you can significantly improve the performance of .NET 8 applications, reducing latency, improving throughput, and efficiently utilizing resources.

tags: net8 - dotnet - performance - optimsation