Optymalizacja programu

Optymalizacja może wystąpić na wielu poziomach. Zazwyczaj wyższe poziomy mają większy wpływ i są trudniejsze do zmiany później w projekcie, wymagając znaczących zmian lub całkowitego przepisania, jeśli muszą być zmienione. Tak więc optymalizacja może zazwyczaj przebiegać poprzez udoskonalanie od wyższego do niższego, przy czym początkowe zyski są większe i osiągane przy mniejszym nakładzie pracy, a późniejsze zyski są mniejsze i wymagają więcej pracy. Jednakże, w niektórych przypadkach ogólna wydajność zależy od wydajności bardzo niskopoziomowych części programu, a małe zmiany na późnym etapie lub wczesne rozważenie niskopoziomowych szczegółów może mieć ogromny wpływ. Zazwyczaj zwraca się uwagę na wydajność w trakcie całego projektu – choć jest to bardzo zróżnicowane – ale większa optymalizacja jest często uważana za udoskonalenie, które powinno być wykonane późno, jeśli w ogóle. W dłużej trwających projektach są zazwyczaj cykle optymalizacji, gdzie poprawa jednego obszaru ujawnia ograniczenia w innym, a te są zazwyczaj ograniczane, gdy wydajność jest akceptowalna lub zyski stają się zbyt małe lub kosztowne.

Jako wydajność jest częścią specyfikacji programu – program, który jest nadmiernie powolny nie nadaje się do celu: gra wideo z 60 Hz (klatek na sekundę) jest do przyjęcia, ale 6 klatek na sekundę jest nie do przyjęcia – wydajność jest rozważana od początku, aby zapewnić, że system jest w stanie dostarczyć wystarczającą wydajność, a wczesne prototypy muszą mieć z grubsza akceptowalną wydajność, aby istniała pewność, że ostateczny system (z optymalizacją) osiągnie akceptowalną wydajność. Czasami jest to pomijane w przekonaniu, że optymalizację zawsze można przeprowadzić później, w wyniku czego prototypowe systemy są o wiele za wolne – często o rząd wielkości lub więcej – i systemy, które ostatecznie okazują się porażkami, ponieważ architektonicznie nie są w stanie osiągnąć swoich celów wydajnościowych, jak Intel 432 (1981); lub takie, które wymagają lat pracy, aby osiągnąć akceptowalną wydajność, jak Java (1995), która osiągnęła akceptowalną wydajność dopiero w HotSpot (1999). Stopień, w jakim wydajność zmienia się między prototypem a systemem produkcyjnym, oraz to, jak podatna jest na optymalizację, może być znaczącym źródłem niepewności i ryzyka.

Poziom projektuEdit

Na najwyższym poziomie projekt może być zoptymalizowany, aby jak najlepiej wykorzystać dostępne zasoby, biorąc pod uwagę cele, ograniczenia i oczekiwane użycie/obciążenie. Projekt architektoniczny systemu ma ogromny wpływ na jego wydajność. Na przykład, system, który jest związany z opóźnieniami sieciowymi (gdzie opóźnienie sieciowe jest głównym ograniczeniem dla ogólnej wydajności) będzie zoptymalizowany tak, aby zminimalizować liczbę podróży sieciowych, najlepiej wykonując pojedyncze żądanie (lub brak żądań, jak w protokole push) zamiast wielu podróży w obie strony. Wybór projektu zależy od celów: podczas projektowania kompilatora, jeśli szybka kompilacja jest kluczowym priorytetem, kompilator jednoprzebiegowy jest szybszy niż kompilator wieloprzebiegowy (zakładając tę samą pracę), ale jeśli szybkość kodu wyjściowego jest celem, wolniejszy kompilator wieloprzebiegowy spełnia ten cel lepiej, nawet jeśli sam zajmuje więcej czasu. Wybór platformy i języka programowania występują na tym poziomie, a ich zmiana często wymaga całkowitego przepisania, chociaż system modułowy może pozwolić na przepisanie tylko niektórych komponentów – na przykład program Python może przepisać krytyczne pod względem wydajności sekcje w C. W systemie rozproszonym wybór architektury (klient-serwer, peer-to-peer itp.) występuje na poziomie projektowania i może być trudny do zmiany, szczególnie jeśli wszystkie komponenty nie mogą być zastąpione synchronicznie (np, starych klientów).

Algorytmy i struktury danychEdit

Dzięki ogólnemu projektowi, dobry wybór wydajnych algorytmów i struktur danych oraz wydajna implementacja tych algorytmów i struktur danych jest następna. Po projekcie, wybór algorytmów i struktur danych wpływa na wydajność bardziej niż jakikolwiek inny aspekt programu. Generalnie struktury danych są trudniejsze do zmiany niż algorytmy, ponieważ założenie struktury danych i jej założenia wydajnościowe są używane w całym programie, choć można to zminimalizować przez użycie abstrakcyjnych typów danych w definicjach funkcji i utrzymanie konkretnych definicji struktur danych ograniczonych do kilku miejsc.

Dla algorytmów polega to przede wszystkim na zapewnieniu, że algorytmy są stałe O(1), logarytmiczne O(log n), liniowe O(n), lub w niektórych przypadkach log-liniowe O(n log n) na wejściu (zarówno w przestrzeni jak i w czasie). Algorytmy o złożoności kwadratowej O(n2) nie skalują się, a nawet algorytmy liniowe powodują problemy, jeśli są wielokrotnie wywoływane, i są zwykle zastępowane stałymi lub logarytmicznymi, jeśli to możliwe.

Poza asymptotycznym porządkiem wzrostu, stałe czynniki mają znaczenie: asymptotycznie wolniejszy algorytm może być szybszy lub mniejszy (ponieważ prostszy) niż asymptotycznie szybszy algorytm, gdy obaj mają do czynienia z małymi danymi wejściowymi, co może być przypadkiem występującym w rzeczywistości. Często algorytm hybrydowy zapewni najlepszą wydajność, ze względu na ten tradeoff zmieniający się wraz z rozmiarem.

Ogólna technika poprawy wydajności polega na unikaniu pracy. Dobrym przykładem jest użycie szybkiej ścieżki dla typowych przypadków, poprawiając wydajność poprzez unikanie niepotrzebnej pracy. Na przykład, użycie prostego algorytmu układu tekstu dla tekstu łacińskiego, tylko przejście do złożonego algorytmu układu dla złożonych skryptów, takich jak Devanagari. Inną ważną techniką jest buforowanie, a w szczególności memoizacja, która pozwala uniknąć zbędnych obliczeń. Ze względu na znaczenie buforowania, często istnieje wiele poziomów buforowania w systemie, co może powodować problemy z wykorzystaniem pamięci oraz problemy z poprawnością wynikające z nieaktualnych buforów.

Poziom kodu źródłowegoEdit

Poza ogólnymi algorytmami i ich implementacją na abstrakcyjnej maszynie, konkretne wybory na poziomie kodu źródłowego mogą stanowić znaczącą różnicę. Na przykład, na wczesnych kompilatorach C, while(1) był wolniejszy niż for(;;) dla pętli bezwarunkowej, ponieważ while(1) ocenił 1, a następnie miał skok warunkowy, który testował, czy jest prawdziwy, podczas gdy for (;;) miał skok bezwarunkowy . Niektóre optymalizacje (takie jak ta) mogą być obecnie wykonywane przez optymalizujące kompilatory. Zależy to od języka źródłowego, docelowego języka maszynowego i kompilatora, i może być zarówno trudne do zrozumienia lub przewidzenia, jak i zmienia się w czasie; jest to kluczowe miejsce, w którym zrozumienie kompilatorów i kodu maszynowego może poprawić wydajność. Ruch kodu niezmiennego w pętli i optymalizacja wartości zwracanej są przykładami optymalizacji, które zmniejszają zapotrzebowanie na zmienne pomocnicze i mogą nawet skutkować szybszą wydajnością poprzez unikanie optymalizacji w obie strony.

Poziom kompilacjiEdit

Pomiędzy poziomem źródła i kompilacji, dyrektywy i flagi kompilacji mogą być użyte do dostrojenia opcji wydajności w kodzie źródłowym i kompilatorze, odpowiednio, takich jak użycie definicji preprocesora do wyłączenia niepotrzebnych funkcji oprogramowania, optymalizacja dla konkretnych modeli procesorów lub możliwości sprzętowych, lub przewidywanie rozgałęzień, na przykład. Systemy dystrybucji oprogramowania opartego na źródłach, takie jak Ports BSD i Portage Gentoo, mogą korzystać z tej formy optymalizacji.

Poziom kompilacjiEdit

Użycie kompilatora optymalizującego zapewnia, że program wykonywalny jest zoptymalizowany co najmniej tak bardzo, jak kompilator może przewidzieć.

Poziom montażuEdit

Na najniższym poziomie, pisanie kodu za pomocą języka asemblera, zaprojektowanego dla konkretnej platformy sprzętowej, może produkować najbardziej wydajny i zwarty kod, jeśli programista korzysta z pełnego repertuaru instrukcji maszynowych. Z tego powodu wiele systemów operacyjnych używanych w systemach wbudowanych było tradycyjnie pisanych w kodzie asemblera. Programy (poza bardzo małymi) są rzadko pisane od początku do końca w asemblerze ze względu na czas i koszty. Większość z nich jest kompilowana z języka wysokiego poziomu do asemblera i stamtąd ręcznie optymalizowana. Gdy wydajność i rozmiar są mniej ważne duże części mogą być napisane w języku wysokiego poziomu.

Z bardziej nowoczesnymi kompilatorami optymalizującymi i większą złożonością ostatnich procesorów, trudniej jest napisać bardziej wydajny kod niż to, co generuje kompilator, a niewiele projektów potrzebuje tego „ostatecznego” kroku optymalizacji.

Wiele kodu napisanego dzisiaj jest przeznaczone do uruchomienia na jak największej liczbie maszyn. W konsekwencji, programiści i kompilatory nie zawsze wykorzystują bardziej wydajne instrukcje dostarczane przez nowsze procesory lub dziwactwa starszych modeli. Dodatkowo, kod asemblera dostrojony dla konkretnego procesora bez użycia takich instrukcji może nadal być nieoptymalny na innym procesorze, oczekując innego dostrojenia kodu.

Typowo dzisiaj zamiast pisać w języku asemblera, programiści będą używać disassemblera do analizowania danych wyjściowych kompilatora i zmieniać kod źródłowy wysokiego poziomu, aby można go było skompilować bardziej wydajnie lub zrozumieć, dlaczego jest nieefektywny.

Czas działaniaEdit

Kompilatory Just-in-time mogą produkować dostosowany kod maszynowy na podstawie danych czasu działania, kosztem narzutu kompilacji. Ta technika pochodzi z najwcześniejszych silników wyrażeń regularnych i stała się powszechna dzięki Java HotSpot i V8 dla JavaScript. W niektórych przypadkach adaptacyjna optymalizacja może być w stanie wykonać optymalizację czasu działania przekraczającą możliwości statycznych kompilatorów poprzez dynamiczne dostosowywanie parametrów zgodnie z rzeczywistymi danymi wejściowymi lub innymi czynnikami.

Profile-guided optimization jest techniką optymalizacji przed czasem (AOT) kompilacji opartą na profilach czasu działania i jest podobna do statycznego „średniego przypadku” analogu dynamicznej techniki adaptacyjnej optymalizacji.

Self-modifying kod może zmienić się w odpowiedzi na warunki czasu pracy w celu optymalizacji kodu; to było bardziej powszechne w programach w języku asemblera.

Niektóre projekty CPU mogą wykonywać pewne optymalizacje w czasie pracy. Niektóre przykłady to Out-of-order execution, Speculative execution, Instruction pipelines, and Branch predictors. Kompilatory mogą pomóc programowi wykorzystać te cechy procesora, na przykład poprzez planowanie instrukcji.

Optymalizacje zależne i niezależne od platformyEdit

Optymalizacja kodu może być również szeroko skategoryzowana jako techniki zależne i niezależne od platformy. Podczas gdy te drugie są skuteczne na większości lub wszystkich platformach, techniki zależne od platformy wykorzystują specyficzne właściwości jednej platformy, lub polegają na parametrach zależnych od pojedynczej platformy lub nawet pojedynczego procesora. W związku z tym może być konieczne napisanie lub wyprodukowanie różnych wersji tego samego kodu dla różnych procesorów. Dla przykładu, w przypadku optymalizacji na poziomie kompilacji, techniki niezależne od platformy są technikami ogólnymi (takimi jak rozwijanie pętli, redukcja wywołań funkcji, procedury efektywnie wykorzystujące pamięć, redukcja warunków, etc.), które wpływają na większość architektur procesorów w podobny sposób. Świetnym przykładem optymalizacji niezależnej od platformy jest wewnętrzna pętla for, gdzie zaobserwowano, że pętla z wewnętrzną pętlą for wykonuje więcej obliczeń w jednostce czasu niż pętla bez niej lub z wewnętrzną pętlą while. Ogólnie rzecz biorąc, służą one do zmniejszenia całkowitej długości ścieżki instrukcji wymaganej do ukończenia programu i/lub zmniejszenia całkowitego użycia pamięci podczas tego procesu. Z drugiej strony, techniki zależne od platformy obejmują planowanie instrukcji, równoległość na poziomie instrukcji, równoległość na poziomie danych, techniki optymalizacji pamięci podręcznej (tj. parametry, które różnią się między różnymi platformami), a optymalne planowanie instrukcji może być różne nawet na różnych procesorach o tej samej architekturze.

.