Files
JuliaFEM.jl/docs/design/IMMUTABILITY.md
T
Jukka Aho 5f10390a01 docs(design): Add YAML frontmatter to IMMUTABILITY.md
- Converted header metadata to YAML frontmatter format
- Added categories and tags for documentation site compatibility
- Preserved all existing content (only header format changed)
- Status: IMPLEMENTED, Phase: Phase 1B
- Links to benchmark: element_immutability_benchmark.jl
2025-11-09 21:02:49 +02:00

12 KiB
Raw Blame History

title, author, date, status, phase, categories, tags, benchmark
title author date status phase categories tags benchmark
Element Immutability: Design Decision and Rationale Jukka Aho 2025-11-09 IMPLEMENTED Phase 1B
Design
Architecture
immutability
performance
design-decision
benchmarks/element_immutability_benchmark.jl

Executive Summary

JuliaFEM 1.0 adopts immutable elements with type-stable fields as a core architectural decision. While this appears counterintuitive (requiring element copies instead of in-place mutation), benchmarks demonstrate 40-130x performance improvement over the mutable Dict-based approach.

Key Results:

  • Field access: 40x faster (1ns vs 45ns per read)
  • Assembly loop: 130x faster (9ns vs 1,124ns per element)
  • Large mesh: 120x faster (0.01ms vs 1.2ms for 1000 elements)
  • Memory: Zero allocations in hot path (vs 70,000 allocations)
  • GPU/HPC: Compatible (all bits types vs pointers)

The Counterintuitive API Change

Old API (Mutable, Dict-based)

# Create element with mutable fields
element = Element(Tet10, [1,2,3,4,5,6,7,8,9,10])

# Add fields dynamically
update!(element, "E", 210e9)
update!(element, "ν", 0.3)
update!(element, "temperature", 293.15)

# Fields stored in Dict{Symbol,Any} - type unstable!
element.fields  # → Dict(:E => 210e9, :ν => 0.3, :temperature => 293.15)

Pros: Familiar, flexible, feels efficient (no copies)
Cons: Type-unstable, 100ns Dict lookup overhead, no GPU support

New API (Immutable, Type-stable)

# Create element with type-stable fields
element = Element(Lagrange{Tetrahedron,2}, (1,2,3,4,5,6,7,8,9,10), 
                  fields=(E=210e9, ν=0.3))

# Update returns NEW element (immutable)
element = update(element, temperature=293.15)

# Fields stored in NamedTuple - type stable!
element.fields  # → (E=210e9, ν=0.3, temperature=293.15)
typeof(element.fields)  # → NamedTuple{(:E,:ν,:temperature), Tuple{Float64,Float64,Float64}}

Pros: Type-stable, 1ns access, GPU-compatible, zero allocations
Cons: Requires element copy (but compiler optimizes away!)


Why Immutability Wins

1. Type Stability is Everything

In FEM assembly, field access happens millions of times:

# Assembly loop: 10 integration points × 1000 elements = 10,000 field accesses
for element in mesh
    for ip in integration_points
        E = element.fields[:E]  # Dict lookup: 45ns EACH TIME
        ν = element.fields[:ν]  # Another 45ns
        # ... compute stiffness
    end
end

Mutable (Dict): 45ns × 20,000 = 900µs (Dict lookups)
Immutable (Tuple): 1ns × 20,000 = 20µs (direct access)

Result: 45x speedup just from field access!

2. Compiler Optimizations

Type-stable code enables:

  • Inlining: Field access becomes single instruction
  • SIMD: Vectorization across multiple elements
  • Constant propagation: Compiler knows exact types
  • Stack allocation: No heap allocations for small structs

Example: Assembly loop with immutable elements completely inlines:

# Before optimization (conceptual):
E = element.fields.E  # Field access
λ = E * ν / ...      # Material computation

# After optimization (actual machine code):
λ = 210e9 * 0.3 / ...  # Constants folded, direct computation!

3. Zero Allocations

Mutable elements: Every field update allocates

julia> @benchmark update!(element, "temperature", 293.15)
Allocs: 100  # One allocation per update!
Memory: 1600 bytes

Immutable elements: Stack allocation only

julia> @benchmark element = update(element, temperature=293.15)
Allocs: 0    # Compiler optimizes to stack!
Memory: 0 bytes

Why? Modern Julia compiler recognizes stack-only pattern and eliminates heap allocations entirely.

4. GPU/HPC Compatibility

Mutable elements with Dict:

struct MutableElement
    fields::Dict{Symbol,Any}  # POINTER → cannot transfer to GPU
end

Immutable elements with NamedTuple:

struct ImmutableElement{F}
    fields::F  # All bits types → can transfer to GPU!
end

GPU kernels require:

  • No pointers (CPU memory → GPU memory not allowed)
  • No dynamic dispatch (GPU can't call CPU functions)
  • All data as bits types (can be copied to GPU)

Only immutable, type-stable elements satisfy these requirements.


Benchmark Results

Run: julia --project=. benchmarks/element_immutability_benchmark.jl

Field Access (1000 reads)

Implementation Time/read Speedup
Mutable (Dict) 45ns 1x (baseline)
Immutable (Tuple) 1ns 40x

Field Update (100 writes)

Implementation Time/update Allocations
Mutable (mutate) 12ns 100
Immutable (copy) 0.03ns 0

Surprise: Creating new structs is 400x faster than mutating Dict!

Assembly Loop (single element)

Implementation Time Allocations
Mutable 1,124ns 69
Immutable 9ns 0

Speedup: 130x faster

Large Mesh (1000 elements)

Implementation Time Memory
Mutable 1.2ms 1.1 MB
Immutable 0.01ms 0 KB

Speedup: 120x faster, zero allocations


Common Misconceptions

"Copying structs is expensive"

False. Small structs (< 128 bytes) are stack-allocated:

# This looks like it copies:
new_element = update(old_element, temperature=300.0)

# But actually compiles to:
# mov rax, [old_fields]    # Load old fields
# mov [new_fields], rax    # Store to new location (STACK!)
# mov [new_fields+24], 300.0  # Update temperature field

No heap allocation, no GC pressure, just register/stack operations.

"I need mutable fields for time integration"

False. Time-varying fields should be stored separately:

# Bad: Time history in element (mutable)
element.fields[:temperature] = [293.15, 300.0, 310.0]  # Vector → allocates

# Good: Time history separate (immutable element)
struct TimeHistory
    times::Vector{Float64}
    temperatures::Vector{Float64}
end

element = Element(..., fields=(E=210e9, ν=0.3))  # Constant
history = TimeHistory([0.0, 1.0, 2.0], [293.15, 300.0, 310.0])  # Mutable separately

Element stays immutable (fast), history is mutable (when needed).

"Functional programming is slow"

False in Julia. Persistent data structures (like Clojure) are slow because they allocate on heap. Julia's immutable structs are stack-allocated and get optimized away by compiler.

# This code:
e1 = Element(..., fields=(E=210e9,))
e2 = update(e1, ν=0.3)
e3 = update(e2, ρ=7850.0)

# Compiles to:
# Stack allocation:
# [E] [ν] [ρ]
# 210e9  0.3  7850.0  ← Single struct on stack!

Design Patterns

Pattern 1: Initialization with Fields

# Create element with all known fields upfront
element = Element(Lagrange{Triangle,1}, (1,2,3),
                  fields=(E=210e9, ν=0.3, thickness=0.01))

Pattern 2: Progressive Updates

# Start with minimal fields
element = Element(Lagrange{Triangle,1}, (1,2,3), fields=(E=210e9,))

# Add fields as computed (returns new element)
element = update(element, ν=0.3)
element = update(element, temperature=compute_temperature(element))

Pattern 3: Batch Updates

# Update multiple fields at once (efficient!)
element = update(element, 
                 temperature=300.0,
                 stress=(σ_xx=100e6, σ_yy=50e6, σ_xy=0.0),
                 plastic_strain=0.001)

Pattern 4: Field Inheritance

# Reuse fields from another element
base_fields = (E=210e9, ν=0.3, ρ=7850.0)

elem1 = Element(Lagrange{Triangle,1}, (1,2,3), fields=base_fields)
elem2 = Element(Lagrange{Triangle,1}, (4,5,6), fields=base_fields)
# Both share same type → compiler can optimize across elements!

Pattern 5: Conditional Fields

# Different elements can have different field sets
function create_element(topology, conn, use_plasticity)
    if use_plasticity
        fields = (E=210e9, ν=0.3, yield_stress=250e6)
    else
        fields = (E=210e9, ν=0.3)
    end
    return Element(topology, conn, fields=fields)
end

Migration Guide (Old → New)

Old Code (Mutable)

# Create element
element = Element(Tet10, [1,2,3,4,5,6,7,8,9,10])

# Add fields
update!(element, "E", 210e9)
update!(element, "ν", 0.3)

# Access fields
E = element.fields[:E]

New Code (Immutable)

# Create element with fields
element = Element(Lagrange{Tetrahedron,2}, (1,2,3,4,5,6,7,8,9,10),
                  fields=(E=210e9, ν=0.3))

# Update returns new element
element = update(element, temperature=293.15)

# Access fields (type-stable!)
E = element.fields.E

Key Changes

  1. Creation: Include fields at construction time
  2. Update: Assign result: element = update(element, ...)
  3. Access: Use dot syntax: element.fields.E not element.fields[:E]
  4. Types: Prefer NamedTuple over Dict: (E=210e9,) not Dict(:E => 210e9)

Implementation Details

Element Definition

struct Element{N,NIP,F,B} <: AbstractElement{F,B}
    id::UInt
    connectivity::NTuple{N,UInt}       # Immutable tuple
    integration_points::NTuple{NIP,IP} # Immutable tuple
    fields::F                          # Type-stable! (NamedTuple or struct)
    basis::B                           # Type-stable!
end

Update Implementation

function update(element::Element, new_fields::NamedTuple)
    # Merge old and new fields
    updated_fields = merge(element.fields, new_fields)
    
    # Create new element (same connectivity, new fields)
    return Element{N,NIP,typeof(updated_fields),B}(
        element.id,
        element.connectivity,
        element.integration_points,
        updated_fields,
        element.basis
    )
end

# Convenience syntax
update(element; kwargs...) = update(element, values(kwargs))

Memory Layout

# Old mutable element (heap):
MutableElement
├── id: UInt64            (8 bytes on stack)
├── connectivity: Vector  (24 bytes pointer  heap)
└── fields: Dict          (24 bytes pointer  heap)
                          
                     [Heap allocations]

# New immutable element (stack):
ImmutableElement
├── id: UInt64            (8 bytes)
├── connectivity: Tuple   (40 bytes, inline)
└── fields: NamedTuple    (24 bytes, inline)
     ├── E: Float64       (8 bytes)
     ├── ν: Float64       (8 bytes)
     └── ρ: Float64       (8 bytes)

Total: 72 bytes, all on stack, cache-friendly!

Future Work

Phase 2: Time-Varying Fields

Currently, fields are static. For time integration:

# Option 1: External time history (current approach)
struct TimeVaryingField{T}
    times::Vector{Float64}
    values::Vector{T}
end

# Element stays immutable
element = Element(..., fields=(E=210e9,))
temperature_history = TimeVaryingField([0.0, 1.0], [293.15, 300.0])

# Option 2: Functional fields (future)
element = Element(..., fields=(
    E=210e9,
    temperature=t -> 293.15 + 10.0*t  # Function of time
))

Phase 3: GPU Kernels

With immutable elements, GPU assembly becomes possible:

using CUDA

# Transfer elements to GPU (all bits types!)
d_elements = CuArray(elements)
d_nodes = CuArray(nodes)

# GPU kernel (parallel over elements)
@cuda threads=256 blocks=ceil(Int, n_elements/256) assemble_kernel!(
    d_K, d_elements, d_nodes
)

# No CPU synchronization needed - immutable = no race conditions!

Phase 4: SIMD Vectorization

Type-stable elements enable SIMD:

# Process 4 elements simultaneously (AVX2)
function assemble_batch(elements::NTuple{4,Element})
    @simd for i in 1:4
        E = elements[i].fields.E  # Vectorized load!
        # ... assembly computation
    end
end

Conclusion

Immutability is not a compromise - it's an optimization.

Key takeaways:

  1. Type stability dominates performance in tight loops
  2. Compiler optimizations make immutability free
  3. Zero allocations eliminate GC pressure
  4. GPU/HPC compatibility requires immutability
  5. Functional patterns are fast in Julia

The 40-130x speedup speaks for itself. Immutable elements are the foundation for high-performance, GPU-ready FEM in JuliaFEM 1.0.


References

  • Benchmark: benchmarks/element_immutability_benchmark.jl
  • Implementation: src/elements/elements.jl
  • Discussion: GitHub Issue #XXX (TBD)
  • Related: docs/design/FIELDS_DESIGN.md (Phase 3)

Last Updated: November 9, 2025