.. _sec_hybridize: Компиляторы и интерпретаторы ============================ До сих пор эта книга была сосредоточена на императивном программировании, которое использует такие операторы, как ``print``, ``+`` и ``if``, для изменения состояния программы. Рассмотрим следующий пример простой императивной программы. .. raw:: latex \diilbookstyleinputcell .. code:: python def add(a, b): return a + b def fancy_func(a, b, c, d): e = add(a, b) f = add(c, d) g = add(e, f) return g print(fancy_func(1, 2, 3, 4)) Python — это *интерпретируемый язык*. При вычислении функции ``fancy_func`` он выполняет операции, составляющие её тело, *последовательно*. То есть он вычислит ``e = add(a, b)`` и сохранит результат в переменной ``e``, тем самым изменяя состояние программы. Следующие два оператора ``f = add(c, d)`` и ``g = add(e, f)`` будут выполнены аналогичным образом: они выполнят сложение и сохранят результаты в переменных. :numref:`fig_compute_graph` иллюстрирует поток данных. .. _fig_compute_graph: .. figure:: ../img/computegraph.svg Поток данных в императивной программе. Хотя императивное программирование удобно, оно может быть неэффективным. С одной стороны, даже если функция ``add`` вызывается многократно в рамках ``fancy_func``, Python будет выполнять эти три вызова функции по отдельности. Если они выполняются, например, на графическом процессоре (или даже на нескольких графических процессорах), накладные расходы, возникающие из-за интерпретатора Python, могут стать чрезмерными. Кроме того, потребуется сохранять значения переменных ``e`` и ``f`` до тех пор, пока не будут выполнены все операторы в ``fancy_func``. Это связано с тем, что мы не знаем, будут ли переменные ``e`` и ``f`` использованы другими частями программы после выполнения операторов ``e = add(a, b)`` и ``f = add(c, d)``. Символьное программирование --------------------------- Рассмотрим альтернативный подход — *символическое программирование*, при котором вычисления обычно выполняются только после того, как процесс полностью определён. Эта стратегия используется во многих фреймворках глубокого обучения, включая Theano и TensorFlow (последний приобрёл императивные расширения). Обычно она включает следующие шаги: 1. Определите операции, подлежащие выполнению. 2. Скомпилируйте операции в исполняемую программу. 3. Предоставьте необходимые входные данные и вызовите скомпилированную программу для выполнения. Это открывает широкие возможности для оптимизации. Во-первых, во многих случаях можно исключить интерпретатор Python, тем самым устранив узкое место в производительности, которое может стать существенным при использовании нескольких быстрых GPU в связке с одним потоком Python на процессоре. Во-вторых, компилятор может оптимизировать и переписать приведённый выше код в ``print((1 + 2) + (3 + 4))`` или даже в ``print(10)``. Это возможно, поскольку компилятор видит весь код целиком до его преобразования в машинные инструкции. Например, он может освобождать память (или вообще не выделять её), когда переменная больше не нужна. Либо он может полностью преобразовать код в эквивалентный фрагмент. Чтобы лучше понять этот процесс, рассмотрим следующую имитацию императивного программирования (ведь это всё-таки Python) ниже. .. raw:: latex \diilbookstyleinputcell .. code:: python def add_(): return ''' def add(a, b): return a + b ''' def fancy_func_(): return ''' def fancy_func(a, b, c, d): e = add(a, b) f = add(c, d) g = add(e, f) return g ''' def evoke_(): return add_() + fancy_func_() + 'print(fancy_func(1, 2, 3, 4))' prog = evoke_() print(prog) y = compile(prog, '', 'exec') exec(y) Различия между императивным (интерпретируемым) программированием и символьным программированием заключаются в следующем: - Императивное программирование проще. При использовании императивного программирования в Python большая часть кода получается простой и легко пишется. Кроме того, проще отлаживать код, написанный в императивном стиле. Это связано с тем, что легче получить и вывести значения всех промежуточных переменных, а также воспользоваться встроенными инструментами отладки Python. - Символическое программирование более эффективно и проще в переносе. Оно упрощает оптимизацию кода на этапе компиляции и позволяет преобразовать программу в формат, независимый от Python. Это дает возможность запускать программу в среде, не связанной с Python, тем самым избегая потенциальных проблем с производительностью, связанных с интерпретатором Python. Гибридное программирование -------------------------- Исторически большинство фреймворков для глубокого обучения выбирают либо императивный, либо символический подход. Например, Theano, TensorFlow (вдохновлённый первым), Keras и CNTK описывают модели символически. Напротив, Chainer и PyTorch используют императивный подход. Императивный режим был добавлен в TensorFlow 2.0 и Keras в последующих версиях. Как упоминалось выше, PyTorch основан на императивном программировании и использует динамические вычислительные графы. Стремясь воспользоваться переносимостью и эффективностью символического программирования, разработчики исследовали возможность объединения преимуществ обеих парадигм программирования. Это привело к появлению TorchScript, который позволяет пользователям разрабатывать и отлаживать код, используя императивное программирование, а также преобразовывать большинство программ в символические для выполнения в условиях, когда требуются вычислительная производительность уровня продукта и развертывание. Гибридизация класса ``Sequential`` ---------------------------------- Самый простой способ понять, как работает гибридный режим, — рассмотреть глубокие сети с несколькими слоями. Обычно интерпретатор Python должен выполнить код для всех слоёв, чтобы сгенерировать инструкцию, которая затем передаётся на центральный или графический процессор. Для одного (быстрого) вычислительного устройства это не вызывает серьёзных проблем. С другой стороны, если мы используем продвинутый сервер с 8 графическими процессорами, например, объект AWS P3dn.24xlarge, интерпретатор Python может не справиться с тем, чтобы все GPU были загружены. В этом случае однопоточный интерпретатор Python становится узким местом. Давайте посмотрим, как мы можем решить эту проблему для значительной части кода, заменив ``Sequential`` на ``HybridSequential``. Сначала мы определим простую многослойную перцептрон (MLP). .. raw:: latex \diilbookstyleinputcell .. code:: python import torch from torch import nn from d2l import torch as d2l # Factory for networks def get_net(): net = nn.Sequential(nn.Linear(512, 256), nn.ReLU(), nn.Linear(256, 128), nn.ReLU(), nn.Linear(128, 2)) return net x = torch.randn(size=(1, 512)) net = get_net() net(x) Преобразуя модель с помощью функции ``torch.jit.script``, мы можем компилировать и оптимизировать вычисления в MLP. Результат вычислений модели остается неизменным. .. raw:: latex \diilbookstyleinputcell .. code:: python net = torch.jit.script(net) net(x) Это кажется почти слишком хорошим, чтобы быть правдой: пишем тот же код, что и раньше, и просто конвертируем модель с помощью ``torch.jit.script``. После этого сеть оптимизируется (мы проведем бенчмарк производительности ниже). Ускорение за счёт гибридизации ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Чтобы продемонстрировать улучшение производительности, достигнутое благодаря компиляции, мы сравним время, необходимое для вычисления ``net(x)``, до и после гибридной оптимизации. Сначала определим класс для измерения этого времени. Он пригодится нам на протяжении всей главы, когда мы будем измерять (и улучшать) производительность. .. raw:: latex \diilbookstyleinputcell .. code:: python #@save class Benchmark: """For measuring running time.""" def __init__(self, description='Done'): self.description = description def __enter__(self): self.timer = d2l.Timer() return self def __exit__(self, *args): print(f'{self.description}: {self.timer.stop():.4f} sec') Теперь мы можем запустить сеть дважды: один раз с TorchScript и один раз без него. .. raw:: latex \diilbookstyleinputcell .. code:: python net = get_net() with Benchmark('Without torchscript'): for i in range(1000): net(x) net = torch.jit.script(net) with Benchmark('With torchscript'): for i in range(1000): net(x) Как видно из приведённых выше результатов, после того как объект ``nn.Sequential`` будет обработан с помощью функции ``torch.jit.script``, производительность вычислений улучшается за счёт использования символического программирования. Сериализация ~~~~~~~~~~~~ Одним из преимуществ компиляции моделей является возможность сериализации (сохранения) модели и её параметров на диск. Это позволяет хранить модель независимо от выбранного языка фронтенда. Благодаря этому мы можем развертывать обученные модели на других устройствах и легко использовать другие языки программирования для фронтенда. При этом код часто работает быстрее, чем в императивном программировании. Давайте посмотрим, как работает функция ``save``. .. raw:: latex \diilbookstyleinputcell .. code:: python net.save('my_mlp') !ls -lh my_mlp* Резюме ------ - Императивное программирование упрощает создание новых моделей, поскольку позволяет писать код с использованием управляющих структур и задействовать обширную экосистему программного обеспечения Python. - Символическое программирование требует предварительного задания и компиляции программы перед её выполнением. Преимущество заключается в повышении производительности. Упражнения ---------- 1. Рассмотрите модели, которые вас интересуют, в предыдущих главах. Можете ли вы улучшить их вычислительную производительность, переписав их? `Обсуждения `__