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;
內容解密:
pRed和pBlue是指向 Byte 的指標,分別用於存取紅色和藍色畫素值。Inc(pBlue, 2)將pBlue指標移動到第一個畫素的藍色分量。- 在迴圈中,交換紅色和藍色畫素值,並將指標移動到下一個畫素。
組合語言的應用
在某些情況下,為了進一步提升效能,可以考慮使用組合語言。組合語言允許直接操作硬體資源,從而達到極高的執行效率。
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;
內容解密:
- 初始化結果:將
Result初始化為0,用於儲存反轉後的整數。 - 迴圈處理:當
value不為0時,持續執行迴圈。 - 反轉邏輯:在每次迴圈中,將
value的最後一位數字(value mod 10)追加到Result的末尾,並將value除以10以移除最後一位。 - 傳回結果:最終傳回反轉後的整數。
更新後的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;
內容解密:
- 宣告一個記錄型別
TRecord和一個指向該記錄的指標型別PRecord。 - 使用
New(rec)為rec分配記憶體,並自動初始化受控欄位。 - 對
rec的欄位進行指定操作,就像操作普通記錄一樣。 - 使用完畢後,透過
Dispose(rec)釋放記憶體。
第四組:Initialize 和 Finalize
嚴格來說,它們不是記憶體管理函式,但幾乎只在動態分配記憶體時使用。
如果使用非 New 或 AllocMem 的函式建立包含受控欄位的變數(例如記錄),則不會正確初始化。受控欄位會包含隨機資料,這將完全破壞程式的執行。要修復這一點,應該呼叫 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;
內容解密:
- 使用
GetMem為記錄分配記憶體,需手動指定大小SizeOf(TRecord)。 - 透過
Initialize(rec^)初始化記錄內容。 - 操作記錄欄位。
- 使用完畢後,透過
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;
圖表翻譯:
此範例程式碼展示瞭如何動態建立和銷毀物件。透過比較物件分配與記錄分配的速度,可以看出記錄分配在某些情況下具有速度優勢。