返回文章列表

Delphi程式碼效能最佳化技巧

本文探討 Delphi 程式碼效能最佳化技巧,涵蓋指標操作、組合語言應用和記憶體管理策略。文章以實際案例說明如何利用指標最佳化位元運算,並比較 Pascal 與組合語言版本向量乘法的效能差異。此外,文章還詳細介紹字串、陣列和 TList 的最佳化技巧,以及 FastMM4 記憶體管理器的使用,提供 Delphi

程式開發 效能最佳化

Delphi 程式碼的效能瓶頸常出現在頻繁的記憶體分配和低效率的演算法。透過指標操作,可以直接存取記憶體地址,減少資料複製和運算開銷,例如在影像處理中,使用指標操作畫素值比位元運算更有效率。此外,針對特定演算法,組合語言能充分發揮硬體效能,例如使用 SSE2 指令集最佳化向量乘法。然而,組合語言難以維護,建議僅用於效能關鍵路徑。FastMM4 作為 Delphi 的記憶體管理器,有效管理記憶體分配和釋放,但仍需注意字串、陣列和 TList 等資料結構的操作,避免頻繁的記憶體重新分配。預先組態記憶體空間或使用容量屬效能有效減少記憶體操作開銷,提升程式效能。

最佳化程式碼的藝術:從位元運算到組合語言

在程式開發中,效能最佳化一直是開發者關注的焦點。本文將探討如何透過指標操作和組合語言來提升程式碼的執行效率,並以Delphi語言為例,展示具體的實作方法和效能比較。

使用指標最佳化位元運算

在處理影像資料時,經常需要進行畫素值的操作。傳統的做法是使用位元運算來處理畫素值,但這種方法不僅複雜,而且效率不高。透過使用指標,我們可以直接操作記憶體中的位元組,從而提高程式碼的執行效率。

傳統的位元運算方法

procedure TfrmPointers.btnArrayClick(Sender: TObject);
var
  rgbData: TArray<Cardinal>;
  i: Integer;
  r, b: Byte;
begin
  rgbData := PrepareData;
  for i := Low(rgbData) to High(rgbData) do
  begin
    b := rgbData[i] AND $00FF0000 SHR 16;
    r := rgbData[i] AND $000000FF;
    rgbData[i] := rgbData[i] AND $FF00FF00 OR (r SHL 16) OR b;
  end;
end;

指標最佳化的程式碼

procedure TfrmPointers.btnPointerClick(Sender: TObject);
var
  rgbData: TArray<Cardinal>;
  i: Integer;
  r: Byte;
  pRed: PByte;
  pBlue: PByte;
begin
  rgbData := PrepareData;
  pRed := @rgbData[0];
  pBlue := pRed;
  Inc(pBlue, 2);
  for i := Low(rgbData) to High(rgbData) do
  begin
    r := pRed^;
    pRed^ := pBlue^;
    pBlue^ := r;
    Inc(pRed, SizeOf(rgbData[0]));
    Inc(pBlue, SizeOf(rgbData[0]));
  end;
end;

內容解密:

  1. pRedpBlue 是指向 Byte 的指標,分別用於存取紅色和藍色畫素值。
  2. Inc(pBlue, 2)pBlue 指標移動到第一個畫素的藍色分量。
  3. 在迴圈中,交換紅色和藍色畫素值,並將指標移動到下一個畫素。

組合語言的應用

在某些情況下,為了進一步提升效能,可以考慮使用組合語言。組合語言允許直接操作硬體資源,從而達到極高的執行效率。

Pascal 版本的向量乘法

type
  TVec4 = packed record
    X, Y, Z, W: Single;
  end;

function Multiply_PAS(const A, B: TVec4): TVec4;
begin
  Result.X := A.X * B.X;
  Result.Y := A.Y * B.Y;
  Result.Z := A.Z * B.Z;
  Result.W := A.W * B.W;
end;

組合語言版本的向量乘法

function Multiply_ASM(const A, B: TVec4): TVec4;
asm
  movups xmm0, [A]
  movups xmm1, [B]
  mulps xmm0, xmm1
  movups [Result], xmm0
end;

圖表翻譯:

此圖示呈現了向量乘法的過程。透過 SSE2 指令集,直接在 xmm0 和 xmm1 暫存器中進行四維向量的乘法運算,大幅提升了計算效率。

結合 Pascal 和組合語言的優點

在實際開發中,可以結合 Pascal 和組合語言的優點,既能保證程式碼的可讀性和可維護性,又能提升關鍵部分的執行效率。

function Multiply(const A, B: TVec4): TVec4;
{$IFDEF ASSEMBLER}
asm
  movups xmm0, [A]
  movups xmm1, [B]
  mulps xmm0, xmm1
  movups [Result], xmm0
end;
{$ELSE}
begin
  Result.X := A.X * B.X;
  Result.Y := A.Y * B.Y;
  Result.Z := A.Z * B.Z;
  Result.W := A.W * B.W;
end;
{$ENDIF}

深入最佳化:從SlowCode到記憶體管理

在上一章中,我們對SlowCode進行了大幅改進,最終使用埃拉託斯特尼篩法(Sieve of Eratosthenes)實作了處理一千萬個數字的最佳化版本,耗時約1,072毫秒。本章將繼續深入最佳化,探索如何進一步提升效能。

最佳化Reverse函式的使用

首先,我們注意到Reverse函式透過逐一追加字元來構建結果字串,這會導致頻繁的記憶體分配。該函式的實作如下:

function Reverse(s: string): string;
var
  ch: char;
begin
  Result := '';
  for ch in s do
    Result := ch + Result;
end;

然而,該函式的使用方式可以被最佳化。Filter方法使用它來反轉數字,但這涉及不必要的字串操作:

reversed := StrToInt(Reverse(IntToStr(i)));

上述操作不僅涉及記憶體分配,還需要解析字串並轉換為整數,這對於不需要字串的操作來說是多餘的。

使用整數反轉函式

我們可以實作一個直接反轉整數的函式,避免使用字串:

function ReverseInt(value: Integer): Integer;
begin
  Result := 0;
  while value <> 0 do
  begin
    Result := Result * 10 + (value mod 10);
    value := value div 10;
  end;
end;

內容解密:

  1. 初始化結果:將Result初始化為0,用於儲存反轉後的整數。
  2. 迴圈處理:當value不為0時,持續執行迴圈。
  3. 反轉邏輯:在每次迴圈中,將value的最後一位數字(value mod 10)追加到Result的末尾,並將value除以10以移除最後一位。
  4. 傳回結果:最終傳回反轉後的整數。

更新後的Filter方法如下:

function Filter(list: TList<Integer>): TArray<Integer>;
var
  i: Integer;
  reversed: Integer;
begin
  SetLength(Result, 0);
  for i in list do
  begin
    reversed := ReverseInt(i);
    if not ElementInDataDivides(list, reversed) then
    begin
      SetLength(Result, Length(Result) + 1);
      Result[High(Result)] := i;
    end;
  end;
end;

編譯器最佳化

此外,啟用編譯器最佳化可以進一步提升效能。僅需新增一行{$OPTIMIZATION ON}即可顯著改善效能。最佳化後的版本將處理一千萬個元素的時間縮短至529毫秒。

記憶體管理

在接下來的章節中,我們將探討記憶體管理的相關知識,包括記憶體分配的內部機制、如何動態建立記錄以及如何在平行程式設計中有效管理記憶體。

記憶體管理基礎

現代計算系統中,記憶體管理是至關重要的。作業系統負責協調多個程式在有限的記憶體空間中共存。當程式需要記憶體時,它會向作業系統請求,作業系統會分配一部分共用記憶體。當記憶體不再被需要時,它可以被釋放回作業系統。

然而,直接從作業系統分配和釋放記憶體是一個相對緩慢的操作。因此,大多數程式語言都會實作自己的內部記憶體管理機制。這種機制會預先從作業系統分配一大塊記憶體,然後在內部分割成更小的區塊供程式使用。

Delphi使用的記憶體管理器稱為FastMM4,它從Delphi 2006版本開始被採用。FastMM4是一個開源專案,由Pierre le Riche等人開發,並由Borland授權使用。儘管在平行程式設計環境中它並非完美,但經過多年的改進,它仍然表現出色。

最佳化字串和陣列分配

當你建立或擴充套件字串或動態陣列時,程式需要分配或重新分配記憶體。如果頻繁進行這種操作,將會導致效能下降。為了減少這種影響,可以預先分配足夠的記憶體,以減少重新分配的次數。

@startuml
skinparam backgroundColor #FEFEFE
skinparam componentStyle rectangle

title Delphi程式碼效能最佳化技巧

package "NumPy 陣列操作" {
    package "陣列建立" {
        component [ndarray] as arr
        component [zeros/ones] as init
        component [arange/linspace] as range
    }

    package "陣列操作" {
        component [索引切片] as slice
        component [形狀變換 reshape] as reshape
        component [堆疊 stack/concat] as stack
        component [廣播 broadcasting] as broadcast
    }

    package "數學運算" {
        component [元素運算] as element
        component [矩陣運算] as matrix
        component [統計函數] as stats
        component [線性代數] as linalg
    }
}

arr --> slice : 存取元素
arr --> reshape : 改變形狀
arr --> broadcast : 自動擴展
arr --> element : +, -, *, /
arr --> matrix : dot, matmul
arr --> stats : mean, std, sum
arr --> linalg : inv, eig, svd

note right of broadcast
  不同形狀陣列
  自動對齊運算
end note

@enduml

圖表翻譯: 此圖示描述了字串或動態陣列擴充套件時的處理流程。如果現有的記憶體空間足夠,則直接擴充套件;否則,需要分配新的記憶體區塊,並進行資料複製和原有記憶體的釋放。

最佳化字串與陣列組態

在程式設計中,字串和陣列的操作是非常常見的。然而,不當的操作可能會導致效能問題。本章節將探討如何最佳化字串與陣列的組態,以提升程式的執行效率。

字串操作的最佳化

首先,我們來看看一個簡單的字串追加範例。當我們點選「Append String」按鈕時,程式會將 ‘*’ 字元追加到字串中 1,000 萬次。看似簡單的程式碼,卻隱藏著潛在的效能問題:

procedure TfrmReallocation.btnAppendStringClick(Sender: TObject);
var
  s: String;
  i: Integer;
begin
  s := '';
  for i := 1 to CNumChars do
    s := s + '*';
end;

內容解密:

  • 上述程式碼的問題在於每次追加字元時,都會重新組態記憶體並複製原有的字串內容,這導致了巨大的效能開銷。
  • 解決方案是使用 SetLength 函式預先組態足夠的記憶體空間。
procedure TfrmReallocation.btnSetLengthClick(Sender: TObject);
var
  s: String;
  i: Integer;
begin
  SetLength(s, CNumChars);
  for i := 1 to CNumChars do
    s[i] := '*';
end;

內容解密:

  • SetLength(s, CNumChars) 預先組態了足夠的記憶體空間,避免了頻繁的記憶體重新組態。
  • 直接透過 s[i] := '*' 指定,避免了追加操作的額外開銷。
  • 經過最佳化後,執行時間從 142 ms 縮短至 33 ms。

陣列操作的最佳化

類別似地,當我們動態擴充套件陣列時,也會遇到效能問題。以下是一個每次追加一個元素的範例:

procedure TfrmReallocation.btnAppendArrayClick(Sender: TObject);
var
  arr: TArray<char>;
  i: Integer;
begin
  SetLength(arr, 0);
  for i := 1 to CNumChars do begin
    SetLength(arr, Length(arr) + 1);
    arr[High(arr)] := '*';
  end;
end;

內容解密:

  • 上述程式碼的問題在於每次擴充套件陣列時,都會重新組態記憶體並複製原有的陣列內容。
  • 解決方案同樣是使用 SetLength 預先組態足夠的記憶體空間。
procedure TfrmReallocation.btnSetLengthArrayClick(Sender: TObject);
var
  arr: TArray<char>;
  i: Integer;
begin
  SetLength(arr, CNumChars);
  for i := 1 to CNumChars do
    arr[i-1] := '*';
end;

內容解密:

  • SetLength(arr, CNumChars) 預先組態了足夠的記憶體空間。
  • 直接透過 arr[i-1] := '*' 指定,避免了追加操作的額外開銷。
  • 經過最佳化後,執行時間從 230 ms 縮短至 26 ms。

清單(TList)操作的最佳化

當我們向 TList<T> 中追加元素時,也會遇到類別似的問題。以下是一個簡單的範例:

procedure TfrmReallocation.btnAppendTListClick(Sender: TObject);
var
  list: TList<Char>;
  i: Integer;
begin
  list := TList<Char>.Create;
  try
    for i := 1 to CNumChars do
      list.Add('*');
  finally
    FreeAndNil(list);
  end;
end;

內容解密:

  • TList<T> 在內部使用動態陣列儲存資料,當容量不足時會自動擴充套件。
  • 為了最佳化,可以預先設定 Capacity 以減少記憶體重新組態的次數。
procedure TfrmReallocation.btnSetCapacityTListClick(Sender: TObject);
var
  list: TList<Char>;
  i: Integer;
begin
  list := TList<Char>.Create;
  try
    list.Capacity := CNumChars;
    for i := 1 to CNumChars do
      list.Add('*');
  finally
    FreeAndNil(list);
  end;
end;

內容解密:

  • 設定 list.Capacity := CNumChars 以預先組態足夠的記憶體空間。
  • 經過最佳化後,執行時間從 167 ms 縮短至 145 ms。
  • 同時也避免了過多的記憶體浪費。

記憶體管理功能詳解

記憶體管理功能可以分為幾大類別,每類別都包含了一系列設計用來協同工作的函式。讓我們深入瞭解這些類別:

第一組:GetMem、AllocMem、ReallocMem 和 FreeMem

  • GetMem(var P: Pointer; Size: Integer) 程式用於分配一個大小為 Size 的記憶體區塊,並將該區塊的位址儲存在指標變數 P 中。這個指標變數不侷限於指標型別,可以是任何指標型別(例如 PByte)。新分配的記憶體區塊不會被初始化,會包含記憶體中原有的資料。
  • AllocMem(Size: Integer): Pointer 函式分配一個記憶體區塊,將其內容填充為零,然後傳回該區塊的位址。
  • ReallocMem(var P: Pointer; Size: Integer) 程式用於改變記憶體區塊的大小。P 變數必須包含一個指向記憶體區塊的指標,而 Size 可以小於或大於原始區塊的大小。FastMM4 會嘗試在原地調整區塊大小。如果失敗,它會分配一個新的記憶體區塊,將原始資料複製到新區塊,釋放舊區塊,並在 P 變數中傳回新區塊的位址。與 GetMem 程式一樣,新分配的位元組不會被初始化。
  • 要釋放以這種方式分配的記憶體,應該呼叫 FreeMem(var P: Pointer) 程式。

第二組:GetMemory、ReallocMemory 和 FreeMemory

這三個函式與第一組中的函式功能相似,但它們可以用於 C++Builder 中。

第三組:New 和 Dispose

這兩個函式用於動態建立和銷毀任何型別的變數。要分配這樣一個變數,可以呼叫 New(var X: Pointer),其中 X 是任何指標型別。編譯器會自動提供正確大小的記憶體區塊,並將所有受控欄位初始化為零。非受控欄位不會被初始化。

要釋放這樣一個變數,不要使用 FreeMem,而是使用 Dispose(var X: Pointer)

程式碼範例:使用 New 和 Dispose 動態建立和銷毀記錄變數

type
  TRecord = record
    s1, s2, s3, s4: string;
  end;
  PRecord = ^TRecord;

procedure TfrmInitFin.btnNewDispClick(Sender: TObject);
var
  rec: PRecord;
begin
  New(rec);
  try
    rec.s1 := '4';
    rec.s2 := '2';
    rec.s4 := rec.s1 + rec.s2 + rec.s4;
    ListBox1.Items.Add('New: ' + rec.s4);
  finally
    Dispose(rec);
  end;
end;

內容解密:

  1. 宣告一個記錄型別 TRecord 和一個指向該記錄的指標型別 PRecord
  2. 使用 New(rec)rec 分配記憶體,並自動初始化受控欄位。
  3. rec 的欄位進行指定操作,就像操作普通記錄一樣。
  4. 使用完畢後,透過 Dispose(rec) 釋放記憶體。

第四組:Initialize 和 Finalize

嚴格來說,它們不是記憶體管理函式,但幾乎只在動態分配記憶體時使用。

如果使用非 NewAllocMem 的函式建立包含受控欄位的變數(例如記錄),則不會正確初始化。受控欄位會包含隨機資料,這將完全破壞程式的執行。要修復這一點,應該呼叫 Initialize(var V),傳入變數(而不是指向該變數的指標)。

在將這樣的變數傳回給記憶體管理器之前,應該透過呼叫 Finalize(var V) 清理所有對受控欄位的參照。最好使用 Dispose,它會自動執行此操作,但有時這不是一個選項,您必須手動執行。

程式碼範例:使用 GetMem 和 Initialize 動態分配記錄變數

procedure TfrmInitFin.btnInitFinClick(Sender: TObject);
var
  rec: PRecord;
begin
  GetMem(rec, SizeOf(TRecord));
  try
    Initialize(rec^);
    rec.s1 := '4';
    rec.s2 := '2';
    rec.s4 := rec.s1 + rec.s2 + rec.s4;
    ListBox1.Items.Add('GetMem+Initialize: ' + rec.s4);
  finally
    Finalize(rec^);
    FreeMem(rec);
  end;
end;

內容解密:

  1. 使用 GetMem 為記錄分配記憶體,需手動指定大小 SizeOf(TRecord)
  2. 透過 Initialize(rec^) 初始化記錄內容。
  3. 操作記錄欄位。
  4. 使用完畢後,透過 Finalize(rec^) 清理受控欄位,最後使用 FreeMem(rec) 釋放記憶體。

為何使用動態記錄分配?

雖然動態建立物件很簡單(只需呼叫 Create 建構函式),但動態分配記錄和其他資料型別(如陣列、字串等)稍微複雜一些。使用記錄而不是物件的主要原因是速度。

程式碼範例:比較物件分配與記錄分配的速度

type
  TNodeObj = class
    Left, Right: TNodeObj;
    Data: NativeUInt;
  end;

procedure TfrmAllocate.btnAllocClassClick(Sender: TObject);
var
  i: Integer;
  nodes: TArray<TNodeObj>;
begin
  SetLength(nodes, CNumNodes);
  for i := 0 to CNumNodes-1 do
    nodes[i] := TNodeObj.Create;
  for i := 0 to CNumNodes-1 do
    nodes[i].Free;
end;

圖表翻譯:

此範例程式碼展示瞭如何動態建立和銷毀物件。透過比較物件分配與記錄分配的速度,可以看出記錄分配在某些情況下具有速度優勢。