Skip to main content

Memory Efficiency

This document explores how Rust's design principles lead to better resource efficiency compared to .NET. You'll analyze concrete examples from the codebase that demonstrate these differences and explain the underlying mechanisms.

Conceptual Overview

Why Resource Efficiency Matters

Efficient resource usage has significant real-world impacts:

  1. Environmental Impact: Lower energy consumption means reduced carbon footprint
  2. Cost Efficiency: Reduced resource needs translate to lower infrastructure costs
  3. Scalability: More efficient applications can serve more users with the same hardware
  4. Mobile & Edge Computing: Resource constraints are critical on limited devices
  5. Responsiveness: Less memory usage often correlates with better performance

.NET's Memory Management

.NET uses a garbage-collected memory management system:

  1. Garbage Collection: The runtime periodically identifies and frees unused objects
  2. Generational Heap: Objects are allocated in different "generations" based on lifetime
  3. Large Object Heap: Special heap for objects larger than 85KB
  4. GC Pauses: Application can pause during garbage collection (especially full/Gen2 collections)
  5. Memory Overhead: Additional memory needed for GC bookkeeping

This approach simplifies development but has efficiency implications:

  • Memory usage tends to be higher due to deferred collection
  • GC pauses can affect application responsiveness
  • Memory fragmentation can occur over time

Rust's Memory Management

Rust uses deterministic memory management through ownership:

  1. Stack Allocation: Values with known size are allocated on the stack when possible
  2. RAII (Resource Acquisition Is Initialization): Resources are tied to object lifetimes
  3. Deterministic Cleanup: Memory is freed immediately when variables go out of scope
  4. Zero Overhead Abstractions: High-level constructs compile to efficient machine code
  5. No Runtime: No garbage collector or runtime environment overhead

Benefits of this approach:

  • Predictable and often lower memory usage
  • No GC pauses affecting responsiveness
  • Efficient resource utilization without manual memory management

Analyzing the Sustainability Examples

Examine the specific examples from the codebase that demonstrate these differences.

.NET Example Analysis

.NET Link

// Store some long-lived data to force GC pressure
var longLivedObjects = new List<byte[]>();

// Run multiple tasks to create memory pressure (one per CPU core)
var tasks = new List<Task>();
for (int i = 0; i < Environment.ProcessorCount; i++)
{
tasks.Add(Task.Run(() => AllocateMemory(i)));
}

// Every second, print memory statistics
while (!Console.KeyAvailable)
{
await Task.Delay(1000);

// Calculate memory metrics
var managedMemory = GC.GetTotalMemory(false) / 1024 / 1024;
var workingSet = Process.GetCurrentProcess().WorkingSet64 / 1024 / 1024;
var gen0Collections = GC.CollectionCount(0);
var gen1Collections = GC.CollectionCount(1);
var gen2Collections = GC.CollectionCount(2);
var uptime = (DateTime.Now - startTime).TotalSeconds;

// [Output statistics code.]

// Every 10 seconds, create long-lived objects that survive collections
if (uptime % 10 < 1)
{
var largeObject = new byte[50 * 1024 * 1024]; // 50 MB
Array.Fill<byte>(largeObject, 1);
longLivedObjects.Add(largeObject);
Console.WriteLine("\n*** Added 50 MB long-lived object to increase memory pressure ***");
}
}

The memory allocation function:

static void AllocateMemory(int taskId)
{
var random = new Random(taskId);
int counter = 0;

while (true)
{
counter++;

// Create large array (this will likely go to the Large Object Heap in .NET)
var size = random.Next(1024 * 1024, 5 * 1024 * 1024);
var largeArray = new byte[size];

// Create many small string objects
var objects = new List<object>(1000);
for (int i = 0; i < 1000; i++)
{
objects.Add(new string('x', random.Next(100, 1000)));
}

// Every so often, create memory pressure to force collections
if (counter % 20 == 0)
{
var tempLargeArrays = new List<byte[]>();
for (int i = 0; i < 5; i++)
{
tempLargeArrays.Add(new byte[10 * 1024 * 1024]); // 10 MB each
}
}

// Allow other tasks to run
Task.Delay(50).Wait();
}
}

Key Observations in the .NET Code:

  1. Memory Growth: The program continuously allocates memory, and while the garbage collector reclaims some, the overall memory usage tends to grow over time.

  2. GC Generations: The code tracks garbage collection events across all three generations:

    • Gen 0: Frequent collections of short-lived objects (highest collection rate)
    • Gen 1: Intermediate collections
    • Gen 2: Full collections that cause the most noticeable pauses
  3. Long-lived Objects: The program deliberately creates long-lived objects that survive collections, forcing the GC to work harder.

  4. Large Object Heap: Large arrays (>85KB) go to the Large Object Heap, which is collected less frequently and can lead to memory fragmentation.

  5. Memory Retention: Objects remain in memory until collected by the GC, not when they go out of scope.

Rust Example Analysis

Rust Example Link

// Initialize memory tracking statistics
let stats = MemoryStats {
allocated: Arc::new(AtomicUsize::new(0)),
deallocated: Arc::new(AtomicUsize::new(0)),
max_rss: Arc::new(AtomicUsize::new(0)),
allocations_count: Arc::new(AtomicUsize::new(0)),
};

// Spawn worker threads based on CPU count (same as .NET example)
let num_cpus = num_cpus::get();
let mut handles = vec![];

// Create worker threads that allocate memory
for id in 0..num_cpus {
let allocated = Arc::clone(&stats.allocated);
let deallocated = Arc::clone(&stats.deallocated);
let allocations_count = Arc::clone(&stats.allocations_count);
let max_rss = Arc::clone(&stats.max_rss);

handles.push(thread::spawn(move || {
allocate_memory(id, allocated, deallocated, allocations_count, max_rss);
}));
}

// Print statistics every second
loop {
thread::sleep(Duration::from_secs(1));

// [Statistics calculation code.]

// Create periodic memory pressure spike (similar to .NET example)
if elapsed % 10.0 < 1.0 && elapsed > 1.0 {
// This large allocation will be freed immediately after this block
println!("\n*** Creating temporary memory spike of 50 MB ***");
let _temp_large_allocation = vec![1u8; 50 * 1024 * 1024]; // 50 MB

// Notice how this memory will be immediately freed when it goes out of scope,
// unlike in .NET where it would remain until garbage collection occurs
}
}

The allocation function:

fn allocate_memory(
id: usize,
allocated: Arc<AtomicUsize>,
deallocated: Arc<AtomicUsize>,
allocations_count: Arc<AtomicUsize>,
max_rss: Arc<AtomicUsize>
) {
let mut rng = rand::thread_rng();
let mut local_counter = 0;

loop {
local_counter += 1;

// Allocate a large vector (similar to byte arrays in .NET)
let size = rng.gen_range(1 * 1024 * 1024..5 * 1024 * 1024);
{
// This scope ensures the memory is freed immediately after use
let _large_vec = vec![0u8; size];
allocated.fetch_add(size, Ordering::Relaxed);
allocations_count.fetch_add(1, Ordering::Relaxed);

// [Tracking code.]

// Small delay to simulate work
thread::sleep(Duration::from_millis(1));
}
// _large_vec is automatically freed here when it goes out of scope

// Create multiple smaller allocations
{
let mut small_vecs = Vec::with_capacity(1000);
for _ in 0..1000 {
let small_size = rng.gen_range(100..1000);
small_vecs.push(vec!['x'; small_size]);
allocated.fetch_add(small_size, Ordering::Relaxed);
allocations_count.fetch_add(1, Ordering::Relaxed);
}
// Small delay to simulate work
thread::sleep(Duration::from_millis(1));
}
// All small_vecs are freed here automatically

// Track that we've deallocated the memory
deallocated.fetch_add(size + 1000 * rng.gen_range(100..1000), Ordering::Relaxed);

// Create occasional large memory pressure
if local_counter % 20 == 0 {
let temp_large_arrays = vec![vec![0u8; 10 * 1024 * 1024]; 5]; // 5 arrays of 10 MB each
// This memory is immediately freed when temp_large_arrays goes out of scope
thread::sleep(Duration::from_millis(10));
}

// Allow other threads to run
thread::sleep(Duration::from_millis(50));
}
}

Key Observations in the Rust Code:

  1. Scoped Memory: The code explicitly uses scopes {} to control when memory is freed:

    {
    // Memory allocated here.
    let _large_vec = vec![0u8; size];
    // .work with the memory.
    }
    // Memory is automatically freed HERE, at end of scope
  2. Immediate Cleanup: Unlike .NET, memory is freed as soon as variables go out of scope, not when a garbage collector decides to run.

  3. Memory Tracking: The code explicitly tracks both allocated and deallocated memory, demonstrating Rust's deterministic memory management.

  4. Explicit Memory Spikes: The code creates temporary memory spikes (similar to .NET), but in Rust, these spikes are immediately cleaned up when they go out of scope.

  5. Stable Memory Usage: Running this program shows that memory usage remains relatively stable despite high allocation throughput.

Side-by-Side Comparison

When running both examples, here are the key differences you would observe:

Memory Growth Pattern

  • .NET: Memory usage tends to grow in a sawtooth pattern:

    • Gradual increase as objects are allocated
    • Sharp drops when garbage collection occurs
    • Overall trend typically shows growth over time, especially with long-lived objects
    • When memory pressure increases, the GC works harder
  • Rust: Memory usage tends to remain stable:

    • Memory is reclaimed immediately when it goes out of scope
    • No sawtooth pattern as seen in garbage-collected systems
    • Memory spikes are quickly resolved
    • Overall memory usage correlates more directly with actual program needs

Memory Overhead

  • .NET:

    • Requires additional memory for GC bookkeeping
    • Memory remains allocated until collected (retention)
    • Memory fragmentation can increase overhead
    • Large Object Heap can lead to additional fragmentation
  • Rust:

    • No garbage collector overhead
    • Memory is freed immediately when no longer needed
    • Better memory locality due to stack allocations where possible
    • Less fragmentation due to deterministic cleanup

Application Pauses

  • .NET:

    • Noticeable pauses during Gen2 (full) collections
    • Pause duration increases with heap size
    • Background GC helps but doesn't eliminate pauses
    • Can affect application responsiveness
  • Rust:

    • No GC pauses
    • Memory operations are distributed throughout normal program execution
    • More consistent, predictable performance

Total Memory Allocation

Both programs allocate similar amounts of memory in terms of raw allocations, but:

  • .NET retains more memory at any given time due to deferred collection
  • Rust shows higher memory throughput (allocate/deallocate cycles) with lower retention

Real-World Impact

Resource Usage Differences

In production scenarios, the differences between Rust and .NET can be substantial:

MetricTypical Rust Advantage
Memory Usage30-70% less memory consumption
CPU Usage10-30% reduction in CPU utilization
Energy Consumption20-50% less energy usage
Server Density1.5-3x more instances per machine

These differences compound at scale. Companies like Dropbox, Discord, and Microsoft have reported significant infrastructure savings after rewriting components in Rust.

Case Studies

  1. Discord:

    • Rewrote their message service from Go to Rust
    • Reduced memory usage from 5GB to 2GB
    • CPU usage dropped from 10% to 1%
    • Eliminated latency spikes from GC pauses
  2. Dropbox:

    • Rewrote file sync engine in Rust
    • Reduced memory usage by 50%
    • Improved performance consistency
    • Better resource utilization on client devices
  3. Microsoft:

    • Using Rust for security-critical and performance-critical components
    • Reported better resource utilization and fewer bugs
    • Eliminates entire classes of memory safety issues
  4. Datadog:

    • Rewrote their AWS Lambda Extension in Rust
    • Better performance and impact on overall function performance in a resource constrainted environment

Conclusion

Rust's approach to memory management represents a fundamental advantage for resource-constrained environments and applications where efficiency matters. The examples we've analyzed demonstrate how Rust's deterministic memory management leads to:

  1. Lower and more stable memory usage
  2. Elimination of garbage collection pauses
  3. More predictable performance
  4. Better resource utilization

For .NET developers, understanding these differences is crucial when evaluating when to use Rust for parts of your system that would benefit from these efficiency improvements.

While .NET continues to improve its memory efficiency, the fundamental design differences mean Rust will likely maintain advantages in scenarios where resource usage is critical. The ideal approach is often to combine both technologies: .NET for rapid development and areas where its ecosystem shines, and Rust for performance-critical, resource-sensitive components.

This combination allows you to develop sustainable applications that minimize environmental impact while maintaining productivity and reliability.