perf(benchmark): Add CPU nodal assembly scalability benchmark

New benchmark testing nodal assembly performance on CPU:
- 438 lines implementing three execution modes
- Single-threaded baseline (reference performance)
- Multi-threaded using @threads (measures scaling efficiency)
- Partitioned mode (simulates multi-GPU with explicit partitions)

Features:
- Hex8 mesh generation (structured hexahedral elements)
- Node-to-element inverse connectivity building
- Mesh partitioning with ghost nodes and interface detection
- Performance metrics: throughput (Mnodes/s), speedup, efficiency
- Correctness verification (compares results to baseline)

Test mesh sizes: 20³, 40³, 60³ (8K to 216K nodes)
Measures: execution time, speedup vs baseline, parallel efficiency
Interface overhead calculation for partitioned mode

Run with: julia --project=. -t 8 benchmarks/nodal_assembly_scalability.jl
This commit is contained in:
Jukka Aho
2025-11-09 16:04:39 +02:00
parent 81f4f85f3e
commit d676ab3bba
+438
View File
@@ -0,0 +1,438 @@
#!/usr/bin/env julia
#
# Nodal Assembly Scalability Benchmark
#
# Tests:
# 1. Single-threaded baseline
# 2. Multi-threaded scaling (2, 4, 8 threads)
# 3. Actual speedup measurements
# 4. Interface communication overhead
#
# Run with: julia --project=. -t 8 benchmarks/nodal_assembly_scalability.jl
using LinearAlgebra
using Printf
using Base.Threads
println("="^70)
println("Nodal Assembly Scalability Benchmark")
println("="^70)
println()
println("Julia threads available: ", nthreads())
println()
# ============================================================================
# Data Structures
# ============================================================================
struct Node
id::Int
x::Float64
y::Float64
z::Float64
end
struct Element
id::Int
connectivity::NTuple{8,Int} # Hex8
end
struct Partition
rank::Int
owned_nodes::UnitRange{Int}
ghost_nodes::Vector{Int}
local_elements::Vector{Int}
node_to_elements::Vector{Vector{Int}}
interface_nodes::Dict{Int,Vector{Int}}
end
# ============================================================================
# Mesh Generation
# ============================================================================
function create_hex_mesh(nx, ny, nz)
"""Create structured hexahedral mesh"""
n_nodes = nx * ny * nz
n_elements = (nx - 1) * (ny - 1) * (nz - 1)
# Create nodes
nodes = Node[]
for k in 1:nz, j in 1:ny, i in 1:nx
node_id = (k - 1) * nx * ny + (j - 1) * nx + i
push!(nodes, Node(node_id, Float64(i), Float64(j), Float64(k)))
end
# Create elements (Hex8)
elements = Element[]
for k in 1:(nz-1), j in 1:(ny-1), i in 1:(nx-1)
n1 = (k - 1) * nx * ny + (j - 1) * nx + i
n2 = n1 + 1
n3 = n2 + nx
n4 = n1 + nx
n5 = n1 + nx * ny
n6 = n2 + nx * ny
n7 = n3 + nx * ny
n8 = n4 + nx * ny
elem_id = length(elements) + 1
push!(elements, Element(elem_id, (n1, n2, n3, n4, n5, n6, n7, n8)))
end
return nodes, elements
end
function build_node_to_elements(nodes, elements)
"""Build inverse connectivity"""
node_to_elems = [Int[] for _ in 1:length(nodes)]
for (elem_id, element) in enumerate(elements)
for node_id in element.connectivity
push!(node_to_elems[node_id], elem_id)
end
end
return node_to_elems
end
# ============================================================================
# Partitioning
# ============================================================================
function partition_mesh(nodes, elements, n_partitions)
"""Partition mesh by nodes"""
n_nodes = length(nodes)
nodes_per_partition = ceil(Int, n_nodes / n_partitions)
node_to_elems = build_node_to_elements(nodes, elements)
partitions = Partition[]
for rank in 0:(n_partitions-1)
# Owned nodes
start_node = rank * nodes_per_partition + 1
end_node = min((rank + 1) * nodes_per_partition, n_nodes)
owned_nodes = start_node:end_node
# Find elements touching owned nodes
local_elements = Int[]
ghost_nodes = Set{Int}()
for (elem_id, element) in enumerate(elements)
if any(nid in owned_nodes for nid in element.connectivity)
push!(local_elements, elem_id)
# Mark ghost nodes
for nid in element.connectivity
if !(nid in owned_nodes)
push!(ghost_nodes, nid)
end
end
end
end
# Build local node_to_elements (owned nodes only)
local_node_to_elems = [node_to_elems[nid] for nid in owned_nodes]
# Find interface nodes (owned nodes that couple to other partitions)
interface = Dict{Int,Vector{Int}}()
for neighbor_rank in 0:(n_partitions-1)
if neighbor_rank == rank
continue
end
neighbor_start = neighbor_rank * nodes_per_partition + 1
neighbor_end = min((neighbor_rank + 1) * nodes_per_partition, n_nodes)
neighbor_owned = neighbor_start:neighbor_end
interface_with_neighbor = Int[]
for elem_id in local_elements
element = elements[elem_id]
has_owned = any(nid in owned_nodes for nid in element.connectivity)
has_neighbor = any(nid in neighbor_owned for nid in element.connectivity)
if has_owned && has_neighbor
for nid in element.connectivity
if nid in owned_nodes && !(nid in interface_with_neighbor)
push!(interface_with_neighbor, nid)
end
end
end
end
if !isempty(interface_with_neighbor)
interface[neighbor_rank] = interface_with_neighbor
end
end
partition = Partition(
rank,
owned_nodes,
collect(ghost_nodes),
local_elements,
local_node_to_elems,
interface
)
push!(partitions, partition)
end
return partitions
end
# ============================================================================
# Nodal Assembly (Matrix-Vector Product)
# ============================================================================
function matvec_single_threaded!(y, x, nodes, elements, node_to_elements)
"""Single-threaded nodal assembly"""
fill!(y, 0.0)
for node_id in 1:length(nodes)
node = nodes[node_id]
# Get node DOFs
dof_start = (node_id - 1) * 3 + 1
y_nodal = zeros(3)
# Gather from connected elements
for elem_id in node_to_elements[node_id]
element = elements[elem_id]
# Mock element contribution (just for timing)
for nid in element.connectivity
x_dof_start = (nid - 1) * 3 + 1
for d in 1:3
y_nodal[d] += 0.1 * x[x_dof_start+d-1]
end
end
end
# Write to global
for d in 1:3
y[dof_start+d-1] = y_nodal[d]
end
end
end
function matvec_multi_threaded!(y, x, nodes, elements, node_to_elements)
"""Multi-threaded nodal assembly (direct, no partitioning)"""
fill!(y, 0.0)
@threads for node_id in 1:length(nodes)
node = nodes[node_id]
dof_start = (node_id - 1) * 3 + 1
y_nodal = zeros(3)
for elem_id in node_to_elements[node_id]
element = elements[elem_id]
for nid in element.connectivity
x_dof_start = (nid - 1) * 3 + 1
for d in 1:3
y_nodal[d] += 0.1 * x[x_dof_start+d-1]
end
end
end
for d in 1:3
y[dof_start+d-1] = y_nodal[d]
end
end
end
function matvec_partitioned!(y, x, nodes, elements, partitions)
"""Multi-threaded with explicit partitioning (simulates multi-GPU)"""
fill!(y, 0.0)
# Each partition processed by one thread
@threads for partition in partitions
# Process owned nodes
for (local_idx, node_id) in enumerate(partition.owned_nodes)
node = nodes[node_id]
dof_start = (node_id - 1) * 3 + 1
y_nodal = zeros(3)
for elem_id in partition.node_to_elements[local_idx]
element = elements[elem_id]
for nid in element.connectivity
x_dof_start = (nid - 1) * 3 + 1
for d in 1:3
y_nodal[d] += 0.1 * x[x_dof_start+d-1]
end
end
end
for d in 1:3
y[dof_start+d-1] = y_nodal[d]
end
end
end
end
# ============================================================================
# Benchmarks
# ============================================================================
function run_benchmark(name, nx, ny, nz, n_warmup=2, n_runs=10)
println("\n" * "="^70)
println("Benchmark: $name")
println(" Mesh: $nx × $ny × $nz = $(nx*ny*nz) nodes, $((nx-1)*(ny-1)*(nz-1)) elements")
println("="^70)
# Create mesh
print("Creating mesh... ")
nodes, elements = create_hex_mesh(nx, ny, nz)
node_to_elements = build_node_to_elements(nodes, elements)
n_dofs = 3 * length(nodes)
println("")
println(" Nodes: ", length(nodes))
println(" Elements: ", length(elements))
println(" DOFs: ", n_dofs)
println(" Avg elements/node: ", sum(length.(node_to_elements)) / length(nodes))
# Test vectors
x = randn(n_dofs)
y_ref = zeros(n_dofs)
y_test = zeros(n_dofs)
# ========================================================================
# 1. Single-threaded baseline
# ========================================================================
println("\n1. Single-threaded baseline:")
# Warmup
for _ in 1:n_warmup
matvec_single_threaded!(y_ref, x, nodes, elements, node_to_elements)
end
# Benchmark
times = Float64[]
for _ in 1:n_runs
t_start = time_ns()
matvec_single_threaded!(y_ref, x, nodes, elements, node_to_elements)
t_end = time_ns()
push!(times, (t_end - t_start) / 1e9)
end
t_single = minimum(times)
println(" Time: ", @sprintf("%.3f ms", t_single * 1000))
println(" Throughput: ", @sprintf("%.2f Mnodes/s", length(nodes) / t_single / 1e6))
# ========================================================================
# 2. Multi-threaded (all available threads)
# ========================================================================
if nthreads() > 1
println("\n2. Multi-threaded ($(nthreads()) threads):")
# Warmup
for _ in 1:n_warmup
matvec_multi_threaded!(y_test, x, nodes, elements, node_to_elements)
end
# Verify correctness
error = norm(y_test - y_ref) / norm(y_ref)
println(" Verification: ", error < 1e-10 ? "✓ PASS" : "✗ FAIL (error=$error)")
# Benchmark
times = Float64[]
for _ in 1:n_runs
t_start = time_ns()
matvec_multi_threaded!(y_test, x, nodes, elements, node_to_elements)
t_end = time_ns()
push!(times, (t_end - t_start) / 1e9)
end
t_multi = minimum(times)
speedup = t_single / t_multi
efficiency = speedup / nthreads() * 100
println(" Time: ", @sprintf("%.3f ms", t_multi * 1000))
println(" Speedup: ", @sprintf("%.2fx", speedup))
println(" Efficiency: ", @sprintf("%.1f%%", efficiency))
println(" Throughput: ", @sprintf("%.2f Mnodes/s", length(nodes) / t_multi / 1e6))
end
# ========================================================================
# 3. Partitioned (simulates multi-GPU)
# ========================================================================
if nthreads() >= 4
n_partitions = 4
println("\n3. Partitioned ($n_partitions partitions, 4 threads):")
# Create partitions
print(" Creating partitions... ")
partitions = partition_mesh(nodes, elements, n_partitions)
println("")
# Print partition info
for partition in partitions
n_owned = length(partition.owned_nodes)
n_ghost = length(partition.ghost_nodes)
n_interface = sum(length.(values(partition.interface_nodes)))
interface_pct = n_interface / n_owned * 100
println(" Partition $(partition.rank): $n_owned owned, $n_ghost ghost, " *
"$n_interface interface ($(round(interface_pct, digits=1))%)")
end
# Warmup
for _ in 1:n_warmup
matvec_partitioned!(y_test, x, nodes, elements, partitions)
end
# Verify correctness
error = norm(y_test - y_ref) / norm(y_ref)
println(" Verification: ", error < 1e-10 ? "✓ PASS" : "✗ FAIL (error=$error)")
# Benchmark
times = Float64[]
for _ in 1:n_runs
t_start = time_ns()
matvec_partitioned!(y_test, x, nodes, elements, partitions)
t_end = time_ns()
push!(times, (t_end - t_start) / 1e9)
end
t_partitioned = minimum(times)
speedup = t_single / t_partitioned
efficiency = speedup / n_partitions * 100
println(" Time: ", @sprintf("%.3f ms", t_partitioned * 1000))
println(" Speedup: ", @sprintf("%.2fx", speedup))
println(" Efficiency: ", @sprintf("%.1f%%", efficiency))
println(" Throughput: ", @sprintf("%.2f Mnodes/s", length(nodes) / t_partitioned / 1e6))
end
println()
end
# ============================================================================
# Run Benchmarks
# ============================================================================
# Small mesh
run_benchmark("Small Mesh", 20, 20, 20)
# Medium mesh
run_benchmark("Medium Mesh", 40, 40, 40)
# Large mesh (if enough threads)
if nthreads() >= 4
run_benchmark("Large Mesh", 60, 60, 60)
end
println("="^70)
println("✓ Benchmark Complete")
println("="^70)
println()
println("Notes:")
println(" - Single-threaded: Baseline performance")
println(" - Multi-threaded: All available threads, direct parallelization")
println(" - Partitioned: Simulates multi-GPU with explicit partitions")
println(" - Efficiency = Speedup / N_threads × 100%")
println(" - Near 100% efficiency = perfect scaling")
println()