Разработчик
0,0
рейтинг
26 декабря 2008 в 20:37

Разработка → Обзор LLVM

LLVM (Low Level Virtual Machine) — это универсальная система анализа, трансформации и оптимизации программ или, как её называют разработчики, «compiler infrastucture».

LLVM — не просто очередной академический проект. Его история началась в 2000 году в Университете Иллинойса, а теперь LLVM используют такие гиганты индустрии как Apple и Adobe. В частности, на LLVM основана подсистема OpenGL в MacOS X 10.5, а iPhone SDK использует GCC с бэкэндом на LLVM. Apple является одним из основных спонсоров проекта, а вдохновитель LLVM — Крис Латтнер — теперь работает в Apple.

В основе LLVM лежит промежуточное представление кода (intermediate representation, IR), над которым можно производить трансформации во время компиляции, компоновки (linking) и выполнения. Из этого представления генерируется оптимизированный машинный код для целого ряда платформ, как статически, так и динамически (JIT-компиляция). LLVM поддерживает генерацию кода для x86, x86-64, ARM, PowerPC, SPARC, MIPS, IA-64, Alpha.

LLVM написана на C++ и портирована на большинство *nix-систем и Windows. Система имеет модульную структуру и может расширяться дополнительными алгоритмами трансформации (compiler passes) и кодогенераторами для новых аппаратных платформ. Пользовательский фронтенд, как правило, линкуется с LLVM и использует C++ API для генерации кода и его преобразований. Однако LLVM включает в себя и standalone утилиты.

Для тех, кто не без оснований считает C++ не лучшим языком для написания компиляторов, с недавних пор в LLVM включена обертка API для OCaml.

Чтобы понять, что можно сделать с помощью LLVM, и на каком уровне придётся работать, давайте разберёмся, что из себя представляет LLVM IR. В одном предложении его можно охарактеризовать как типизированный трёхадресный код в SSA-форме.

Здесь и далее мы будем использовать текстовую форму записи промежуточного кода, своеобразный «ассемблер» LLVM. На практике для хранения кода используется эффективное бинарное представление (bitcode). Генерировать же код обычно удобнее всего не в текстовой форме, и тем более не в бинарной, а с помощью специального API. До биткода в этом случае может и не доходить: код формируется в виде внтуренних структур в памяти, над которыми и проводятся все операции, вплоть до генерации машинного кода.


Типы данных


В LLVM поддерживаются следующие примитивные типы:
  • Целые числа произвольной разрядности:
    i1 ; булево значение — 0 или 1<br/> i32 ; 32-разрядное целое<br/> i17 ; даже так<br/> i256 ; ого!
    Генерация машинного кода для типов очень большой разрядности не поддерживается. Например, для x86 вам придётся ограничиться i64, а для x86-64 и других 64-разрядных платформ — 128-битными целыми. Но для промежуточного представления никаких ограничений нет.
    Числа считаются представленными в дополнительном коде. Различий между знаковыми и беззнаковыми целыми на уровне типов не делается: в тех случаях, когда это имеет значение, с ними работают разные инструкции.
  • Числа с плавающей точкой: float, double, а также ряд типов, специфичных для конкретной платформы (например, x86_fp80).
  • void — пустое значение.
Производные типы:
  • Указатели
    тип*<br/> i32* ; указатель на 32-битное целое<br/>
  • Массивы
    [число элементов x тип]<br/> [10 x i32]<br/> [8 x double]<br/>
  • Структуры
    { i32, i32, double }
  • Вектор — специальный тип для упрощения SIMD-операций. Вектор состоит из 2n значений примитивного типа — целого или с плавающей точкой.
    < число элементов x тип ><br/> < 4 x float ><br/>
  • Функции
    i32 (i32, i32)<br/> float ({ float, float }, { float, float })<br/>
Система типов рекурсивна, поэтому можно использовать многомерные массивы, массивы структур, указатели на структуры и функции, и т. д.


Операции


Большинство инструкций в LLVM принимают два аргумента (операнда) и возвращают одно значение (трёхадресный код). Значения определяются текстовым идентификатором. Локальные значения обозначаются префиксом %, а глобальные — @. Локальные значения также называют регистрами, а LLVM — виртуальной машиной с бесконечным числом регистров. Пример:
%sum = add i32 %n, 5<br/> %diff = sub double %a, %b<br/> %z = add <4 x float> %v1, %v2 ; поэлементное сложение<br/> %cond = icmp eq %x, %y ; Сравнение целых чисел. Результат имеет тип i1.<br/> %success = call i32 @puts(i8* %str)<br/>
Тип операндов всегда указывается явно, и однозначно определяет тип результата. Операнды арифметических инструкций должны иметь одинаковый тип, но сами инструкции «перегружены» для любых числовых типов и векторов.

Вместо утомительного перечисления инструкций (всего их 52) скажем, что LLVM поддерживает полный набор арифметических операций, побитовых логических операций и операций сдвига, а также специальные инструкции для работы с векторами.

LLVM IR строго типизирован, поэтому не обойтись без приведений типов, которые явно кодируются специальными инструкциями. Набор из 9 инструкций покрывает всевозможные приведения между различными числовыми типами: целыми и с плавающей точкой, со знаком и без, различной разрядности и пр. Кроме этого есть инструкции преобразования между целыми и указателями, а так же инструкция bitcast, которая приведёт всё ко всему, но за результат вы отвечаете сами.

Теперь должно быть понятно, как компилировать в LLVM простые выражения: каждый узел дерева выражения кроме листьев (констант и переменных) заменяется промежуточным значением-регистром — результатом инструкции, операндами которой являются дочерние узлы.
; x = (a + b) * c - d / e <br/> %tmp1 = add float %a, %b <br/> %tmp2 = mul float %tmp1, %c <br/> %tmp3 = fdiv float %d, %e <br/> %x = sub float %tmp2, %tmp3 <br/>
Забегая вперёд, за пределы этой статьи, заметим, что при использовании LLVM API для генерации кода всё становится ещё проще, потому что оно следует принципу «инструкция — это значение». Нам не придётся заниматься генерацией уникальных имён для промежуточных значений: функция, генерирующая инструкцию, возвращает значение (объект C++), которое может быть передано как аргумент в другие такие функции.


SSA


SSA (static single assignment form) — это такая форма промежуточного представления кода, в которой любое значение присваивается только один раз. Таким образом, нельзя написать:
%z = sum i32 %x, %y <br/> %z = sum i32 %z, 5 <br/>
Новое значение должно получить новое имя:
%z.1 = sum i32 %z, 5
Однако, спросите вы, как быть, если одна и та же переменная должна получить разные значения в зависимости от какого-то условия? Или как организовать переменную цикла?

Начнём немного издалека. Код в SSA-форме удобно рассматривать не как линейную последовательность инструкций, а как граф потока управления (control flow graph, CFG). Вершины этого графа — так называемые базовые блоки (basic blocks), содержащие последовательность инструкций, заканчивающуюся инструкцией-терминатором, явно передающей управление в другой блок. Базовые блоки в LLVM обозначаются метками, а терминаторами являются следующие инструкции:
  • ret тип значение — возврат значения из функции
  • br i1 условие, label метка_1, label метка_2 — условный переход. Например:
    define float @max(float %x, float %y) <br/> { <br/>     %cond = fcmp ogt float %x, %y <br/>     br i1 %cond, label %IfTrue, label %IfFalse <br/> IfTrue: <br/>     ret float %x <br/> IfFalse: <br/>     ret float %y <br/> }
    (Синтаксис определения функции, думаю, очевиден).
    Есть также форма безусловного перехода:
    br label метка
  • switch — обобщение br, позволяет организовать таблицу переходов:
    switch i32 %n, label %Default, [i32 0, label %IfZero i32 5, label %IfFive]
  • invoke и unwind — используются для организации исключений, в этой статье останавливаться на них мы не будем.
  • unreachable — специальная инструкция, показывающая компилятору, что выполнение никогда не достигнет этой точки. Например, эта инструкция может быть вставлена после вызова системной функции завершения процесса.
Вернёмся к вопросу, как сделать переменные изменяемыми. В SSA-форме к прочим инструкциям добавляется специальная функция φ, которая возвращает одно из перечисленных значений в зависимости от того, какой блок передал управление текущему.

В LLVM этой функции соответствует инструкция phi, которая имеет следующую форму:
phi тип, [значение_1, label метка_1], ..., [значение_N, label метка_N]
В качестве примера рассмотрим функцию вычисления факториала, которую на Си можно было бы записать так:
int factorial(int n) <br/> { <br/>     int result = n; <br/>     int i; <br/>     for (i = n - 1; i > 1; --i) <br/>         result *= i; <br/>     return result; <br/> } <br/>
Примечание: блок, который начинается с входа в функцию обозначается %0.
define i32 @factorial(i32 %n) <br/> { <br/>     %i.start = sub i32 %n, 1 <br/>     br label %LoopEntry <br/> LoopEntry: <br/>     %result = phi i32 [%n, %0], [%result.next, %LoopBody] <br/>     %i = phi i32 [%i.start, %0], [%i.next, %LoopBody] <br/>     %cond = icmp sle i32 %i, 1 <br/>     br i1 %cond, label %Exit, label %LoopBody <br/> LoopBody: <br/>     %result.next = mul i32 %result, %i <br/>     %i.next = sub i32 %i, 1 <br/>     br label %LoopEntry <br/> Exit: <br/>     ret i32 %result <br/> }
Пусть вас не смущают бессмысленные на первый взгляд переходы на метку, следующую сразу за инструкцией перехода. Как мы уже сказали, базовый блок обязан заканчиваться явной передачей управления. LLVM также требует, чтобы все phi-инструкции шли в начале блока, и до них не было никаких других инструкций.
Здесь, наверное, многие воскликнут: но это же ужасно неудобно! Действительно, хотя SSA-форма позволяет производить много полезных трансформаций, непосредственно генерировать её из кода на императивном языке затруднительно, хотя есть хорошо известные алгоритмы преобразования в SSA. К счастью, при написании компилятора на основе LLVM нет никакой необходимости заниматься этим, потому что система умеет генерировать SSA самостоятельно. Как и из чего, мы сейчас узнаем.


Память


Помимо значений-регистров, в LLVM есть и работа с памятью. Значения в памяти адресуются типизированными указателями, о которых мы говорили выше. Обратиться к памяти можно только с помощью двух инструкций, названия которых говорят сами за себя: load и store. Например:
%x = load i32* %x.ptr        ; загрузили значение типа i32 по указателю %x.ptr <br/> %tmp = add i32 %x, 5         ; прибавили 5 <br/> store i32 %tmp, i32* %x.ptr  ; и положили обратно <br/>
Но чтобы пользоваться указателями, надо как-то выделять память под значения, на которые они указывают.

Инструкция malloc транслируется в вызов одноименной системной функции и выделяет память на куче, возвращая значение — указатель определенного типа. В паре с ней конечно же идёт инструкция free.
%struct.ptr = malloc { double, double } <br/> %string = malloc i8, i32 %length <br/> %array = malloc [16 x i32] <br/> free i8* %string <br/>
Официальной рекомендации не использовать инструкцию malloc нет, но разработчики признаются, что особого смысла в её существовании сейчас не имеется. Вы можете вызвать вместо неё функцию @malloc или написать свою собственную функцию-аллокатор, отвечающую каким-то особым требованиям.

А вот инструкция alloca незаменима и очень важна. Она имеет такой же формат, но выделяет память на стеке.
%x.ptr = alloca double ; %x.ptr имеет тип double* <br/> %array = alloca float, i32 8 ; %array имеет тип float*, а не [8 x float]! <br/>
Память, выделенная alloca, автоматически освобождается при выходе из функции при помощи инструкций ret или unwind.
С помощью alloca, load и store мы можем пользоваться локальными переменными так же, как и в любом императивном языке. Например, наша многострадальная функция факториала:
define i32 @factorial(i32 %n) <br/> { <br/>     %result.ptr = alloca i32     ; выделить память под result <br/>     %i.ptr = alloca i32          ; и под i <br/>     store i32 %n, i32* %result.ptr  ; инициализация result = n <br/>     %tmp1 = sub i32 %n, 1 <br/>     store i32 %tmp1, i32* %i.ptr ; i = n - 1 <br/>     br label %Loop <br/> Loop: <br/>     %i = load i32* %i.ptr        ; загружаем значение i <br/>     %cond = icmp sle i32 %i, 1   ; и проверяем условие i <= 1<br/>     br i1 %cond, label %Exit, label %LoopBody ; если да, переход к возврату значения <br/> LoopBody: <br/>     %tmp2 = load i32* %result.ptr <br/>     %tmp3 = mul i32 %tmp2, %i <br/>     store i32 %tmp3, i32* %result.ptr   ; result *= i <br/>     %i.next = sub i32 %i, 1 <br/>     store i32 %i.next, i32* %i.ptr      ; --i <br/>     br label %Loop <br/> Exit: <br/>     %result = load i32* %result.ptr <br/>     ret i32 %result ; return result <br/> } <br/>
Достаточно многословно, но скажите, где ещё кроме подобной статьи вы будете писать код на LLVM вручную? :-)
Хорошая новость в том, что из подобного кода LLVM умеет строить SSA-форму. Этот процесс называется «promote memory to register». Вот что получится из функции factorial после прохода этого алгоритма:
define i32 @factorial(i32 %n) { <br/> ; <label>:0 <br/>     %tmp1 = sub i32 %n, 1 <br/>     br label %Loop <br/> Loop: <br/>     %i.ptr.0 = phi i32 [ %tmp1, %0 ], [ %i.next, %LoopBody ] <br/>     %result.ptr.0 = phi i32 [ %n, %0 ], [ %tmp3, %LoopBody ] <br/>     %cond = icmp sle i32 %i.ptr.0, 1 <br/>     br i1 %cond, label %Exit, label %LoopBody <br/> LoopBody: <br/>     %tmp3 = mul i32 %result.ptr.0, %i.ptr.0 <br/>     %i.next = sub i32 %i.ptr.0, 1 <br/>     br label %Loop <br/> Exit: <br/>     ret i32 %result.ptr.0 <br/> } <br/>

Операции с указателями


Массивы в LLVM очень похожи на таковые в Си, но адресной арифметики, как в Си, нет. То есть нельзя написать:
%ptr = add i32* %array, i32 %index
Для вычисления адресов элементов массивов, структур и т. д. с правильной типизацией есть специальная инструкция getelementptr.
%array = alloca i32, i32 %size <br/> %ptr = getelementptr i32* %array, i32 %index ; значение типа i32* <br/>
getelementptr только вычисляет адрес, но не обращается к памяти. Инструкция принимает произвольное количество индексов и может разыменовывать структуры любой вложенности. Например, из следующего кода на Си:
struct s { <br/>     int n; <br/>     char *a[4]; <br/> }; <br/> struct *s = ...; <br/> char c = s->a[2][5]; <br/>
будет сгенерирована такая последовательность инструкций:
%ptr = getelementptr { i32, [4 x i8*] }* %s, i32 1, i32 2, i32 5 <br/> %c = load i8* %ptr <br/>
Как вы заметили, индексы отсчитываются от нуля.
Есть очень похожая на getelementptr пара инструкций extractvalue и insertvalue. Они отличаются тем, что принимают не указатель на агрегатный тип данных (массив или структуру), а самое значение такого типа. extractvalue возвращает соответственное значение подэлемента, а не указатель на него, а insertvalue порождает новое значение агрегатного типа.
%n = extractvalue { i32, [4 x i8*] } %s, 0 <br/> %tmp = add i32 %n, 1 <br/> %s.1 = insertvalue { i32, [4 x i8*] } %s, i32 %tmp, 0 <br/>

Встроенные функции и аннотации


Ряд примитивов представляются в LLVM не инструкциями, а встроенными функциями (intrinsic functions). Например, некоторые математические функции: @llvm.sqrt.*, @llvm.sin.* и т. д. Есть также примитивы для атомарных операций и некоторые другие.

Интересно то, что вызовы этих функций в промежуточном коде вовсе не обязаны превращаться в вызовы функций в машинном коде, или даже в инлайн-подстановки функций. Они могут просто нести служебную информацию для какой-то подсистемы компилятора. Например, так организована генерация отладочной информации в формате DWARF: в IR вставляются вызовы функций %llvm.dbg.stoppoint (задаёт соответствие между строками исходного кода и генерируемым кодом), %llvm.dbg.declare (задаёт описание локальной переменной) и др., в качестве аргументов которым передаются указатели на специальные структуры.

Аналогичным образом реализована поддержка сборки мусора. LLVM не содержит какого-либо алгоритма сборки мусора, вместо этого предоставляя интерфейс для написания собственного точного GC. Примитивы @llvm.gcroot, @llvm.gcread и @llvm.gc.write позволяют закодировать информацию, необходимую для работы GC, а интерфейс плагина к компилятору LLVM — сгенерировать по этой информации нужные структуры данных и вставить обращения к рантайму.


Что умеет оптимизатор LLVM


Просто перечислим часть алгоритмов. Все они платформо-независимы и преобразуют IR. Проходы оптимизатора могут быть вызваны независимо в нужном порядке.
  • Удаление неиспользуемого кода (dead code elimination).
  • Выделение одинаковых подвыражений (common subexpression elimination).
  • Распространение констант (constant propagation, condition propagation).
  • Инлайн-подстановка функций.
  • Разворот хвостовой рекурсии. LLVM также умеет в некоторых случаях разворачивать не хвостовые рекурсивные вызовы за счёт ввода дополнительной переменной-аккумулятора, как это зачастую делают в функциональных языках. Например, в этой функции рекурсивный вызов будет успешно заменён условным переходом.
    int factorial(int n) <br/> { <br/>     if (n < 2) return 1; <br/>     return n * factorial(n - 1); <br/> } <br/>
  • Раскрутка и размыкание циклов, вынос инвариантов за пределы цикла.
Преобразование может быть не только оптимизирующим, но и использоваться для анализа и инструментации. Например, LLVM может генерировать CFG в формате Graphviz.

Вместо заключения


Из этого краткого обзора видно, что промежуточное представление LLVM достаточно близко соответствует коду на низкоуровневых процедурных языках вроде Си. Транслятор Си на основе LLVM будет достаточно прост и прямолинеен, но при этом сгенерированный им машинный код по производительности сможет тягаться с последними версиями GCC.

При трансляции высокоуровневых языков — объектно-ориентированных, функциональных, динамических — придётся выполнить гораздо больше промежуточных преобразований, а также написать специализированный рантайм. Но и в этом случае LLVM снимает с разработчика компилятора проблемы кодогенерации для конкретной платформы, берёт на себя большинство независимых от языка оптимизаций — и делает их качественно. Помимо этого, мы получаем готовую инфраструктуру для JIT-компиляции и возможность link-time оптимизации между различными языками, компилируемыми в LLVM.

LLVM пытается достичь баланса между удобством и гибкостью, не навязывая какую-то конкретную парадигму программирования, не ограничивая систему типов.

Полноценный фронтенд на сегодняшний день существуют только для C, C++, Ada и Fortran — это llvm-gcc. Идёт работа по созданию независимого от GCC компилятора C/C++ — clang. Оба проекта поддерживаются основной командой LLVM.

Остальные проекты компиляторов известных языков на базе LLVM пока не достигли уровня практической применимости. Но перспективы заманчивы. Увидим ли мы компилятор современного функционального или динамического (PyPy?) языка на LLVM — покажет время.

продолжение следует…
Руслан Хайров @khayrov
карма
87,8
рейтинг 0,0
Разработчик
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (23)

  • 0
    Если сравнить с чистым gcc, какой прирост по скорости даёт использование этого бекэнда? Есть какие-то бенчи на эту тему?
    • +1
      Ну вот например, тесты по недоброй славы shootout: leonardo-m.livejournal.com/73732.html
      В среднем одинаково с GCC 4.
      В их собственной системе «ночного» тестирования есть тесты из SPEC CPU, но самих результатов SPEC я не видел.
      • 0
        Спасибо!

        То есть, в трёх словах, конечный пользователь имеет возможность код на С/С++ компилить сразу под несколько архитектур с оптимизацией, которая учитывает особенности этих архитектур. Я правильно всё понял?

        Сорри, что много вопросов — вещь интересная, копать некогда :-)
        • –1
          llvm-gcc может порождать файлы с IR, которые можно скомпилировать на целевой платформе при наличии соответствующей инфраструктуры. Но едва ли это основной сценарий использования: всё-таки к программам на C/C++ неприлично тащить многомегабайтный компилятор.
          llvm-gcc — это мощнейший стресс-тест для LLVM и способ интеграции существующего кода на C/C++. Просто как компилятор C++ особых преимуществ перед GCC он не имеет.
          С помощью LLVM можно оптимизировать программы не только при развёртывании, но и в рантайме — оптимизация «горячих» участков кода. Это особенно актуально для динамических языков.
  • 0
    мне вот интересно, почему Mono не на LLVM

    или LLVM непригодно для JIT-компилятора?

    P.S. а что не в тематическом блоге?
    • +1
      > мне вот интересно, почему Mono не на LLVM
      Судя по всему, когда Mono начинал развиваться, возможности LLVM были неадекватными: комент на LtU разработчика Mono.
      Сейчас идёт работа над реализацией CLR и JVM поверх LLVM: VMKit
      > или LLVM непригодно для JIT-компилятора?
      Ещё как пригодно, возможность заложена изначально.
      > а что не в тематическом блоге?
      Не придумал в какой.
      • 0
        >> Не придумал в какой.

        просто топики в личных блогах на главную не попадают
        • 0
          Ну раз такое дело, двинул пока в ЯП :-)
  • +1
    Спасибо за статью!!! Очень интересно
    от себя могу добавить что именно через LLVM идет компиляция C/C++ в AS3 в адобовской алхимии и там действительно все очень поразительно шоколадно получается.

    В общем штука офигительно перспективная. И есть надежда что адобе продвинет ее достаточно далеко. Дажешь ОШЕ любому скриптовому языку за 5 копеек! :)
  • +1
    Опечатка, ОШЕ — это JIT :)
  • 0
    И майкрософт не отстаёт ;)
    connect.microsoft.com/Phoenix
  • 0
    Также энтузиастами ведется портирование фронт-енда языка программирования D (если кто не знает — это современный системный язык программирования, разрабатывается как замена С++) на LLVM, и она близка к завершению(лучше всего поддерживается linux X86, остальные платформы — немного отстают. Проект называется ldc, адрес www.dsource.org/projects/ldc/
  • 0
    Такой вопрос, не касающийся исключительно LLVM (хотя и в большей степени), про JIT: сгенерированные нативные машинные инструкции отправляются в хип процесса, управление процессором передаётся по адресу этих инструкций, но что об этом знает сам процессор? ОС?
    Взять тот же кэш процессора — он как-то задействуется?

    Теории по JIT'у навалом, но это либо радостные возгласы, либо констатация фактов: «теперь у нас есть такая крутая штука», более никаких подробностей. Да, есть исходники, но их разбор шибко тяжкий труд.
    • 0
      LLVM берёт память под JIT не в куче процесса, а запрашивает у ОС страницы (mmap/VirtualAlloc) с правам read, write, execute и управляет этим куском памяти собственным менеджером.
      Instruction cache задействуется в любом случае, какие тут нужны дополнительные телодвижения?
      • 0
        Ясно, спасибо.

        >>Instruction cache задействуется в любом случае, какие тут нужны дополнительные телодвижения?
        Понятия не имею, поэтому и спросил.
  • 0
    Было бы интересно почитать про GC и exception handling… :)
    • 0
      Едва ли я смогу рассказать про GC в LLVM лучше, чем в документации, потому что сам пока с ним не экспериментировал (но буду :-).
      Насчёт исключений: invoke позволяет вызвать функцию, задав две точки продолжения: нормальную и ту, куда попадёт управление, если где-то в цепочке вызовов встретится unwind. Всё остальное — забота рантайма. Помимо документации здесь бывает полезно посмотреть на вывод llvm-g++ --emit-llvm — во что он превращает те или иные конструкции.
  • +1
    В функции factorial, приведённой в качестве примера хвостовой рекурсии, рекурсия вовсе не хвостовая. Конечно, хорошо, что LLVM умеет преобразовывать такую рекурсию в цикл (если это действительно так), но получается, что LLVM может оптимизировать не только хвостовую рекурсию, но и другие частные случаи общей рекурсии.
    • 0
      Да, а за статью спасибо :)
    • 0
      Да, вы правы, стоит переформулировать, что LLVM умеет в некоторых случаях преобразовывать рекурсию в хвостовую за счёт ввода переменной-аккумулятора, так же как это делают вручную в ФЯП.
  • 0
    Не понимаю назначения всё рано… ИМХО, чтобы я и такие как я поняли, нужно обзорно описать полный процесс компиляции, и показать, на каких его этапах найдено место для разрезания и вставки LLVM.
    • +1
      Вот тут очень хорошо все рассказано
      • 0
        Спасибо, это прям то, что доктор прописал!

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.