Files
JuliaFEM.jl/test/assemblers/test_dof_based_zero_alloc.jl

239 lines
9.3 KiB
Julia
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# This file is a part of JuliaFEM.
# License is MIT: see https://github.com/JuliaFEM/JuliaFEM.jl/blob/master/LICENSE.md
"""
Regression test: DOF-based assembler must stay zero-allocation
and bit-equivalent to the element-based assembler.
This test exists to lock in the December 2025 zero-allocation work
and to catch any regression as the assembler evolves.
Each problem size:
1. Build a structured Hex8 / Tet4 mesh
2. Create elements + DOFManager
3. Build both assemblers
4. Warm up
5. Assert: 0 bytes allocated by the DOF-based `assemble!`
6. Assert: matrix matches the element-based assembler to round-off
"""
using Test
using JuliaFEM
using JuliaFEM: DOFBasedCOOAssembler, DOFBasedCOOCache
using JuliaFEM: COOAssembler, create_cache
using JuliaFEM: create_elements!, @DOFSet, DOF, Displacement, Vertex
using JuliaFEM: local_dof_layout, DOFLayoutEntry
using LinearAlgebra
using SparseArrays
using Tensors
using InteractiveUtils # @code_typed, code_llvm
# ----------------------------------------------------------------------------
# Helpers (kept local to this test, no leak into package)
# ----------------------------------------------------------------------------
"""
Build a structured Hex8 box mesh with `nx × ny × nz` elements over [0,1]^3.
"""
function _build_hex8_box_mesh(nx::Int, ny::Int, nz::Int)
nodes = Vec{3,Float64}[]
nidx(i, j, k) = (i - 1) + (j - 1) * (nx + 1) + (k - 1) * (nx + 1) * (ny + 1) + 1
for k in 1:(nz + 1), j in 1:(ny + 1), i in 1:(nx + 1)
push!(nodes, Vec{3}((Float64(i - 1) / nx,
Float64(j - 1) / ny,
Float64(k - 1) / nz)))
end
conns = NTuple{8,UInt32}[]
for k in 1:nz, j in 1:ny, i in 1:nx
n1 = nidx(i, j, k)
n2 = nidx(i + 1, j, k)
n3 = nidx(i + 1, j + 1, k)
n4 = nidx(i, j + 1, k)
n5 = nidx(i, j, k + 1)
n6 = nidx(i + 1, j, k + 1)
n7 = nidx(i + 1, j + 1, k + 1)
n8 = nidx(i, j + 1, k + 1)
push!(conns, (UInt32(n1), UInt32(n2), UInt32(n3), UInt32(n4),
UInt32(n5), UInt32(n6), UInt32(n7), UInt32(n8)))
end
return Mesh{8,Hexahedron{8}}(nodes, conns)
end
"""
Build a single Tet4 mesh.
"""
function _build_single_tet4_mesh()
nodes = Vec{3,Float64}[
Vec{3}((0.0, 0.0, 0.0)),
Vec{3}((1.0, 0.0, 0.0)),
Vec{3}((0.5, 1.0, 0.0)),
Vec{3}((0.5, 0.5, 1.0)),
]
conns = [(UInt32(1), UInt32(2), UInt32(3), UInt32(4))]
return Mesh{Tetrahedron{4}}(nodes, conns)
end
"""
Setup elasticity assembly fixture for a given mesh + topology.
Returns (cache_dof, asm_dof, cache_elem, asm_elem, kernel, mesh).
"""
function _setup_assembly(mesh, ::Type{Topo}) where {Topo}
material = LinearElastic(E = 210e9, ν = 0.3)
kernel = ContinuumKernel(ContinuumFormulation{FullThreeD}(),
material, Displacement{3}())
S = @DOFSet{u::DOF{Displacement{3}, Vertex}}
elements, dof_mgr = create_elements!(mesh, Element{Topo, Lagrange{1}, S})
asm_dof = DOFBasedCOOAssembler()
cache_dof = DOFBasedCOOCache(elements, dof_mgr, mesh, kernel)
asm_elem = COOAssembler()
cache_elem = create_cache(asm_elem, mesh, kernel)
return cache_dof, asm_dof, cache_elem, asm_elem, kernel, mesh
end
# ----------------------------------------------------------------------------
# Tests
# ----------------------------------------------------------------------------
@testset "DOF-based assembler: zero allocation regression" begin
println("\n" * "=" ^ 70)
println("DOF-BASED ASSEMBLER ZERO-ALLOCATION REGRESSION")
println("=" ^ 70)
# ------------------------------------------------------------------------
# 1. Single Tet4
# ------------------------------------------------------------------------
@testset "Single Tet4" begin
mesh = _build_single_tet4_mesh()
cache_dof, asm_dof, cache_elem, asm_elem, kernel, m =
_setup_assembly(mesh, Tetrahedron{4})
# warmup
assemble!(cache_dof, asm_dof, kernel, m)
assemble!(cache_elem, asm_elem, kernel, m)
K_dof, _ = extract_system(cache_dof)
K_elem, _ = extract_system(cache_elem)
# Equivalence
rel = maximum(abs, Matrix(K_dof) - Matrix(K_elem)) /
max(maximum(abs, Matrix(K_elem)), 1.0)
@test rel < 1e-12
# Zero-alloc
GC.gc()
a = @allocated assemble!(cache_dof, asm_dof, kernel, m)
@test a == 0
println(" Single Tet4 ........................ allocs=$a, rel=$(round(rel; sigdigits=3))")
end
# ------------------------------------------------------------------------
# 2. Growing Hex8 cube meshes
# ------------------------------------------------------------------------
@testset "Hex8 cube $(nx)×$(ny)×$(nz)" for (nx, ny, nz) in
[(1, 1, 1), (2, 1, 1), (4, 2, 2), (6, 3, 3), (8, 4, 4)]
mesh = _build_hex8_box_mesh(nx, ny, nz)
cache_dof, asm_dof, cache_elem, asm_elem, kernel, m =
_setup_assembly(mesh, Hexahedron{8})
# warmup
assemble!(cache_dof, asm_dof, kernel, m)
assemble!(cache_elem, asm_elem, kernel, m)
K_dof, _ = extract_system(cache_dof)
K_elem, _ = extract_system(cache_elem)
rel = maximum(abs, Matrix(K_dof) - Matrix(K_elem)) /
max(maximum(abs, Matrix(K_elem)), 1.0)
@test rel < 1e-12
GC.gc()
a = @allocated assemble!(cache_dof, asm_dof, kernel, m)
@test a == 0
nelems = length(m.connectivity)
ndofs = 3 * length(m.nodes)
println(" Hex8 $(nx)×$(ny)×$(nz) $(lpad(nelems,5)) elem " *
"$(lpad(ndofs,5)) dof allocs=$a rel=$(round(rel; sigdigits=3))")
end
end
# ----------------------------------------------------------------------------
# Type stability check (no untyped ::Any in inferred output)
# ----------------------------------------------------------------------------
@testset "DOF-based assembler: type stability" begin
mesh = _build_hex8_box_mesh(2, 1, 1)
cache_dof, asm_dof, _, _, kernel, m = _setup_assembly(mesh, Hexahedron{8})
assemble!(cache_dof, asm_dof, kernel, m) # warmup
# Inferred return type must be concrete (Nothing).
rt = Base.promote_op(assemble!, typeof(cache_dof), typeof(asm_dof),
typeof(kernel), typeof(m))
@test rt === Nothing
# No method ambiguities or `Any` in the top-level signature
code = code_typed(assemble!, (typeof(cache_dof), typeof(asm_dof),
typeof(kernel), typeof(m)); optimize = true)
@test !isempty(code)
info = code[1]
@test isconcretetype(info.second)
println(" assemble! inferred return type: $(info.second)")
end
# ----------------------------------------------------------------------------
# Element-as-template: local_dof_layout(E) is compile-time constant and
# `assemble!` has zero GC allocation sites in the optimized LLVM IR.
# ----------------------------------------------------------------------------
@testset "DOF-based assembler: element template + LLVM allocs" begin
mesh = _build_hex8_box_mesh(2, 1, 1)
cache_dof, asm_dof, _, _, kernel, m = _setup_assembly(mesh, Hexahedron{8})
assemble!(cache_dof, asm_dof, kernel, m) # warmup
# ------------------------------------------------------------------
# 1. local_dof_layout returns the expected NTuple of DOFLayoutEntry
# for an Hex8 displacement element (24 DOFs, 8 vertices × 3 comp.)
# ------------------------------------------------------------------
ET = eltype(cache_dof.elements)
layout = local_dof_layout(ET)
@test layout isa NTuple{24, DOFLayoutEntry}
@test all(e -> e.field_idx == 1, layout)
@test [Int(e.entity_local) for e in layout] ==
repeat(1:8; inner = 3)
@test [Int(e.component) for e in layout] ==
repeat(1:3, 8)
# ------------------------------------------------------------------
# 2. local_dof_layout(ET) call is constant-folded by the compiler:
# @allocated must be 0 and the optimized typed-IR must report a
# `Core.Const` for the layout in `assemble!` — exercised already
# by the zero-allocation tests above. We assert separately here.
# ------------------------------------------------------------------
GC.gc()
a_layout = @allocated local_dof_layout(ET)
@test a_layout == 0
# ------------------------------------------------------------------
# 3. Optimized LLVM IR for `assemble!` must have ZERO GC allocation
# sites. This is the strongest guarantee: even if @allocated == 0
# today, this catches any future change that would re-introduce
# a heap-allocating call inside the inner loop.
# ------------------------------------------------------------------
iob = IOBuffer()
code_llvm(iob, assemble!,
Tuple{typeof(cache_dof), typeof(asm_dof),
typeof(kernel), typeof(m)}; optimize = true)
ir = String(take!(iob))
n_alloc =
length(collect(eachmatch(r"call.*julia\.gc_alloc", ir))) +
length(collect(eachmatch(r"call.*jl_gc_pool_alloc", ir))) +
length(collect(eachmatch(r"call.*jl_gc_big_alloc", ir))) +
length(collect(eachmatch(r"call.*jl_gc_alloc_typed", ir)))
@test n_alloc == 0
println(" assemble! optimized LLVM IR: $n_alloc gc-alloc sites " *
"($(length(ir)) IR chars)")
end