Skip to content

Download MATLAB script

cuDSS plane-element solver comparison¤

This notebook-style script compares the OpenSees CPU sparse solvers with the optional NVIDIA cuDSS backend.

The model is a square plane-stress panel made from four-node quad elements. Its left edge is fixed and a uniformly distributed vertical load is applied to its right edge. The mesh is automatically selected to approach each requested number of active equations.

image_0.png

Solver Selection Recommendations

  • General nonlinear problems: Use CuDSS, especially above approximately 1,000 equations. It provides a stable 3–4× speedup for larger systems.
  • Guaranteed SPD systems: Use CuDSSSPD. It achieves the best large-model performance, approaching 4–5× speedup.
  • Small SPD systems: BandSPD, ProfileSPD, or SparseSPD may be faster below approximately 1,000 equations.
  • CPU-only general problems: Use UmfPack as the reliable baseline. SuperLU is mainly competitive for smaller systems.
  • Band solvers: Use only for small, regularly ordered systems with narrow bandwidth. Their performance deteriorates as model size and bandwidth increase.
  • Strong nonlinear earthquake analysis: Prefer CuDSS in GPU or Umfpack in CPU unless the tangent matrix is guaranteed to remain symmetric positive definite throughout the analysis.

Model:

image_1.png

1
2
3
clear;
clc;
close all;

User requirements and configuration¤

To select CuDSS, the user machine must provide:

  1. A supported NVIDIA GPU and a sufficiently recent NVIDIA display driver.

  2. A CUDA runtime supported by the distributed MEX (CUDA 12 or CUDA 13).

  3. NVIDIA cuDSS 0.8 built for the selected CUDA major version.

  4. The CUDA and cuDSS runtime DLLs on PATH, or a cuDSS installation supplied

through OPENSEES_CUDSS_ROOT or RuntimePath below.

The user does not need a CUDA-capable GPU to run the CPU solvers. CUDA is required at build time only when compiling a new GPU-enabled MEX;

end users load the CUDA/cuDSS runtime dynamically when ops.system('CuDSS') is called.

1
2
3
4
5
% for cuda v12.6, for example
cudssPath = "C:\Program Files\NVIDIA cuDSS\v0.8\bin\12";
cudaPath = "C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v12.6\bin";

opsMat = OpenSeesMatlab();
Output
============================================================ OpenSeesMatlab v3.8.0.2 OpenSees MEX Interface for MATLAB Copyright (c) 2026, By Yexiang Yan Type 'help OpenSeesMatlab' in MATLAB for documentation. Documentation also available at https://openseesmatlab.readthedocs.io/en/latest/ ============================================================
1
ops = opsMat.opensees;

Benchmark definition¤

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
RequestedDOFs = [100 500 1000 2000 5000 10000 20000 50000 100000 500000];
TargetDOFs = sort(unique(RequestedDOFs,"stable"));
Solvers = ["CuDSS" "CuDSSSPD" "UmfPack" "SuperLU" "SparseSPD" "BandSPD" "ProfileSPD" "BandGeneral"];
NumberOfLoadSteps = 3;

% Iterative refinement is disabled for this comparison. cuDSS 0.8 can report
% CUDSS_STATUS_IR_FAILED for the 100000-DOF case with a very strict 1e-12
% refinement tolerance, while its direct double-precision solution agrees
% with UmfPack to approximately 1e-12 or better.
CuDSSOptions = {"-cudaPath", cudaPath, "-cudssPath", cudssPath, "-verbose"};

%% Optional cuDSS initialization measurement
% CUDA context and cuDSS runtime initialization occur only on the first GPU
% selection/use in a MATLAB session. Measure that one-time cost separately so
% it does not distort every solver comparison. Set CuDSSAvailable=false after
% a failure and the CPU comparisons will still run normally.

CuDSSAvailable = true;
CuDSSInitializationSeconds = NaN;
try
    initializationTimer = tic;
    runPlaneCase(ops,100,"CuDSS",1,CuDSSOptions);
    CuDSSInitializationSeconds = toc(initializationTimer);
    fprintf("CuDSS initialization plus 100-DOF smoke case: %.4f s\n", ...
        CuDSSInitializationSeconds);
catch exception
    CuDSSAvailable = false;
    warning("CuDSS is unavailable; CPU benchmarks will continue.\n%s", ...
        getReport(exception,"basic","hyperlinks","off"));
end
Output
[OpenSees] CuDSS: loaded CUDA 12 and cuDSS 0.8 at runtime [OpenSees] CuDSS: selected GPU 0 (compute capability 8.6) [OpenSees] CuDSS: enabled pinned host buffers and asynchronous transfers CuDSS initialization plus 100-DOF smoke case: 0.6133 s

Run all plane-element cases¤

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
% Build time is recorded separately. FirstSolveSeconds measures the first
% analyze(1) call for a newly created system. WarmSolveSeconds is the median
% of the remaining calls and is most representative of a nonlinear history
% analysis that repeatedly solves tangent systems after GPU initialization.

rows = cell(0,1);
for targetDOF = TargetDOFs
    for solver = Solvers

        if ismember(targetDOF, [500000, 1000000]) && ~ismember(solver, ["CuDSS", "CuDSSSPD", "UmfPack"])
            fprintf("Skipping target %d DOF with %s...\n", targetDOF, solver);
            continue;
        end

        if ismember(solver, ["CuDSS", "CuDSSSPD"])  && ~CuDSSAvailable
            rows{end+1,1} = failedRow(targetDOF,solver,"cuDSS runtime unavailable"); %#ok<SAGROW>
            continue;
        end

        fprintf("Running target %d DOF with %s...\n",targetDOF,solver);
        try
            rows{end+1,1} = runPlaneCase(ops,targetDOF,solver, ...
                NumberOfLoadSteps,CuDSSOptions); %#ok<SAGROW>
        catch exception
            rows{end+1,1} = failedRow(targetDOF,solver, ...
                string(getReport(exception,"basic","hyperlinks","off"))); %#ok<SAGROW>
        end
    end
end
Output
Running target 100 DOF with CuDSS... [OpenSees] CuDSS: loaded CUDA 12 and cuDSS 0.8 at runtime [OpenSees] CuDSS: selected GPU 0 (compute capability 8.6) [OpenSees] CuDSS: enabled pinned host buffers and asynchronous transfers Running target 100 DOF with CuDSSSPD... [OpenSees] CuDSS: loaded CUDA 12 and cuDSS 0.8 at runtime [OpenSees] CuDSS: selected GPU 0 (compute capability 8.6) [OpenSees] CuDSS: enabled pinned host buffers and asynchronous transfers Running target 100 DOF with UmfPack... Running target 100 DOF with SuperLU... Running target 100 DOF with SparseSPD... Running target 100 DOF with BandSPD... Running target 100 DOF with ProfileSPD... Running target 100 DOF with BandGeneral... Running target 500 DOF with CuDSS... [OpenSees] CuDSS: loaded CUDA 12 and cuDSS 0.8 at runtime [OpenSees] CuDSS: selected GPU 0 (compute capability 8.6) [OpenSees] CuDSS: enabled pinned host buffers and asynchronous transfers Running target 500 DOF with CuDSSSPD... [OpenSees] CuDSS: loaded CUDA 12 and cuDSS 0.8 at runtime [OpenSees] CuDSS: selected GPU 0 (compute capability 8.6) [OpenSees] CuDSS: enabled pinned host buffers and asynchronous transfers Running target 500 DOF with UmfPack... Running target 500 DOF with SuperLU... Running target 500 DOF with SparseSPD... Running target 500 DOF with BandSPD... Running target 500 DOF with ProfileSPD... Running target 500 DOF with BandGeneral... Running target 1000 DOF with CuDSS... [OpenSees] CuDSS: loaded CUDA 12 and cuDSS 0.8 at runtime [OpenSees] CuDSS: selected GPU 0 (compute capability 8.6) [OpenSees] CuDSS: enabled pinned host buffers and asynchronous transfers Running target 1000 DOF with CuDSSSPD... [OpenSees] CuDSS: loaded CUDA 12 and cuDSS 0.8 at runtime [OpenSees] CuDSS: selected GPU 0 (compute capability 8.6) [OpenSees] CuDSS: enabled pinned host buffers and asynchronous transfers Running target 1000 DOF with UmfPack... Running target 1000 DOF with SuperLU... Running target 1000 DOF with SparseSPD... Running target 1000 DOF with BandSPD... Running target 1000 DOF with ProfileSPD... Running target 1000 DOF with BandGeneral... Running target 2000 DOF with CuDSS... [OpenSees] CuDSS: loaded CUDA 12 and cuDSS 0.8 at runtime [OpenSees] CuDSS: selected GPU 0 (compute capability 8.6) [OpenSees] CuDSS: enabled pinned host buffers and asynchronous transfers Running target 2000 DOF with CuDSSSPD... [OpenSees] CuDSS: loaded CUDA 12 and cuDSS 0.8 at runtime [OpenSees] CuDSS: selected GPU 0 (compute capability 8.6) [OpenSees] CuDSS: enabled pinned host buffers and asynchronous transfers Running target 2000 DOF with UmfPack... Running target 2000 DOF with SuperLU... Running target 2000 DOF with SparseSPD... Running target 2000 DOF with BandSPD... Running target 2000 DOF with ProfileSPD... Running target 2000 DOF with BandGeneral... Running target 5000 DOF with CuDSS... [OpenSees] CuDSS: loaded CUDA 12 and cuDSS 0.8 at runtime [OpenSees] CuDSS: selected GPU 0 (compute capability 8.6) [OpenSees] CuDSS: enabled pinned host buffers and asynchronous transfers Running target 5000 DOF with CuDSSSPD... [OpenSees] CuDSS: loaded CUDA 12 and cuDSS 0.8 at runtime [OpenSees] CuDSS: selected GPU 0 (compute capability 8.6) [OpenSees] CuDSS: enabled pinned host buffers and asynchronous transfers Running target 5000 DOF with UmfPack... Running target 5000 DOF with SuperLU... Running target 5000 DOF with SparseSPD... Running target 5000 DOF with BandSPD... Running target 5000 DOF with ProfileSPD... Running target 5000 DOF with BandGeneral... Running target 10000 DOF with CuDSS... [OpenSees] CuDSS: loaded CUDA 12 and cuDSS 0.8 at runtime [OpenSees] CuDSS: selected GPU 0 (compute capability 8.6) [OpenSees] CuDSS: enabled pinned host buffers and asynchronous transfers Running target 10000 DOF with CuDSSSPD... [OpenSees] CuDSS: loaded CUDA 12 and cuDSS 0.8 at runtime [OpenSees] CuDSS: selected GPU 0 (compute capability 8.6) [OpenSees] CuDSS: enabled pinned host buffers and asynchronous transfers Running target 10000 DOF with UmfPack... Running target 10000 DOF with SuperLU... Running target 10000 DOF with SparseSPD... Running target 10000 DOF with BandSPD... Running target 10000 DOF with ProfileSPD... Running target 10000 DOF with BandGeneral... Running target 20000 DOF with CuDSS... [OpenSees] CuDSS: loaded CUDA 12 and cuDSS 0.8 at runtime [OpenSees] CuDSS: selected GPU 0 (compute capability 8.6) [OpenSees] CuDSS: enabled pinned host buffers and asynchronous transfers Running target 20000 DOF with CuDSSSPD... [OpenSees] CuDSS: loaded CUDA 12 and cuDSS 0.8 at runtime [OpenSees] CuDSS: selected GPU 0 (compute capability 8.6) [OpenSees] CuDSS: enabled pinned host buffers and asynchronous transfers Running target 20000 DOF with UmfPack... Running target 20000 DOF with SuperLU... Running target 20000 DOF with SparseSPD... Running target 20000 DOF with BandSPD... Running target 20000 DOF with ProfileSPD... Running target 20000 DOF with BandGeneral... Running target 50000 DOF with CuDSS... [OpenSees] CuDSS: loaded CUDA 12 and cuDSS 0.8 at runtime [OpenSees] CuDSS: selected GPU 0 (compute capability 8.6) [OpenSees] CuDSS: enabled pinned host buffers and asynchronous transfers Running target 50000 DOF with CuDSSSPD... [OpenSees] CuDSS: loaded CUDA 12 and cuDSS 0.8 at runtime [OpenSees] CuDSS: selected GPU 0 (compute capability 8.6) [OpenSees] CuDSS: enabled pinned host buffers and asynchronous transfers Running target 50000 DOF with UmfPack... Running target 50000 DOF with SuperLU... Running target 50000 DOF with SparseSPD... Running target 50000 DOF with BandSPD... Running target 50000 DOF with ProfileSPD... Running target 50000 DOF with BandGeneral... Running target 100000 DOF with CuDSS... [OpenSees] CuDSS: loaded CUDA 12 and cuDSS 0.8 at runtime [OpenSees] CuDSS: selected GPU 0 (compute capability 8.6) [OpenSees] CuDSS: enabled pinned host buffers and asynchronous transfers Running target 100000 DOF with CuDSSSPD... [OpenSees] CuDSS: loaded CUDA 12 and cuDSS 0.8 at runtime [OpenSees] CuDSS: selected GPU 0 (compute capability 8.6) [OpenSees] CuDSS: enabled pinned host buffers and asynchronous transfers Running target 100000 DOF with UmfPack... Running target 100000 DOF with SuperLU... Running target 100000 DOF with SparseSPD... Running target 100000 DOF with BandSPD... Running target 100000 DOF with ProfileSPD... Running target 100000 DOF with BandGeneral... Running target 500000 DOF with CuDSS... [OpenSees] CuDSS: loaded CUDA 12 and cuDSS 0.8 at runtime [OpenSees] CuDSS: selected GPU 0 (compute capability 8.6) [OpenSees] CuDSS: enabled pinned host buffers and asynchronous transfers Running target 500000 DOF with CuDSSSPD... [OpenSees] CuDSS: loaded CUDA 12 and cuDSS 0.8 at runtime [OpenSees] CuDSS: selected GPU 0 (compute capability 8.6) [OpenSees] CuDSS: enabled pinned host buffers and asynchronous transfers Running target 500000 DOF with UmfPack... Skipping target 500000 DOF with SuperLU... Skipping target 500000 DOF with SparseSPD... Skipping target 500000 DOF with BandSPD... Skipping target 500000 DOF with ProfileSPD... Skipping target 500000 DOF with BandGeneral...
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Results = vertcat(rows{:});

%% Calculate accuracy and speedup
% UmfPack is used only as the numerical reference. RelativeTipError compares
% the two-component displacement of the middle node on the loaded edge.

Results.RelativeTipError = NaN(height(Results),1);
Results.FirstSpeedupVsUmfPack = NaN(height(Results),1);
Results.WarmSpeedupVsUmfPack = NaN(height(Results),1);

for targetDOF = unique(Results.TargetDOF)'
    selection = Results.TargetDOF == targetDOF;
    reference = Results(selection & Results.Solver == "UmfPack" & ...
        Results.Status == "ok",:);
    if isempty(reference)
        continue;
    end

    Results.RelativeTipError(selection) = hypot( ...
        Results.TipUx(selection)-reference.TipUx, ...
        Results.TipUy(selection)-reference.TipUy) ./ ...
        max(hypot(reference.TipUx,reference.TipUy),eps);
    Results.FirstSpeedupVsUmfPack(selection) = ...
        reference.FirstSolveSeconds ./ Results.FirstSolveSeconds(selection);
    Results.WarmSpeedupVsUmfPack(selection) = ...
        reference.WarmSolveSeconds ./ Results.WarmSolveSeconds(selection);
end

Results
TargetDOF ActualDOF Nx Ny Solver Status BuildSeconds FirstSolveSeconds WarmSolveSeconds TotalSolveSeconds TipUx TipUy Message RelativeTipError FirstSpeedupVsUmfPack WarmSpeedupVsUmfPack
1 100 100 5 9 "CuDSS" "ok" 0.0934 0.0645 0.0014 0.0674 -1.3423e-05 -3.3259e-04 "" 4.2372e-15 0.0542 0.3100
2 100 100 5 9 "CuDSSSPD" "ok" 0.0924 0.0627 6.2255e-04 0.0640 -1.3423e-05 -3.3259e-04 "" 3.3140e-16 0.0557 0.7216
3 100 100 5 9 "UmfPack" "ok" 0.0887 0.0035 4.4925e-04 0.0044 -1.3423e-05 -3.3259e-04 "" 0 1 1
4 100 100 5 9 "SuperLU" "ok" 0.0308 7.2600e-04 1.7525e-04 0.0011 -1.3423e-05 -3.3259e-04 "" 3.2595e-15 4.8106 2.5635
5 100 100 5 9 "SparseSPD" "ok" 9.9570e-04 5.8860e-04 1.4965e-04 8.8790e-04 -1.3423e-05 -3.3259e-04 "" 3.7494e-15 5.9336 3.0020
6 100 100 5 9 "BandSPD" "ok" 7.3180e-04 4.5130e-04 1.0595e-04 6.6320e-04 -1.3423e-05 -3.3259e-04 "" 3.7496e-15 7.7388 4.2402
7 100 100 5 9 "ProfileSPD" "ok" 7.0360e-04 3.3970e-04 7.7600e-05 4.9490e-04 -1.3423e-05 -3.3259e-04 "" 4.5697e-15 10.2811 5.7893
8 100 100 5 9 "BandGeneral" "ok" 6.5200e-04 6.4510e-04 2.7165e-04 0.0012 -1.3423e-05 -3.3259e-04 "" 2.2878e-15 5.4139 1.6538
9 500 500 10 24 "CuDSS" "ok" 0.0013 0.0644 0.0010 0.0665 1.3350e-19 -3.3887e-04 "" 6.4173e-15 0.5384 2.3044
10 500 500 10 24 "CuDSSSPD" "ok" 0.0840 0.0635 9.1725e-04 0.0654 2.6580e-19 -3.3887e-04 "" 2.3053e-14 0.5459 2.6128
11 500 500 10 24 "UmfPack" "ok" 0.0842 0.0347 0.0024 0.0395 -3.0892e-20 -3.3887e-04 "" 0 1 1
12 500 500 10 24 "SuperLU" "ok" 0.0016 0.0030 0.0012 0.0053 2.5796e-19 -3.3887e-04 "" 9.3176e-15 11.7363 2.0037
13 500 500 10 24 "SparseSPD" "ok" 0.0017 0.0025 8.6295e-04 0.0042 1.9588e-20 -3.3887e-04 "" 7.0404e-15 14.0887 2.7772
14 500 500 10 24 "BandSPD" "ok" 0.0017 0.0015 4.0190e-04 0.0024 2.3431e-19 -3.3887e-04 "" 2.2250e-14 22.3902 5.9632

Plot first and repeated solve times¤

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
figure("Name","Plane-element solver time","Color","w", 'Position', [100, 100, 800, 500]);
tiledlayout(1,2,"TileSpacing","compact","Padding","compact");

nexttile;
plotMetric(Results,"FirstSolveSeconds",Solvers);
ax = gca;
ax.XScale = "log";
title("First solve for each new system");
ylabel("Time (s)");

nexttile;
plotMetric(Results,"WarmSolveSeconds",Solvers);
ax = gca;
ax.XScale = "log";
title("Repeated tangent solve");
ylabel("Median time (s)");
legend("Location","northwest");
figure_0.png

Plot displacement accuracy¤

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
% Zero UmfPack self-error is replaced only for plotting on a logarithmic axis;
% the Results table retains the exact zero.

figure("Name","Plane-element solver accuracy","Color","w");
hold on;
for solver = Solvers
    data = Results(Results.Solver == solver & Results.Status == "ok",:);
    plotError = max(data.RelativeTipError,eps);
    loglog(data.ActualDOF,plotError,"-o","LineWidth",1.4, ...
        "DisplayName",solver);
end
ax = gca;
ax.XScale = "log";
ax.YScale = "log";
grid on;
xlabel("Active equations");
ylabel("Relative loaded-edge displacement error vs UmfPack");
title("Double-precision solution agreement");
legend("Location","best");
figure_1.png

Plot speedup relative to UmfPack¤

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
% A value greater than one means that the solver is faster than UmfPack.

figure("Name","Plane-element solver speedup","Color","w");
hold on;
for solver = Solvers
    data = Results(Results.Solver == solver & Results.Status == "ok",:);
    semilogx(data.ActualDOF,data.WarmSpeedupVsUmfPack,"-o", ...
        "LineWidth",1.4,"DisplayName",solver);
end
yline(1,"--","UmfPack baseline","HandleVisibility","off");
grid on;
ax = gca;
ax.XScale = "log";
xlabel("Active equations");
ylabel("Repeated-solve speedup vs UmfPack");
title("Solver speedup after one-time GPU initialization");
legend("Location","bestoutside");
figure_2.png

Local benchmark functions¤

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
function row = runPlaneCase(ops,targetDOF,solver,numberOfSteps,cudssOptions)
    [nx,ny,actualDOF] = balancedMesh(targetDOF);
    % cleanup = onCleanup(@() ops.wipe());

    buildTimer = tic;
    ops.wipe();
    ops.model("basic","-ndm",2,"-ndf",2);
    ops.nDMaterial("ElasticIsotropic",1,2.0e11,0.30,7850.0);
    ops.block2D(nx,ny,1,1,"quad",0.10,"PlaneStress",1, ...
        1,0,0, 2,10,0, 3,10,10, 4,0,10);
    ops.fixX(0.0,1,1);
    ops.timeSeries("Linear",1);
    ops.pattern("Plain",1,1);

    rightEdgeTags = (0:ny)*(nx+1)+nx+1;
    verticalLoad = -1.0e6/numel(rightEdgeTags);
    for nodeTag = rightEdgeTags
        ops.load(nodeTag,0.0,verticalLoad);
    end
    buildSeconds = toc(buildTimer);

    ops.constraints("Plain");
    ops.numberer("RCM");
    selectSystem(ops,solver,cudssOptions);
    ops.integrator("LoadControl",1/numberOfSteps);
    ops.algorithm("Linear");
    ops.analysis("Static");

    solveSeconds = zeros(1,numberOfSteps);
    for step = 1:numberOfSteps
        solveTimer = tic;
        returnCode = ops.analyze(1);
        solveSeconds(step) = toc(solveTimer);
        if returnCode ~= 0
            error("CuDSSExample:AnalyzeFailed", ...
                "ops.analyze returned %d at load step %d.",returnCode,step);
        end
    end

    middleRightTag = floor(ny/2)*(nx+1)+nx+1;
    tipDisplacement = ops.nodeDisp(middleRightTag);
    if numberOfSteps > 1
        warmSeconds = median(solveSeconds(2:end));
    else
        warmSeconds = NaN;
    end

    row = table(targetDOF,actualDOF,nx,ny,solver,"ok",buildSeconds, ...
        solveSeconds(1),warmSeconds,sum(solveSeconds), ...
        tipDisplacement(1),tipDisplacement(2),"", ...
        'VariableNames',{'TargetDOF','ActualDOF','Nx','Ny','Solver','Status', ...
        'BuildSeconds','FirstSolveSeconds','WarmSolveSeconds','TotalSolveSeconds', ...
        'TipUx','TipUy','Message'});
end

function selectSystem(ops,solver,cudssOptions)
    switch solver
        case "CuDSS"
            ops.system("CuDSS",cudssOptions{:});
        case "CuDSSSPD"
            ops.system("CuDSSSPD",cudssOptions{:});
        case "UmfPack"
            ops.system("UmfPack");
        case "SuperLU"
            ops.system("SuperLU");
        case "SparseSPD"
            ops.system("SparseSPD");
        case "BandGeneral"
            ops.system("BandGeneral");
        case "ProfileSPD"
            ops.system("ProfileSPD")
        case "BandSPD"
            ops.system("BandSPD")
        otherwise
            error("CuDSSExample:UnknownSolver","Unknown solver: %s",solver);
    end
end

function row = failedRow(targetDOF,solver,message)
    row = table(targetDOF,NaN,NaN,NaN,solver,"failed",NaN,NaN,NaN,NaN, ...
        NaN,NaN,string(message), ...
        'VariableNames',{'TargetDOF','ActualDOF','Nx','Ny','Solver','Status', ...
        'BuildSeconds','FirstSolveSeconds','WarmSolveSeconds','TotalSolveSeconds', ...
        'TipUx','TipUy','Message'});
end

function plotMetric(results,metric,solvers)
    hold on;
    for solver = solvers
        data = results(results.Solver == solver & results.Status == "ok",:);
        loglog(data.ActualDOF,data.(metric),"-o","LineWidth",1.4, ...
            "DisplayName",solver);
    end
    grid on;
    xlabel("Active equations");
end

function [nx,ny,actualDOF] = balancedMesh(targetDOF)
    center = max(1,round(sqrt(targetDOF/2)));
    best = [Inf Inf 1 1];
    for nxCandidate = max(1,center-20):center+20
        estimatedNy = max(1,round(targetDOF/(2*nxCandidate)-1));
        for nyCandidate = max(1,estimatedNy-1):estimatedNy+1
            candidateDOF = 2*nxCandidate*(nyCandidate+1);
            score = [abs(candidateDOF-targetDOF), ...
                abs(nxCandidate-nyCandidate),nxCandidate,nyCandidate];
            changedIndex = find(score ~= best,1);
            if ~isempty(changedIndex) && score(changedIndex) < best(changedIndex)
                best = score;
            end
        end
    end
    nx = best(3);
    ny = best(4);
    actualDOF = 2*nx*(ny+1);
end