Jak przetłumaczyliśmy 10 milionów linii kodu C++ na standard C++14 (a potem na C++17)

Jakiś czas temu (jesienią 2016 roku), podczas prac nad nową wersją platformy technologicznej 1C:Enterprise w zespole deweloperskim pojawiło się pytanie o wsparcie nowego standardu C++14 w naszym kodzie. Przejście na nowy standard, jak zakładaliśmy, pozwoliłoby nam pisać wiele rzeczy elegancko, prosto i niezawodnie, uprościłoby wsparcie i utrzymanie kodu. I w tłumaczeniu nie ma nic nadzwyczajnego, gdyby nie rozmiar bazy kodu i specyficzne cechy naszego kodu.

Dla tych, którzy nie wiedzą, 1C:Enterprise to środowisko do szybkiego rozwoju aplikacji biznesowych na różnych platformach i czas wykonywania ich w różnych systemach operacyjnych i bazach danych. W skrócie, skład produktu obejmuje:

  • Klaster serwerów aplikacji, działający na Windows i Linux
  • Klient, który łączy się z serwerem przez http(s) lub własny protokół binarny, działa na Windows, Linux, macOS
  • Klient internetowy, działający w przeglądarkach Chrome, Internet Explorer, Microsoft Edge, Firefox, Safari (napisany w JavaScript)
  • Środowisko programistyczne (Konfigurator), działające na Windows, Linux, macOS
  • Narzędzia administracyjne serwerów aplikacji, działają na Windows, Linux, macOS
  • Klient mobilny, łączący się z serwerem przez http(s), działa na urządzeniach mobilnych z systemem Android, iOS, Windows
  • Platforma mobilna — framework do tworzenia offline'owych aplikacji mobilnych z możliwością synchronizacji, działających na Android, iOS, Windows
  • Środowisko deweloperskie 1C:Enterprise Development Tools, napisana w Java
  • Serwer Systemy Interakcji

Staraliśmy się pisać jeden kod na różne systemy operacyjne — baza kodu serwera jest wspólna w 99%, klienta — w około 95%. Platforma technologiczna 1C:Enterprise jest w przeważającej mierze napisana w C++ i poniżej przedstawiamy przybliżone charakterystyki kodu:

  • 10 milionów lini kodu C++,
  • 14 tysięcy plików,
  • 60 tysięcy klas,
  • pół miliona metod.

I to wszystko trzeba było przenieść na C++14. O tym, jak to zrobiliśmy i z czym się zmierzyliśmy w trakcie, opowiemy dzisiaj.

Jak przetłumaczyliśmy 10 milionów linii kodu C++ na standard C++14 (a potem na C++17)

Wyłączenie odpowiedzialności

Wszystko, co napisano poniżej na temat powolnej/szybkiej pracy, (nie) dużego zużycia pamięci implementacjami standardowych klas w różnych bibliotekach, oznacza jedno: jest to prawda DLA NAS. Możliwe, że dla Twoich zadań standardowe implementacje będą najlepsze. My jednak opieraliśmy się na swoich potrzebach: braliśmy typowe dla naszych klientów dane, uruchamialiśmy na nich typowe scenariusze, analizowaliśmy wydajność, zużycie pamięci itd., i ocenialiśmy – czy takie wyniki są dla nas i naszych klientów satysfakcjonujące, czy nie. I postępowaliśmy w zależności od tego.

Co mieliśmy

Początkowo pisaliśmy kod platformy 1C: Przedsiębiorstwo 8 w Microsoft Visual Studio. Projekt rozpoczął się na początku lat 2000-tych i mieliśmy tylko wersję na Windows. Naturalnie, od tego czasu kod intensywnie ewoluował, wiele mechanizmów zostało całkowicie przepisanych. Ale kod był pisany zgodnie z normą z 1998 roku, a na przykład prawe nawiasy były oddzielone spacjami, aby kompilacja przeszła pomyślnie, tak:

vector<vector > IntV;

W 2006 roku, z wydaniem wersji platformy 8.1, zaczęliśmy wspierać Linux i przeszliśmy na zewnętrzną standardową bibliotekę STLPort. Jednym z powodów przejścia była obsługa szerokich łańcuchów. W naszym kodzie powszechnie używamy std::wstring, opartego na typie wchar_t. Jego rozmiar w Windows wynosi 2 bajty, a w Linuxie domyślnie 4 bajty. Prowadziło to do niekompatybilności naszych protokołów binarnych między klientem a serwerem, a także różnych danych trwałych. Opcjami gcc można wskazać, aby rozmiar wchar_t podczas kompilacji wynosił też 2 bajty, ale wtedy można zapomnieć o używaniu standardowej biblioteki od kompilatora, ponieważ korzysta ona z glibc, która z kolei została skompilowana pod 4-bajtowy wchar_t. Innymi powodami były lepsze implementacje standardowych klas, wsparcie dla tablic mieszających, a nawet emulacja semantyki przenoszenia wewnątrz kontenerów, z której aktywnie korzystaliśmy. A jeszcze jednym powodem, że tak powiem, ostatnim, ale nie mniej ważnym, była wydajność łańcuchów. Mieliśmy swoją klasę dla łańcuchów, ponieważ w związku z charakterystyką naszego oprogramowania operacje na łańcuchach są stosowane bardzo szeroko i są dla nas krytyczne.

Nasz łańcuch oparty jest na pomysłach optymalizacji łańcuchów, wyrażonych jeszcze na początku lat 2000-tych Andrieja Alexandrescu. Później, gdy Alexandrescu pracował w Facebooku, na jego wniosek w silniku Facebooka zastosowano ciąg działający na podobnych zasadach (zob. biblioteka folly).

W naszym ciągu użyto dwóch głównych technologii optymalizacji:

  1. Dla krótkich wartości używany jest wewnętrzny bufor w samym obiekcie ciągu (nie wymagający dodatkowej alokacji pamięci).
  2. Dla wszystkich pozostałych używa się mechaniki Copy On Write. Wartość ciągu jest przechowywana w jednym miejscu, przy przypisaniu/modyfikacji używany jest licznik odwołań.

Aby przyspieszyć kompilację platformy, wykluczyliśmy z naszej wersji STLPort implementację stream (której nie używaliśmy), co dało nam przyspieszenie kompilacji o około 20%. Później musieliśmy ograniczyć korzystanie z Boost. Boost aktywnie używa stream, w szczególności w swoich API usługowych (np. do logowania), dlatego musieliśmy go zmodyfikować, wykluczając z niego użycie stream. To z kolei utrudniało nam przejście na nowsze wersje Boost.

Trzeci sposób

Przy przejściu na standard C++14 rozważaliśmy takie opcje:

  1. Podnieść zmodyfikowany przez nas STLPort do standardu C++14. Opcja ta jest bardzo skomplikowana, ponieważ wsparcie dla STLPortu zostało zaprzestane w 2010 roku, a podniesienie całego jego kodu musielibyśmy wykonać samodzielnie.
  2. Przejście na inną implementację STL, kompatybilną z C++14. Niezbędne jest, aby ta implementacja działała zarówno na Windows, jak i Linux.
  3. Użycie w kompilacji wbudowanej w odpowiedni kompilator biblioteki dla każdego systemu operacyjnego.

Pierwsza opcja została odrzucona od razu z powodu zbyt dużego zakresu pracy.

Przez jakiś czas rozważaliśmy drugą opcję; jako kandydata braliśmy pod uwagę libc++, ale w tamtym czasie nie działał pod Windows. Aby przenieść libc++ na Windows, trzeba by było wykonać wiele pracy — na przykład napisać samodzielnie wszystko, co związane z wątkami, synchronizacją wątków i atomowością, ponieważ w libc++ w tych obszarach używano POSIX API.

I wybraliśmy trzeci sposób.

Przejście

Tak więc musieliśmy zastąpić użycie STLPort bibliotekami odpowiednich kompilatorów (Visual Studio 2015 dla Windows, gcc 7 dla Linux, clang 8 dla macOS).

Na szczęście nasz kod był pisany głównie według wytycznych i nie używał różnych trików, więc migracja na nowe biblioteki przebiegła stosunkowo gładko, za pomocą skryptów, które zastępowały w źródłowych plikach nazwy typów, klas, przestrzeni nazw i dołączeń. Migracja objęła 10 000 źródłowych plików (z 14 000). wchar_t został zastąpiony przez char16_t; postanowiliśmy zrezygnować z użycia wchar_t, ponieważ char16_t zajmuje 2 bajty we wszystkich systemach operacyjnych i nie psuje kompatybilności kodu między Windows a Linux.

Nie obyło się bez małych przygód. Na przykład w STLPort iterator można było niejawnie rzutować na wskaźnik do elementu, i w niektórych miejscach naszego kodu to wykorzystywano. W nowych bibliotekach nie było to już możliwe, więc te miejsca trzeba było ręcznie analizować i przepisywać.

Tak więc migracja kodu została zakończona, kod jest kompilowany dla wszystkich systemów operacyjnych. Nadszedł czas na testy.

Testy po przejściu pokazały spadek wydajności (miejscami o 20-30%) i wzrost zużycia pamięci (do 10-15%) w porównaniu ze starą wersją kodu. Było to, w szczególności, związane z nieoptymalną pracą standardowych łańcuchów. Dlatego musieliśmy znowu użyć własnego łańcucha, nieco zmodyfikowanego.

Odkryto również interesującą cechę implementacji kontenerów w wbudowanych bibliotekach: puste (bez elementów) std::map i std::set z wbudowanych bibliotek alokują pamięć. A w naszym kodzie, z powodu specyfiki implementacji, tworzy się w niektórych miejscach dużo pustych kontenerów tego typu. Standardowe kontenery alokują trochę pamięci, dla jednego elementu głównego, ale dla nas okazało się to krytyczne – w niektórych scenariuszach wydajność wyraźnie spadła, a zużycie pamięci wzrosło (w porównaniu ze STLPort). Dlatego zastąpiliśmy w naszym kodzie te dwa typy kontenerów z wbudowanych bibliotek ich implementacją od Boost, gdzie te kontenery nie miały takiej cechy, co rozwiązało problem z opóźnieniem i zwiększonym zużyciem pamięci.

Jak często bywa po dużych zmianach w dużych projektach, pierwsza iteracja źródeł nie działała bez problemów, i tu bardzo przydała się, między innymi, obsługa iteracji debugujących w wersji Windows. Krok po kroku posuwaliśmy się naprzód i na wiosnę 2017 (wersja 8.3.11 1C:Przedsiębiorstwa) migracja została zakończona.

Podsumowanie

Przejście na standard C++14 zajęło nam około 6 miesięcy. Większą część czasu nad projektem pracował jeden (ale bardzo wykwalifikowany) programista, a na etapie końcowym dołączyli przedstawiciele zespołów odpowiedzialnych za konkretne obszary — UI, klaster serwerów, narzędzia do tworzenia i administracji itd.

Przejście znacznie uprościło nam pracę nad migracją do najnowszych wersji standardu. Tak, wersja 1C:Enterprise 8.3.14 (w opracowaniu, premiera zaplanowana na początek przyszłego roku) została już przetłumaczona na standard C++17.

Po migracji programiści zyskali więcej możliwości. Jeśli wcześniej mieliśmy swoją zmodyfikowaną wersję STL i jedną przestrzeń nazw std, to teraz w przestrzeni nazw std znajdują się standardowe klasy z wbudowanych bibliotek kompilatora, w przestrzeni nazw stdx – nasze, zoptymalizowane dla naszych zadań, łańcuchy i kontenery, w boost – nowa wersja boost. I programista korzysta z tych klas, które najlepiej pasują do rozwiązania jego zadań.

Pomaga w rozwoju również «rodzima» implementacja konstruktorów przenoszenia (move constructors) dla szeregu klas. Jeśli klasa ma konstruktor przenoszenia i ta klasa jest umieszczana w kontenerze, to STL optymalizuje kopiowanie elementów wewnątrz kontenera (na przykład, gdy kontener się rozszerza i trzeba zmienić pojemność i zrealokować pamięć).

Łyżka dziegciu

Najbardziej, być może, nieprzyjemnym (ale nie krytycznym) skutkiem migracji — napotkaliśmy zwiększenie objętości plików obj, a pełny wynik kompilacji ze wszystkimi plikami pośrednimi zaczął zajmować po 60 – 70 GB. Takie zachowanie związane jest z cechami nowoczesnych standardowych bibliotek, które zaczęły mniej krytycznie podchodzić do objętości generowanych plików pomocniczych. To nie wpływa na działanie skompilowanej aplikacji, ale sprawia szereg niedogodności w programowaniu, w szczególności zwiększa czas kompilacji. Wzrastają również wymagania co do wolnego miejsca na dysku na serwerach budowlanych i na maszynach programistów. Nasi programiści równolegle pracują nad kilkoma wersjami platformy, a setki gigabajtów plików pośrednich czasami stwarzają trudności w pracy. Problem jest nieprzyjemny, ale nie krytyczny, jego rozwiązanie na razie odłożyliśmy. Jako jeden z możliwych sposobów jego rozwiązania rozważamy technikę unity build (między innymi, wykorzystuje to Google podczas opracowywania przeglądarki Chrome).

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster