アプリケーション開発と数値計算ソルバー開発における設計思想の違い

アプリケーション開発と数値計算ソルバー開発における設計思想の違い

はじめに

ソフトウェア開発にはアプリケーションからシステム開発までさまざまな分野が存在します。今回はその中でも、私が経験してきた「アプリケーション開発」と「数値計算ソルバー開発」という二つの領域に着目し、それぞれのコード設計思想の違いを掘り下げてみます。
厳密な定義はありませんが、本稿では便宜上、次のように呼び分けます。

  • アプリケーション開発
    主にUIを含み、仕様変更への柔軟性やチーム開発での生産性が重視される分野。CAE分野でいえばCADなど。
  • ソルバー開発
    物理シミュレーションなどの数値計算を主目的とし、大規模データを扱うバックエンド処理。計算結果の正確さと速度が最優先される分野。CAE分野では有限要素ソルバー。

アプリケーション開発は多くの場合、企業に所属しチーム開発となります。開発効率を重視するため、結果的に再利用性や可読性の高いコードが評価されます。
一方で、研究開発起点のソルバーでは研究成果が主目的となるため、保守性などは後回しにされがちです。それゆえ時間とともにスパゲッティ化し、扱える人がいなくなるというのはありがちな話です。

この特色の違いから課題が見えてきますが、その根本として、アプリケーション開発者とソルバー開発者は、出身(バックグラウンド)が大きく異なるといえるでしょう。
アプリケーション開発者は、プログラミング言語やソフトウェア工学を習熟し、「プログラムをうまく書く(設計や思想)ことで開発効率を向上させる」ことが求められます。
対してソルバー開発者は、数学や工学を出自とし、それを数値計算に落とし込む過程でシミュレーション分野を習熟していきます。彼らはアルゴリズムやハードウェアなど、ソフトウェア以外の工学的知識を強く求められます。

ここからは、両者の経験がある私の視点で、同じ要件をコードに落とし込む際にどのような「思想の違い」が生まれるかを示します。特に、トレードオフとなりやすい「保守性」と「速度」の観点で見ていきましょう。

プログラミング言語はアプリケーション開発やソルバー開発など、多岐に使われているC++を対象とします。

ポリモーフィズム

ポリモーフィズム(多態性)は、アプリケーション開発において「保守性」と「拡張性」を担保するための最も重要なオブジェクト指向の概念の一つです。

ポリモーフィズムの代表的な説明として、乗り物が例として挙げられます。ここでも、乗り物(自転車と自動車)を例としてポリモーフィズムを説明します。

  • 自転車 (Bicycle): 人力なので、少しずつしか進みません。
  • 自動車 (Car): エンジンがあるので、一気に遠くまで進みます。

アプリケーション開発(オブジェクト指向)の領域では、この「移動ロジックの違い(例えばmove関数)」をクラスの中に隠蔽します。利用する側は、「それが自転車なのか車なのか」を具体的に知る必要はなく、ただ「進め(move())」と命令するだけで、それぞれの性能に応じた場所に移動してくれます。

コード例A:ポリモーフィズム

#include 
#include 
#include 

// ==========================================
// 1. 基底クラス:乗り物
// ==========================================
// 「すべての乗り物は、現在地(position)を持ち、移動(move)できる」
class Vehicle {
protected:
    double position = 0.0; // 内部状態:現在の位置(0km地点からスタート)
public:
    // 純粋仮想関数:「進む」命令。具体的にどれだけ進むかは各乗り物に任せる
    virtual void move() = 0;

    // 現在地を確認するための共通機能
    double get_position() const { return position; }
    virtual ~Vehicle() = default;
};

// ==========================================
// 2. 派生クラス:自転車
// ==========================================
class Bicycle : public Vehicle {
public:
    void move() override {
        // 自転車は遅いので、1ステップで 2km だけ進む
        position += 2.0;
    }
};

// ==========================================
// 3. 派生クラス:自動車
// ==========================================
class Car : public Vehicle {
public:
    void move() override {
        // 自動車は速いので、1ステップで 20km 進む
        position += 20.0;
    }
};

// ==========================================
// メイン処理(アプリケーション側の視点)
// ==========================================
int main() {
    // 種類の違う乗り物を「Vehicle」としてひとまとめに管理
    std::vector<std::unique_ptr> vehicles;

    vehicles.push_back(std::make_unique()); // 1台目は自転車
    vehicles.push_back(std::make_unique());     // 2台目は車

    std::cout << "--- スタート ---" << std::endl;

    // 時間を経過させてみる(3ステップ)
    for(int time=1; time<=3; ++time) {
        std::cout << "[Time " << time << "]" <move();
            // 現在地を表示
            std::cout << "  Position: " <get_position() << " km地点" << std::endl;
        }
    }
    return 0;
}

この実装のメリットは明白です。将来的に「飛行機」などの新たな乗り物を追加したとしても、派生クラスを作成し move() を実装するだけで動作します。main関数などの呼び出し側を修正する必要がないため、大規模なコードでもバグの温床になりにくい設計です。

一方でソルバー開発では、ホットループに入る部分ほど「呼び出し方」そのもののコストが効いてきます。代表例が仮想関数呼び出しのオーバーヘッドです。v->move() を呼び出す際、プログラムは「vtable(仮想関数テーブル)」を参照して実行先を決めます。ループ回数が数億回規模になると、この差が積み上がってきます。

極端な例ですが、あえてポリモーフィズムを使わず、switch文で分岐させる実装と比較してみましょう。

コード例B:条件分岐

#include <iostream>
#include <vector>

// 乗り物の種類を番号で管理する
enum VehicleType {
    BICYCLE, // 0: 自転車
    CAR      // 1: 自動車
};

// 単なるデータの塊(クラスではなく構造体)
struct Vehicle {
    VehicleType type;
    double position;
};

int main() {
    std::vector<Vehicle> vehicles;

    // データを作る時に「種類」を指定しないといけない
    vehicles.push_back({BICYCLE, 0.0});
    vehicles.push_back({CAR, 0.0});

    std::cout << "--- スタート ---" << std::endl;
    for(int time=1; time<=3; ++time) {
        std::cout << "[Time " << time << "]" << std::endl;
        for(auto& v : vehicles) {
            switch(v.type) {
                case BICYCLE:
                    v.position += 2.0; // 自転車のロジック
                    break;
                case CAR:
                    v.position += 20.0; // 自動車のロジック
                    break;
            }
            std::cout << "  Position: " << v.position << " km地点" << std::endl;
        }
    }
    return 0;
}

このコードは、新しい乗り物を追加する際にmain関数の修正が必要となり、保守性はCode Aに劣りますが、vtable参照がない分、ホットループでは実行速度に効いてくる場面が多いです。

ソルバーへの適用と限界

では、ソルバーとして有限要素法を考えてみましょう。

ポリモーフィズムの考え方は、有限要素法にもきれいに当てはめることが可能です。 四面体や六面体を「要素(Element)」として扱い、Element.Stiffness()(剛性計算)を呼べば、計算式が自動的に選ばれます。材料モデルも同様で、弾性体や弾塑性体を意識せずにプログラムを書くことができます。 これはソフトウェア工学の視点から見れば、「変更に強く、可読性が高い、理想的な設計」と言えます。これがオブジェクト指向の醍醐味です。

しかし、計算速度を求めるソルバー開発においては、この「理想的な設計」を犠牲にし分岐(if/switch文)で愚直に実装する場合があります。分岐処理に書き換えることで、仮想関数呼び出しのコストを削り、CPU命令レベルの負担を軽くできます。その代償として、新しい要素を追加するたびにSwitch文を修正しなければならず、「変更への強さ」や「可読性」はポリモーフィズムを利用する場合に比べて相対的に下がります。

ここまでは概念の話だったので、実際に「数値計算っぽい演算」でどれくらい差が出うるかを、簡略化したFEMで見てみます。

有限要素法を例に、ポリモーフィズム版と、仮想関数を排除してswitch文で要素タイプを分岐させた実装を同じ条件で比較してみます。

まずはオブジェクト指向らしくポリモーフィズム版を考えてみましょう。

コード例:ポリモーフィズム

オブジェクト指向らしく、要素を Element インターフェースとして抽象化し、要素ごとの処理を AssembleInternalForce() に隠蔽します。呼び出し側は要素種別(Hex/Tetなど)を意識せず、共通インターフェースを呼べます。

この形にしておくと、将来要素種別が増えても「呼び出し側のループ」は基本的に変えずに済みます。

// Element Interface
class Element {
public:
  virtual ~Element() = default;
  virtual void AssembleInternalForce() const = 0;
};

  auto AssembleInternalForces = [&](const Mesh &m) {
    for (const auto &e : m.Elements()) {
      e->AssembleInternalForce();
    }
  };

コード例:条件分岐

次は条件分岐版です。仮想関数を削除した単純な条件分岐版を考えてみます。

この実装では、要素を enum + struct のデータとして保持し、要素ループ側で switch 分岐してカーネル関数(例:AssembleHex8)を呼びます。

Switch で分岐する設計は、「差分を if/switch に集める」設計です。

つまり要素追加とは、要素データを増やすだけでなく、分岐が存在する箇所を全て更新する作業もセットになります。ここが設計思想としての分水嶺です。

ソルバーは1つの処理だけでは成立せず、内部力・剛性・質量・拘束・内部変数更新・出力など、要素依存のロジックが複数のパスに分かれて増えていきます。

Switch 設計では、要素種別が増えるほど case が増えるだけでなく、ロジック(=要素ごとに分岐したい処理)が増えるほど switch 文そのものが複製され、変更箇所が増殖します。結果として「ある要素だけ実装が抜ける」「ある処理だけ古い分岐のまま残る」といった保守上の不整合が発生しやすくなります。このデメリットは、要素種別が少ないうちは見えにくく、規模が大きくなって初めて効いてきます。

//  Element Definition
enum class ElementType { Hex8 };

struct Element {
  ElementType type;
  int node_ids[8]; // 8 nodes for Hex8
};

//...

  void AddHex8(int n0, int n1, int n2, int n3, int n4, int n5, int n6, int n7) {
    auto e = std::make_unique<Element>();
    e->type = ElementType::Hex8;
    e->node_ids[0] = n0;
    e->node_ids[1] = n1;
    e->node_ids[2] = n2;
    e->node_ids[3] = n3;
    e->node_ids[4] = n4;
    e->node_ids[5] = n5;
    e->node_ids[6] = n6;
    e->node_ids[7] = n7;
    elements_.push_back(std::move(e));
  }

//...

  auto AssembleInternalForces = [&](Mesh &m,
                                    const LinearElastic3DMaterial &mat) {
    std::vector<Node> &nodes = m.Nodes();
    for (const auto &ep : m.Elements()) {
      const Element &e = *ep;
      switch (e.type) {
      case ElementType::Hex8:
        AssembleHex8(e, mat, nodes);
        break;
      }
    }
  };

実際にコードを実行し、ポリモーフィズム版と条件分岐版を比較すると25%程条件分岐版が高速でした。
※これらのコードは、ポリモーフィズム思想と分岐思想起点でコードを実装していますので、厳密には仮想関数の差だけでなく様々な要因を含みますが、自然に実装すると少なくともこの程度差が出るといえるでしょう。

ここまでで、仮想関数のオーバーヘッドなどを避けることで、CPU命令レベルでは確かに高速化できることが分かりました。

実行に用いたコードはGitHubに置いてあります。興味がある方は実際に実行してみてください。

では、Code Bまでやれば十分でしょうか。ホットループが大規模になるほど、命令の実行を軽くした次は「データがキャッシュに届くか」が支配的になってきます。(キャッシュは、CPUとメインメモリ(RAM)の間にある小容量・高速なメモリで、直近で使ったデータや近くで使われそうなデータを一時的に保持して、メモリアクセス待ち(レイテンシ)を減らす仕組みです。)

データレイアウト(AoSとSoA)

ここまでの比較では、仮想関数を避けることで「命令の実行」を軽くできることを見ました。
しかし有限要素ソルバーのようにデータ規模が大きくなると、CPUが計算をする前に「必要なデータがキャッシュに届くか」が支配的になります。そこで次に意識したくなるのが、データの持ち方(データレイアウト)です。

キャッシュラインという制約

CPUはメインメモリからデータを読むとき、必要な1変数だけを取りに行くのではなく、キャッシュラインと呼ばれる固定長の塊(一般的には64バイト程度)でまとめて読み込みます。
つまり、あるループで必要な値が散らばっていると、計算に使わない値まで一緒に運ぶことになり、メモリ帯域やキャッシュが無駄に消費されます。CAEでは節点数・要素数・積分点数が大きく、この差が性能差として表面化しやすくなります。

AoS:オブジェクト単位でまとめる

先ほどの比較コードでは、Node構造体に座標・変位・力をまとめて持ち、それを配列(vector)として並べています。
このように「1つのオブジェクト(節点など)に属する属性を一塊にして並べる」配置は、AoS(Array of Structures)と呼ばれます。
実装は直感的で、設計も自然になりやすい形式です。

#include <vector>

struct NodeAos {
    double x, y, z;      // position
    double ux, uy, uz;   // displacement
    double fx, fy, fz;   // force
};

std::vector<NodeAos> nodes; // AoS: array of NodeAos

SoA:属性ごとに配列として持つ

もう一つの代表がSoA(Structure of Arrays)です。
こちらはオブジェクトの塊を分解し、属性ごとに独立した配列として保持します(座標の配列、変位の配列、力の配列…)。
同じ種類のデータが連続するため、特定の成分だけを大量に処理するループではキャッシュラインを有効に使いやすく、メモリ帯域を活かした実装を組みやすくなります。

#include <vector>

struct NodesSoa {
    std::vector<double> x, y, z;      // position
    std::vector<double> ux, uy, uz;   // displacement
    std::vector<double> fx, fy, fz;   // force
};

NodesSoa nodes; // SoA: one array per field

SIMDなど上級者向けチューニングの足場になる

SoAのような「同種データが連続している」配置は、SIMD(自動ベクトル化を含む)やスレッド並列化とも相性が良い傾向があります。
CAEでは、節点場の更新だけでなく、積分点の内部変数(塑性ひずみや損傷変数など)の更新を大量に回す場面が多く、データレイアウト設計が性能に効いてきます。

本稿ではデータレイアウトの実測比較までは踏み込みませんが、ソルバーをさらに高速化したい局面では「ポリモーフィズムを避ける」だけでなく「データの並べ方を設計する」という選択肢があることを押さえておくと、次の一手を考えやすくなります。

まとめ

同じアルゴリズム(計算ロジック)であっても、データの構造(プログラミングの設計)を変えるだけで、速度には大きな差が生まれます。

アプリケーション開発者は、「人間の気持ち(可読性・保守性)」を重視し、ポリモーフィズムを駆使して複雑な仕様変更に備えます。 一方、ソルバー開発者は、「計算機の気持ち(メモリ効率・速度)」を重視し、時には保守性を犠牲にします。

どちらか一方が正しいというわけではありません。現代の開発ではこの両者の視点が必要とされています。 ソルバー開発においても、スパゲッティコード化を防ぐためにアプリケーション開発の設計思想を取り入れる必要がありますし、アプリケーション開発においても、バックエンドの処理速度を上げるためにハードウェアの挙動を理解する必要があります。

例えば、同じ有限要素を扱うCAEという分野においても、ユーザーインターフェースを伴うCAD開発ではポリモーフィズムを利用する利点が多く、計算エンジンであるソルバー開発ではデータレイアウトやアクセスパターンを意識した設計が採用されるなど、同じ分野でも目的次第で最適な設計思想は大きく異なります。

重要なのはバランスです。 パフォーマンスが重要となる「ホットループ」ではソルバー開発の思想を、それ以外の箇所では保守性を意識したアプリケーション開発の思想を適用するなど、状況に応じたコーディングが求められます。

ここまで「実行時の柔軟性(ポリモーフィズム)」と「実行速度(switch/分岐)」のトレードオフを見てきましたが、現代のC++(C++17以降)では、この両立を図るための機能が拡充されています。 その代表例が if constexpr を用いたコンパイル時分岐です。

例えば、テンプレートを用いて要素の型情報をコンパイル時に確定させることができれば、コード上は通常のif文のように書けますが、コンパイルされた結果は「分岐そのものが消滅した専用関数」になります。

// テンプレート引数 T によって、コンパイル時に分岐が決定される
template <typename T>
void AssembleElement(const T& element) {
    if constexpr (std::is_same_v<T, Hex8>) {
        // T が Hex8 の場合、このブロックだけがコンパイルされ、if判定すら残らない
        AssembleHex8(element);
    } else if constexpr (std::is_same_v<T, Tet4>) {
        AssembleTet4(element);
    }
}

このように、アプリケーション開発的な「読みやすさ・書きやすさ」を維持しつつ、コンパイラの力を借りてソルバー開発的な「ゼロオーバーヘッド」を実現する手法(静的ポリモーフィズムなど)も、近年のハイパフォーマンスコンピューティング分野では積極的に採用されています。

「人間が読みやすい形」と「計算機が走りやすい形」。 この相反する要素を理解し、状況に応じて適切なバランスで融合させていくことこそが、これからのエンジニアリングに求められています。 LLMにより誰でも簡単にコードが書けるようになってきましたが、このような「設計方針の意思決定」こそが、人間に残された重要な仕事といえるでしょう。

Author