Files
JuliaFEM.jl/docs/book/adr-005-element-integration-point-indices.md
T
Jukka Aho e2917cdba8 docs: Add ADR-005 on integration point indices architecture
Document decision to store integration point indices instead of data in Element struct.
Key rationale: Elements should store relationships (indices), not data, for memory
efficiency and consistency with node connectivity pattern. Aligns with nodal assembly
approach and GPU-friendly architecture.
2025-11-12 00:49:17 +02:00

7.2 KiB
Raw Blame History

title, date, author, status, tags
title date author status tags
ADR-005: Integration Point Indices Instead of Data 2025-11-11 Jukka Aho Accepted
architecture
elements
integration-points
material-state

Status

Accepted (November 11, 2025)

Context

Current Element struct stores integration points as data:

struct Element{N,NIP,F,B} <: AbstractElement{F,B}
    id::UInt
    connectivity::NTuple{N,UInt}           # Node IDs
    integration_points::NTuple{NIP,IP}     # ← Integration point DATA
    fields::F
    basis::B
end

Problems with this approach:

  1. Redundancy: All elements of the same type have identical integration points

    • Seg2 elements all use same 2-point Gauss quadrature
    • Tri3 elements all use same 3-point triangle quadrature
    • Storing this in every element wastes memory
  2. Inconsistent with connectivity pattern: Connectivity stores indices (node IDs), not node data

    • We don't store node coordinates in element
    • We store node IDs and look up coordinates elsewhere
  3. Inconsistent with material state design: Material state is stored separately

    • Material state per integration point stored in global vector
    • Element needs indices to find its material state
    • Current design has no way to link element to material state
  4. Assembly workflow needs indices:

    for element in elements
        node_ids = element.connectivity  # Get node indices
        node_coords = X[node_ids]        # Look up coordinates
    
        # What about material state?
        # Need: ip_ids = element.ip_indices  # Get IP indices
        #       mat_state = material_states[ip_ids]  # Look up state
    

Decision

Store integration point INDICES instead of DATA in elements.

New Element Structure

struct Element{N,NIP,F,B} <: AbstractElement{F,B}
    id::UInt
    connectivity::NTuple{N,UInt}        # Node IDs (indices)
    ip_indices::NTuple{NIP,UInt}        # ← Integration point IDs (indices)
    fields::F
    basis::B
end

Global Integration Point Registry

Integration points are computed once per topology+order combination:

# Precompute integration points for each topology type
const INTEGRATION_POINTS = Dict{Tuple{Type,Int}, Vector{Tuple{Float64,Vec}}}(
    (Triangle, 1) => get_gauss_points!(Triangle, Gauss{1}),
    (Triangle, 2) => get_gauss_points!(Triangle, Gauss{2}),
    (Quadrilateral, 2) => get_gauss_points!(Quadrilateral, Gauss{2}),
    # ... etc
)

# Material state stored separately (one entry per integration point)
struct MaterialState
    stress::SymmetricTensor{2,3}
    strain::SymmetricTensor{2,3}
    plastic_strain::SymmetricTensor{2,3}
    # ... etc
end

material_states::Vector{MaterialState} = [MaterialState(...) for _ in 1:n_total_ips]

Assembly Workflow

# Element creation (during mesh setup)
element_id = 1
node_ids = (1, 2, 3)  # Triangle nodes
ip_start = 1000       # First IP for this element
ip_indices = (ip_start, ip_start+1, ip_start+2)  # 3 IPs for Tri3
element = Element(Triangle, node_ids, ip_indices=ip_indices)

# Assembly loop
for element in elements
    # Get node data
    node_ids = element.connectivity
    X = node_coords[node_ids]      # Coordinates
    u = displacements[node_ids]    # Displacements
    
    # Get integration points (from global registry)
    topology = typeof(element.basis).parameters[1]  # Triangle
    order = typeof(element.basis).parameters[2]     # 1
    ips = INTEGRATION_POINTS[(topology, order)]
    
    # Get material state (from global vector)
    ip_ids = element.ip_indices
    mat_states = material_states[ip_ids]
    
    # Assembly loop over integration points
    for (i, (w, ξ)) in enumerate(ips)
        mat_state = mat_states[i]
        
        # Compute strain from displacements
        ε = compute_strain(element, u, ξ)
        
        # Compute stress from material state
        σ = compute_stress(mat_state, ε)
        
        # Assemble...
    end
end

Consequences

Positive

  1. Memory efficiency:

    • Old: 1M Tet10 elements × 4 IPs × 32 bytes = 128 MB for IP data
    • New: 1 global table × 4 IPs × 32 bytes = 128 bytes (1000× reduction!)
  2. Consistency: Same pattern for all element data

    • Nodes: Store indices, look up data
    • Integration points: Store indices, look up data
    • Material state: Store indices, look up data
  3. Clear data ownership:

    • Node coordinates: Owned by mesh/node array
    • Integration points: Owned by global registry
    • Material state: Owned by material state vector
    • Element: Only owns indices to these
  4. GPU-friendly:

    • All material states in one contiguous array (easy to transfer)
    • All integration point data in global tables (transfer once)
    • Elements are small (just indices)
  5. Natural partitioning:

    • Element ownership → node ownership (already clear)
    • Integration point ownership → element ownership (via indices)
    • Clear for domain decomposition / MPI

Negative

  1. API change: Existing code expects element.integration_points

    • Mitigation: Provide compatibility function get_integration_points(element)
  2. Index management: Need to assign IP indices during mesh setup

    • Mitigation: Automatic during element creation
  3. Lookup overhead: Extra indirection to get IP data

    • Mitigation: Negligible (one array lookup), data still cache-friendly

Implementation Plan

Phase 1: Add ip_indices field (keep integration_points for compatibility)

struct Element{N,NIP,F,B}
    id::UInt
    connectivity::NTuple{N,UInt}
    integration_points::NTuple{NIP,IP}  # DEPRECATED, keep temporarily
    ip_indices::NTuple{NIP,UInt}        # NEW
    fields::F
    basis::B
end

Phase 2: Update assembly code to use indices

  • Update assemble! functions to use element.ip_indices
  • Look up IP data from global registry
  • Look up material state from global vector

Phase 3: Remove integration_points field

struct Element{N,NIP,F,B}
    id::UInt
    connectivity::NTuple{N,UInt}
    ip_indices::NTuple{NIP,UInt}  # Only indices remain
    fields::F
    basis::B
end

Rationale

Core principle: Elements should store relationships (indices), not data.

Just as we don't store node coordinates in elements, we shouldn't store integration point data. The element's job is to define which nodes and which integration points participate in its stiffness matrix, not to own their data.

This aligns perfectly with the nodal assembly approach documented in docs/src/book/multigpu_nodal_assembly.md:

  • Nodes own displacement DOFs
  • Integration points own material state
  • Elements define relationships between them

References

  • Golden Standard: docs/src/book/multigpu_nodal_assembly.md - Nodal assembly architecture
  • ADR-004: Integration Points API Design
  • Material State Design: llm/FIELDS_DESIGN.md - Field system redesign

Notes

This decision was made during test migration (November 11, 2025) when updating Dirichlet tests to use immutable Element API. The question arose: "Why does each element store integration points when they're identical for all elements of the same type?"

The answer: They shouldn't. This ADR documents the correct architecture.