R04-03.DOC

(379 KB) Pobierz
Szablon dla tlumaczy

1

 

Rozdział 4.             Kompilacja i techniki optymalizacyjne

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

Rozdział ten poświęcony jest tym elementom C++Buildera, które dokonują przekształcenia źródłowej postaci projektu w końcowe pliki wykonywalne – czyli kompilatorowi i konsolidatorowi – a dokładniej dwóm najważniejszym aspektom ich pracy: optymalności samego zabiegu owego „przekształcania” oraz optymalności produktu końcowego, głównie pod względem szybkości jego wykonywania (chociaż także i innych czynników, jak na przykład zajętość pamięci czy efektywność operacji dyskowych).

Większość obecnych kompilatorów, w tym również oczywiście C++Builder, dokonuje różnego rodzaju optymalizacji tworzonego przekładu, by uczynić go możliwie szybkim i zwięzłym. Czynności kompilacji i optymalizacji nierozerwalnie splatają się ze sobą, jeżeli pod pierwszym z tych określeń rozumieć tłumaczenie fragmentów kodu w języku wysokiego poziomu (C++) na równoważne fragmenty w kodzie maszynowym, zaś pod drugim – możliwie najlepszy dobór instrukcji tego kodu pod względem jego efektywności i rozmiaru.

Niezależnie jednak od najbardziej spektakularnych przejawów „inteligencji” kompilatora największy przyczynek do efektywności kodu wynikowego wnieść może sam programista, w przeciwieństwie bowiem do kompilatora, decydującego o tym, jak przetłumaczyć ustalony fragment kodu źródłowego, określa on, co ma zostać przetłumaczone; najbardziej nawet wyrafinowany kompilator, nie znający istoty rozwiązywanego problemu i intencji programisty, nie jest w stanie zniwelować skutków nieoptymalnego kodowania. W tym rozdziale omawiamy więc nie tylko różnorodne techniki optymalizacji automatycznej, lecz przede wszystkim zwracamy uwagę na te aspekty konstruowania aplikacji, które w największym stopniu odpowiedzialne są za efektywność wygenerowanego kodu.

Od C++ do modułu wykonywalnego

Co tak naprawdę dzieje się, gdy naciskamy klawisz F9, bądź wybieramy z menu głównego którąkolwiek opcję powodującą skompilowanie projektu? Co prawda odpowiedź na to pytanie nie jest bynajmniej niezbędna dla samego tworzenia (i kompilowania) projektów, należy jednak zdawać sobie sprawę z oczywistego faktu, iż kompilator jest na tyle istotnym elementem C++Buildera, iż bez niego pozostałe elementy stałyby się niemal bezużyteczne! Kompilator ten stosuje daleko posunięte zabiegi optymalizacyjne, dając w efekcie bardzo efektywny kod o jakości konkurencyjnej w stosunku do wytworów innych współczesnych kompilatorów C++. W procesie przekształcania kodu źródłowego w C++ w plik wykonywalny *.exe lub bibliotekę *.dll wyodrębnić można cztery następujące fazy:

·         przetwarzanie wstępne (preprocessing) – w tej fazie następuje rozwijanie makr i dyrektyw zawartych w kodzie źródłowym; w szczególności włączane są pliki nagłówkowe określone przez dyrektywy #include;

·         rozbiór syntaktyczny i semantyczny – w kodzie stanowiącym rezultat pracy preprocesora wydzielane są poszczególne jednostki składniowe i następuje określenie znaczenia (semantyki) tych jednostek; w ostateczności generowane jest tzw. drzewo wyprowadzenia syntaktycznego (ang. syntax tree), stanowiące podstawę generowania kodu wynikowego;

·         generowanie kodu – poszczególne instrukcje zastępowane są odpowiednikami w kodzie maszynowym; generowany kod jest optymalizowany głównie pod kątem tzw. parowania instrukcji (ang. instruction pairing) i cech specyficznych dla danego procesora. Kod wynikowy każdego z modułów zapisywany jest w odrębnym pliku *.obj; na końcu każdego z tych plików dołączana jest opcjonalnie informacja niezbędna w procesie śledzenia symbolicznego (debugging), odzwierciedlająca powiązanie poszczególnych fragmentów wygenerowanego kodu binarnego z poszczególnymi instrukcjami i elementami danych kodu źródłowego;

·         konsolidacja – konsolidator analizuje zawartość każdego z wynikowych plików *.obj, tworząc globalną tablicę symboli, na podstawie której realizowane są następnie międzymodułowe odwołania do funkcji i danych definiowanych w poszczególnych modułach. Kod zawarty w plikach *.obj łączony jest z kodem plików zasobowych i statycznie dołączanych bibliotek, dając w efekcie gotowy do wykonania plik *.exe lub bibliotekę *.dll.

 

Celowo napisaliśmy „fazy”, a nie „etapy” – bowiem wymienienie powyższych faz w określonej kolejności nie ma bynajmniej sugerować kolejnych, odrębnych stadiów obróbki kodu źródłowego. Kompilator, analizując kod źródłowy, posługuje się metodą tzw. zejścia rekurencyjnego z nieograniczonym wyprzedzeniem przeglądania (ang. recursive descent model with infinite lookahead), co oznacza, iż każda jednostka semantyczna rozpatrywana jest w podziale na prostsze jednostki składowe, zaś do rozpoznania kolejnej jednostki wymagane jest wczytanie pewnej, nie ograniczanej z góry, liczby znaków kodu[1]. Rekursywny charakter analizy polega natomiast na równoległym, rekursywnym wywoływaniu procedur realizujących wymienione fazy.

Każda z wymienionych faz stwarza pewną okazję do dokonywania optymalizacji, niemniej jednak wszelkie czynności optymalizacyjne podzielić można na dwie kategorie: te mające wpływ na postać drzewa syntaktycznego, odnoszące się więc do kodu źródłowego i zwane stąd wysokopoziomowymi, i te niskopoziomowe związane ściśle z architekturą docelowego procesora i repertuarem jego instrukcji. Do pierwszej z wymienionych kategorii zaliczyć można m.in.: upraszczanie podwyrażeń (ang. subexpression folding), polegające na zastępowaniu działań na wartościach stałych ich wynikami, zastępowanie mnożeń i dzieleń przez potęgi dwójki operacjami przesunięć bitowych, rozwijanie funkcji wstawialnych (inlining) itp. Optymalizacje niskopoziomowe mają charakter bardziej subtelny, a ich przykładem może być eliminacja sąsiadujących rozkazów nie powodujących w sumie żadnego efektu, jak np. dwa początkowe rozkazy w poniższej sekwencji:

 

push eax

pop  eax

mov  ebx,eax

push edx

 

czy też eliminacja zbędnych rozkazów, jak w poniższym przykładzie:

 

mov  edx,A

mov  eax,B

add  eax,edx

push eax

mov  edx,A   ¬

mov  eax,F

sub  eax,edx

push eax

 

 

gdzie rozkaz wskazany przez strzałkę jest po prostu zbędny.

Nie są natomiast wykonywane żadne optymalizacje, wynikające z zależności przekraczających granice poszczególnych funkcji.

Większość opcji sterujących przebiegiem kompilacji i postacią kodu wynikowego dostępna jest na kartach: Compiler, Advanced Compiler, Linker i Advanced Linker opcji projektu, niektóre dostępne są jednak tylko z poziomu głównego pliku projektu (*.bpr) lub tylko w wywołaniach kompilatora z wiersza poleceń.

Jedną z nowości wersji 5 C++Buildera jest to, iż plik *.bpr ma format charakterystyczny dla języka XML. Jego zawartość edytować można z poziomu IDE, wybierając opcję Project|Edit Option Source z menu głównego. Opcje dotyczące (odpowiednio): kompilatora, konsolidatora, kompilatora zasobów, kompilatora Object Pascala i Asemblera znajdują się w sekcji <OPTIONS> w pozycjach (odpowiednio): <CFLAG1  >, <LFLAGS  >, <RFLAGS … >, <PFLAGS  > i <AFLAGS  >.

W podkatalogu Examples lokalnej instalacji C++Buildera znajduje się ciekawy projekt o nazwie WinTools. Ilustruje on znaczenie poszczególnych opcji kompilatora i innych programów narzędziowych C++Buildera, których kompletny wykaz (wraz z opisem poszczególnych opcji) znaleźć można w systemie pomocy.

Przyspieszanie kompilacji

Kompilator C++Buildera, niezależnie od wysokiej jakości tworzonego kodu, sam w sobie jest szybkim programem. Jest niemal dwa razy szybszy od kompilatora GNU C++ i porównywalny pod względem szybkości z kompilatorem Visual C++. Dla użytkowników posługujących się jednocześnie Delphi wydaje się on jednak cokolwiek powolny, jeżeli rozpatrywać porównywalne pod względem rozmiaru aplikacje w C++ i Object Pascalu; porównując jednak Delphi z C++Builderem należy mieć na uwadze różnorodne czynniki mające wpływ na tę różnicę szybkości, między innymi:

·         C++ wykorzystuje intensywnie pliki nagłówkowe, których brak jest w Object Pascalu[2]. Ze względu na to, iż pliki te mogą być zagnieżdżane, rzeczywisty kod, z którym uporać się musi kompilator, przybrać może rozmiary znacznie większe od tych wynikających z pierwszego spojrzenia na dany projekt – skomplikowane, rekurencyjne zagnieżdżenie kilku zaledwie plików nagłówkowych o długości 10 wierszy każdy może dać w efekcie kod o długości setek tysięcy wierszy! Z oczywistych względów musi się to kompilować dłużej niż 10-20-wierszowy projekt w Delphi.

·         W Object Pascalu nie występują makra, które w C++ interpretowane są w czasie kompilacji.

·         Charakterystyczny dla C++, nieobecny w Object Pascalu, mechanizm szablonów znacznie komplikuje proces analizy kodu źródłowego.

·         Semantyka C++ podporządkowana jest standardom ANSI i jest nieporównanie bardziej złożona od „gramatyki” Object Pascala, stanowiącej arbitralny standard Borlanda.

 

Generalnie C++ oferuje programiście o wiele większe możliwości w zakresie tworzenia aplikacji niż Delphi oparte na Object Pascalu. Za ofertę tę trzeba jednak zapłacić cenę w postaci bardziej czasochłonnej kompilacji i (często) mniej czytelnego kodu źródłowego. Tym większego znaczenia nabierają więc oferowane przez C++Builder mechanizmy umożliwiające przyspieszenie kompilacji – omówimy je teraz w kolejnych punktach.

Prekompilowane nagłówki

Jedną z najbardziej skutecznych metod przyspieszania kompilacji jest unikanie powtórnej kompilacji tych samych plików nagłówkowych lub ich powtarzającej się sekwencji w różnych modułach źródłowych. Zaznaczając opcję Use pre–compiled headers w sekcji Pre–compiled headers na karcie Compiler opcji projektu spowodujemy zapisywanie we wskazanym pliku dyskowym skompilowanej postaci każdego z nagłówków, tworzonej przy pierwszym napotkaniu odwołania do tegoż nagłówka i wykorzystywanej przy kolejnych odwołaniach. Zaznaczając opcję Cache pre–compiled headers, zyskujemy dalsze przyspieszenie kompilacji poprzez buforowanie prekompilowanych nagłówków w pamięci operacyjnej.

 

Napotkanie przez kompilator (w module źródłowym) dyrektywy #pragma hdrstop stanowi dla niego polecenie zaprzestania wykorzystywania prekompilowanych nagłówków podczas dalszej kompilacji modułu. Nagłówki włączane do tegoż modułu przez dyrektywy #include poprzedzające dyrektywę #pragma hdrstop podlegać będą prekompilacji, należy jednak wspomnieć tutaj o dość istotnym uwarunkowaniu tego mechanizmu. Otóż prekompilacji podlegają nie tyle oddzielne nagłówki, ile ich konkretne zestawy w konkretnej kolejności (wynikającej z kolejności odnośnych dyrektyw #include). Dwa różne moduły źródłowe współdzielić więc będą tę samą prekompilowaną porcję nagłówków jedynie wówczas, gdy lista dołączanych plików nagłówkowych (przed dyrektywą #pragma hdrstop) będzie w obydwu tych modułach identyczna co do zestawu i kolejności plików. W modułach generowanych automatycznie przez C++Builder taką prekompilowaną listę stanowią nagłówki charakterystyczne dla biblioteki VCL – lista ta poprzedza dyrektywę #pragma hdrstop, po której odbywa się dołączanie nagłówków charakterystycznych dla danego modułu i nie podlegających prekompilacji. Oto ilustracja tej idei na przykładzie dwóch (fikcyjnych) modułów źródłowych:

 

Wydruk 4.1. Dwa moduły źródłowe współdzielące prekompilowaną listę nagłówków

 

//..................................

//

// LoadPage.cpp

 

#include <vcl.h>

#include <System.hpp>

#include <Windows.hpp>

#include "SearchMain.h"

#pragma hdrstop

 

#include "LoadPage.h"

#include "CacheClass.h"

//..................................

//

 

...

 

//..................................

//

// ViewOptions.cpp

 

#include <vcl.h>

#include <System.hpp>

#include <Windows.hpp>

#include "SearchMain.h"

#pragma hdrstop

 

#include <Graphics.hpp>

#include "ViewOptions.h"

//..................................

//

 

...

 

 

Jak pokazuje praktyka, umiejętne grupowanie dołączanych plików nagłówkowych skutkować może nawet dziesięciokrotnym przyspieszeniem kompilacji!

Autorzy oryginału proponują w tym miejscu artykuł zawierający więcej informacji na temat prekompilowanych nagłówków, znajdujący się pod adresem http://www.bcbdev.com/articles/pch.htm

Inne metody przyspieszania kompilacji

Najbardziej oczywistym sposobem skracania czasu kompilacji projektu jest unikanie kompilowania tych fragmentów kodu, które i tak nie zostaną w projekcie wykorzystane. Dotyczy to w pierwszym rzędzie zbędnych plików nagłówkowych – związane z nimi dyrektywy #include najprościej po prostu „wykomentować”.

Istnieje jednak istotny wyjątek od tej zasady. Jak przed chwilą napisaliśmy, prekompilacja nagłówków przynosi widoczne korzyści jedynie wówczas, gdy dwa moduły (lub większa ich liczba) posługują się identyczną listą dołączanych plików nagłówkowych. W poniższym przykładzie dwa (fikcyjne) moduły źródłowe nie spełniają tego warunku:

 

Wydruk 4.2. Sytuacja, w której dołączenie niewykorzystywanych plików nagłówkowych przyspieszy kompilację

 

//..................................

//

// FirstModule.cpp

 

#include <vcl.h>

#include <System.hpp>

#include <Windows.hpp>

¬

#include "ScanModules.h"

#pragma hdrstop

 

#include "LoadPage.h"

#include "CacheClass.h"

//..................................

//

 

...

 

//..................................

//

// SecondModule.cpp

 

#include <vcl.h>

#include <System.hpp>

#include <Windows.hpp>

#include "SearchMain.h"

¬

#pragma hdrstop

 

#include <Graphics.hpp>

#include "ViewOptions.h"

//..................................

//

 

...

 

 

Jeżeli jednak do modułu FirstModule wstawić (w odpowiednim miejscu) dyrektywę #include "SearchMain.h", zaś do modułu SecondModule – dyrektywę #include "ScanModules.h", moduły zaczną kompilować się szybciej, bowiem zysk wynikający ze współdzielenia prekompilowanej listy nagłówków przewyższy z pewnością stratę czasu spowodowaną kompilacją niewykorzystywanych plików nagłówkowych.

 

Równie oczywistym sposobem zaoszczędzenia czasu kompilacji jest kompilowanie tylko tych modułów źródłowych, które faktycznie tej kompilacji wymagają. Wybierając opcję Make… z menu Project nakazujemy kompilatorowi skompilować tylko te moduły, które jeszcze kompilowane nie były (tj. nie posiadają odpowiadającego pliku *.obj) oraz te, których treść była modyfikowana od czasu ostatniej kompilacji; wybranie opcji Build… spowodowałoby natomiast kompilację wszystkich modułów projektu, trwającą zazwyczaj nieco dłużej.

Kompilacja w trybie Make nie zawsze jednak daje pożądane rezultaty. Jedynym bowiem kryterium, którym kieruje się kompilator, oceniając konieczność ponownej kompilacji modułu, jest data jego ostatniej modyfikacji (w konfrontacji z datą ostatniej modyfikacji odpowiedniego pliku *.obj); tymczasem modyfikacja kodu źródłowego modułu nie jest bynajmniej jedyną okolicznością uzasadniającą jego rekompilację – równie istotną przesłanką może być np. zmodyfikowanie opcji projektu, której to przesłanki kompilator nie weźmie jednak pod uwagę i gwarancję uzyskania aktualnego kodu wynikowego daje wówczas tylko kompilacja w trybie Build.

Kolejne źródło oszczędności czasu kompilacji kryje się w szczegółowości generowanego kodu, a konkretnie – w informacjach symbolicznych dla debuggerów. W sytuacji, gdy testowanie programu polega wyłącznie na obserwacji zewnętrznych przejawów jego działania (bez pracy krokowej), korzystne może okazać się wyłączenie opcji związanych ze śledzeniem na kartach Compiler...

Zgłoś jeśli naruszono regulamin