- Design principle: users never see CPU/GPU differences
- Three-layer architecture: User API / Backend Abstraction / Implementations
- Auto() backend selection based on hardware availability
- Physics{ElasticityPhysicsType} as single problem type
- Internal conversion between CPU arrays and GPU arrays
- solve!() with automatic dispatch to CPU or GPU backend
- 611 lines: Complete architecture design proposal
14 KiB
title, date, author, status, last_updated, tags
| title | date | author | status | last_updated | tags | ||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| Backend-Transparent Architecture | 2025-11-11 | Jukka Aho | Design Proposal | 2025-11-11 |
|
Design Principle: Backend Transparency
Core principle: Users should never see or care whether computation happens on CPU or GPU.
What users write:
using JuliaFEM
# Create problem
physics = Physics(Elasticity, "beam", 3)
add_elements!(physics, elements)
add_dirichlet!(physics, nodes, components, values)
# Solve - backend chosen automatically!
solution = solve!(physics)
What happens behind the scenes:
- If
using CUDA+ GPU available → GPU backend (50-100× faster) - Otherwise → CPU backend with multithreading
- User code identical - no
solve_gpu!()vssolve_cpu!() - No GPU types in user API - no
GPUElasticityData,CuArray, etc.
Current Problems
1. GPU in Type Names
# ❌ BAD: Exposes implementation
struct GPUElasticityData
nodes::CuArray{Float64,2}
# ...
end
Problem: Name reveals backend. What about CPU? CPUElasticityData? Then user has two different types!
2. GPU in Function Names
# ❌ BAD: User must choose backend
solve_elasticity_gpu!(physics)
solve_elasticity_cpu!(physics)
Problem: User code must change based on hardware. Not portable!
3. GPU Types in Public API
# ❌ BAD: User sees CUDA types
function foo(data::GPUElasticityData)
nodes = data.nodes # CuArray!
end
Problem: Users must understand GPU programming. Violates abstraction!
Proposed Solution: Three-Layer Architecture
Layer 1: User API (Backend-Agnostic)
User-facing types and functions - NO backend details:
# Problem type (same for all backends)
physics = Physics(Elasticity, "problem_name", dimension)
# Add data (CPU arrays only - backend converts internally)
add_elements!(physics, elements::Vector{Element})
add_dirichlet!(physics, nodes::Vector{Int}, components::Vector{Int}, value)
add_neumann!(physics, surface_element::Element, traction::Vec)
# Solve (backend chosen automatically)
solution = solve!(physics; backend=Auto()) # or GPU(), CPU(4)
Key: All input/output uses CPU arrays. Backend conversion is internal.
Layer 2: Backend Abstraction
Abstract types defining interface:
# Backend selection
abstract type AbstractBackend end
struct Auto <: AbstractBackend end # Choose automatically
struct GPU <: AbstractBackend end # Force GPU (error if unavailable)
struct CPU <: AbstractBackend # Force CPU
nthreads::Int
end
# Internal data (not exposed to user)
abstract type AbstractElasticityData end
# Implementations (in package extensions)
struct ElasticityDataGPU <: AbstractElasticityData
nodes::CuArray{Float64,2}
# ... GPU-resident data
end
struct ElasticityDataCPU <: AbstractElasticityData
nodes::Matrix{Float64}
# ... CPU data with multithreading
end
Key: Abstract interface, concrete implementations in extensions.
Layer 3: Backend Implementations
GPU backend (in ext/JuliaFEMCUDAExt/):
module JuliaFEMCUDAExt
using JuliaFEM
using CUDA
# Only loaded if user has 'using CUDA'
struct ElasticityDataGPU <: AbstractElasticityData
# ... CuArray fields
end
function initialize_backend(::GPU, physics::Physics{Elasticity})
# Convert CPU data to GPU
return ElasticityDataGPU(...)
end
function solve_backend!(data::ElasticityDataGPU, physics)
# GPU kernels + CG solver
end
end # module
CPU backend (in ext/JuliaFEMThreadsExt/ or built-in):
struct ElasticityDataCPU <: AbstractElasticityData
nodes::Matrix{Float64}
elements::Matrix{Int32}
# ... CPU arrays
end
function initialize_backend(::CPU, physics::Physics{Elasticity})
# Keep data on CPU
return ElasticityDataCPU(...)
end
function solve_backend!(data::ElasticityDataCPU, physics)
# Threaded assembly + CG solver
end
Implementation: User API
Core solve function
"""
solve!(physics::Physics{Elasticity}; backend=Auto(), kwargs...)
Solve elasticity problem. Backend is chosen automatically unless specified.
# Arguments
- `physics`: Problem definition with elements and BCs
- `backend`: Backend selection (Auto(), GPU(), or CPU(nthreads))
- `kwargs...`: Solver options (tol, max_iter, etc.)
# Returns
- `solution`: Solution struct with displacement field
# Examples
```julia
# Automatic backend selection (GPU if available, else CPU)
sol = solve!(physics)
# Force GPU backend (errors if GPU unavailable)
sol = solve!(physics; backend=GPU())
# Force CPU with 8 threads
sol = solve!(physics; backend=CPU(8))
""" function solve!(physics::Physics{Elasticity}; backend::AbstractBackend=Auto(), tol=1e-6, max_iter=1000)
# 1. Choose backend
backend_impl = select_backend(backend)
# 2. Initialize backend-specific data
data = initialize_backend(backend_impl, physics)
# 3. Solve using backend
u = solve_backend!(data, physics; tol, max_iter)
# 4. Return solution (CPU array)
return ElasticitySolution(physics, Array(u)) # Convert back to CPU
end
### Backend selection
```julia
function select_backend(::Auto)
# Try GPU first
if @isdefined(CUDA) && CUDA.functional()
@info "Using GPU backend"
return GPU()
else
nthreads = Threads.nthreads()
@info "Using CPU backend with $nthreads threads"
return CPU(nthreads)
end
end
select_backend(::GPU) = begin
if !(@isdefined(CUDA) && CUDA.functional())
error("GPU backend requested but CUDA not available")
end
return GPU()
end
select_backend(cpu::CPU) = cpu
Package Extension Structure
Directory layout:
JuliaFEM.jl/
├── src/
│ ├── JuliaFEM.jl # Main module
│ ├── physics/
│ │ └── elasticity.jl # Physics{Elasticity}, add_elements!, etc.
│ ├── backend/
│ │ ├── abstract.jl # AbstractBackend, AbstractElasticityData
│ │ ├── selection.jl # select_backend(), solve!()
│ │ └── cpu.jl # CPU backend (always available)
│ └── ...
├── ext/
│ └── JuliaFEMCUDAExt/ # GPU backend (loaded only if CUDA available)
│ ├── JuliaFEMCUDAExt.jl
│ ├── elasticity_gpu.jl
│ └── kernels.jl
└── Project.toml # Weak dependency on CUDA
Project.toml (weak dependencies):
[deps]
LinearAlgebra = "..."
Tensors = "..."
# ... other always-required deps
[weakdeps]
CUDA = "..." # Only loaded if user does 'using CUDA'
[extensions]
JuliaFEMCUDAExt = "CUDA"
Benefits
1. User Code Portability
Same code runs on laptop or HPC cluster:
# Works everywhere!
sol = solve!(physics)
No if CUDA.functional() checks, no platform-specific branches.
2. Performance Transparency
Backend selection is automatic:
# Laptop (no GPU): → CPU backend (8 threads)
# Workstation (RTX 4090): → GPU backend (50× faster)
# HPC (no CUDA): → CPU backend (64 threads)
User sees performance improvement without code changes!
3. Clean API
No GPU types in user API:
# ✅ GOOD: User only sees CPU arrays
nodes = [1.0 0.0 0.0; 0.0 1.0 0.0] # Matrix{Float64}
elements = [Element(...), ...] # Vector{Element}
physics = Physics(Elasticity, "prob", 3)
add_elements!(physics, elements)
sol = solve!(physics)
u = sol.u # Vector{Float64} (always CPU!)
4. Gradual GPU Adoption
Users can try GPU without changing code:
# Day 1: CPU only
using JuliaFEM
sol = solve!(physics) # Uses CPU
# Day 2: Install CUDA, get GPU speedup!
using JuliaFEM, CUDA
sol = solve!(physics) # Automatically uses GPU!
No code rewrite required!
Implementation Phases
Phase 1: Refactor Current GPU Code ✅ (This PR)
Rename types:
GPUElasticityData→ElasticityDataGPU(internal only)solve_elasticity_gpu!()→solve_backend!(::ElasticityDataGPU, ...)
Add CPU backend:
- Create
ElasticityDataCPUwith same interface - Implement threaded assembly (Threads.@threads)
- Implement CPU CG solver
Unified solve:
solve!(physics; backend=Auto())dispatches to correct backend
Phase 2: Package Extensions (Week 2)
Move GPU code to extension:
- Create
ext/JuliaFEMCUDAExt/ - Move CUDA-specific code (kernels, CuArray handling)
- Test:
using JuliaFEMworks without CUDA installed
Benefits:
- Smaller package size (no CUDA dependency unless needed)
- Faster loading time
- Cleaner dependency tree
Phase 3: Backend Optimization (Weeks 3-4)
Optimize CPU backend:
- Threaded assembly (element loop parallelization)
- SIMD vectorization (basis function evaluation)
- Cache blocking (better memory access patterns)
Optimize GPU backend:
- Kernel fusion (reduce launches)
- Shared memory (reduce global memory traffic)
- Preconditioning (Chebyshev-Jacobi)
Phase 4: Advanced Backends (Months 2-3)
Multi-GPU:
sol = solve!(physics; backend=MultiGPU([0, 1])) # 2 GPUs
Distributed CPU:
sol = solve!(physics; backend=MPI(64)) # 64 MPI ranks
Hybrid:
sol = solve!(physics; backend=Hybrid(4, 2)) # 4 nodes × 2 GPUs/node
Example: User Code (Before vs After)
Before (Current - Backend Exposed) ❌
using JuliaFEM, CUDA
# User must know they're using GPU!
include("src/gpu_physics_elasticity.jl")
using .GPUElasticityPhysics
physics = Physics(Elasticity, "beam", 3)
add_elements!(physics, elements)
add_dirichlet!(physics, nodes, comps, 0.0)
# GPU-specific function!
sol = solve_elasticity_gpu!(physics) # ← Hardcoded backend!
# What if no GPU? User must write:
if CUDA.functional()
sol = solve_elasticity_gpu!(physics)
else
sol = solve_elasticity_cpu!(physics) # Different function!
end
Problems:
- User must understand GPU programming
- Code not portable (GPU-specific function)
- Manual backend selection (error-prone)
After (Proposed - Backend Transparent) ✅
using JuliaFEM
# Optional: using CUDA (enables GPU backend automatically)
physics = Physics(Elasticity, "beam", 3)
add_elements!(physics, elements)
add_dirichlet!(physics, nodes, comps, 0.0)
# One function, automatic backend!
sol = solve!(physics) # ← Chooses GPU if available, else CPU
# Advanced: explicit backend control
sol = solve!(physics; backend=GPU()) # Force GPU
sol = solve!(physics; backend=CPU(8)) # Force CPU, 8 threads
sol = solve!(physics; backend=Auto()) # Automatic (default)
Benefits:
- User never sees "GPU" in their code
- Same code runs on any hardware
- Automatic optimal backend selection
Type Names: Proposed Changes
Before (❌ Exposes Backend)
struct GPUElasticityData # ← "GPU" in public API!
nodes::CuArray{...} # ← CUDA type visible!
# ...
end
solve_elasticity_gpu!(...) # ← Backend in function name!
After (✅ Backend Hidden)
Internal types (not exported):
# In ext/JuliaFEMCUDAExt/ (not visible to users)
struct ElasticityDataGPU <: AbstractElasticityData
nodes::CuArray{...}
# ...
end
# In src/backend/cpu.jl (not visible to users)
struct ElasticityDataCPU <: AbstractElasticityData
nodes::Matrix{...}
# ...
end
User-facing API:
# User only sees these (no backend details)
Physics{Elasticity}
solve!(physics; backend=Auto())
ElasticitySolution
Naming Convention
Internal backend types:
ElasticityDataGPU- GPU-resident data (CuArrays)ElasticityDataCPU- CPU-resident data (Matrix)ElasticityDataMPI- Distributed data (future)
Suffix indicates implementation, not user-facing interface!
User types (no backend suffix):
Physics{Elasticity}- Problem definition (backend-agnostic)ElasticitySolution- Solution (always CPU arrays)Material,BoundaryCondition, etc. - All backend-agnostic
Related Design Decisions
1. Data Transfer
Automatic GPU transfer:
# User provides CPU arrays
elements = [Element(...), ...] # Vector{Element} on CPU
# solve! converts internally
sol = solve!(physics; backend=GPU())
# → Elements copied to GPU
# → Computation on GPU
# → Solution copied back to CPU
User never sees CuArray!
2. Backend Fallback
Graceful degradation:
# User requests GPU, but not available
sol = solve!(physics; backend=GPU())
# → Warning: "GPU requested but not available, falling back to CPU"
# → Uses CPU backend
Prevents errors, maintains usability.
3. Performance Hints
Inform user about backend choice:
sol = solve!(physics)
# Info: Using GPU backend (NVIDIA RTX 4090)
# Info: Transferred 1.2 GB to device
# Info: CG converged in 120 iterations (2.3s)
User understands what happened without controlling it.
Migration Path
Step 1: Rename (This Week)
GPUElasticityData→ElasticityDataGPUsolve_elasticity_gpu!()→solve_backend!(::ElasticityDataGPU, ...)- Mark old names as
@deprecate
Step 2: Add CPU Backend (This Week)
- Create
ElasticityDataCPU - Implement
solve_backend!(::ElasticityDataCPU, ...) - Add
solve!()with backend selection
Step 3: Update Demos (This Week)
- Change
solve_elasticity_gpu!()→solve!() - Remove explicit CUDA checks
- Show automatic backend selection
Step 4: Package Extension (Next Week)
- Move GPU code to
ext/JuliaFEMCUDAExt/ - Test without CUDA installed
- Update documentation
Conclusion
Design principle: Backend is implementation detail, not user concern.
User code:
sol = solve!(physics) # ← Simple, portable, fast!
Behind the scenes:
- Automatic backend selection (GPU > CPU)
- Optimal performance for available hardware
- No user code changes needed
Benefits:
- ✅ Portable code (laptop → workstation → cluster)
- ✅ Clean API (no GPU types in user code)
- ✅ Performance transparency (automatic optimization)
- ✅ Gradual adoption (install CUDA → instant speedup)
This is the Julia way - multiple dispatch + package extensions = backend transparency!