ここ数年、Embarcadero では、大規模なアプリケーションを抱える開発者がDelphiでより快適に作業できるように、Delphi Object Pascalコンパイラのパフォーマンスの最適化とメモリ使用量の削減に多大な努力を払ってきました。主な焦点はWin32コンパイラに当てられてきましたが、他のコンパイラも同様に改善されてきました。
多くのユースケースシナリオによってコンパイラは大きな進展がありましたが、同時にお客様の多くのプロジェクトを調査することで、コンパイラが期待通りに動作していないケースも多数発見されました。一部のシナリオには無効なもの(例えば。存在しないフォルダをコンパイラに渡すなど)もありますが、近い将来、これらの問題のほとんどに対処する予定です。同時に現在の開発者の作業をさらに向上させるために、プロジェクトの設定やソースコードに何かできることはないかという要望もお客様からいただいています。
このブログでは、現行バージョンのDelphiを使用してコンパイラを高速化し、メモリ消費量を削減するためのいくつかのヒントと提案を提供いたします。このブログで紹介するアイデアのほとんどは、コンパイル時とDelphi LSPを利用したCode Insightを使用するときの両方に適用されます。 コンパイラを支援することは同時にCode Insight も支援されることになります。
Table of Contents
検索パスまたはライブラリパスのエントリが無効な場合
コンパイラは、uses文によってコード内で参照されるユニットを、プロジェクトフォルダ、プロジェクト検索パス、ライブラリパス、あるいはその他のパスから検索します。これらのパスに無効なフォルダが存在していると、コンパイラはパスを繰り返し検索することになりますので、オペレーティングシステムレベルでは、存在しないパスを検索する操作はかなりオーバーヘッドがかかるため、パフォーマンスが低下することがわかっています。
その一例として「Delphi compiler works extremely slow when several invalid paths are present in LIB PATH」という表題のhttps://quality.embarcadero.com/browse/RSP-39317のレポートをご覧ください。
小規模なアプリケーションによる複数のテストケースを作成し、それを検証した結果、以下のようにコンパイル時間に違いがありました。
- 無効なパスが存在する場合のコンパイル時間 40.3秒
- 無効なパスが存在しない場合のコンパイル時間 1.7秒
ご覧のように、コンパイル時間の差は非常に大きいです。そのため、開発者自身が参照するパスのリストを整理し、無効なパスを手動で除外することが非常に効果的です。ただ、今後の改善計画では、無効なパスを事前に検証して除外するようにコンパイラを変更する予定です。
UNCドライブ上のソースコードファイル
前節に関連したコンパイル時間にかかわる問題として、プロジェクト全体をコンパイルする際、プロジェクトが参照するユニットファイルがどこに配置されているかということも重要です。
例えば、ローカルドライブ上に配置されているユニットファイルと、ネットワークにマップされたドライブ上に配置されたユニットファイルを比較すると、後者のほうがオーバーヘッドが大きいため、パフォーマンスが低下することになります。
またローカルドライブ上のソースコードのコンパイル速度を向上させるには、ローカルストレージはハードディスクではなくSSDドライブに変更するほうがコンパイルの速度差が顕著に現れますので、SSDドライブに変更することを推奨いたします。
ユニットエイリアス
ユニットエイリアスは、uses文のユニット名を別の実際のユニット名にマップするオプションを提供します。このオプションは、古いコードを移行するときに便利であり、コア ユニットの一部がマージまたは名前変更されたときに、長年にわたりDelphi で使用されていました。
例えば、Delphiの古いバージョンでは、以下のようなユニットエイリアスがあります。
[crayon-6768e37feae39883635224/]現在、新しいプロジェクト向けに定義されたデフォルトのユニットエイリアスはありませんが、古いバージョンの既存プロジェクトには、ユニットエイリアスが設定されている場合があります。
より良い方法として既存プロジェクトのユニットエイリアスを削除し、プロジェクトのソース コードを正しいユニットへ参照するように更新することを推奨いたします。
ユニットエイリアスを大量に使用すると、コンパイラに余分な作業が加わります。ユニットエイリアスの定義で発生するオーバーヘッドはそれほど大きくはありませんが、ユニットエイリアスを削除することによってお客様のコードをよりきれいに、より読みやすく保つことができます。
ユニットスコープ名と未修飾ユニット名
10年以上前、Delphiはユニットスコープ名を導入しました。ユニットスコープ名はVcl.FormsやSystem.SysUtilsのように、デフォルトライブラリの一部であるすべてのユニットの前に付ける接頭辞のことです。
古いバージョンで使用されていた短いユニット名を持つ既存のプロジェクトがある場合、ユニットスコープ名をプロジェクトオプションとして定義することで、古いバージョンのプロジェクトを迅速に移行させることができます。
上記の画像のようにDelphi IDEは新しいプロジェクトであっても、デフォルトで多数のユニットスコープ名を追加しています(このデフォルト設定は、今後変更することを検討しています)。
現在、コード内の未修飾ユニット(短いユニット)名は、検索パスまたはライブラリパスのすべてのフォルダで、考えられる各接頭辞のファイル検索が行われます
例えば、20個のフォルダと20個の接頭辞が存在している場合、潜在的には400個のファイルを検索することになり、余計なオーバーヘッドが生じます。この一例として https://quality.embarcadero.com/browse/RSP-18130のケースを参照ください。
コンパイラには作業を軽減するためのキャッシュロジックがありますが、ユニットスコープ名の定義は、コードの移行を助ける機能にすぎません。そのため最終的に開発者自身でuses文を整理する必要があります。
例えば、古いバージョンの既存プロジェクトコード内のuses文で定義しているEmbarcaderoのライブラリの全てユニットを未修飾ユニット(短いユニット)名ではなく、完全修飾ユニット名に変更することを推奨いたします。
これによって、プロジェクトオプションで定義されているユニットスコープ名を全て削除することができるため、コンパイラを高速化できるメリットがあります。
繰り返されるユニット名と重複したユニット名
ユニット名といえば、問題を引き起こすことが知られている別のシナリオがあります。ただ、これはパフォーマンスの問題というよりは、特にCode InsightやIDEツールの一部で不具合が発生する可能性があるシナリオです。
例えば、IDEでプロジェクトグループを開き、同じ名前の異なるユニット(異なるフォルダーにある)を含む複数のプロジェクトの場合、あるプロジェクトにはMyUnit.pasがあり、他のプロジェクトにはMyUnit.pasという別の名前の無関係なユニットが別のフォルダーにあるとします。
このシナリオは、通常、コンパイラがうまく処理しますが、「Compiler confuses unit names in project group」という表題のhttps://quality.embarcadero.com/browse/RSP-39293 というケースで報告されているように、常に正しく処理されるわけではありません。
Embarcaderoでは数年前に、同様のシナリオでデバッガが混同しないよう、具体的な作業を行いました。しかし、DelphiLSPは同じ名前を持つユニットによって混同することがあります。
そのため可能な限り、異なるユニットに同じ名前を使用することは避けることを推奨いたします。
循環ユニット参照
コンパイラのパフォーマンスに影響を与えるシナリオに戻しますが、implementation認識している最も重要な問題の1つは、ユニットの実装セクション(implementation)での循環ユニット参照の過度の使用です。
ご存知のように、ユニットのインターフェースセクションでは、相互のユニット参照や循環的なユニット参照は許可されていません。ただし、ユニットの実装セクションでは、理論的にはプロジェクト全体の他のすべてのユニットを参照できます。
Delphiがユニットをコンパイルする際、使用されているすべてのユニットを調べてグラフ全体をナビゲートし、サイクルをチェックするためにソースを確認する必要があります。何百ものユニットを持つ複雑なグラフの場合、このナビゲーションプロセスのコストは非常に大きくなります。
最近、Embarcaderoのサポートチームは興味深い実験を行いました。サポートチームは、すべてのユニットが他のすべてのユニットを使用するようなアプリケーションを作成するコードジェネレータを作成しました。(ただこれは明らかに極端な使用例で、このようなプロジェクトがないことを切に願っています。 )
このシナリオでは、ユニットを1つ追加すると、コンパイル時間がほぼ2倍になります。言い換えれば、
このようなシナリオでは、ユニットを1つ追加すると、コンパイル時間がほぼ2倍になります。言い換えれば、プロジェクト内のすべてのユニットが、他のすべてのユニットによって使用されるユニットがもう 1 つ追加されると、コンパイル時間が指数関数的に低下します (追加のユニットごとに n*2 ユニット参照が追加されるため、指数関数的になります。 n は既存のユニットの数です)。
実はこのシナリオはコンパイラが既に最適化されており、検索と名前検索を使用済みのハッシュ辞書や同様の最適化されたデータ構造に移行していますので、現在、コンパイラを改善するために積極的に取り組んでいる分野です。
例えば、Delphi 10.4.2 で解決されたhttps://quality.embarcadero.com/browse/RSP-28811のケースを参照ください。
ただし、循環するuses文の使用は、使用するユニットのスパゲッティグラフになってしまうので、適切なプログラミング プラクティスに反する使用例のシナリオです。ユニット間やクラス間の依存関係を減らすことは、良いプラクティスです。
線形的な依存関係(高レベル ユニットで使用される低レベル ユニット、データ アクセス ユニットを使用する UI ユニット等で、相互ではない)を持つことは許容されますが、複雑なグラフを持つことは、長期間にわたるコードの保守性を損ないます。さらにコンパイラのパフォーマンスやメモリ使用量に大きな負担をかけることになります。
かなり大規模なアプリケーションを開発しているDelphiの開発者から、uses文を再構成して循環ユニット参照を回避することで、コンパイル時間が最大90%短縮(例えば、10分が1分に短縮)され、Code Insightが応答性も向上したという実際の報告もあります。
この問題に対処したい場合は、以下のツールのいずれかを検討することをお勧めします。これは、アプリケーションの現在の使用単位グラフを特定し、サイクルを検出するのに役立ちます。
- GExpert Project Dependencies (以下の画像を参照): https://gexperts.org/tour/index.html?project_dependencies.html
- Peganza Pascal Analyzer: https://www.peganza.com/#PAL
- Delphi Unit Dependency Scanner: https://github.com/norgepaul/DUDS
余談ですが、コンパイラは、同じユニットが異なる順序で使用されることを嫌います (そのユニットを使用するユニットの依存関係グラフに影響するため)。
例えば、2 つのユニットが両方ともユニットAとユニットBを使う場合、両方のユニットで「uses A,B;」と記述するほうが、片方のユニットで「uses B, A;」と記述するよりも良いということです。これは、循環ユニット参照を回避することに比べればかなり些細なことですが、それでも非常に大規模なアプリケーションでは顕著になります。
最後に、不要なuses文の削除も検討してください。例えば、「Delphi Parser」というツールを使用すると、具体的な解決策を示してくれます。
https://delphiparser.com/product/remove-superfluous-uses-optimizer-wizard-evaluation-edition/
繰り返されるジェネリック型のインスタンス
Delphiコンパイラを支援するという点でEmbarcaderoからの最後の提案は、もう少し議論の余地があり、数年後にはコンパイラがより優れたものになるはずです。つまりコンパイル時間とメモリ使用量の両方の点で改善を行うことで、メモリ不足のコンパイラエラーのリスクを回避できます。
Delphiの型システムは(従来の Turbo Pascal と同様)型宣言の等価性に基づいています。つまり、つまり、2 つの型が同じ名前と同じ構造を持っていても、別のユニットで宣言されている場合、それらは異なります。 例えば、 MyUnit1.TMyType 型の変数を MyUnit2.TMyType 型の変数に割り当てると、2 つの型が同じ構造を持っているにもかかわらず、コンパイラエラーが発生します。
この規則の唯一の例外は、ジェネリック型の場合です。TList型の変数を宣言した場合、ジェネリック型 TListに基づく暗黙の型TListも作成されます。異なるユニットで宣言された複数の型インスタンスがあり、そのすべてがTListのシグネチャに一致していても、コンパイラはそれらを互換性があると見なし、それらを別の型に割り当てることができます。
しかし(コンパイラの)舞台裏では、コンパイラが異なるユニットで発生するそれぞれの新しい一時的な型を生成しています。つまり、例えばMyUnt1にTList(一時的な型名付き)、MyUnit2にTList(別の一時的な型名付き)を持つことになります。これらの型のそれぞれについて、コンパイラはジェネリック型のインスタンスを作成しますが、ジェネリック型の各メソッドについてもインスタンスを作成します。大量の複雑なジェネリック型(継承され、ネストされたもの)がある場合、コンパイラによってこの作業に多大な労力がかかる可能性があります。
この問題の解決策は、ジェネリックの使用をやめることではなく、可能な限りジェネリック型の共有インスタンスを作成することです。つまり、TListを何十もの異なるユニットで使用するのではなく、以下のように宣言すればよいのです。
[crayon-6768e37feae41435051943/]TList を使用するすべてのユニットで TListOfStrings 型を使用します。 単純な例では、違いはほとんど目立ちませんが、 ジェネリック型を大量に使用する大規模なアプリケーションでは、非常に大きな違いが生じる可能性があります。
繰り返しになりますが、コンパイラが同様のシナリオをより適切に処理できることは誰もが知っており、Embarcaderoではこのユースケースの改善に取り組んできました 。そして、ここ数年でかなり改善されました。それでも、コードの一部をその方向にリファクタリングすることで、コンパイラを高速化し、メモリ使用量を減らすことができます。つまり、総称型の等価性が存在しないようなコードを書くことです。
それでも、コードの一部をその方向にリファクタリングすることで、コンパイラの高速化とメモリ使用量の削減を支援することができます。つまり、ジェネリック型の等価性が存在しないようなコードを書くことです。
まとめ
このブログで紹介した内容は、Delphiコンパイラのパフォーマンスを調査した際に発見したシナリオの一部で、お客様のコードの変更によって部分的に対処できるものです。
特にユニットエイリアスと未修飾ユニットを削除し、循環ユニット参照の検出と削除を行うことをお勧めします。ジェネリックを多用している場合は、上記の最後の提案も検討してください。
また大規模なアプリケーションで、コンパイラの動作が遅くなる場合は、この方法をお勧めします。ほとんどの Delphi 開発者は、コンパイラのパフォーマンスに非常に満足しており、この点に関して何もする必要はありません。
最後にコードや設定の変更で部分的に対処できるコンパイラの速度低下の原因となるシナリオをこのブログですべて取り上げていないことも事実です。もしお客様の経験からここで紹介していない さらにもっと良い提案があれば、どうかEmbarcaderoの開発チームへお知らせください。